@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
@@ -1,10 +1,93 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { general } from '../../errors.js';
2
3
  import { nowIso } from '../../fs-utils.js';
3
4
  import { guestEnv } from '../guest-env.js';
4
5
  import { buildBootstrapScript } from './blaxel-bootstrap.js';
5
6
  import { BlaxelMachineProvider } from './blaxel.js';
6
- function stableRouteName(home) {
7
- return `${home.providerSandboxId}-web`;
7
+ // recreate-race-fix.md §3.2 — a volume GET is one cheap API call; 1s resolution is plenty
8
+ // against an expected seconds-scale detach.
9
+ const VOLUME_DETACH_POLL_MS = 1_000;
10
+ // Expected wait is seconds (observed live); 120s is ~20-60x headroom over that, so this still
11
+ // fails deterministically inside one wake's lifetime rather than hanging forever on a genuine
12
+ // platform-side stuck detach.
13
+ const VOLUME_DETACH_TIMEOUT_MS = 120_000;
14
+ // orphan-cleanup-fix.md §4 — pause before the single bounded retry of a create that failed on a
15
+ // server-side deployment transient, to let the platform-side condition clear.
16
+ const CREATE_RETRY_DELAY_MS = 5_000;
17
+ function stableRouteName(sandboxId) {
18
+ return `${sandboxId}-web`;
19
+ }
20
+ /** Mint a fresh per-generation sandbox name. `providerVolumeId` is the home's stable base
21
+ * (never suffixed), so names stay bounded: `hearth-zt-test-1-gmchq3x2a`. Sequential recreates
22
+ * are seconds apart, so a ms timestamp cannot collide for one home (recreate-race-fix.md §3.2). */
23
+ function mintSandboxName(providerVolumeId) {
24
+ return `${providerVolumeId}-g${Date.now().toString(36)}`;
25
+ }
26
+ /** Escape a volumeId for safe use inside a RegExp so the generation-family anchor is exact —
27
+ * a home base name is user-influenced, so a literal `.` or `+` in it must not become a
28
+ * wildcard (orphan-cleanup-fix.md §3.1). */
29
+ function escapeRegExp(value) {
30
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
32
+ /** Predicate matching every generation of a home's name family: the stable base itself, or a
33
+ * `<base>-g<suffix>` generation. The `-g` anchor is load-bearing — it prevents `<base>0-g…`
34
+ * (e.g. `hearth-zt-test-10-g…`) from matching base `hearth-zt-test-1` (orphan-cleanup-fix.md
35
+ * §3.1). Suffix chars are base36 (lowercase alnum), matching `mintSandboxName`. */
36
+ function generationFamilyMatcher(volumeId) {
37
+ const gen = new RegExp(`^${escapeRegExp(volumeId)}-g[0-9a-z]+$`);
38
+ return (name) => name === volumeId || gen.test(name);
39
+ }
40
+ /** Serialize an arbitrary rejection to a non-empty marker string. `JSON.stringify` RETURNS
41
+ * `undefined` (does not throw) for `undefined`, a function, or a symbol — falling through to
42
+ * that would omit the `error` field from the marker's JSON line entirely, so fall back to
43
+ * `String(...)` whenever it yields `undefined` (matches wake.ts/relay.ts `serializeError`). */
44
+ function serialize(error) {
45
+ if (error instanceof Error)
46
+ return error.message;
47
+ try {
48
+ const json = JSON.stringify(error);
49
+ return json === undefined ? String(error) : json;
50
+ }
51
+ catch {
52
+ return String(error);
53
+ }
54
+ }
55
+ /** Emit one structured stderr line for a recreate-lifecycle marker (sweep / volume_wait /
56
+ * cleanup_failed / create_retry). Single writer for all four so the JSON shape stays uniform
57
+ * and ops log-alerting keys on `marker` (orphan-cleanup-fix.md §3.3/§3.4/§4). */
58
+ function emitMarker(marker, fields, level = 'info') {
59
+ process.stderr.write(`${JSON.stringify({ level, marker, ...fields })}\n`);
60
+ }
61
+ /** Classify a failed create+deploy as a retryable server-side deployment transient
62
+ * (orphan-cleanup-fix.md §4). Retryable IFF `status_code >= 500` OR `code === 'DEPLOYMENT_FAILED'`
63
+ * on the rejection object, with a message-substring fallback for Error-wrapped variants whose
64
+ * structured fields were flattened into `.message`. NEVER on 4xx — a 409 here is a real name
65
+ * conflict the design must surface, not absorb, so an explicit 4xx status vetoes the retry even
66
+ * if a `DEPLOYMENT_FAILED` code is also present. */
67
+ function isRetryableDeploymentError(error) {
68
+ if (error !== null && typeof error === 'object') {
69
+ // Only the canonical HTTP-status keys — NOT a bare `status`, which is an overloaded field
70
+ // name some envelopes use for a non-HTTP state enum/count (MINOR-2).
71
+ const obj = error;
72
+ const status = [obj.status_code, obj.statusCode].find((v) => typeof v === 'number');
73
+ if (typeof status === 'number') {
74
+ if (status >= 400 && status < 500)
75
+ return false; // 4xx: real conflict, never absorb
76
+ if (status >= 500)
77
+ return true;
78
+ }
79
+ if (obj.code === 'DEPLOYMENT_FAILED')
80
+ return true;
81
+ }
82
+ // Fallback for Error-wrapped variants: only POSITIVE server-side signals, never inferring a 4xx.
83
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
84
+ if (message !== '') {
85
+ if (/DEPLOYMENT_FAILED/.test(message))
86
+ return true;
87
+ if (/\bstatus[_ ]?code\b\D{0,8}5\d\d/i.test(message))
88
+ return true;
89
+ }
90
+ return false;
8
91
  }
9
92
  function ensureReadyHome(input) {
10
93
  if (input.providerName !== 'blaxel') {
@@ -30,10 +113,10 @@ function isRunningMachine(machine) {
30
113
  function previewEndpoint(route, fallback) {
31
114
  return route.concreteUrl ?? route.url ?? fallback ?? null;
32
115
  }
33
- function refreshedPointers(home, provider, route, status, machine) {
116
+ function refreshedPointers(home, provider, route, status, sandboxId, machine) {
34
117
  return {
35
118
  providerName: 'blaxel',
36
- providerSandboxId: home.providerSandboxId,
119
+ providerSandboxId: sandboxId,
37
120
  providerVolumeId: home.providerVolumeId,
38
121
  previewEndpoint: route === null ? (home.previewEndpoint ?? null) : previewEndpoint(route, home.previewEndpoint),
39
122
  route,
@@ -80,25 +163,211 @@ export class BlaxelHomeBackend {
80
163
  await this.provider.exec(current.providerSandboxId, { command: bootstrapScript, background: true, cwd: this.config.guestHome });
81
164
  await this.provider.waitForPort(current.providerSandboxId, this.config.webPort, { maxWaitMs: 30_000 });
82
165
  }
83
- }
84
- else {
85
- await this.provider.createMachine({
86
- tenantId: current.tenantId,
87
- name: current.providerSandboxId,
88
- envs: guestEnv(this.config),
89
- volumeMount: { volumeName: current.providerVolumeId, path: this.config.guestHome },
90
- ports: [{ target: this.config.webPort, protocol: 'HTTP' }],
166
+ const route = await this.provider.publishHttpPort(current.providerSandboxId, {
167
+ machineId: current.providerSandboxId,
168
+ targetPort: this.config.webPort,
169
+ routeName: stableRouteName(current.providerSandboxId),
170
+ routeId: stableRouteName(current.providerSandboxId),
91
171
  });
92
- await this.provider.exec(current.providerSandboxId, { command: bootstrapScript, background: true, cwd: this.config.guestHome });
93
- await this.provider.waitForPort(current.providerSandboxId, this.config.webPort, { maxWaitMs: 60_000 });
172
+ return refreshedPointers(current, this.provider, route, 'running', current.providerSandboxId, await this.getMachine(current.providerSandboxId));
173
+ }
174
+ // Terminated-machine branch (recreate-race-fix.md §3.4, orphan-cleanup-fix.md §3.3/§3.4):
175
+ // the crash-recovery path for a sandbox deleted recently out-of-band OR by a crashed/failed
176
+ // roll (a Fly redeploy restarting the CP mid-roll lands here on the next wake).
177
+ // `sweepAndReleaseVolume` destroys EVERY generation in this home's name family — including
178
+ // an unpointed leftover a crashed roll left holding the volume, which the old point-in-time
179
+ // attachedTo read could miss once Blaxel autonomously detached it on idle→STANDBY — then
180
+ // polls until the volume is confirmed free before minting a fresh name and creating.
181
+ await this.sweepAndReleaseVolume(current.providerVolumeId, current.homeId);
182
+ const { sandboxName, route, machine: created } = await this.createGeneration({
183
+ homeId: current.homeId,
184
+ tenantId: current.tenantId,
185
+ volumeId: current.providerVolumeId,
186
+ bootstrapScript,
187
+ });
188
+ return refreshedPointers(current, this.provider, route, 'running', sandboxName, created);
189
+ }
190
+ /** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
191
+ * point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
192
+ * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). The
193
+ * destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
194
+ * name-family fallback (MAJOR-1) — so after destroying it, read the volume's current holder
195
+ * ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
196
+ * holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
197
+ * never detach on its own, so fail fast with an actionable error instead of silently spinning
198
+ * the detach poll to its timeout. Otherwise poll
199
+ * until the platform reports the volume detached. Safe because at sweep time the replacement
200
+ * does not exist yet (its name is minted after) and any live family member is by construction
201
+ * either the generation being replaced or an uncommitted leftover — the home's durable state
202
+ * is the volume, never a sandbox; one wake per home runs at a time. */
203
+ async sweepAndReleaseVolume(volumeId, homeId) {
204
+ // Scope the list to sandboxes Hearth tagged for THIS home (externalId = volumeId), then keep
205
+ // the name-family matcher as an AND-intersection on top (orphan-cleanup-fix MAJOR-1). The
206
+ // destroy set requires BOTH keys to agree — the tag says "Hearth created this for home X",
207
+ // the name says "it is in X's generation family" — so a single fault (a mistagged create, a
208
+ // future API silently ignoring the filter param) cannot widen the blast radius back onto a
209
+ // foreign home. Fail-safe defense-in-depth on a destructive op, NOT a lenient fallback.
210
+ const inFamily = generationFamilyMatcher(volumeId);
211
+ const doomed = (await this.provider.listMachines({ externalId: volumeId })).map((m) => m.machineId).filter(inFamily);
212
+ for (const name of doomed)
213
+ await this.provider.destroy(name); // sequential; destroy tolerates not_found
214
+ if (doomed.length > 0)
215
+ emitMarker('hearth.recreate.sweep', { home_id: homeId, volume_id: volumeId, destroyed: doomed });
216
+ // Loud guard: the sweep above is strictly tag-scoped, so it can only ever destroy a sandbox
217
+ // that was created with `externalId = volumeId`. Read the volume's current holder ONCE,
218
+ // before the detach poll, to catch the case that sweep is structurally unable to fix — a
219
+ // holder that is NOT one of the sandboxes just destroyed (`doomed`) will never detach, and
220
+ // the poll below would spin the full VOLUME_DETACH_TIMEOUT_MS only to throw the same thing
221
+ // less clearly. Fail fast instead, naming the holder so an operator can act on it directly.
222
+ const holder = await this.provider.getVolumeAttachment(volumeId);
223
+ if (holder !== null) {
224
+ const holderName = holder.startsWith('sandbox:') ? holder.slice('sandbox:'.length) : holder;
225
+ if (!doomed.includes(holderName)) {
226
+ throw general(`legacy untagged sandbox ${holderName} holds volume ${volumeId} for home ${homeId} — the tag-scoped sweep cannot reclaim it (no externalId tag); migrate this home (delete the sandbox so a plain wake recreates it tagged) before rolling`, { homeId, volumeId, holder: holderName });
227
+ }
94
228
  }
95
- const route = await this.provider.publishHttpPort(current.providerSandboxId, {
96
- machineId: current.providerSandboxId,
97
- targetPort: this.config.webPort,
98
- routeName: stableRouteName(current),
99
- routeId: stableRouteName(current),
229
+ await this.pollVolumeDetached(volumeId, homeId);
230
+ }
231
+ /** Poll the volume until the platform reports it detached — the create-precondition gate
232
+ * (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
233
+ * whenever it actually had to wait, so the re-proof can quantify headroom against the budget. */
234
+ async pollVolumeDetached(volumeId, homeId) {
235
+ const pollStartedAt = Date.now();
236
+ const deadline = pollStartedAt + VOLUME_DETACH_TIMEOUT_MS;
237
+ let waited = false;
238
+ for (;;) {
239
+ const attached = await this.provider.getVolumeAttachment(volumeId);
240
+ if (attached === null)
241
+ break;
242
+ waited = true;
243
+ if (Date.now() >= deadline) {
244
+ throw general(`volume ${volumeId} still attached to ${attached} after ${VOLUME_DETACH_TIMEOUT_MS}ms — cannot recreate home ${homeId}`, {
245
+ homeId,
246
+ volumeId,
247
+ attached,
248
+ });
249
+ }
250
+ await delay(VOLUME_DETACH_POLL_MS);
251
+ }
252
+ if (waited) {
253
+ emitMarker('hearth.recreate.volume_wait', { home_id: homeId, volume_id: volumeId, waited_ms: Date.now() - pollStartedAt });
254
+ }
255
+ }
256
+ /** Create one fresh generation and bring it to a serving state (create → bootstrap exec →
257
+ * waitForPort → publish → getMachine), with provider self-cleanup and a single bounded
258
+ * retry (orphan-cleanup-fix.md §3.4/§4). Each attempt destroys the generation IT created if
259
+ * anything from `createMachine` through the final `getMachine` fails — no code path exits
260
+ * leaving a live generation it created and did not return — then rethrows the ORIGINAL error
261
+ * (cleanup never masks it). On the first failure, if the error is a genuine server-side
262
+ * deployment transient, pause, re-run ONLY the volume-free poll (the sweep already ran once),
263
+ * mint a NEW name, and retry exactly once; a second failure throws. The sweep is the caller's
264
+ * responsibility and runs before the first attempt only. */
265
+ async createGeneration(args) {
266
+ const attempt = async (sandboxName) => {
267
+ try {
268
+ await this.provider.createMachine({
269
+ tenantId: args.tenantId,
270
+ name: sandboxName,
271
+ // Tag EVERY generation (first-name and retry-name alike) with the home's stable volume
272
+ // id so the sweep's filtered list only ever sees this home's family (MAJOR-1).
273
+ externalId: args.volumeId,
274
+ ...(args.image === undefined ? {} : { image: args.image }),
275
+ envs: guestEnv(this.config),
276
+ volumeMount: { volumeName: args.volumeId, path: this.config.guestHome },
277
+ ports: [{ target: this.config.webPort, protocol: 'HTTP' }],
278
+ });
279
+ await this.provider.exec(sandboxName, { command: args.bootstrapScript, background: true, cwd: this.config.guestHome });
280
+ await this.provider.waitForPort(sandboxName, this.config.webPort, { maxWaitMs: 60_000 });
281
+ const route = await this.provider.publishHttpPort(sandboxName, {
282
+ machineId: sandboxName,
283
+ targetPort: this.config.webPort,
284
+ routeName: stableRouteName(sandboxName),
285
+ routeId: stableRouteName(sandboxName),
286
+ });
287
+ return { route, machine: await this.getMachine(sandboxName) };
288
+ }
289
+ catch (error) {
290
+ // §3.4 self-cleanup: destroy the generation this attempt created; never mask the original.
291
+ await this.provider
292
+ .destroy(sandboxName)
293
+ .catch((cleanupErr) => emitMarker('hearth.recreate.cleanup_failed', { home_id: args.homeId, sandbox: sandboxName, error: serialize(cleanupErr) }, 'warn'));
294
+ throw error;
295
+ }
296
+ };
297
+ const firstName = mintSandboxName(args.volumeId);
298
+ try {
299
+ return { sandboxName: firstName, ...(await attempt(firstName)) };
300
+ }
301
+ catch (error) {
302
+ if (!isRetryableDeploymentError(error))
303
+ throw error;
304
+ emitMarker('hearth.recreate.create_retry', { home_id: args.homeId, discarded_generation: firstName, error: serialize(error) }, 'warn');
305
+ await delay(CREATE_RETRY_DELAY_MS);
306
+ // Re-run ONLY the poll — NOT a re-sweep. This sits outside attempt()'s try/catch, so a
307
+ // poll-timeout here would otherwise mask the ORIGINAL deployment error (MINOR-3). The
308
+ // original is what the caller must see (it is already recorded in the create_retry
309
+ // marker above); on a poll failure during the retry path, rethrow it, not the timeout.
310
+ try {
311
+ await this.pollVolumeDetached(args.volumeId, args.homeId);
312
+ }
313
+ catch {
314
+ throw error;
315
+ }
316
+ const secondName = mintSandboxName(args.volumeId);
317
+ return { sandboxName: secondName, ...(await attempt(secondName)) }; // second failure throws (self-cleaned)
318
+ }
319
+ }
320
+ /** Targeted destroy→recreate onto a SPECIFIC image (auto-upgrade roll and
321
+ * rollback, design §4/§5). Unlike `wake()`'s standby-resume path, this is the ONE path
322
+ * that force-destroys the home's sandboxes before recreating. Cleanup is a deterministic
323
+ * name-family sweep (orphan-cleanup-fix.md §3.3): every generation of this home is destroyed
324
+ * before the fresh one is minted, so a failed roll-forward generation can never survive as a
325
+ * live orphan even after Blaxel autonomously detaches it from the volume. The recreate lands
326
+ * on a FRESH sandbox name every call — a name that has never existed cannot 409 against a
327
+ * lingering same-name delete. The durable volume is a separate resource and is NEVER touched
328
+ * (flow-D durability) — user state is byte-identical across the flip, which is exactly what
329
+ * makes both the upgrade and the rollback lossless. The new `providerSandboxId` is returned on
330
+ * the refresh; callers persist it (`refreshProviderPointers` treats it as a refreshable
331
+ * pointer). `templateVersion` is baked into the bootstrap's fail-fast version assert (the
332
+ * baked crtr must match) AND reported back so the CP records the target version — the caller
333
+ * still independently asserts `crtr sys version` in-guest before trusting it (wake.ts
334
+ * `rollHome`/`rollbackHome`, against `refresh.providerSandboxId`, never a stale captured
335
+ * name). */
336
+ async recreateOnImage(homeInput, imageRef, templateVersion) {
337
+ const current = ensureReadyHome(homeInput);
338
+ if (imageRef.trim() === '') {
339
+ throw general(`recreateOnImage for home ${current.homeId} requires a non-empty image ref`, { homeId: current.homeId });
340
+ }
341
+ if (templateVersion.trim() === '') {
342
+ throw general(`recreateOnImage for home ${current.homeId} requires a non-empty template version`, { homeId: current.homeId });
343
+ }
344
+ // Sweep the whole name family (destroys the failed roll-forward generation whether or not it
345
+ // still holds the volume — the exact Run-3 orphan gap) then wait until the volume is confirmed
346
+ // detached before minting a fresh name and creating (orphan-cleanup-fix.md §3.3).
347
+ await this.sweepAndReleaseVolume(current.providerVolumeId, current.homeId);
348
+ const bootstrapScript = buildBootstrapScript({
349
+ guestHome: this.config.guestHome,
350
+ crtrVersion: templateVersion,
351
+ relayToken: current.relayToken,
352
+ webPort: this.config.webPort,
353
+ nodeId: current.homeAgentTarget,
354
+ piAuthJson: this.config.piAuthJson,
355
+ piPackages: this.config.piPackages,
356
+ isFirstProvision: false,
357
+ });
358
+ const { sandboxName, route, machine } = await this.createGeneration({
359
+ homeId: current.homeId,
360
+ tenantId: current.tenantId,
361
+ volumeId: current.providerVolumeId,
362
+ bootstrapScript,
363
+ image: imageRef,
100
364
  });
101
- return refreshedPointers(current, this.provider, route, 'running', await this.getMachine(current.providerSandboxId));
365
+ // Report the TARGET version, not the provider's construction-time value:
366
+ // the sandbox was recreated on `imageRef`, whose baked crtr is `templateVersion`.
367
+ return {
368
+ ...refreshedPointers(current, this.provider, route, 'running', sandboxName, machine),
369
+ templateVersion,
370
+ };
102
371
  }
103
372
  async suspend(homeInput) {
104
373
  const current = ensureReadyHome(homeInput);
@@ -121,7 +390,7 @@ export class BlaxelHomeBackend {
121
390
  providerSandboxId: current.providerSandboxId,
122
391
  });
123
392
  }
124
- return refreshedPointers(current, this.provider, null, machine.state, machine);
393
+ return refreshedPointers(current, this.provider, null, machine.state, current.providerSandboxId, machine);
125
394
  }
126
395
  async getMachine(machineId) {
127
396
  const machine = await this.provider.getMachine(machineId);
@@ -7,6 +7,7 @@ interface BlaxelMachineProviderOptions {
7
7
  templateVersion?: string;
8
8
  volumeSizeGb?: number;
9
9
  }
10
+ export declare function isMissingResourceError(error: unknown): boolean;
10
11
  export declare class BlaxelMachineProvider implements MachineProvider {
11
12
  readonly providerName: "blaxel";
12
13
  readonly imageRef: string;
@@ -18,6 +19,9 @@ export declare class BlaxelMachineProvider implements MachineProvider {
18
19
  name: string;
19
20
  }): Promise<VolumeRef>;
20
21
  createMachine(input: CreateMachineInput): Promise<MachineRef>;
22
+ listMachines(filter?: {
23
+ externalId?: string;
24
+ }): Promise<MachineRef[]>;
21
25
  getMachine(machineId: string): Promise<MachineRef>;
22
26
  exec(machineId: string, input: ExecInput): Promise<ExecResult>;
23
27
  waitForPort(machineId: string, port: number, opts?: {
@@ -27,5 +31,6 @@ export declare class BlaxelMachineProvider implements MachineProvider {
27
31
  health(): Promise<HealthResult>;
28
32
  destroy(machineId: string): Promise<DestroyOutcome>;
29
33
  destroyVolume(volumeId: string): Promise<DestroyOutcome>;
34
+ getVolumeAttachment(volumeId: string): Promise<string | null>;
30
35
  }
31
36
  export {};
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
- import { SandboxInstance, VolumeInstance } from '@blaxel/core';
3
+ import { SandboxInstance, VolumeInstance, getVolume, listSandboxes } from '@blaxel/core';
4
4
  import { general } from '../../errors.js';
5
5
  const TERMINATED_STATES = new Set(['TERMINATED', 'DELETING', 'DEACTIVATED', 'DEACTIVATING', 'FAILED']);
6
6
  const PROVISIONING_STATES = new Set(['UPLOADING', 'BUILDING', 'DEPLOYING', 'BUILT']);
@@ -81,10 +81,28 @@ function resolveExecCommand(input) {
81
81
  }
82
82
  return argv.map(shellQuote).join(' ');
83
83
  }
84
- function isMissingResourceError(error) {
85
- const message = error instanceof Error ? error.message : String(error);
84
+ // The Blaxel SDK (@blaxel/core 0.2.94) does not always reject with an `Error` instance — a 404
85
+ // comes back as a plain object (e.g. `{code:404,error:"Sandbox not found"}`), and `String()` on
86
+ // such an object yields the useless literal "[object Object]", which matches none of the regex
87
+ // below. Recognize the structured shape directly (a numeric-or-string `404` status field) AND
88
+ // fall back to JSON-serializing the value (mirroring wake.ts's `serializeError`) before running
89
+ // the message regex, so both an `Error("... not found")` and a plain-object 404 are caught.
90
+ export function isMissingResourceError(error) {
91
+ const fields = record(error);
92
+ if (fields !== null) {
93
+ for (const key of ['code', 'status', 'status_code', 'statusCode']) {
94
+ const value = fields[key];
95
+ if (value === 404 || value === '404')
96
+ return true;
97
+ }
98
+ }
99
+ const message = error instanceof Error ? error.message : (JSON.stringify(error) ?? String(error));
86
100
  return /\b(not found|404|does not exist|missing)\b/i.test(message);
87
101
  }
102
+ // Accepts either a live `SandboxInstance` or a raw wire `Sandbox` page item — only the
103
+ // `metadata`/`status`/`spec` fields are read, so mapping raw list pages avoids constructing
104
+ // `SandboxInstance`s (whose `list()` eagerly warms an H2 session per sandbox — pointless for a
105
+ // destroy sweep).
88
106
  function toMachineRef(sandbox) {
89
107
  const metadata = sandbox.metadata;
90
108
  const status = sandbox.status ?? undefined;
@@ -142,7 +160,16 @@ export class BlaxelMachineProvider {
142
160
  async createMachine(input) {
143
161
  const sandbox = await SandboxInstance.createIfNotExists({
144
162
  name: input.name ?? input.tenantId,
145
- image: this.config.image,
163
+ // Tag every sandbox with the home's stable volume id so the recreate sweep can scope its
164
+ // destructive list to this home's family only (orphan-cleanup-fix MAJOR-1). Unset on
165
+ // callers that pass no externalId.
166
+ ...(input.externalId === undefined ? {} : { externalId: input.externalId }),
167
+ // A targeted recreate (auto-upgrade roll / rollback, design §4.1) passes
168
+ // an explicit `image`; the normal provision/wake path leaves it unset
169
+ // and uses the provider's construction-time config image. `createIfNotExists`
170
+ // only honors this on genuine creation — the roll path destroys the old
171
+ // sandbox first so the name is free (blaxel-home.ts `recreateOnImage`).
172
+ image: input.image ?? this.config.image,
146
173
  memory: this.config.memory,
147
174
  region: this.config.region,
148
175
  volumes: input.volumeMount === undefined || input.volumeMount === null ? undefined : [{ name: input.volumeMount.volumeName, mountPath: input.volumeMount.path, readOnly: false }],
@@ -158,6 +185,38 @@ export class BlaxelMachineProvider {
158
185
  await sandbox.wait({ maxWait: 90_000, interval: 500 }).catch(() => sandbox);
159
186
  return toMachineRef(sandbox);
160
187
  }
188
+ // orphan-cleanup-fix.md §3.2 + MAJOR-1/MINOR-1: the deterministic name-family sweep enumerates
189
+ // a home's generations via LIST scoped to the caller-owned `externalId` tag, then destroys the
190
+ // ones whose name is in the family. Over-listing is harmless — a FAILED/detached record still
191
+ // shows up here and destroy tolerates not_found, which is exactly what closes the autonomous-
192
+ // standby-detach orphan gap. Drives the generated `listSandboxes` client directly (bypassing
193
+ // `SandboxInstance.list()`, which never follows a cursor and warms an H2 session per sandbox):
194
+ // loops until exhausted so a future SDK version crossing Blaxel-Version 2026-04-28 (cursor
195
+ // pagination, 50/page) cannot silently truncate the sweep to page 1. At the SDK's pinned
196
+ // 2026-04-16 the server returns a bare array (the full set) and the loop breaks on the first
197
+ // page.
198
+ async listMachines(filter) {
199
+ const machines = [];
200
+ let cursor;
201
+ for (;;) {
202
+ const { data: raw } = await listSandboxes({
203
+ query: {
204
+ limit: 200,
205
+ ...(cursor === undefined ? {} : { cursor }),
206
+ ...(filter?.externalId === undefined ? {} : { externalId: filter.externalId }),
207
+ },
208
+ throwOnError: true,
209
+ });
210
+ const page = Array.isArray(raw) ? raw : (raw?.data ?? []);
211
+ machines.push(...page.map(toMachineRef));
212
+ if (Array.isArray(raw))
213
+ break; // legacy bare array (Blaxel-Version < 2026-04-28) = full set
214
+ cursor = raw?.meta?.nextCursor || undefined; // empty when there are no more pages
215
+ if (cursor === undefined)
216
+ break;
217
+ }
218
+ return machines;
219
+ }
161
220
  async getMachine(machineId) {
162
221
  try {
163
222
  const sandbox = await SandboxInstance.get(machineId);
@@ -279,4 +338,27 @@ export class BlaxelMachineProvider {
279
338
  throw error;
280
339
  }
281
340
  }
341
+ // recreate-race-fix.md §3.1: read the platform's own positive attachment signal
342
+ // (`Volume.state.attachedTo`) rather than inferring detach from a sandbox GET/delete —
343
+ // that read/create consistency split is exactly what raced the old same-name recreate.
344
+ // `VolumeInstance.get`'s backing `volume` field is TS-private (no public state getter
345
+ // in @blaxel/core 0.2.94), so this calls the underlying `getVolume` SDK function
346
+ // directly instead — same request `VolumeInstance.get` makes, with `state` exposed.
347
+ // A 404 (or any other failure) throws, wrapped with the volumeId for context: a missing
348
+ // home volume is a real incident, never something to continue past (volume-is-sacred).
349
+ async getVolumeAttachment(volumeId) {
350
+ let attachedTo;
351
+ try {
352
+ const { data } = await getVolume({ path: { volumeName: volumeId }, throwOnError: true });
353
+ attachedTo = data.state?.attachedTo;
354
+ }
355
+ catch (error) {
356
+ throw general(`blaxel volume ${volumeId} attachment check failed`, {
357
+ volumeId,
358
+ cause: error instanceof Error ? error.message : String(error),
359
+ });
360
+ }
361
+ const trimmed = (attachedTo ?? '').trim();
362
+ return trimmed === '' ? null : trimmed;
363
+ }
282
364
  }
@@ -22,10 +22,12 @@ export interface VolumeRef {
22
22
  }
23
23
  export interface CreateMachineInput {
24
24
  tenantId: string;
25
+ externalId?: string;
25
26
  template?: string | null;
26
27
  envs?: Record<string, string>;
27
28
  enableNetwork?: boolean;
28
29
  name?: string | null;
30
+ image?: string | null;
29
31
  ports?: {
30
32
  target: number;
31
33
  protocol: 'HTTP';
@@ -65,6 +67,16 @@ export interface MachineProvider {
65
67
  }): Promise<VolumeRef>;
66
68
  createMachine(input: CreateMachineInput): Promise<MachineRef>;
67
69
  getMachine(machineId: MachineId): Promise<MachineRef>;
70
+ /** List machine records, optionally scoped to a caller-owned `externalId` tag. The recreate
71
+ * cleanup sweep passes the home's `providerVolumeId` as the filter so only sandboxes Hearth
72
+ * itself tagged for this home come back, then intersects that with the generation NAME FAMILY
73
+ * matcher — a tag∩name intersection on a destructive op, so no id-format or workspace name
74
+ * discipline is load-bearing for other homes' safety (orphan-cleanup-fix MAJOR-1). The result
75
+ * is NOT a promise of every machine in the workspace; without a filter it lists broadly, with
76
+ * one it lists only tagged matches. Implementations must page until exhausted (MINOR-1). */
77
+ listMachines(filter?: {
78
+ externalId?: string;
79
+ }): Promise<MachineRef[]>;
68
80
  exec(machineId: MachineId, input: ExecInput): Promise<ExecResult>;
69
81
  waitForPort(machineId: MachineId, port: number, opts?: {
70
82
  maxWaitMs?: number;
@@ -73,5 +85,9 @@ export interface MachineProvider {
73
85
  health(): Promise<HealthResult>;
74
86
  destroy(machineId: MachineId): Promise<DestroyOutcome>;
75
87
  destroyVolume(volumeId: string): Promise<DestroyOutcome>;
88
+ /** The resource currently holding this volume ("sandbox:<name>"), or null if detached.
89
+ * A missing volume (404) throws — a home volume disappearing is always an incident,
90
+ * never something to continue past (recreate-race-fix.md §3.1). */
91
+ getVolumeAttachment(volumeId: string): Promise<string | null>;
76
92
  }
77
93
  export type { MachineState, M0Config, RouteRef };
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ // Bug-regression test (independent review of commit 6018050, recreate-race-fix.md §3.6):
4
+ // `JSON.stringify` returns `undefined` (does not throw) for `undefined`, a function, or a
5
+ // symbol — a naive `JSON.stringify(error)` rider would silently hand that `undefined` back to
6
+ // callers. In `relay.ts` that `undefined` message then makes `message.startsWith('relay ')`
7
+ // THROW inside the very catch block meant to handle it (a control-flow regression §3.6
8
+ // promised not to introduce); in `wake.ts` it would OMIT the `error` field from the
9
+ // `hearth.roll.incident` JSON line entirely. Both serializers must fall back to `String`
10
+ // whenever `JSON.stringify` yields `undefined`, not only when it throws.
11
+ test('serializeError (wake.ts) never returns undefined for undefined/function/symbol rejections', async () => {
12
+ const { serializeError } = await import('../wake.js');
13
+ for (const value of [undefined, function unnamed() { }, Symbol('boom')]) {
14
+ const result = serializeError(value);
15
+ assert.equal(typeof result, 'string', `serializeError(${String(value)}) must return a string, never JS \`undefined\``);
16
+ assert.ok(result.length > 0, `serializeError(${String(value)}) must be a non-empty string`);
17
+ }
18
+ });
19
+ test('serializeRelayError (relay.ts) never returns undefined for undefined/function/symbol rejections, and the relay control-flow checks it feeds never throw', async () => {
20
+ const { serializeRelayError } = await import('../relay.js');
21
+ for (const value of [undefined, function unnamed() { }, Symbol('boom')]) {
22
+ const result = serializeRelayError(value);
23
+ assert.equal(typeof result, 'string');
24
+ assert.ok(result.length > 0, `serializeRelayError(${String(value)}) must be a non-empty string`);
25
+ // The exact control flow `proxyHttp`'s catch runs on this value — must not throw.
26
+ assert.doesNotThrow(() => result.startsWith('relay '));
27
+ assert.doesNotThrow(() => /timed out/i.test(result));
28
+ }
29
+ });
@@ -0,0 +1,44 @@
1
+ // Regression test for the P2 review Major fix: a stale, abandoned OAuth
2
+ // marker must not make a later `beginOAuth` on the same home read as
3
+ // non-serving. See serving.ts's `beginOAuth`/`oauthMarkerLive`.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mock } from 'node:test';
7
+ import { beginOAuth, endOAuth, isHomeServing, resetServingForTests } from '../serving.js';
8
+ const HOME = 'home-oauth-marker-test';
9
+ const OAUTH_MARKER_TTL_MS = 5 * 60_000;
10
+ test('a second beginOAuth refreshes a stale marker and stays live until both finishes land', () => {
11
+ resetServingForTests();
12
+ mock.timers.enable({ apis: ['Date'] });
13
+ try {
14
+ // Start A begins an OAuth window; it is immediately serving.
15
+ beginOAuth(HOME);
16
+ assert.equal(isHomeServing(HOME), true, 'start A should mark the home as serving');
17
+ // Start A is abandoned: advance the clock past the 5-minute TTL with no
18
+ // matching endOAuth. The marker goes stale, but the counter (1) is still
19
+ // positive because start A was never finished.
20
+ mock.timers.tick(OAUTH_MARKER_TTL_MS + 1_000);
21
+ assert.equal(isHomeServing(HOME), false, 'a stale marker with no refresh must read as not-serving');
22
+ // Start B is a fresh retry on the same home. Per the fix, beginOAuth
23
+ // refreshes oauthStartedAt on EVERY call, not only when the counter
24
+ // transitions from 0 — so B's own window must read as live immediately.
25
+ beginOAuth(HOME);
26
+ assert.equal(isHomeServing(HOME), true, 'a second beginOAuth must refresh the marker and read as serving');
27
+ // B's window survives well past when A's original (unrefreshed) TTL
28
+ // would have expired, proving the refresh — not the original timestamp
29
+ // — is what's being judged now.
30
+ mock.timers.tick(OAUTH_MARKER_TTL_MS - 1_000);
31
+ assert.equal(isHomeServing(HOME), true, 'B\'s window must still be live before its own TTL elapses');
32
+ // A late finish for start A arrives: counter floors from 2 -> 1. Still
33
+ // serving, because B's own start/finish window has not closed yet.
34
+ endOAuth(HOME);
35
+ assert.equal(isHomeServing(HOME), true, 'one matched finish (of two starts) must not end serving early');
36
+ // B finishes: counter 1 -> 0, marker cleared, no longer serving.
37
+ endOAuth(HOME);
38
+ assert.equal(isHomeServing(HOME), false, 'both starts matched by a finish must end serving');
39
+ }
40
+ finally {
41
+ mock.timers.reset();
42
+ resetServingForTests();
43
+ }
44
+ });