@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
@@ -0,0 +1,44 @@
1
+ // Regression test for the P2 review Major fix: a stale, abandoned OAuth
2
+ // marker must not make a later `beginOAuth` on the same home read as
3
+ // non-serving. See serving.ts's `beginOAuth`/`oauthMarkerLive`.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mock } from 'node:test';
7
+ import { beginOAuth, endOAuth, isHomeServing, resetServingForTests } from '../serving.js';
8
+ const HOME = 'home-oauth-marker-test';
9
+ const OAUTH_MARKER_TTL_MS = 5 * 60_000;
10
+ test('a second beginOAuth refreshes a stale marker and stays live until both finishes land', () => {
11
+ resetServingForTests();
12
+ mock.timers.enable({ apis: ['Date'] });
13
+ try {
14
+ // Start A begins an OAuth window; it is immediately serving.
15
+ beginOAuth(HOME);
16
+ assert.equal(isHomeServing(HOME), true, 'start A should mark the home as serving');
17
+ // Start A is abandoned: advance the clock past the 5-minute TTL with no
18
+ // matching endOAuth. The marker goes stale, but the counter (1) is still
19
+ // positive because start A was never finished.
20
+ mock.timers.tick(OAUTH_MARKER_TTL_MS + 1_000);
21
+ assert.equal(isHomeServing(HOME), false, 'a stale marker with no refresh must read as not-serving');
22
+ // Start B is a fresh retry on the same home. Per the fix, beginOAuth
23
+ // refreshes oauthStartedAt on EVERY call, not only when the counter
24
+ // transitions from 0 — so B's own window must read as live immediately.
25
+ beginOAuth(HOME);
26
+ assert.equal(isHomeServing(HOME), true, 'a second beginOAuth must refresh the marker and read as serving');
27
+ // B's window survives well past when A's original (unrefreshed) TTL
28
+ // would have expired, proving the refresh — not the original timestamp
29
+ // — is what's being judged now.
30
+ mock.timers.tick(OAUTH_MARKER_TTL_MS - 1_000);
31
+ assert.equal(isHomeServing(HOME), true, 'B\'s window must still be live before its own TTL elapses');
32
+ // A late finish for start A arrives: counter floors from 2 -> 1. Still
33
+ // serving, because B's own start/finish window has not closed yet.
34
+ endOAuth(HOME);
35
+ assert.equal(isHomeServing(HOME), true, 'one matched finish (of two starts) must not end serving early');
36
+ // B finishes: counter 1 -> 0, marker cleared, no longer serving.
37
+ endOAuth(HOME);
38
+ assert.equal(isHomeServing(HOME), false, 'both starts matched by a finish must end serving');
39
+ }
40
+ finally {
41
+ mock.timers.reset();
42
+ resetServingForTests();
43
+ }
44
+ });
@@ -0,0 +1,230 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ // Bug-regression tests for recreate-race-fix.md §3.3/§3.5: under generation naming, a roll
4
+ // or rollback recreate destroys the PRE-recreate sandbox and creates a fresh-named one.
5
+ // `rollHome`/`rollbackHome` must assert the in-guest `crtr sys version` against the sandbox
6
+ // name the recreate ACTUALLY reports (`refresh.providerSandboxId`) — asserting against a
7
+ // `sandboxName` captured from `home.provider_sandbox_id` at entry (the pre-fix behavior)
8
+ // would exec into the already-destroyed old generation instead. These tests fail if that
9
+ // regresses (they pin the exec target, not just that the call succeeds).
10
+ function fixtureHome(overrides = {}) {
11
+ return {
12
+ home_id: 'home-1',
13
+ tenant_id: 't1',
14
+ user_id: 'u1',
15
+ provider: 'blaxel',
16
+ provider_sandbox_id: 'hearth-home-1',
17
+ provider_volume_id: 'vol-home-1',
18
+ preview_endpoint: 'https://old.example',
19
+ home_agent_target: 'node-1',
20
+ relay_auth_ref: 'ref-1',
21
+ status: 'running',
22
+ template_version: '0.3.35',
23
+ last_ready_at: null,
24
+ last_wake_at: null,
25
+ last_suspend_at: null,
26
+ last_wake_latency_ms: null,
27
+ health_verdict: 'healthy',
28
+ created_at: '2026-07-01T00:00:00.000Z',
29
+ updated_at: '2026-07-01T00:00:00.000Z',
30
+ ...overrides,
31
+ };
32
+ }
33
+ function fixtureTarget(overrides = {}) {
34
+ return {
35
+ channel: 'home',
36
+ target_template_version: '0.3.36',
37
+ target_image_ref: 'crouter-hearth-home-0-3-36',
38
+ published_at: '2026-07-01T00:00:00.000Z',
39
+ source: 'test',
40
+ last_bad_target_version: null,
41
+ ...overrides,
42
+ };
43
+ }
44
+ test('a successful plain (non-roll) wake clears a stale degraded health verdict to healthy', async () => {
45
+ const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
46
+ const orig = { ...wakeInternals };
47
+ resetWakeCacheForTests();
48
+ const home = fixtureHome({ home_id: 'home-plain-wake', provider_sandbox_id: 'hearth-home-plain-wake', health_verdict: 'degraded' });
49
+ let healthVerdict = null;
50
+ wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
51
+ // No hearth target registered, so runEnsureAwake takes the normal wake path, not the roll branch.
52
+ wakeInternals.getHearthTarget = (() => null);
53
+ wakeInternals.resolveSecret = (() => 'relay-token');
54
+ wakeInternals.loadM0Config = (() => ({}));
55
+ wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
56
+ wakeInternals.probeNodeReady = (async () => true);
57
+ wakeInternals.setHomeStatus = (() => home);
58
+ wakeInternals.setHealthVerdict = ((_id, verdict) => {
59
+ healthVerdict = verdict;
60
+ return home;
61
+ });
62
+ wakeInternals.touchWake = (() => home);
63
+ wakeInternals.touchReady = (() => home);
64
+ wakeInternals.getHomeBackend = (() => ({
65
+ wake: async () => ({
66
+ providerName: 'blaxel',
67
+ providerSandboxId: home.provider_sandbox_id,
68
+ providerVolumeId: 'vol-home-1',
69
+ previewEndpoint: home.preview_endpoint,
70
+ route: null,
71
+ homeAgentTarget: 'node-1',
72
+ relayAuthRef: null,
73
+ templateVersion: home.template_version,
74
+ status: 'running',
75
+ machine: null,
76
+ updatedAt: new Date().toISOString(),
77
+ }),
78
+ recreateOnImage: async () => {
79
+ throw new Error('recreateOnImage must not be called on the plain-wake path');
80
+ },
81
+ suspend: async () => {
82
+ throw new Error('unused');
83
+ },
84
+ getMachine: async () => null,
85
+ exec: async () => {
86
+ throw new Error('exec must not be called on the plain-wake path');
87
+ },
88
+ }));
89
+ try {
90
+ const result = await ensureAwake(home.home_id);
91
+ assert.equal(result.preview_endpoint, home.preview_endpoint);
92
+ assert.equal(healthVerdict, 'healthy', 'a successful plain wake must clear a stale degraded verdict to healthy');
93
+ }
94
+ finally {
95
+ Object.assign(wakeInternals, orig);
96
+ resetWakeCacheForTests();
97
+ }
98
+ });
99
+ test('rollHome asserts guest version against the RECREATED sandbox name, not the stale pre-roll name', async () => {
100
+ const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
101
+ const orig = { ...wakeInternals };
102
+ resetWakeCacheForTests();
103
+ const execCalls = [];
104
+ const home = fixtureHome({ home_id: 'home-roll-ok', provider_sandbox_id: 'hearth-home-roll-ok' });
105
+ const target = fixtureTarget();
106
+ const freshName = 'hearth-home-roll-ok-gfresh1';
107
+ wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
108
+ wakeInternals.getHearthTarget = (() => target);
109
+ wakeInternals.resolveSecret = (() => 'relay-token');
110
+ wakeInternals.loadM0Config = (() => ({}));
111
+ wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
112
+ wakeInternals.probeNodeReady = (async () => true);
113
+ wakeInternals.refreshProviderPointers = ((_id, patch) => ({ ...home, ...patch }));
114
+ wakeInternals.setHomeStatus = (() => home);
115
+ wakeInternals.setHealthVerdict = (() => home);
116
+ wakeInternals.touchWake = (() => home);
117
+ wakeInternals.touchReady = (() => home);
118
+ wakeInternals.recordBadTarget = (() => null);
119
+ wakeInternals.getHomeBackend = (() => ({
120
+ wake: async () => {
121
+ throw new Error('wake() must not be called on the roll path');
122
+ },
123
+ recreateOnImage: async () => ({
124
+ providerName: 'blaxel',
125
+ providerSandboxId: freshName,
126
+ providerVolumeId: 'vol-home-1',
127
+ previewEndpoint: 'https://new.example',
128
+ route: null,
129
+ homeAgentTarget: 'node-1',
130
+ relayAuthRef: null,
131
+ templateVersion: target.target_template_version,
132
+ status: 'running',
133
+ machine: null,
134
+ updatedAt: new Date().toISOString(),
135
+ }),
136
+ suspend: async () => {
137
+ throw new Error('unused');
138
+ },
139
+ getMachine: async () => null,
140
+ exec: async (machineId) => {
141
+ execCalls.push(machineId);
142
+ return { exitCode: 0, timedOut: false, stdout: JSON.stringify({ version: target.target_template_version }), stderr: '' };
143
+ },
144
+ }));
145
+ try {
146
+ const result = await ensureAwake(home.home_id);
147
+ assert.equal(result.preview_endpoint, 'https://new.example');
148
+ assert.deepEqual(execCalls, [freshName], 'assertGuestVersion must exec into the RECREATED sandbox name, never the stale pre-roll name');
149
+ }
150
+ finally {
151
+ Object.assign(wakeInternals, orig);
152
+ resetWakeCacheForTests();
153
+ }
154
+ });
155
+ test('rollbackHome asserts guest version against the RECREATED rollback sandbox name, and rollback success sets healthy with no incident', async () => {
156
+ const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
157
+ const orig = { ...wakeInternals };
158
+ resetWakeCacheForTests();
159
+ const execCalls = [];
160
+ const home = fixtureHome({ home_id: 'home-roll-fail', provider_sandbox_id: 'hearth-home-roll-fail' });
161
+ const target = fixtureTarget();
162
+ const rollbackFreshName = 'hearth-home-roll-fail-gback1';
163
+ let recreateCalls = 0;
164
+ let badTargetRecorded = null;
165
+ let healthVerdict = null;
166
+ wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
167
+ wakeInternals.getHearthTarget = (() => target);
168
+ wakeInternals.resolveSecret = (() => 'relay-token');
169
+ wakeInternals.loadM0Config = (() => ({}));
170
+ wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
171
+ wakeInternals.probeNodeReady = (async () => true);
172
+ wakeInternals.refreshProviderPointers = ((_id, patch) => ({ ...home, ...patch }));
173
+ wakeInternals.setHomeStatus = (() => home);
174
+ wakeInternals.setHealthVerdict = ((_id, verdict) => {
175
+ healthVerdict = verdict;
176
+ return home;
177
+ });
178
+ wakeInternals.touchWake = (() => home);
179
+ wakeInternals.touchReady = (() => home);
180
+ wakeInternals.recordBadTarget = ((version) => {
181
+ badTargetRecorded = version;
182
+ return null;
183
+ });
184
+ wakeInternals.getHomeBackend = (() => ({
185
+ wake: async () => {
186
+ throw new Error('unused');
187
+ },
188
+ recreateOnImage: async (_descriptor, _imageRef, templateVersion) => {
189
+ recreateCalls += 1;
190
+ if (recreateCalls === 1) {
191
+ // Simulate the roll-forward recreate succeeding but the guest version assert
192
+ // failing downstream — rollHome's catch delegates to rollbackHome regardless of
193
+ // WHERE in the try it failed.
194
+ throw new Error('simulated roll failure (version mismatch)');
195
+ }
196
+ return {
197
+ providerName: 'blaxel',
198
+ providerSandboxId: rollbackFreshName,
199
+ providerVolumeId: 'vol-home-1',
200
+ previewEndpoint: 'https://rollback.example',
201
+ route: null,
202
+ homeAgentTarget: 'node-1',
203
+ relayAuthRef: null,
204
+ templateVersion,
205
+ status: 'running',
206
+ machine: null,
207
+ updatedAt: new Date().toISOString(),
208
+ };
209
+ },
210
+ suspend: async () => {
211
+ throw new Error('unused');
212
+ },
213
+ getMachine: async () => null,
214
+ exec: async (machineId) => {
215
+ execCalls.push(machineId);
216
+ return { exitCode: 0, timedOut: false, stdout: JSON.stringify({ version: home.template_version }), stderr: '' };
217
+ },
218
+ }));
219
+ try {
220
+ const result = await ensureAwake(home.home_id);
221
+ assert.equal(result.preview_endpoint, 'https://rollback.example');
222
+ assert.equal(badTargetRecorded, target.target_template_version, 'the roll failure must still poison the target (brake unconditional on roll failure)');
223
+ assert.equal(healthVerdict, 'healthy', 'a successful rollback must clear health to healthy, not leave/raise an incident');
224
+ assert.deepEqual(execCalls, [rollbackFreshName], "rollback must assert against ITS OWN recreated sandbox name — never the failed roll's name nor the stale pre-roll name");
225
+ }
226
+ finally {
227
+ Object.assign(wakeInternals, orig);
228
+ resetWakeCacheForTests();
229
+ }
230
+ });
@@ -457,6 +457,32 @@ BEGIN
457
457
  END;
458
458
  `);
459
459
  }
460
+ /** v9 — the single global auto-upgrade target (design.md §2/§9). ONE row,
461
+ * `channel = 'home'`, holding the DESIRED crtr image line every home rolls
462
+ * onto: `target_template_version` is the semver the fleet should be running,
463
+ * `target_image_ref` its immutable Blaxel image name (must equal
464
+ * `hearthHomeImageRefForVersion(target_template_version)` — the admin route
465
+ * enforces that). `last_bad_target_version` is the ONLY brake in the
466
+ * zero-touch loop: a version recorded here already failed a live roll for
467
+ * the fleet, so the wake-path stale predicate excludes it until the target
468
+ * advances to a newer release. There is deliberately NO `roll_armed` column
469
+ * — the loop is full zero-touch (§9.1), so there is no per-release human arm
470
+ * gate to persist. `home.template_version` stays the ACTUAL running version;
471
+ * this row is the target. Single-tenant: one image line for all homes, so no
472
+ * per-home target column. */
473
+ function hearthTargetTable(db) {
474
+ db.exec(`
475
+ CREATE TABLE IF NOT EXISTS hearth_target (
476
+ channel TEXT PRIMARY KEY DEFAULT 'home',
477
+ target_template_version TEXT NOT NULL,
478
+ target_image_ref TEXT NOT NULL,
479
+ published_at TEXT NOT NULL,
480
+ source TEXT NOT NULL,
481
+ last_bad_target_version TEXT,
482
+ CHECK (channel = 'home')
483
+ );
484
+ `);
485
+ }
460
486
  /** The ordered migration list. Index `i` is migration version `i + 1`; the
461
487
  * db's `user_version` tracks how many have been applied. Append only. */
462
488
  export const MIGRATIONS = [
@@ -468,6 +494,7 @@ export const MIGRATIONS = [
468
494
  /* v6 */ triggerAuditTable,
469
495
  /* v7 */ tenantIdentityTriggers,
470
496
  /* v8 */ parentIdentityUpdateGuards,
497
+ /* v9 */ hearthTargetTable,
471
498
  ];
472
499
  /** Bring `db` up to the latest schema version. Reads `user_version`, runs
473
500
  * each pending migration in order, and bumps `user_version` after each so
@@ -0,0 +1,23 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import type { HearthTarget, SetHearthTargetInput } from './types.js';
3
+ /** Read the single global target row, or `null` when no image has ever been
4
+ * registered (fresh CP). A `null` means the wake path treats every home as
5
+ * up to date (nothing to roll onto yet). */
6
+ export declare function getHearthTarget(db?: DatabaseSync): HearthTarget | null;
7
+ /** Upsert the global target to a newly-registered image. IDEMPOTENT: a
8
+ * re-post of the version already recorded is a no-op (the row, including its
9
+ * `published_at` and any `last_bad_target_version` brake, is left exactly as
10
+ * is) — the image workflow can fire the notify more than once for one
11
+ * release without churning state. Advancing to a DIFFERENT version rewrites
12
+ * the target/image/published_at/source but deliberately preserves
13
+ * `last_bad_target_version` (a brake recorded for an older release is
14
+ * historical and harmless once the target has moved past it; the wake
15
+ * predicate keys off `target !== last_bad`). */
16
+ export declare function setHearthTarget(input: SetHearthTargetInput, db?: DatabaseSync): HearthTarget;
17
+ /** Record a version whose live roll failed for the fleet (§5.4). After this,
18
+ * the wake-path stale predicate excludes the current target (target ===
19
+ * last_bad_target_version → no home rolls) until a NEW release advances the
20
+ * target past it. This is the ONLY brake in the zero-touch loop (roadmap
21
+ * decision 1: never auto-advance onto a release that already failed a roll).
22
+ * Returns `null` if no target row exists (nothing to brake). */
23
+ export declare function recordBadTarget(badVersion: string, db?: DatabaseSync): HearthTarget | null;
@@ -0,0 +1,68 @@
1
+ // control-plane/hearth-target.ts — the CP store for the single global
2
+ // auto-upgrade target row (`hearth_target`, db.ts v9). One row, `channel =
3
+ // 'home'`. This module is the ONLY reader/writer of that row; the admin
4
+ // notify route (server.ts) upserts it, the wake path (wake.ts) reads it to
5
+ // decide whether a home is stale, and the rollback path records a bad target
6
+ // through it. It goes through the shared CP db handle (`getControlPlaneDb`)
7
+ // exactly like `registry.ts` — never a second connection.
8
+ import { getControlPlaneDb } from './db.js';
9
+ const TARGET_COLUMNS = [
10
+ 'channel',
11
+ 'target_template_version',
12
+ 'target_image_ref',
13
+ 'published_at',
14
+ 'source',
15
+ 'last_bad_target_version',
16
+ ];
17
+ const SELECT_TARGET = `SELECT ${TARGET_COLUMNS.join(', ')} FROM hearth_target WHERE channel = 'home'`;
18
+ function nowIso() {
19
+ return new Date().toISOString();
20
+ }
21
+ /** Read the single global target row, or `null` when no image has ever been
22
+ * registered (fresh CP). A `null` means the wake path treats every home as
23
+ * up to date (nothing to roll onto yet). */
24
+ export function getHearthTarget(db = getControlPlaneDb()) {
25
+ const row = db.prepare(SELECT_TARGET).get();
26
+ if (row === undefined || row === null)
27
+ return null;
28
+ return row;
29
+ }
30
+ /** Upsert the global target to a newly-registered image. IDEMPOTENT: a
31
+ * re-post of the version already recorded is a no-op (the row, including its
32
+ * `published_at` and any `last_bad_target_version` brake, is left exactly as
33
+ * is) — the image workflow can fire the notify more than once for one
34
+ * release without churning state. Advancing to a DIFFERENT version rewrites
35
+ * the target/image/published_at/source but deliberately preserves
36
+ * `last_bad_target_version` (a brake recorded for an older release is
37
+ * historical and harmless once the target has moved past it; the wake
38
+ * predicate keys off `target !== last_bad`). */
39
+ export function setHearthTarget(input, db = getControlPlaneDb()) {
40
+ const existing = getHearthTarget(db);
41
+ if (existing !== null && existing.target_template_version === input.target_template_version) {
42
+ return existing;
43
+ }
44
+ const published_at = input.published_at ?? nowIso();
45
+ db.prepare(`
46
+ INSERT INTO hearth_target (channel, target_template_version, target_image_ref, published_at, source, last_bad_target_version)
47
+ VALUES ('home', ?, ?, ?, ?, NULL)
48
+ ON CONFLICT(channel) DO UPDATE SET
49
+ target_template_version = excluded.target_template_version,
50
+ target_image_ref = excluded.target_image_ref,
51
+ published_at = excluded.published_at,
52
+ source = excluded.source
53
+ `).run(input.target_template_version, input.target_image_ref, published_at, input.source);
54
+ return getHearthTarget(db);
55
+ }
56
+ /** Record a version whose live roll failed for the fleet (§5.4). After this,
57
+ * the wake-path stale predicate excludes the current target (target ===
58
+ * last_bad_target_version → no home rolls) until a NEW release advances the
59
+ * target past it. This is the ONLY brake in the zero-touch loop (roadmap
60
+ * decision 1: never auto-advance onto a release that already failed a roll).
61
+ * Returns `null` if no target row exists (nothing to brake). */
62
+ export function recordBadTarget(badVersion, db = getControlPlaneDb()) {
63
+ const existing = getHearthTarget(db);
64
+ if (existing === null)
65
+ return null;
66
+ db.prepare(`UPDATE hearth_target SET last_bad_target_version = ? WHERE channel = 'home'`).run(badVersion);
67
+ return getHearthTarget(db);
68
+ }
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import type { CreateHomeInput, Home, HomeId, HomePatch, HomeStatus, RefreshProviderPointersInput, ResolvedHome, TenantSession, TouchWakeInput } from './types.js';
2
+ import type { CreateHomeInput, Home, HomeHealthVerdict, HomeId, HomePatch, HomeStatus, RefreshProviderPointersInput, ResolvedHome, TenantSession, TouchWakeInput } from './types.js';
3
3
  export declare function createHome(input: CreateHomeInput, db?: DatabaseSync): Home;
4
4
  export declare function getHomeById(home_id: HomeId | null | undefined, db?: DatabaseSync): Home | null;
5
5
  /** Resolve a tenant session to its registry row. A null return is a hard
@@ -12,6 +12,11 @@ export declare function getHomeBySandbox(provider_sandbox_id: string | null | un
12
12
  export declare function updateHome(home_id: HomeId, mutate: (current: Home) => HomePatch, db?: DatabaseSync): Home;
13
13
  export declare function refreshProviderPointers(home_id: HomeId, pointers: RefreshProviderPointersInput, db?: DatabaseSync): Home;
14
14
  export declare function setHomeStatus(home_id: HomeId, status: HomeStatus, db?: DatabaseSync): Home;
15
+ /** Set the home's health verdict. The auto-upgrade roll (wake.ts `rollHome`)
16
+ * is the first natural producer of `health_verdict`: a completed roll/
17
+ * rollback writes `'healthy'`, a roll AND rollback both failing writes
18
+ * `'degraded'` (design §4/§5). */
19
+ export declare function setHealthVerdict(home_id: HomeId, health_verdict: HomeHealthVerdict, db?: DatabaseSync): Home;
15
20
  export declare function touchWake(home_id: HomeId, input?: TouchWakeInput, db?: DatabaseSync): Home;
16
21
  export declare function touchReady(home_id: HomeId, at?: string, db?: DatabaseSync): Home;
17
22
  export declare function touchSuspend(home_id: HomeId, at?: string, db?: DatabaseSync): Home;
@@ -147,6 +147,13 @@ export function refreshProviderPointers(home_id, pointers, db = getControlPlaneD
147
147
  export function setHomeStatus(home_id, status, db = getControlPlaneDb()) {
148
148
  return updateHome(home_id, () => ({ status }), db);
149
149
  }
150
+ /** Set the home's health verdict. The auto-upgrade roll (wake.ts `rollHome`)
151
+ * is the first natural producer of `health_verdict`: a completed roll/
152
+ * rollback writes `'healthy'`, a roll AND rollback both failing writes
153
+ * `'degraded'` (design §4/§5). */
154
+ export function setHealthVerdict(home_id, health_verdict, db = getControlPlaneDb()) {
155
+ return updateHome(home_id, () => ({ health_verdict }), db);
156
+ }
150
157
  export function touchWake(home_id, input = {}, db = getControlPlaneDb()) {
151
158
  return updateHome(home_id, () => ({
152
159
  last_wake_at: input.at ?? nowIso(),
@@ -5,6 +5,10 @@ import { type ControlPlaneConfig } from './config.js';
5
5
  import { type WakeResult } from './wake.js';
6
6
  import type { HomeId, ResolvedHome } from './types.js';
7
7
  import type { RelayDispatcher } from './server.js';
8
+ /** Serialize a non-`Error` rejection for a log line without collapsing it to `"[object Object]"`
9
+ * (`String(error)` on a plain object) — recreate-race-fix.md §3.6. Falls back to `String` only
10
+ * if the value itself isn't JSON-serializable (a circular structure, a BigInt, etc). */
11
+ export declare function serializeRelayError(error: unknown): string;
8
12
  /** Join the validated origin-form request target onto `previewEndpoint` and
9
13
  * reject anything that would escape that origin (criterion 4: "reject
10
14
  * scheme-relative/absolute external escapes"). */
@@ -35,6 +35,7 @@ import { BROKER_READ_CAPS } from '../../core/runtime/broker-protocol.js';
35
35
  import { general } from '../../core/errors.js';
36
36
  import { loadControlPlaneConfig } from './config.js';
37
37
  import { ensureAwake, invalidateWarmVerdict, resolveRunning } from './wake.js';
38
+ import { beginHttp, beginWs, endHttp, endWs } from './serving.js';
38
39
  import { redactSecrets } from './secrets.js';
39
40
  import { tenantSessionAuthRedactedMessage } from './session.js';
40
41
  const hopByHopHeaders = new Set([
@@ -69,6 +70,22 @@ function redactDeep(value) {
69
70
  function logError(message, details = {}) {
70
71
  process.stderr.write(`${JSON.stringify({ level: 'error', message, details: redactDeep(details) })}\n`);
71
72
  }
73
+ /** Serialize a non-`Error` rejection for a log line without collapsing it to `"[object Object]"`
74
+ * (`String(error)` on a plain object) — recreate-race-fix.md §3.6. Falls back to `String` only
75
+ * if the value itself isn't JSON-serializable (a circular structure, a BigInt, etc). */
76
+ export function serializeRelayError(error) {
77
+ try {
78
+ // `JSON.stringify` returns `undefined` (does not throw) for `undefined`, a function, or a
79
+ // symbol. Falling through to that here would hand the `message.startsWith('relay ')`/
80
+ // `/timed out/i` control-flow checks right after this an `undefined` string and THROW
81
+ // inside the catch block — exactly the control-flow change this rider promised not to make.
82
+ const json = JSON.stringify(error);
83
+ return json === undefined ? String(error) : json;
84
+ }
85
+ catch {
86
+ return String(error);
87
+ }
88
+ }
72
89
  function requestPath(req) {
73
90
  return req.url ?? '/';
74
91
  }
@@ -339,7 +356,25 @@ export async function proxyHttp(req, res, home, deps = {}) {
339
356
  const running = resolveRunningImpl(home.home_id);
340
357
  if (running !== null) {
341
358
  try {
342
- await forwardUpstream(req, res, running, controller);
359
+ // Mark this home as actively serving ONLY around the actual data
360
+ // transfer, never around the wake DECISION above it. Bumping the
361
+ // counter any earlier (e.g. unconditionally on function
362
+ // entry, before `ensureAwake` runs below) would make a home's very
363
+ // FIRST request after being suspended see ITS OWN just-begun counter
364
+ // and read as "serving" — `isHomeStale && !homeIsServing` would then
365
+ // never be true for any home, ever, silently disabling every roll
366
+ // (verified live: `beginHttp` then `isHomeServing` in the same tick
367
+ // returns true with zero other activity). Scoping begin/end tightly
368
+ // around each `forwardUpstream` call still protects the real target
369
+ // (a genuinely separate, already-open session on this home) because
370
+ // that session's OWN counter is independent and already non-zero.
371
+ beginHttp(home.home_id);
372
+ try {
373
+ await forwardUpstream(req, res, running, controller);
374
+ }
375
+ finally {
376
+ endHttp(home.home_id);
377
+ }
343
378
  return;
344
379
  }
345
380
  catch (error) {
@@ -358,10 +393,21 @@ export async function proxyHttp(req, res, home, deps = {}) {
358
393
  const awakened = await withTimeout(ensureAwakeImpl(home.home_id), config.wakeTimeoutMs, 'control plane relay wake');
359
394
  if (controller.signal.aborted)
360
395
  throw general('request closed while waking');
361
- await forwardUpstream(req, res, awakened, controller);
396
+ beginHttp(home.home_id);
397
+ try {
398
+ await forwardUpstream(req, res, awakened, controller);
399
+ }
400
+ finally {
401
+ endHttp(home.home_id);
402
+ }
362
403
  }
363
404
  catch (error) {
364
- const message = error instanceof Error ? error.message : String(error);
405
+ // A non-`Error` rejection (e.g. a raw object thrown from outside the SDK's recognized error
406
+ // shapes) must not serialize to "[object Object]" here — that loss of the real error is what
407
+ // cost the recreate-race incident its diagnosis (recreate-race-fix.md §3.6). `message` below
408
+ // still drives the control-flow checks that follow (relay-prefixed/timeout messages), which
409
+ // only ever come from `Error` instances (`general()`), so this branch change cannot affect them.
410
+ const message = error instanceof Error ? error.message : serializeRelayError(error);
365
411
  logError('hearth control plane relay HTTP request failed', {
366
412
  home_id: home.home_id,
367
413
  path: requestPath(req).replace(/\?.*$/, ''),
@@ -489,6 +535,12 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
489
535
  const handshakeAt = Date.now();
490
536
  let upstream = null;
491
537
  let teardownStarted = false;
538
+ // Whether THIS bridge has actually incremented `wsOpen` (set true only
539
+ // once the upstream leg is being established — see below —
540
+ // and guards the `close`-handler `endWs` call so a bridge whose own
541
+ // `ensureAwake` failed before ever counting itself can't decrement a
542
+ // DIFFERENT, still-live session's counter for the same home).
543
+ let wsCounted = false;
492
544
  let upstreamOpenAt = null;
493
545
  let browserClose = null;
494
546
  let upstreamClose = null;
@@ -551,6 +603,10 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
551
603
  if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
552
604
  sendSocketClose(upstream, code, closeReason);
553
605
  }
606
+ if (wsCounted) {
607
+ wsCounted = false;
608
+ endWs(home.home_id);
609
+ }
554
610
  });
555
611
  browserWs.on('error', (error) => {
556
612
  logError('hearth control plane relay browser websocket failed', { home_id: home.home_id, error: error instanceof Error ? error.message : String(error) });
@@ -565,6 +621,19 @@ async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
565
621
  beginTeardown();
566
622
  return;
567
623
  }
624
+ // Mark this home as actively serving ONLY once the wake DECISION above
625
+ // has already resolved — not on function entry.
626
+ // `ensureAwake` (inside the wait above) makes the roll-vs-normal-wake
627
+ // call by consulting this very tracker; counting a bridge as "open"
628
+ // before that decision runs would make a home's very first WS reconnect
629
+ // after being suspended see ITS OWN not-yet-real session and read as
630
+ // "serving," permanently blocking the roll this connection was itself
631
+ // supposed to trigger (mirrors the identical HTTP hazard in `proxyHttp`
632
+ // above — verified live for the HTTP case; same mechanism applies here).
633
+ // A genuinely separate, already-open bridge for this home is unaffected:
634
+ // its own counter is already live and independent of this one.
635
+ beginWs(home.home_id);
636
+ wsCounted = true;
568
637
  upstream = createUpstream(toWssUrl(awakened.preview_endpoint, target), awakened.relayToken);
569
638
  upstream.on('open', () => {
570
639
  if (teardownStarted || browserWs.readyState !== WebSocket.OPEN) {
@@ -7,6 +7,20 @@
7
7
  * an unknown handle — the thrown message never includes a resolved value,
8
8
  * only the handle name. */
9
9
  export declare function resolveSecret(handle: string, env?: NodeJS.ProcessEnv): string;
10
+ /** The secret HANDLE for the CP admin bearer token that gates the
11
+ * `POST /__admin/*` ingress (the image workflow's push-notify, design
12
+ * §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
13
+ * handle: the per-handle env override
14
+ * `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
15
+ * key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
16
+ * and the deployed Fly CP must carry the SAME value. */
17
+ export declare const HEARTH_CP_ADMIN_TOKEN_HANDLE = "HEARTH_CP_ADMIN_TOKEN";
18
+ /** Resolve the CP admin bearer token, or `null` when it is not configured.
19
+ * Unlike `resolveSecret` (which throws on an unknown handle), this returns
20
+ * `null` so the admin route can FAIL CLOSED — a CP with no admin token
21
+ * configured rejects every `/__admin/*` request rather than crashing the
22
+ * request handler. Never logs the value. */
23
+ export declare function resolveCpAdminToken(env?: NodeJS.ProcessEnv): string | null;
10
24
  /** Redact every secret value resolved so far this process out of a log
11
25
  * line/message. CP code building a log line from data that may carry a
12
26
  * resolved secret (e.g. an upstream request trace) MUST pass it through this
@@ -92,6 +92,27 @@ export function resolveSecret(handle, env = process.env) {
92
92
  }
93
93
  throw general(`unknown secret handle: ${trimmed}`, { handle: trimmed });
94
94
  }
95
+ /** The secret HANDLE for the CP admin bearer token that gates the
96
+ * `POST /__admin/*` ingress (the image workflow's push-notify, design
97
+ * §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
98
+ * handle: the per-handle env override
99
+ * `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
100
+ * key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
101
+ * and the deployed Fly CP must carry the SAME value. */
102
+ export const HEARTH_CP_ADMIN_TOKEN_HANDLE = 'HEARTH_CP_ADMIN_TOKEN';
103
+ /** Resolve the CP admin bearer token, or `null` when it is not configured.
104
+ * Unlike `resolveSecret` (which throws on an unknown handle), this returns
105
+ * `null` so the admin route can FAIL CLOSED — a CP with no admin token
106
+ * configured rejects every `/__admin/*` request rather than crashing the
107
+ * request handler. Never logs the value. */
108
+ export function resolveCpAdminToken(env = process.env) {
109
+ try {
110
+ return resolveSecret(HEARTH_CP_ADMIN_TOKEN_HANDLE, env);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
95
116
  /** Redact every secret value resolved so far this process out of a log
96
117
  * line/message. CP code building a log line from data that may carry a
97
118
  * resolved secret (e.g. an upstream request trace) MUST pass it through this