@crouton-kit/crouter 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +720 -747
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/attach/view-socket.d.ts +8 -1
  9. package/dist/clients/attach/view-socket.js +15 -1
  10. package/dist/clients/web/server.js +5 -28
  11. package/dist/clients/web/web-cmd.js +1 -1
  12. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  13. package/dist/commands/canvas-rebuild-index.js +5 -5
  14. package/dist/commands/memory/shared.d.ts +7 -3
  15. package/dist/commands/memory/shared.js +35 -5
  16. package/dist/commands/memory/write.js +5 -3
  17. package/dist/commands/node-snapshot.js +9 -1
  18. package/dist/commands/node.js +37 -17
  19. package/dist/commands/push.js +8 -0
  20. package/dist/commands/revive.d.ts +10 -0
  21. package/dist/commands/revive.js +28 -10
  22. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  23. package/dist/commands/sys/doctor.js +1 -1
  24. package/dist/commands/sys/setup-core.js +3 -2
  25. package/dist/commands/sys/setup.js +1 -1
  26. package/dist/commands/worktree.d.ts +2 -0
  27. package/dist/commands/worktree.js +94 -0
  28. package/dist/core/__tests__/boot.test.js +4 -4
  29. package/dist/core/__tests__/canvas.test.js +19 -7
  30. package/dist/core/__tests__/child-followup.test.js +15 -5
  31. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  32. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  33. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  36. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  37. package/dist/core/__tests__/grace-clock.test.js +18 -2
  38. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  39. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  40. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  41. package/dist/core/__tests__/revive.test.js +18 -4
  42. package/dist/core/__tests__/session-cycles.test.js +77 -0
  43. package/dist/core/__tests__/worktree.test.js +85 -0
  44. package/dist/core/canvas/boot.js +12 -7
  45. package/dist/core/canvas/canvas.d.ts +27 -8
  46. package/dist/core/canvas/canvas.js +54 -26
  47. package/dist/core/canvas/db.js +14 -0
  48. package/dist/core/canvas/history.js +1 -0
  49. package/dist/core/canvas/paths.d.ts +10 -9
  50. package/dist/core/canvas/paths.js +10 -9
  51. package/dist/core/canvas/pid.d.ts +155 -1
  52. package/dist/core/canvas/pid.js +306 -1
  53. package/dist/core/canvas/status-glyph.d.ts +7 -0
  54. package/dist/core/canvas/status-glyph.js +10 -1
  55. package/dist/core/canvas/types.d.ts +34 -0
  56. package/dist/core/fault-classifier.js +5 -1
  57. package/dist/core/profiles/select.d.ts +4 -2
  58. package/dist/core/profiles/select.js +30 -4
  59. package/dist/core/runtime/bearings.js +4 -0
  60. package/dist/core/runtime/branded-host.d.ts +7 -0
  61. package/dist/core/runtime/branded-host.js +44 -17
  62. package/dist/core/runtime/broker-sdk.js +28 -68
  63. package/dist/core/runtime/broker.js +35 -4
  64. package/dist/core/runtime/host.d.ts +3 -3
  65. package/dist/core/runtime/host.js +148 -33
  66. package/dist/core/runtime/launch.d.ts +15 -15
  67. package/dist/core/runtime/launch.js +58 -4
  68. package/dist/core/runtime/naming.js +3 -2
  69. package/dist/core/runtime/nodes.d.ts +3 -1
  70. package/dist/core/runtime/nodes.js +1 -0
  71. package/dist/core/runtime/pi-cli.d.ts +2 -0
  72. package/dist/core/runtime/pi-cli.js +51 -0
  73. package/dist/core/runtime/placement.d.ts +1 -1
  74. package/dist/core/runtime/placement.js +5 -2
  75. package/dist/core/runtime/recap.js +2 -1
  76. package/dist/core/runtime/revive.d.ts +21 -15
  77. package/dist/core/runtime/revive.js +87 -52
  78. package/dist/core/runtime/session-cycles.d.ts +30 -0
  79. package/dist/core/runtime/session-cycles.js +77 -0
  80. package/dist/core/runtime/spawn.d.ts +4 -0
  81. package/dist/core/runtime/spawn.js +152 -100
  82. package/dist/core/runtime/tmux.d.ts +7 -10
  83. package/dist/core/runtime/tmux.js +9 -11
  84. package/dist/core/worktree.d.ts +35 -0
  85. package/dist/core/worktree.js +158 -0
  86. package/dist/daemon/crtrd.d.ts +17 -8
  87. package/dist/daemon/crtrd.js +191 -40
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.js +7 -0
  90. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  91. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  92. package/dist/pi-extensions/canvas-stophook.js +11 -5
  93. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  94. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  95. package/dist/web-client/index.html +2 -2
  96. package/docs/compat/hearth-crtr-v1.md +191 -0
  97. package/docs/public-api.md +75 -0
  98. package/package.json +3 -4
  99. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  100. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  101. package/dist/core/hearth/config.d.ts +0 -3
  102. package/dist/core/hearth/config.js +0 -108
  103. package/dist/core/hearth/guest-env.d.ts +0 -9
  104. package/dist/core/hearth/guest-env.js +0 -27
  105. package/dist/core/hearth/index.d.ts +0 -4
  106. package/dist/core/hearth/index.js +0 -4
  107. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  108. package/dist/core/hearth/model-auth-guest.js +0 -430
  109. package/dist/core/hearth/provider.d.ts +0 -36
  110. package/dist/core/hearth/provider.js +0 -10
  111. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
  112. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  113. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  114. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
  115. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  116. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  117. package/dist/core/hearth/providers/blaxel.js +0 -364
  118. package/dist/core/hearth/providers/types.d.ts +0 -93
  119. package/dist/core/hearth/types.d.ts +0 -155
  120. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  121. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  122. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  123. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  124. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  125. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  126. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  127. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  128. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  129. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  130. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  131. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  132. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  133. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  134. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  135. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  136. package/dist/hearth/control-plane/config.d.ts +0 -21
  137. package/dist/hearth/control-plane/config.js +0 -77
  138. package/dist/hearth/control-plane/db.d.ts +0 -30
  139. package/dist/hearth/control-plane/db.js +0 -561
  140. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  141. package/dist/hearth/control-plane/hearth-target.js +0 -68
  142. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  143. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  144. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  145. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  146. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  147. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  148. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  149. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  150. package/dist/hearth/control-plane/main.d.ts +0 -1
  151. package/dist/hearth/control-plane/main.js +0 -88
  152. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  153. package/dist/hearth/control-plane/node-message.js +0 -98
  154. package/dist/hearth/control-plane/register.d.ts +0 -15
  155. package/dist/hearth/control-plane/register.js +0 -34
  156. package/dist/hearth/control-plane/registry.d.ts +0 -22
  157. package/dist/hearth/control-plane/registry.js +0 -168
  158. package/dist/hearth/control-plane/relay.d.ts +0 -44
  159. package/dist/hearth/control-plane/relay.js +0 -711
  160. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  161. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  162. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  163. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  164. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  165. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  166. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  167. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  168. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  169. package/dist/hearth/control-plane/secrets.js +0 -134
  170. package/dist/hearth/control-plane/server.d.ts +0 -27
  171. package/dist/hearth/control-plane/server.js +0 -482
  172. package/dist/hearth/control-plane/serving.d.ts +0 -15
  173. package/dist/hearth/control-plane/serving.js +0 -106
  174. package/dist/hearth/control-plane/session.d.ts +0 -68
  175. package/dist/hearth/control-plane/session.js +0 -273
  176. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  177. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  178. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  179. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  180. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  181. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  182. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  183. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  184. package/dist/hearth/control-plane/types.d.ts +0 -86
  185. package/dist/hearth/control-plane/types.js +0 -1
  186. package/dist/hearth/control-plane/wake.d.ts +0 -86
  187. package/dist/hearth/control-plane/wake.js +0 -550
  188. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  189. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  190. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  191. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  192. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  193. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  194. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  195. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -1,362 +0,0 @@
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
- });
255
- // Contract: when the volume is held by a sandbox outside the tag-scoped destroy set — an
256
- // untagged holder the sweep cannot reach — the guard must detect it immediately and throw an
257
- // actionable error, never spin `pollVolumeDetached` to its VOLUME_DETACH_TIMEOUT_MS deadline.
258
- /** A fake whose tag-scoped `listMachines` returns `[]` (nothing tagged for this home) while
259
- * `getVolumeAttachment` reports the volume held by an untagged sandbox outside the destroy set.
260
- * Counts `getVolumeAttachment` calls so the test can assert the guard short-circuits on the
261
- * first read rather than looping into `pollVolumeDetached`. */
262
- function makeUntaggedHolderProvider(holderName, destroyed, attachmentCalls) {
263
- const fake = {
264
- providerName: 'blaxel',
265
- imageRef: 'crouter-hearth-home-0-3-35',
266
- templateVersion: '0.3.35',
267
- volumeSizeGb: 4,
268
- async listMachines() {
269
- return []; // tag-scoped list finds nothing — the holder carries no externalId tag
270
- },
271
- async destroy(machineId) {
272
- destroyed.push(machineId);
273
- return { kind: 'destroyed' };
274
- },
275
- async getVolumeAttachment() {
276
- attachmentCalls.count += 1;
277
- return `sandbox:${holderName}`;
278
- },
279
- async createMachine(input) {
280
- return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
281
- },
282
- async exec() {
283
- return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
284
- },
285
- async waitForPort() { },
286
- async publishHttpPort(machineId, input) {
287
- return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
288
- },
289
- async getMachine(machineId) {
290
- return { providerName: 'blaxel', machineId, state: 'running' };
291
- },
292
- };
293
- return fake;
294
- }
295
- test('loud guard: an untagged legacy sandbox holding the volume fails FAST with an actionable error, never the 120s poll', async () => {
296
- const destroyed = [];
297
- const attachmentCalls = { count: 0 };
298
- const backend = new BlaxelHomeBackend(makeConfig(), makeUntaggedHolderProvider('hearth-prod-1', destroyed, attachmentCalls));
299
- await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => {
300
- assert.ok(err instanceof Error, 'must throw an Error');
301
- assert.match(err.message, /hearth-prod-1/, 'error must name the untagged holder sandbox');
302
- assert.match(err.message, /migrate/i, 'error must instruct the operator to migrate the home');
303
- return true;
304
- });
305
- // The tag-scoped sweep destroyed nothing (empty family) and the guard fired on the FIRST
306
- // attachment read — it never looped into `pollVolumeDetached`'s wait/retry cycle.
307
- assert.equal(destroyed.length, 0, 'nothing was destroyed — the holder was never tag-scoped');
308
- assert.equal(attachmentCalls.count, 1, 'getVolumeAttachment must be read exactly once before failing fast, never polled to deadline');
309
- });
310
- /** A fake modeling the normal path: the row-pointed generation is tag-scoped and destroyed, and
311
- * the volume reports still attached to THAT generation on the guard's read, then detached (null)
312
- * on the next read inside `pollVolumeDetached` — a real destroy+detach seconds apart. Confirms
313
- * the guard does NOT fire for a holder within the destroy set, and the poll still runs. */
314
- function makeDestroyedHolderProvider(destroyed, attachmentCalls) {
315
- const doomedName = `${VOLUME_ID}-gold`;
316
- const fake = {
317
- providerName: 'blaxel',
318
- imageRef: 'crouter-hearth-home-0-3-35',
319
- templateVersion: '0.3.35',
320
- volumeSizeGb: 4,
321
- async listMachines(filter) {
322
- if (filter?.externalId !== VOLUME_ID)
323
- return [];
324
- return [{ providerName: 'blaxel', machineId: doomedName, state: 'running' }];
325
- },
326
- async destroy(machineId) {
327
- destroyed.push(machineId);
328
- return { kind: 'destroyed' };
329
- },
330
- async getVolumeAttachment() {
331
- attachmentCalls.count += 1;
332
- // First read (the guard, right after destroy): still shows the just-destroyed generation
333
- // as the holder — a real detach is not instantaneous. Every subsequent read (inside
334
- // `pollVolumeDetached`): the platform has confirmed detach.
335
- return attachmentCalls.count === 1 ? `sandbox:${doomedName}` : null;
336
- },
337
- async createMachine(input) {
338
- return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
339
- },
340
- async exec() {
341
- return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
342
- },
343
- async waitForPort() { },
344
- async publishHttpPort(machineId, input) {
345
- return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
346
- },
347
- async getMachine(machineId) {
348
- return { providerName: 'blaxel', machineId, state: 'running' };
349
- },
350
- };
351
- return fake;
352
- }
353
- test('loud guard: does NOT fire when the holder is a sandbox the tag-scoped sweep just destroyed — normal recreate still completes', async () => {
354
- const destroyed = [];
355
- const attachmentCalls = { count: 0 };
356
- const backend = new BlaxelHomeBackend(makeConfig(), makeDestroyedHolderProvider(destroyed, attachmentCalls));
357
- const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
358
- assert.ok(destroyed.includes(`${VOLUME_ID}-gold`), 'the tag-scoped generation must still be destroyed');
359
- assert.ok(attachmentCalls.count >= 2, 'the guard read plus at least one poll read must both occur');
360
- assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
361
- assert.equal(refresh.templateVersion, '0.3.36');
362
- });
@@ -1,12 +0,0 @@
1
- export interface BuildBootstrapScriptOpts {
2
- guestHome: string;
3
- crtrVersion: string;
4
- relayToken: string;
5
- webPort: number;
6
- nodeId?: string | null;
7
- piAuthJson?: string | null;
8
- piPackages?: string[];
9
- modelLaddersJson?: string | null;
10
- isFirstProvision: boolean;
11
- }
12
- export declare function buildBootstrapScript(opts: BuildBootstrapScriptOpts): string;
@@ -1,147 +0,0 @@
1
- import { dirname, join } from 'node:path';
2
- import { general } from '../../errors.js';
3
- import { guestEnv } from '../guest-env.js';
4
- // The baked, read-only product layer (deploy/hearth-blaxel/Dockerfile WORKDIR,
5
- // and config.ts's ENV.guestCode default). Bootstrap never installs anything
6
- // here at runtime — it only reads what the image already baked.
7
- const GUEST_CODE_PATH = '/opt/crouter';
8
- // The baked crtr binary's fixed, image-symlinked path (deploy/hearth-blaxel/Dockerfile
9
- // `ln -sf /opt/crouter/node_modules/.bin/crtr /usr/local/bin/crtr`). Every crtr
10
- // invocation this script makes targets this exact path rather than a bare `crtr`
11
- // PATH lookup: guest-env.ts's PATH still lists a legacy `$HOME/.crtr-runtime/bin`
12
- // dir ahead of /usr/local/bin — a holdover from the pre-cutover boot-time `npm
13
- // install --prefix` flow that P3.1 deleted — and nothing populates that directory
14
- // anymore, but a volume provisioned before this cutover can still have a stale
15
- // crtr binary sitting there. Targeting the baked path directly makes PATH order
16
- // irrelevant for this script's own invocations.
17
- const BAKED_CRTR_PATH = '/usr/local/bin/crtr';
18
- function shellQuote(value) {
19
- return `'${value.replace(/'/g, `'"'"'`)}'`;
20
- }
21
- function uniqueStrings(values) {
22
- const result = [];
23
- const seen = new Set();
24
- for (const value of values) {
25
- const trimmed = value?.trim();
26
- if (!trimmed || seen.has(trimmed))
27
- continue;
28
- seen.add(trimmed);
29
- result.push(trimmed);
30
- }
31
- return result;
32
- }
33
- function appendJsonSeed(lines, path, marker, content) {
34
- const raw = content?.trim();
35
- if (!raw)
36
- return;
37
- lines.push(`cat > ${shellQuote(path)} <<'${marker}'`, raw, marker);
38
- }
39
- /** Fail-fast assertion: baked crtr is on PATH and pinned to the exact
40
- * expected version, and the bootstrap command surface it depends on
41
- * (`crtr surface web serve`) actually exists. No install, no fallback to an
42
- * older command name — command drift fails the boot instead of silently
43
- * taking a different path (the M0 published-install incident this replaces). */
44
- function appendCommandSurfaceAssertions(lines, crtrVersion) {
45
- const crtr = shellQuote(BAKED_CRTR_PATH);
46
- lines.push(`if [ ! -x ${crtr} ]; then`, ` printf "baked crtr not found at %s\\n" ${crtr} >&2`, ' exit 1', 'fi',
47
- // Stale-PATH shadow guard: even though every invocation below targets the
48
- // baked path directly, assert that a bare `crtr` lookup ALSO resolves
49
- // there — catches a stale volume-installed binary shadowing the baked one
50
- // ahead of time, before anything downstream of this script (e.g. a node
51
- // engine spawned later in this session) could be tricked by it.
52
- 'resolved_crtr="$(command -v crtr 2>/dev/null || true)"', `if [ "$resolved_crtr" != ${crtr} ]; then`, ` printf "stale crtr shadowing baked binary: PATH resolves crtr to %s, expected %s\\n" "$resolved_crtr" ${crtr} >&2`, ' exit 1', 'fi', `actual_crtr_version="$(${crtr} --json sys version | node -e 'let s="";process.stdin.on("data",c=>s+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(s).version)})')"`, `if [ "$actual_crtr_version" != ${shellQuote(crtrVersion)} ]; then`, ` printf "crtr version assertion FAILED: baked %s, expected %s\\n" "$actual_crtr_version" ${shellQuote(crtrVersion)} >&2`, ' exit 1', 'fi', `if ! ${crtr} surface web -h >/dev/null 2>&1 || ! ${crtr} surface web serve -h >/dev/null 2>&1; then`, ' printf "command surface assertion FAILED: crtr surface web serve not exposed\\n" >&2', ' exit 1', 'fi');
53
- }
54
- /** Cache-prime the baked pi Claude OAuth adapter into pi's user npm install
55
- * root (`$HOME/.pi/agent/npm/node_modules/pi-claude-oauth-adapter`) so pi's
56
- * package manager finds it already present and skips a network install
57
- * (see @earendil-works/pi-coding-agent's getNpmInstallPath/needsInstall:
58
- * scope "user" resolves to exactly this path and is used as-is when it
59
- * exists). Existence-gated and unconditional (not first-boot-only) so a
60
- * volume provisioned before this priming existed self-heals on its next
61
- * boot; never overwrites an already-primed copy. */
62
- function appendOauthAdapterCachePrime(lines, guestHome) {
63
- const bakedDir = join(GUEST_CODE_PATH, 'node_modules', 'pi-claude-oauth-adapter');
64
- const cacheDir = join(guestHome, '.pi', 'agent', 'npm', 'node_modules', 'pi-claude-oauth-adapter');
65
- lines.push(`if [ -d ${shellQuote(bakedDir)} ] && [ ! -d ${shellQuote(cacheDir)} ]; then`, ` mkdir -p ${shellQuote(dirname(cacheDir))}`, ` cp -R ${shellQuote(bakedDir)} ${shellQuote(cacheDir)}`, 'fi');
66
- }
67
- export function buildBootstrapScript(opts) {
68
- if (opts.relayToken.trim() === '') {
69
- throw general('buildBootstrapScript: relayToken must not be empty — the web lane must never bind without relay-token enforcement', {
70
- guestHome: opts.guestHome,
71
- });
72
- }
73
- const nodeId = (opts.nodeId ?? '').trim();
74
- if (!opts.isFirstProvision && nodeId === '') {
75
- throw general('buildBootstrapScript: nodeId is required when isFirstProvision is false', { guestHome: opts.guestHome });
76
- }
77
- // Single source of truth for the home env table (HOME/CRTR_HOME on the
78
- // durable volume; CRTR_SOCK_DIR/CRTR_PIDFILE on tmpfs; CRTR_CANVAS_DB left
79
- // unset) — see guest-env.ts. Re-exported here because this script is its
80
- // own provider exec, not guaranteed to inherit the sandbox-level envs set
81
- // at machine creation. `guestEnv` only dereferences `guestHome`, so a
82
- // minimal config stand-in is enough.
83
- const env = guestEnv({ guestHome: opts.guestHome });
84
- // Override the PATH guestEnv() emits: it still lists the legacy (dead)
85
- // `$HOME/.crtr-runtime/bin` ahead of /usr/local/bin (see BAKED_CRTR_PATH
86
- // above). This script's own crtr invocations target the baked path
87
- // directly regardless, but fix the exported PATH too so anything else
88
- // spawned downstream in this session (e.g. the daemon and the node
89
- // engines it launches) that calls bare `crtr` also resolves to the baked
90
- // binary, not a stale volume copy.
91
- env['PATH'] = '/usr/local/bin:/usr/bin:/bin';
92
- const runtimeDir = join(opts.guestHome, '.crtr-runtime');
93
- const settingsPath = join(opts.guestHome, '.pi', 'agent', 'settings.json');
94
- const builtinPiExtensionsDir = join(GUEST_CODE_PATH, 'node_modules', '@crouton-kit', 'crouter', 'dist', 'builtin-pi-packages');
95
- const packages = uniqueStrings([
96
- ...(opts.piPackages ?? []),
97
- join(builtinPiExtensionsDir, 'pi-crtr-extensions'),
98
- ]);
99
- const settingsJson = JSON.stringify({
100
- packages,
101
- defaultProvider: 'anthropic',
102
- defaultModel: 'claude-opus-4-8',
103
- defaultThinkingLevel: 'high',
104
- enableSkillCommands: true,
105
- steeringMode: 'all',
106
- quietStartup: true,
107
- theme: 'dark',
108
- }, null, 2);
109
- const lines = [
110
- 'set -e',
111
- ...Object.entries(env).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
112
- // Defense-in-depth: CRTR_CANVAS_DB must stay unset on the guest so
113
- // canvas.db follows CRTR_HOME onto the durable volume (its fcntl/WAL
114
- // locks need real local-disk semantics) rather than a stray NFS path.
115
- 'unset CRTR_CANVAS_DB 2>/dev/null || true',
116
- `mkdir -p "$HOME/.crouter" "$HOME/.pi/agent" ${shellQuote(env['CRTR_SOCK_DIR'] ?? '')} ${shellQuote(dirname(env['CRTR_PIDFILE'] ?? ''))} ${shellQuote(runtimeDir)}`,
117
- // The provisioner launches this script as a detached (background) exec, so its stdout is not
118
- // captured. Redirect everything to a log on the durable volume so seed/daemon/node-new
119
- // failures are inspectable, and so the first-provision node id can be read from a file (below).
120
- 'exec >>"$HOME/.crtr-runtime/bootstrap.log" 2>&1',
121
- ];
122
- appendCommandSurfaceAssertions(lines, opts.crtrVersion);
123
- if (opts.isFirstProvision) {
124
- appendJsonSeed(lines, settingsPath, 'PI_SETTINGS_JSON', settingsJson);
125
- appendJsonSeed(lines, join(opts.guestHome, '.crouter', 'config.json'), 'CRTR_CONFIG_JSON', opts.modelLaddersJson);
126
- if ((opts.piAuthJson?.trim() ?? '') !== '') {
127
- appendJsonSeed(lines, join(opts.guestHome, '.pi', 'agent', 'auth.json'), 'PI_AUTH_JSON', opts.piAuthJson);
128
- }
129
- }
130
- appendOauthAdapterCachePrime(lines, opts.guestHome);
131
- // Tmpfs sock/pidfile dirs are already mkdir'd above, before this line.
132
- lines.push(`${shellQuote(BAKED_CRTR_PATH)} sys daemon start </dev/null >>"$HOME/.crtr-runtime/daemon.log" 2>&1`);
133
- if (opts.isFirstProvision) {
134
- lines.push('home_node_output="$(', `${shellQuote(BAKED_CRTR_PATH)} --json node new --root --kind general --name home <<'SEED'`, 'Hearth home seed for the Blaxel home backend.', 'SEED', ')"', "home_node_id=\"$(node -e 'const o = JSON.parse(process.argv[1]); process.stdout.write(String(o.node_id ?? o.nodeId ?? o.id ?? \"\"));' \"$home_node_output\")\"", 'if [ -z "$home_node_id" ]; then', ' printf "failed to parse home node id from crtr node new output\n%s\n" "$home_node_output" >&2', ' exit 1', 'fi', 'printf "__HEARTH_NODE_ID__=%s\n" "$home_node_id"',
135
- // The provisioner polls this file (background exec stdout is not captured).
136
- 'printf "%s\n" "$home_node_id" > "$HOME/.crtr-runtime/home-node-id"');
137
- }
138
- else {
139
- lines.push(`${shellQuote(BAKED_CRTR_PATH)} canvas revive ${shellQuote(nodeId)}`);
140
- }
141
- // `crtr surface web serve` is asserted present above; never fall back to the
142
- // retired top-level `crtr web serve` alias. --token is always passed and
143
- // always non-empty (checked above) so every lane (bridge/SSE/relay) enforces
144
- // the relay token on this non-loopback bind.
145
- lines.push(`${shellQuote(BAKED_CRTR_PATH)} surface web serve --host 0.0.0.0 --port ${opts.webPort} --token ${shellQuote(opts.relayToken)} </dev/null >>"$HOME/.crtr-runtime/web.log" 2>&1`);
146
- return lines.join('\n');
147
- }
@@ -1,60 +0,0 @@
1
- import type { HomeProviderDescriptor, HomeProviderRefresh, M0Config, MachineRef } from '../types.js';
2
- import { BlaxelMachineProvider } from './blaxel.js';
3
- import type { ExecInput, ExecResult } from './types.js';
4
- export declare class BlaxelHomeBackend {
5
- private readonly config;
6
- private readonly provider;
7
- constructor(config: M0Config, provider?: BlaxelMachineProvider);
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). The
12
- * destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
13
- * name-family fallback (MAJOR-1) — so after destroying it, read the volume's current holder
14
- * ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
15
- * holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
16
- * never detach on its own, so fail fast with an actionable error instead of silently spinning
17
- * the detach poll to its timeout. Otherwise poll
18
- * until the platform reports the volume detached. Safe because at sweep time the replacement
19
- * does not exist yet (its name is minted after) and any live family member is by construction
20
- * either the generation being replaced or an uncommitted leftover — the home's durable state
21
- * is the volume, never a sandbox; one wake per home runs at a time. */
22
- private sweepAndReleaseVolume;
23
- /** Poll the volume until the platform reports it detached — the create-precondition gate
24
- * (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
25
- * whenever it actually had to wait, so the re-proof can quantify headroom against the budget. */
26
- private pollVolumeDetached;
27
- /** Create one fresh generation and bring it to a serving state (create → bootstrap exec →
28
- * waitForPort → publish → getMachine), with provider self-cleanup and a single bounded
29
- * retry (orphan-cleanup-fix.md §3.4/§4). Each attempt destroys the generation IT created if
30
- * anything from `createMachine` through the final `getMachine` fails — no code path exits
31
- * leaving a live generation it created and did not return — then rethrows the ORIGINAL error
32
- * (cleanup never masks it). On the first failure, if the error is a genuine server-side
33
- * deployment transient, pause, re-run ONLY the volume-free poll (the sweep already ran once),
34
- * mint a NEW name, and retry exactly once; a second failure throws. The sweep is the caller's
35
- * responsibility and runs before the first attempt only. */
36
- private createGeneration;
37
- /** Targeted destroy→recreate onto a SPECIFIC image (auto-upgrade roll and
38
- * rollback, design §4/§5). Unlike `wake()`'s standby-resume path, this is the ONE path
39
- * that force-destroys the home's sandboxes before recreating. Cleanup is a deterministic
40
- * name-family sweep (orphan-cleanup-fix.md §3.3): every generation of this home is destroyed
41
- * before the fresh one is minted, so a failed roll-forward generation can never survive as a
42
- * live orphan even after Blaxel autonomously detaches it from the volume. The recreate lands
43
- * on a FRESH sandbox name every call — a name that has never existed cannot 409 against a
44
- * lingering same-name delete. The durable volume is a separate resource and is NEVER touched
45
- * (flow-D durability) — user state is byte-identical across the flip, which is exactly what
46
- * makes both the upgrade and the rollback lossless. The new `providerSandboxId` is returned on
47
- * the refresh; callers persist it (`refreshProviderPointers` treats it as a refreshable
48
- * pointer). `templateVersion` is baked into the bootstrap's fail-fast version assert (the
49
- * baked crtr must match) AND reported back so the CP records the target version — the caller
50
- * still independently asserts `crtr sys version` in-guest before trusting it (wake.ts
51
- * `rollHome`/`rollbackHome`, against `refresh.providerSandboxId`, never a stale captured
52
- * name). */
53
- recreateOnImage(homeInput: HomeProviderDescriptor, imageRef: string, templateVersion: string): Promise<HomeProviderRefresh>;
54
- suspend(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
55
- getMachine(machineId: string): Promise<MachineRef | null>;
56
- /** P2.6 stdin-capable exec seam, passed through verbatim — the `HomeBackend` interface is the
57
- * only door CP code uses to reach it (see `provider.ts`), so callers never import
58
- * `BlaxelMachineProvider` directly. */
59
- exec(machineId: string, input: ExecInput): Promise<ExecResult>;
60
- }