@crouton-kit/crouter 0.3.37 → 0.3.39

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 (143) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/{builtin-personas/runtime-base.md → builtin-memory/00-runtime-base.md} +7 -0
  3. package/dist/{builtin-personas/spine/has-manager.md → builtin-memory/01-spine/00-has-manager.md} +8 -0
  4. package/dist/builtin-memory/01-spine/01-no-manager.md +10 -0
  5. package/dist/{builtin-personas/lifecycle/terminal.md → builtin-memory/02-lifecycle/00-terminal.md} +8 -0
  6. package/dist/{builtin-personas/lifecycle/resident.md → builtin-memory/02-lifecycle/01-resident.md} +8 -0
  7. package/dist/{builtin-personas/waiting.md → builtin-memory/03-waiting.md} +7 -0
  8. package/dist/{builtin-personas/orchestration-kernel.md → builtin-memory/04-orchestration-kernel.md} +8 -0
  9. package/dist/{builtin-personas/advisor/PERSONA.md → builtin-memory/05-kinds/advisor/00-base.md} +5 -2
  10. package/dist/{builtin-personas/design/PERSONA.md → builtin-memory/05-kinds/design/00-base.md} +5 -2
  11. package/dist/{builtin-personas/design/orchestrator.md → builtin-memory/05-kinds/design/01-orchestrator.md} +5 -4
  12. package/dist/{builtin-personas/developer/PERSONA.md → builtin-memory/05-kinds/developer/00-base.md} +5 -2
  13. package/dist/{builtin-personas/developer/orchestrator.md → builtin-memory/05-kinds/developer/01-orchestrator.md} +5 -4
  14. package/dist/{builtin-personas/explore/PERSONA.md → builtin-memory/05-kinds/explore/00-base.md} +5 -2
  15. package/dist/{builtin-personas/explore/orchestrator.md → builtin-memory/05-kinds/explore/01-orchestrator.md} +5 -3
  16. package/dist/{builtin-personas/general/PERSONA.md → builtin-memory/05-kinds/general/00-base.md} +5 -2
  17. package/dist/{builtin-personas/general/orchestrator.md → builtin-memory/05-kinds/general/01-orchestrator.md} +5 -3
  18. package/dist/{builtin-personas/plan/PERSONA.md → builtin-memory/05-kinds/plan/00-base.md} +5 -2
  19. package/dist/{builtin-personas/plan/orchestrator.md → builtin-memory/05-kinds/plan/01-orchestrator.md} +5 -4
  20. package/dist/{builtin-personas/plan/reviewers/architecture-fit/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/architecture-fit.md} +5 -2
  21. package/dist/{builtin-personas/plan/reviewers/code-smells/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/code-smells.md} +5 -2
  22. package/dist/{builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md} +5 -2
  23. package/dist/{builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md} +5 -2
  24. package/dist/{builtin-personas/plan/reviewers/security/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/security.md} +5 -2
  25. package/dist/{builtin-personas/product/PERSONA.md → builtin-memory/05-kinds/product/00-base.md} +5 -2
  26. package/dist/{builtin-personas/product/orchestrator.md → builtin-memory/05-kinds/product/01-orchestrator.md} +5 -4
  27. package/dist/{builtin-personas/product/teardown/PERSONA.md → builtin-memory/05-kinds/product/teardown.md} +5 -3
  28. package/dist/{builtin-personas/review/PERSONA.md → builtin-memory/05-kinds/review/00-base.md} +5 -2
  29. package/dist/{builtin-personas/review/orchestrator.md → builtin-memory/05-kinds/review/01-orchestrator.md} +5 -3
  30. package/dist/{builtin-personas/spec/PERSONA.md → builtin-memory/05-kinds/spec/00-base.md} +5 -2
  31. package/dist/{builtin-personas/spec/orchestrator.md → builtin-memory/05-kinds/spec/01-orchestrator.md} +5 -4
  32. package/dist/{builtin-personas/spec/requirements/PERSONA.md → builtin-memory/05-kinds/spec/requirements.md} +5 -2
  33. package/dist/builtin-memory/internal/INDEX.md +1 -1
  34. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  35. package/dist/builtin-views/prompt-review/core.mjs +6 -134
  36. package/dist/builtin-views/settings/core.mjs +26 -94
  37. package/dist/builtin-views/settings/text.mjs +0 -5
  38. package/dist/builtin-views/settings/tui.mjs +1 -7
  39. package/dist/builtin-views/settings/web.jsx +3 -49
  40. package/dist/clients/attach/attach-cmd.js +416 -416
  41. package/dist/commands/memory/read.js +3 -3
  42. package/dist/commands/memory/shared.d.ts +13 -8
  43. package/dist/commands/memory/shared.js +22 -8
  44. package/dist/commands/memory/write.js +5 -3
  45. package/dist/commands/node.js +40 -11
  46. package/dist/commands/profile/add-project.d.ts +1 -0
  47. package/dist/commands/profile/add-project.js +42 -0
  48. package/dist/commands/profile/delete.d.ts +1 -0
  49. package/dist/commands/profile/delete.js +39 -0
  50. package/dist/commands/profile/list.d.ts +1 -0
  51. package/dist/commands/profile/list.js +35 -0
  52. package/dist/commands/profile/new.d.ts +1 -0
  53. package/dist/commands/profile/new.js +50 -0
  54. package/dist/commands/profile/remove-project.d.ts +1 -0
  55. package/dist/commands/profile/remove-project.js +42 -0
  56. package/dist/commands/profile/rename.d.ts +1 -0
  57. package/dist/commands/profile/rename.js +42 -0
  58. package/dist/commands/profile/show.d.ts +1 -0
  59. package/dist/commands/profile/show.js +50 -0
  60. package/dist/commands/profile.d.ts +2 -0
  61. package/dist/commands/profile.js +33 -0
  62. package/dist/commands/sys/config.js +6 -21
  63. package/dist/commands/sys/prompt-review.js +90 -17
  64. package/dist/core/canvas/canvas.js +10 -9
  65. package/dist/core/canvas/db.js +11 -0
  66. package/dist/core/canvas/history.js +1 -0
  67. package/dist/core/canvas/types.d.ts +10 -0
  68. package/dist/core/config.d.ts +44 -1
  69. package/dist/core/config.js +155 -12
  70. package/dist/core/hearth/provider.d.ts +8 -0
  71. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.d.ts +1 -0
  72. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +254 -0
  73. package/dist/core/hearth/providers/blaxel-home.d.ts +39 -0
  74. package/dist/core/hearth/providers/blaxel-home.js +271 -21
  75. package/dist/core/hearth/providers/blaxel.d.ts +5 -0
  76. package/dist/core/hearth/providers/blaxel.js +86 -4
  77. package/dist/core/hearth/providers/types.d.ts +16 -0
  78. package/dist/core/memory-resolver.d.ts +26 -15
  79. package/dist/core/memory-resolver.js +158 -40
  80. package/dist/core/profiles/manifest.d.ts +33 -0
  81. package/dist/core/profiles/manifest.js +332 -0
  82. package/dist/core/profiles/select.d.ts +11 -0
  83. package/dist/core/profiles/select.js +115 -0
  84. package/dist/core/runtime/bearings.d.ts +9 -0
  85. package/dist/core/runtime/bearings.js +31 -0
  86. package/dist/core/runtime/front-door.js +9 -2
  87. package/dist/core/runtime/launch.d.ts +25 -7
  88. package/dist/core/runtime/launch.js +42 -55
  89. package/dist/core/runtime/nodes.d.ts +7 -0
  90. package/dist/core/runtime/nodes.js +6 -0
  91. package/dist/core/runtime/persona.js +18 -32
  92. package/dist/core/runtime/spawn.d.ts +13 -0
  93. package/dist/core/runtime/spawn.js +25 -0
  94. package/dist/core/scope.d.ts +14 -7
  95. package/dist/core/scope.js +76 -20
  96. package/dist/core/substrate/index.d.ts +3 -3
  97. package/dist/core/substrate/index.js +5 -3
  98. package/dist/core/substrate/on-read.js +3 -2
  99. package/dist/core/substrate/render.d.ts +16 -11
  100. package/dist/core/substrate/render.js +249 -62
  101. package/dist/core/substrate/schema.d.ts +11 -3
  102. package/dist/core/substrate/schema.js +25 -0
  103. package/dist/core/substrate/subject.d.ts +15 -2
  104. package/dist/core/substrate/subject.js +24 -2
  105. package/dist/hearth/control-plane/__tests__/error-serialization.test.d.ts +1 -0
  106. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +29 -0
  107. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +1 -0
  108. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +44 -0
  109. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +1 -0
  110. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +230 -0
  111. package/dist/hearth/control-plane/db.js +27 -0
  112. package/dist/hearth/control-plane/hearth-target.d.ts +23 -0
  113. package/dist/hearth/control-plane/hearth-target.js +68 -0
  114. package/dist/hearth/control-plane/registry.d.ts +6 -1
  115. package/dist/hearth/control-plane/registry.js +7 -0
  116. package/dist/hearth/control-plane/relay.d.ts +4 -0
  117. package/dist/hearth/control-plane/relay.js +72 -3
  118. package/dist/hearth/control-plane/secrets.d.ts +14 -0
  119. package/dist/hearth/control-plane/secrets.js +21 -0
  120. package/dist/hearth/control-plane/server.js +140 -4
  121. package/dist/hearth/control-plane/serving.d.ts +15 -0
  122. package/dist/hearth/control-plane/serving.js +106 -0
  123. package/dist/hearth/control-plane/types.d.ts +20 -0
  124. package/dist/hearth/control-plane/wake.d.ts +8 -2
  125. package/dist/hearth/control-plane/wake.js +241 -3
  126. package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
  127. package/dist/types.d.ts +57 -1
  128. package/dist/types.js +83 -1
  129. package/dist/web-client/assets/{index-MSKSWwV0.js → index-CbO8L0mN.js} +4 -4
  130. package/dist/web-client/assets/index-DwO46Cs5.css +2 -0
  131. package/dist/web-client/index.html +2 -2
  132. package/package.json +2 -2
  133. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +0 -56
  134. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +0 -57
  135. package/dist/builtin-memory/crouter-development/personas.md +0 -112
  136. package/dist/builtin-personas/spine/no-manager.md +0 -2
  137. package/dist/core/personas/index.d.ts +0 -13
  138. package/dist/core/personas/index.js +0 -11
  139. package/dist/core/personas/loader.d.ts +0 -159
  140. package/dist/core/personas/loader.js +0 -327
  141. package/dist/core/personas/resolve.d.ts +0 -59
  142. package/dist/core/personas/resolve.js +0 -376
  143. package/dist/web-client/assets/index-DUThOUzU.css +0 -2
@@ -1,7 +1,8 @@
1
1
  import { join } from 'node:path';
2
- import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultCanvasNavConfig, defaultModelLaddersConfig } from '../types.js';
2
+ import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultCanvasNavConfig, defaultModelLaddersConfig, defaultKindsConfig } from '../types.js';
3
3
  import { readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
4
- import { scopeRoot, requireScopeRoot } from './scope.js';
4
+ import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
5
+ import { profileRoot } from './profiles/manifest.js';
5
6
  function configPathFor(root) {
6
7
  return join(root, CONFIG_FILE);
7
8
  }
@@ -150,14 +151,15 @@ function mergeCanvasNav(raw) {
150
151
  graphBinds: mergeBinds(defaults.graphBinds, r.graphBinds, normalizeGraphBind),
151
152
  };
152
153
  }
153
- function mergeModelLadders(raw) {
154
- const defaults = defaultModelLaddersConfig();
154
+ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
155
+ const defaults = base;
155
156
  if (raw === null || typeof raw !== 'object')
156
157
  return defaults;
157
158
  const r = raw;
158
159
  const out = {
159
160
  anthropic: { ...defaults.anthropic },
160
161
  openai: { ...defaults.openai },
162
+ ...(defaults.defaultProvider !== undefined ? { defaultProvider: defaults.defaultProvider } : {}),
161
163
  };
162
164
  if (r.defaultProvider === 'anthropic' || r.defaultProvider === 'openai') {
163
165
  out.defaultProvider = r.defaultProvider;
@@ -174,13 +176,42 @@ function mergeModelLadders(raw) {
174
176
  }
175
177
  return out;
176
178
  }
177
- function mergePersonaStrengths(raw) {
178
- const out = {};
179
+ /** Validate one raw `kinds.<name>` entry into a `KindConfig`, or drop it
180
+ * (return null) rather than throwing — an invalid kind entry in config.json
181
+ * must not break config parsing for every other kind. `whenToUse` is the
182
+ * only required field. */
183
+ function normalizeKindEntry(raw) {
179
184
  if (raw === null || typeof raw !== 'object')
180
- return out;
181
- for (const [persona, value] of Object.entries(raw)) {
182
- if (value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light') {
183
- out[persona] = value;
185
+ return null;
186
+ const r = raw;
187
+ if (typeof r.whenToUse !== 'string' || r.whenToUse.trim() === '')
188
+ return null;
189
+ const out = { whenToUse: r.whenToUse };
190
+ if (typeof r.model === 'string' && r.model.trim() !== '')
191
+ out.model = r.model;
192
+ if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
193
+ out.orchestratorModel = r.orchestratorModel;
194
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
195
+ if (isStringArray(r.tools))
196
+ out.tools = r.tools;
197
+ if (isStringArray(r.extensions))
198
+ out.extensions = r.extensions;
199
+ if (isStringArray(r.availableTo))
200
+ out.availableTo = r.availableTo;
201
+ return out;
202
+ }
203
+ /** Merge a raw `kinds` block over `base` (defaulting to the builtin registry):
204
+ * each valid entry adds or shadows a kind by name; invalid entries are
205
+ * dropped, never thrown. Mirrors `mergeModelLadders`'s layer-over-defaults
206
+ * shape so a user/project config.json can add or override a single kind
207
+ * without restating the whole registry. */
208
+ function mergeKinds(raw, base = defaultKindsConfig()) {
209
+ const out = { ...base };
210
+ if (raw !== null && typeof raw === 'object') {
211
+ for (const [kind, value] of Object.entries(raw)) {
212
+ const normalized = normalizeKindEntry(value);
213
+ if (normalized !== null)
214
+ out[kind] = normalized;
184
215
  }
185
216
  }
186
217
  return out;
@@ -215,8 +246,120 @@ function mergeConfig(partial) {
215
246
  : defaults.max_panes_per_window;
216
247
  const canvasNav = mergeCanvasNav(partial.canvasNav);
217
248
  const modelLadders = mergeModelLadders(partial.modelLadders);
218
- const personaStrengths = mergePersonaStrengths(partial.personaStrengths);
219
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, modelLadders, personaStrengths };
249
+ const kinds = mergeKinds(partial.kinds);
250
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, modelLadders, kinds };
251
+ }
252
+ /** Raw (un-defaulted) partial config for one scope, or null if the scope has
253
+ * no root or no config.json. Used by `readMergedLaunchConfig` to layer
254
+ * scopes onto each other WITHOUT each scope's own default-fill masking a
255
+ * lower-precedence scope's real customization (see that function's comment
256
+ * for why `readConfig(scope)`, which already defaults-fills, is unusable
257
+ * for cross-scope layering). */
258
+ function readRawScopeConfig(scope) {
259
+ const root = scopeRoot(scope);
260
+ if (!root)
261
+ return null;
262
+ return readJsonIfExists(configPathFor(root));
263
+ }
264
+ /** Raw partial config at `<profileRoot(profileId)>/config.json`, or null when
265
+ * no profile is selected, the profile id no longer resolves (deleted/
266
+ * invalid), or it has no config.json. `profileRoot` centralizes the id-shape
267
+ * + containment checks (never a raw path join), and any resolution failure
268
+ * is swallowed here — launch config resolution is a hot path that must never
269
+ * throw on a stale `CRTR_PROFILE_ID`. */
270
+ function readRawProfileConfig(profileId) {
271
+ if (profileId === null || profileId === '')
272
+ return null;
273
+ try {
274
+ return readJsonIfExists(configPathFor(profileRoot(profileId)));
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ /** Raw partial configs for the whole project-scope STACK (`findProjectScopeRoots`
281
+ * — every ancestor `.crouter/`, widened by a selected profile's `projects`),
282
+ * ordered FARTHEST-first so `readMergedLaunchConfig` can layer them on last
283
+ * and have the NEAREST root win — mirroring the resolver's nearest-project-
284
+ * strongest replacement precedence. */
285
+ function readRawProjectScopeConfigs() {
286
+ const nearestFirst = findProjectScopeRoots();
287
+ const farthestFirst = [...nearestFirst].reverse();
288
+ const out = [];
289
+ for (const root of farthestFirst) {
290
+ const raw = readJsonIfExists(configPathFor(root));
291
+ if (raw !== null)
292
+ out.push(raw);
293
+ }
294
+ return out;
295
+ }
296
+ /** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
297
+ * project stack > profile > user > builtin precedence — the same precedence
298
+ * order used for memory resolution. A kind or ladder cell declared at a
299
+ * more-specific scope shadows the same key from a less-specific scope; the
300
+ * project STACK (`findProjectScopeRoots` — every ancestor `.crouter/`,
301
+ * widened by a selected profile's `projects`) layers nearest-root-strongest;
302
+ * a key no scope declares falls through to the builtin default registry
303
+ * (`defaultScopeConfig().kinds` / `.modelLadders`).
304
+ *
305
+ * Layers RAW (un-defaulted) partial config per scope, not `readConfig(scope)`
306
+ * — `readConfig` already fills in every default kind for a scope that
307
+ * customizes even one, so naively overlaying two already-defaulted
308
+ * `ScopeConfig.kinds` objects would let an untouched project-scope kind
309
+ * (silently defaulted) clobber a real user-scope customization of that same
310
+ * kind. Layering the raw partials avoids that.
311
+ *
312
+ * Callers (launch, kind registry) go through this function rather than
313
+ * `readConfig` directly, so `ScopeConfig.kinds`/`modelLadders`/tools/
314
+ * extensions/`availableTo` all honor the same profile + multi-project
315
+ * precedence. */
316
+ export function readMergedLaunchConfig() {
317
+ const defaults = defaultScopeConfig();
318
+ const userRaw = readRawScopeConfig('user');
319
+ const profileRaw = readRawProfileConfig(process.env['CRTR_PROFILE_ID'] || null);
320
+ const projectRawsFarthestFirst = readRawProjectScopeConfigs();
321
+ let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
322
+ let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
323
+ if (profileRaw !== null) {
324
+ kinds = mergeKinds(profileRaw.kinds, kinds);
325
+ modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
326
+ }
327
+ for (const raw of projectRawsFarthestFirst) {
328
+ kinds = mergeKinds(raw.kinds, kinds);
329
+ modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
330
+ }
331
+ return { kinds, modelLadders };
332
+ }
333
+ /** The effective `KindConfig` for one full kind string (top-level, e.g.
334
+ * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
335
+ * project > user > builtin precedence. Returns `undefined` for a kind no
336
+ * scope registers — existence is deliberately NOT validated here (kind
337
+ * existence/launch-menu enumeration is a Phase 3 caller concern); this only
338
+ * resolves the config for a kind the caller already knows about. */
339
+ export function resolveKindConfig(kind) {
340
+ return readMergedLaunchConfig().kinds[kind];
341
+ }
342
+ /** The sub-kinds available to spawn FROM a given top-level kind — every
343
+ * registered sub-kind (full path contains `/`) whose `availableTo` (default:
344
+ * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
345
+ * `['plan']`) includes `kind` or `'*'`. The single source both `sys
346
+ * prompt-review --list`'s `subPersonas` metadata and the live sub-persona
347
+ * spawn-menu splice (`core/substrate/render.ts`) read, so the two can never
348
+ * drift apart. Sorted by full kind name for stable rendering. */
349
+ export function subKindsAvailableTo(kind) {
350
+ const registry = readMergedLaunchConfig().kinds;
351
+ const out = [];
352
+ for (const [full, cfg] of Object.entries(registry)) {
353
+ const slash = full.indexOf('/');
354
+ if (slash === -1)
355
+ continue;
356
+ const topAncestor = full.slice(0, slash);
357
+ const availableTo = cfg.availableTo ?? [topAncestor];
358
+ if (!(availableTo.includes('*') || availableTo.includes(kind)))
359
+ continue;
360
+ out.push({ kind: full, whenToUse: cfg.whenToUse });
361
+ }
362
+ return out.sort((a, b) => a.kind.localeCompare(b.kind));
220
363
  }
221
364
  export function updateConfig(scope, mutate) {
222
365
  const cfg = readConfig(scope);
@@ -11,6 +11,14 @@ export interface HomeBackend {
11
11
  * and return refreshed provider pointers for the CP registry to persist.
12
12
  */
13
13
  wake(home: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
14
+ /**
15
+ * Targeted destroy→recreate of a home onto a SPECIFIC image, reattaching the same durable
16
+ * volume (auto-upgrade roll and rollback, design §4/§5). Force-destroys the sandbox first so
17
+ * the recreate genuinely lands on `imageRef` (the volume is never touched), reboots the guest,
18
+ * and reports `templateVersion` for the CP to record. The caller independently asserts the
19
+ * running `crtr sys version` in-guest before trusting the flip.
20
+ */
21
+ recreateOnImage(home: HomeProviderDescriptor, imageRef: string, templateVersion: string): Promise<HomeProviderRefresh>;
14
22
  /** Pause the home and return refreshed provider pointers for the CP registry to persist. */
15
23
  suspend(home: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
16
24
  /** Machine resources (cpu/mem) for status, or null when unavailable. */
@@ -0,0 +1,254 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { BlaxelHomeBackend } from '../blaxel-home.js';
4
+ import { isMissingResourceError } from '../blaxel.js';
5
+ // Regression for the Run-3 orphan gap (orphan-cleanup-fix.md §1/§3.3): cleanup used a
6
+ // point-in-time `attachedTo` read to find "the sandbox holding the volume", but Blaxel
7
+ // autonomously drops that claim on idle→STANDBY, so a failed roll-forward generation that had
8
+ // already been detached was invisible to cleanup and lived on as a billed, route-registered,
9
+ // volume-claiming orphan. The fix destroys the entire generation NAME FAMILY via `listMachines`
10
+ // with ZERO dependence on attachment state — so a family member NOT holding the volume must
11
+ // still be destroyed, and a same-prefixed foreign name must NOT.
12
+ const VOLUME_ID = 'hearth-zt-test-1';
13
+ function makeConfig() {
14
+ return {
15
+ providerName: 'blaxel',
16
+ guestUser: 'agent',
17
+ guestHome: '/home/agent',
18
+ guestCodePath: '/home/agent/code',
19
+ piPackages: [],
20
+ webPort: 8080,
21
+ nodeKind: 'developer',
22
+ imageRef: 'crouter-hearth-home-0-3-35',
23
+ templateVersion: '0.3.35',
24
+ };
25
+ }
26
+ function makeDescriptor() {
27
+ return {
28
+ homeId: 'home-1',
29
+ tenantId: 'tenant-1',
30
+ providerName: 'blaxel',
31
+ providerSandboxId: `${VOLUME_ID}-gold`,
32
+ providerVolumeId: VOLUME_ID,
33
+ homeAgentTarget: 'guest-node-1',
34
+ relayToken: 'relay-token',
35
+ };
36
+ }
37
+ /** A fake provider whose `listMachines` HONORS the `externalId` filter over a tagged inventory:
38
+ * the row-pointed generation, a DETACHED/standby family member NOT holding the volume (the Run-3
39
+ * miss), a FAILED family record (a create that 500'd but left a row, e.g. Blaxel's `gmr…azqr`),
40
+ * and the bare base name — all tagged `externalId = VOLUME_ID`. Plus survivors: a same-prefixed
41
+ * FOREIGN home (`hearth-zt-test-10-…`, numeric-adjacent, spared by the `-g` anchor too); the
42
+ * suffix-extension collision `<base>-gfoo` that IS in the name family but belongs to a DIFFERENT
43
+ * home (tagged with its own volume id) — destroyed by the old name-only sweep, spared now by the
44
+ * tag filter; and a same-name-family-but-UNTAGGED record (a legacy pre-backfill member, no
45
+ * externalId) — excluded by the filter, so it survives as the alerted self-heals orphan class,
46
+ * NOT a collateral destroy. Records every destroy target. */
47
+ function makeFakeProvider(destroyed) {
48
+ const inventory = [
49
+ { ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gold`, state: 'running' }, externalId: VOLUME_ID }, // row pointer
50
+ { ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gdetached`, state: 'running' }, externalId: VOLUME_ID }, // detached standby orphan — the miss
51
+ { ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gfailed`, state: 'terminated' }, externalId: VOLUME_ID }, // FAILED leftover record
52
+ { ref: { providerName: 'blaxel', machineId: VOLUME_ID, state: 'running' }, externalId: VOLUME_ID }, // bare base name
53
+ { ref: { providerName: 'blaxel', machineId: 'hearth-zt-test-10-gxyz', state: 'running' }, externalId: 'hearth-zt-test-10' }, // FOREIGN numeric-adjacent home, must survive
54
+ { ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gfoo`, state: 'running' }, externalId: `${VOLUME_ID}-gfoo` }, // suffix-extension collision, DIFFERENT home — must survive
55
+ { ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gstale`, state: 'running' } }, // same-name-family but UNTAGGED (legacy) — must survive
56
+ { ref: { providerName: 'blaxel', machineId: 'unrelated-sandbox', state: 'running' } }, // unrelated, must survive
57
+ ];
58
+ const fake = {
59
+ providerName: 'blaxel',
60
+ imageRef: 'crouter-hearth-home-0-3-35',
61
+ templateVersion: '0.3.35',
62
+ volumeSizeGb: 4,
63
+ async listMachines(filter) {
64
+ return inventory.filter((e) => filter?.externalId === undefined || e.externalId === filter.externalId).map((e) => e.ref);
65
+ },
66
+ async destroy(machineId) {
67
+ destroyed.push(machineId);
68
+ return { kind: 'destroyed' };
69
+ },
70
+ // Volume already free (Blaxel detached it) — the poll passes on the first read.
71
+ async getVolumeAttachment() {
72
+ return null;
73
+ },
74
+ async createMachine(input) {
75
+ return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
76
+ },
77
+ async exec(_id, _input) {
78
+ return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
79
+ },
80
+ async waitForPort() { },
81
+ async publishHttpPort(machineId, input) {
82
+ return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
83
+ },
84
+ async getMachine(machineId) {
85
+ return { providerName: 'blaxel', machineId, state: 'running' };
86
+ },
87
+ };
88
+ return fake;
89
+ }
90
+ // Regression for the recovery-wake live re-proof (SHA 567cd4f, backfill-recovery-evidence.md §3):
91
+ // the Blaxel SDK (@blaxel/core 0.2.94) rejects a not-found lookup with a PLAIN OBJECT
92
+ // (`{code:404,error:"Sandbox not found"}`), not an `Error` instance. `String()` on that object
93
+ // yields the literal "[object Object]", which matched none of the message regex, so the
94
+ // predicate returned `false` for a genuine 404 — breaking the `getMachine` terminated-machine
95
+ // fallback (the sweep's entry point) AND the `destroy`/`destroyVolume` not_found tolerance the
96
+ // sweep's per-name destroy loop depends on.
97
+ test('isMissingResourceError recognizes the Blaxel SDK plain-object 404 shape', () => {
98
+ assert.equal(isMissingResourceError({ code: 404, error: 'Sandbox not found' }), true, 'plain-object 404 (the exact live shape) must be recognized');
99
+ assert.equal(isMissingResourceError({ code: 500, error: 'boom' }), false, 'an unrelated plain-object error must not be mistaken for not-found');
100
+ });
101
+ test('isMissingResourceError preserves existing Error-message-regex behavior', () => {
102
+ assert.equal(isMissingResourceError(new Error('sandbox hearth-zt-test-1-gxyz not found')), true, 'an Error whose message matches the regex must still be recognized');
103
+ assert.equal(isMissingResourceError(new Error('boom')), false, 'an unrelated Error must not be mistaken for not-found');
104
+ });
105
+ test('sweepAndReleaseVolume (via recreateOnImage) destroys EVERY name-family member — including a detached one — and spares foreign homes', async () => {
106
+ const destroyed = [];
107
+ const backend = new BlaxelHomeBackend(makeConfig(), makeFakeProvider(destroyed));
108
+ const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
109
+ // Both the row-pointed generation AND the detached standby orphan (the exact Run-3 miss) are
110
+ // destroyed, plus the FAILED leftover and the bare base name.
111
+ assert.ok(destroyed.includes(`${VOLUME_ID}-gold`), 'row-pointed generation must be destroyed');
112
+ assert.ok(destroyed.includes(`${VOLUME_ID}-gdetached`), 'DETACHED standby orphan (Run-3 miss) must be destroyed');
113
+ assert.ok(destroyed.includes(`${VOLUME_ID}-gfailed`), 'FAILED leftover record must be destroyed');
114
+ assert.ok(destroyed.includes(VOLUME_ID), 'bare base-name generation must be destroyed');
115
+ // The `-g` anchor keeps a same-prefixed foreign home and unrelated sandboxes safe.
116
+ assert.ok(!destroyed.includes('hearth-zt-test-10-gxyz'), 'foreign home hearth-zt-test-10 must NOT be swept (-g anchor)');
117
+ assert.ok(!destroyed.includes('unrelated-sandbox'), 'unrelated sandbox must NOT be swept');
118
+ // MAJOR-1: the suffix-extension collision (`<base>-gfoo`, a DIFFERENT home) IS in the name
119
+ // family but carries a different externalId, so the tag-scoped list never surfaces it — the
120
+ // old name-only sweep would have collateral-destroyed it. And a same-name-family but UNTAGGED
121
+ // legacy record is likewise excluded by the filter, surviving as the self-heals orphan class.
122
+ assert.ok(!destroyed.includes(`${VOLUME_ID}-gfoo`), 'suffix-extension foreign home <base>-gfoo must NOT be swept (untagged — MAJOR-1)');
123
+ assert.ok(!destroyed.includes(`${VOLUME_ID}-gstale`), 'same-name-family but UNTAGGED record must NOT be swept (tag filter excludes it)');
124
+ // The four tagged family members are destroyed before the fresh generation is created.
125
+ assert.equal(destroyed.length, 4, 'exactly the four tagged family members are destroyed');
126
+ // A fresh `-g` generation is minted and reported on the target version.
127
+ assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
128
+ assert.notEqual(refresh.providerSandboxId, `${VOLUME_ID}-gold`);
129
+ assert.equal(refresh.templateVersion, '0.3.36');
130
+ });
131
+ /** A fake modeling a provider that assembles its filtered result from MULTIPLE cursor pages
132
+ * before returning (MINOR-1). The tagged family spans 3 pages; `listMachines` walks all pages,
133
+ * concatenates, and honors the externalId filter — the same total the real paginated
134
+ * `listSandboxes` loop assembles. Asserts the sweep destroys every tagged member across every
135
+ * page, so a family member beyond page 1 can never survive as an orphan. */
136
+ function makePaginatedFakeProvider(familyIds, destroyed) {
137
+ // Split the tagged family across three pages; sprinkle in a foreign untagged record per page.
138
+ const pages = [[], [], []];
139
+ familyIds.forEach((id, i) => pages[i % 3].push({ ref: { providerName: 'blaxel', machineId: id, state: 'running' }, externalId: VOLUME_ID }));
140
+ pages[0].push({ ref: { providerName: 'blaxel', machineId: 'foreign-a', state: 'running' }, externalId: 'other-home' });
141
+ pages[2].push({ ref: { providerName: 'blaxel', machineId: 'foreign-b', state: 'running' } });
142
+ const fake = {
143
+ providerName: 'blaxel',
144
+ imageRef: 'crouter-hearth-home-0-3-35',
145
+ templateVersion: '0.3.35',
146
+ volumeSizeGb: 4,
147
+ async listMachines(filter) {
148
+ // Assemble across all pages (what the real cursor loop does) then apply the filter.
149
+ return pages.flat().filter((e) => filter?.externalId === undefined || e.externalId === filter.externalId).map((e) => e.ref);
150
+ },
151
+ async destroy(machineId) {
152
+ destroyed.push(machineId);
153
+ return { kind: 'destroyed' };
154
+ },
155
+ async getVolumeAttachment() {
156
+ return null;
157
+ },
158
+ async createMachine(input) {
159
+ return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
160
+ },
161
+ async exec() {
162
+ return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
163
+ },
164
+ async waitForPort() { },
165
+ async publishHttpPort(machineId, input) {
166
+ return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
167
+ },
168
+ async getMachine(machineId) {
169
+ return { providerName: 'blaxel', machineId, state: 'running' };
170
+ },
171
+ };
172
+ return fake;
173
+ }
174
+ test('MINOR-1 pagination: a family served across multiple pages is fully found+destroyed by the sweep', async () => {
175
+ const destroyed = [];
176
+ // A family that would straddle page boundaries: the base plus several `-g` generations.
177
+ const familyIds = [VOLUME_ID, `${VOLUME_ID}-ga`, `${VOLUME_ID}-gb`, `${VOLUME_ID}-gc`, `${VOLUME_ID}-gd`, `${VOLUME_ID}-ge`, `${VOLUME_ID}-gf`];
178
+ const backend = new BlaxelHomeBackend(makeConfig(), makePaginatedFakeProvider(familyIds, destroyed));
179
+ await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
180
+ // Every tagged family member across every page is destroyed — none survives beyond page 1.
181
+ for (const id of familyIds)
182
+ assert.ok(destroyed.includes(id), `family member ${id} (across pages) must be destroyed`);
183
+ assert.equal(destroyed.length, familyIds.length, 'exactly the tagged family is destroyed — foreign records on the same pages survive');
184
+ assert.ok(!destroyed.includes('foreign-a'), 'foreign tagged record must survive');
185
+ assert.ok(!destroyed.includes('foreign-b'), 'foreign untagged record must survive');
186
+ });
187
+ /** A fake whose `createMachine` runs a scripted sequence of outcomes ('ok' | a rejection value)
188
+ * per call, with an empty family (isolating the §4 retry logic from the sweep). Records every
189
+ * destroy so self-cleanup and the discarded generation are observable. */
190
+ function makeCreateScriptedProvider(script, destroyed) {
191
+ let call = 0;
192
+ const fake = {
193
+ providerName: 'blaxel',
194
+ imageRef: 'crouter-hearth-home-0-3-35',
195
+ templateVersion: '0.3.35',
196
+ volumeSizeGb: 4,
197
+ async listMachines() {
198
+ return [];
199
+ },
200
+ async destroy(machineId) {
201
+ destroyed.push(machineId);
202
+ return { kind: 'destroyed' };
203
+ },
204
+ async getVolumeAttachment() {
205
+ return null;
206
+ },
207
+ async createMachine(input) {
208
+ const outcome = script[call++];
209
+ if (outcome !== 'ok')
210
+ throw outcome;
211
+ return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
212
+ },
213
+ async exec() {
214
+ return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
215
+ },
216
+ async waitForPort() { },
217
+ async publishHttpPort(machineId, input) {
218
+ return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
219
+ },
220
+ async getMachine(machineId) {
221
+ return { providerName: 'blaxel', machineId, state: 'running' };
222
+ },
223
+ };
224
+ return fake;
225
+ }
226
+ test('§4 retry: a genuine 5xx DEPLOYMENT_FAILED create is self-cleaned and retried ONCE onto a fresh name', async () => {
227
+ const destroyed = [];
228
+ // First create fails with a server-side deployment transient; the bounded retry then succeeds.
229
+ const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([{ status_code: 500, code: 'DEPLOYMENT_FAILED', message: 'deployment failed, please try again' }, 'ok'], destroyed));
230
+ const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
231
+ // The failed first generation is self-cleaned (destroyed) — no orphan — and exactly one
232
+ // discarded generation was destroyed (empty family means the only destroy is the self-cleanup).
233
+ assert.equal(destroyed.length, 1, 'exactly the failed first generation is self-cleaned');
234
+ // The returned generation is the SECOND minted name, not the discarded one.
235
+ assert.notEqual(refresh.providerSandboxId, destroyed[0]);
236
+ assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
237
+ assert.equal(refresh.templateVersion, '0.3.36');
238
+ });
239
+ test('§4 retry: a 4xx create is NEVER retried — it self-cleans and rethrows the original error', async () => {
240
+ const destroyed = [];
241
+ const boom = { status_code: 409, code: 'SANDBOX_ALREADY_EXISTS', message: 'conflict' };
242
+ const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([boom, 'ok'], destroyed));
243
+ await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => err === boom, 'a 4xx must rethrow the ORIGINAL rejection, never be absorbed by a retry');
244
+ // Self-cleanup destroyed the one failed generation, and the retry path never ran a second create.
245
+ assert.equal(destroyed.length, 1, 'the failed 4xx generation is still self-cleaned exactly once');
246
+ });
247
+ test('§4 retry: a second consecutive retryable failure throws (bounded to one retry) and both generations are self-cleaned', async () => {
248
+ const destroyed = [];
249
+ const boom = { status_code: 503, code: 'DEPLOYMENT_FAILED', message: 'still failing' };
250
+ const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([boom, boom], destroyed));
251
+ await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => err === boom);
252
+ // Both the first and the retry generation are self-cleaned — no orphan survives a double failure.
253
+ assert.equal(destroyed.length, 2, 'both failed generations are self-cleaned');
254
+ });
@@ -6,6 +6,45 @@ export declare class BlaxelHomeBackend {
6
6
  private readonly provider;
7
7
  constructor(config: M0Config, provider?: BlaxelMachineProvider);
8
8
  wake(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
9
+ /** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
10
+ * point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
11
+ * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). Then
12
+ * poll until the platform reports the volume detached. Safe because at sweep time the
13
+ * replacement does not exist yet (its name is minted after) and any live family member is by
14
+ * construction either the generation being replaced or an uncommitted leftover — the home's
15
+ * durable state is the volume, never a sandbox; one wake per home runs at a time. */
16
+ private sweepAndReleaseVolume;
17
+ /** Poll the volume until the platform reports it detached — the create-precondition gate
18
+ * (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
19
+ * whenever it actually had to wait, so the re-proof can quantify headroom against the budget. */
20
+ private pollVolumeDetached;
21
+ /** Create one fresh generation and bring it to a serving state (create → bootstrap exec →
22
+ * waitForPort → publish → getMachine), with provider self-cleanup and a single bounded
23
+ * retry (orphan-cleanup-fix.md §3.4/§4). Each attempt destroys the generation IT created if
24
+ * anything from `createMachine` through the final `getMachine` fails — no code path exits
25
+ * leaving a live generation it created and did not return — then rethrows the ORIGINAL error
26
+ * (cleanup never masks it). On the first failure, if the error is a genuine server-side
27
+ * deployment transient, pause, re-run ONLY the volume-free poll (the sweep already ran once),
28
+ * mint a NEW name, and retry exactly once; a second failure throws. The sweep is the caller's
29
+ * responsibility and runs before the first attempt only. */
30
+ private createGeneration;
31
+ /** Targeted destroy→recreate onto a SPECIFIC image (auto-upgrade roll and
32
+ * rollback, design §4/§5). Unlike `wake()`'s standby-resume path, this is the ONE path
33
+ * that force-destroys the home's sandboxes before recreating. Cleanup is a deterministic
34
+ * name-family sweep (orphan-cleanup-fix.md §3.3): every generation of this home is destroyed
35
+ * before the fresh one is minted, so a failed roll-forward generation can never survive as a
36
+ * live orphan even after Blaxel autonomously detaches it from the volume. The recreate lands
37
+ * on a FRESH sandbox name every call — a name that has never existed cannot 409 against a
38
+ * lingering same-name delete. The durable volume is a separate resource and is NEVER touched
39
+ * (flow-D durability) — user state is byte-identical across the flip, which is exactly what
40
+ * makes both the upgrade and the rollback lossless. The new `providerSandboxId` is returned on
41
+ * the refresh; callers persist it (`refreshProviderPointers` treats it as a refreshable
42
+ * pointer). `templateVersion` is baked into the bootstrap's fail-fast version assert (the
43
+ * baked crtr must match) AND reported back so the CP records the target version — the caller
44
+ * still independently asserts `crtr sys version` in-guest before trusting it (wake.ts
45
+ * `rollHome`/`rollbackHome`, against `refresh.providerSandboxId`, never a stale captured
46
+ * name). */
47
+ recreateOnImage(homeInput: HomeProviderDescriptor, imageRef: string, templateVersion: string): Promise<HomeProviderRefresh>;
9
48
  suspend(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
10
49
  getMachine(machineId: string): Promise<MachineRef | null>;
11
50
  /** P2.6 stdin-capable exec seam, passed through verbatim — the `HomeBackend` interface is the