@nocobase/app-plugin-releases 0.1.0-beta.2 → 0.1.0-beta.20

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 (268) hide show
  1. package/CHANGELOG.md +341 -0
  2. package/LICENSE.txt +129 -0
  3. package/README.md +91 -16
  4. package/dist/client/components/environment-form.d.ts +1 -1
  5. package/dist/client/components/environment-form.d.ts.map +1 -1
  6. package/dist/client/components/environment-form.js +9 -2
  7. package/dist/client/components/environment-form.js.map +1 -1
  8. package/dist/client/components/page-header.d.ts.map +1 -1
  9. package/dist/client/components/page-header.js +1 -1
  10. package/dist/client/components/page-header.js.map +1 -1
  11. package/dist/client/components/release-badges.d.ts.map +1 -1
  12. package/dist/client/components/release-badges.js +13 -1
  13. package/dist/client/components/release-badges.js.map +1 -1
  14. package/dist/client/components/route-overlay.d.ts +4 -1
  15. package/dist/client/components/route-overlay.d.ts.map +1 -1
  16. package/dist/client/components/route-overlay.js +32 -3
  17. package/dist/client/components/route-overlay.js.map +1 -1
  18. package/dist/client/components/runtime-policy-fields.d.ts +7 -1
  19. package/dist/client/components/runtime-policy-fields.d.ts.map +1 -1
  20. package/dist/client/components/runtime-policy-fields.js +19 -5
  21. package/dist/client/components/runtime-policy-fields.js.map +1 -1
  22. package/dist/client/components/runtime-state.d.ts +6 -0
  23. package/dist/client/components/runtime-state.d.ts.map +1 -0
  24. package/dist/client/components/runtime-state.js +67 -0
  25. package/dist/client/components/runtime-state.js.map +1 -0
  26. package/dist/client/components/shared-host.d.ts +20 -0
  27. package/dist/client/components/shared-host.d.ts.map +1 -0
  28. package/dist/client/components/shared-host.js +88 -0
  29. package/dist/client/components/shared-host.js.map +1 -0
  30. package/dist/client/components/ui/progress.d.ts +9 -0
  31. package/dist/client/components/ui/progress.d.ts.map +1 -0
  32. package/dist/client/components/ui/progress.js +20 -0
  33. package/dist/client/components/ui/progress.js.map +1 -0
  34. package/dist/client/components/use-route-overlay.d.ts +2 -9
  35. package/dist/client/components/use-route-overlay.d.ts.map +1 -1
  36. package/dist/client/components/use-route-overlay.js +5 -3
  37. package/dist/client/components/use-route-overlay.js.map +1 -1
  38. package/dist/client/components/variable-alerts.d.ts.map +1 -1
  39. package/dist/client/components/variable-alerts.js +6 -18
  40. package/dist/client/components/variable-alerts.js.map +1 -1
  41. package/dist/client/hooks/use-app-summary.d.ts +1 -1
  42. package/dist/client/hooks/use-app-summary.d.ts.map +1 -1
  43. package/dist/client/hooks/use-app-summary.js +4 -1
  44. package/dist/client/hooks/use-app-summary.js.map +1 -1
  45. package/dist/client/hooks/use-releases.d.ts +4 -1
  46. package/dist/client/hooks/use-releases.d.ts.map +1 -1
  47. package/dist/client/hooks/use-releases.js +6 -2
  48. package/dist/client/hooks/use-releases.js.map +1 -1
  49. package/dist/client/lib/app-state.d.ts +2 -2
  50. package/dist/client/lib/app-state.d.ts.map +1 -1
  51. package/dist/client/lib/app-state.js +10 -3
  52. package/dist/client/lib/app-state.js.map +1 -1
  53. package/dist/client/lib/errors.d.ts.map +1 -1
  54. package/dist/client/lib/errors.js +7 -0
  55. package/dist/client/lib/errors.js.map +1 -1
  56. package/dist/client/lib/format.d.ts +2 -0
  57. package/dist/client/lib/format.d.ts.map +1 -1
  58. package/dist/client/lib/format.js +7 -0
  59. package/dist/client/lib/format.js.map +1 -1
  60. package/dist/client/lib/paths.d.ts +5 -0
  61. package/dist/client/lib/paths.d.ts.map +1 -1
  62. package/dist/client/lib/paths.js +10 -0
  63. package/dist/client/lib/paths.js.map +1 -1
  64. package/dist/client/lib/permissions.d.ts +5 -0
  65. package/dist/client/lib/permissions.d.ts.map +1 -1
  66. package/dist/client/lib/permissions.js +8 -0
  67. package/dist/client/lib/permissions.js.map +1 -1
  68. package/dist/client/lib/runtime-state.d.ts +25 -0
  69. package/dist/client/lib/runtime-state.d.ts.map +1 -0
  70. package/dist/client/lib/runtime-state.js +58 -0
  71. package/dist/client/lib/runtime-state.js.map +1 -0
  72. package/dist/client/locales/de-DE.d.ts +5 -0
  73. package/dist/client/locales/de-DE.d.ts.map +1 -0
  74. package/dist/client/locales/de-DE.js +876 -0
  75. package/dist/client/locales/de-DE.js.map +1 -0
  76. package/dist/client/locales/en-US.d.ts +83 -9
  77. package/dist/client/locales/en-US.d.ts.map +1 -1
  78. package/dist/client/locales/en-US.js +80 -9
  79. package/dist/client/locales/en-US.js.map +1 -1
  80. package/dist/client/locales/es-ES.d.ts +5 -0
  81. package/dist/client/locales/es-ES.d.ts.map +1 -0
  82. package/dist/client/locales/es-ES.js +882 -0
  83. package/dist/client/locales/es-ES.js.map +1 -0
  84. package/dist/client/locales/fr-FR.d.ts +5 -0
  85. package/dist/client/locales/fr-FR.d.ts.map +1 -0
  86. package/dist/client/locales/fr-FR.js +882 -0
  87. package/dist/client/locales/fr-FR.js.map +1 -0
  88. package/dist/client/locales/id-ID.d.ts +5 -0
  89. package/dist/client/locales/id-ID.d.ts.map +1 -0
  90. package/dist/client/locales/id-ID.js +876 -0
  91. package/dist/client/locales/id-ID.js.map +1 -0
  92. package/dist/client/locales/index.d.ts.map +1 -1
  93. package/dist/client/locales/index.js +10 -0
  94. package/dist/client/locales/index.js.map +1 -1
  95. package/dist/client/locales/ja-JP.d.ts +5 -0
  96. package/dist/client/locales/ja-JP.d.ts.map +1 -0
  97. package/dist/client/locales/ja-JP.js +876 -0
  98. package/dist/client/locales/ja-JP.js.map +1 -0
  99. package/dist/client/locales/ko-KR.d.ts +5 -0
  100. package/dist/client/locales/ko-KR.d.ts.map +1 -0
  101. package/dist/client/locales/ko-KR.js +879 -0
  102. package/dist/client/locales/ko-KR.js.map +1 -0
  103. package/dist/client/locales/pt-BR.d.ts +5 -0
  104. package/dist/client/locales/pt-BR.d.ts.map +1 -0
  105. package/dist/client/locales/pt-BR.js +882 -0
  106. package/dist/client/locales/pt-BR.js.map +1 -0
  107. package/dist/client/locales/ru-RU.d.ts +5 -0
  108. package/dist/client/locales/ru-RU.d.ts.map +1 -0
  109. package/dist/client/locales/ru-RU.js +891 -0
  110. package/dist/client/locales/ru-RU.js.map +1 -0
  111. package/dist/client/locales/vi-VN.d.ts +5 -0
  112. package/dist/client/locales/vi-VN.d.ts.map +1 -0
  113. package/dist/client/locales/vi-VN.js +876 -0
  114. package/dist/client/locales/vi-VN.js.map +1 -0
  115. package/dist/client/locales/zh-CN.d.ts.map +1 -1
  116. package/dist/client/locales/zh-CN.js +83 -9
  117. package/dist/client/locales/zh-CN.js.map +1 -1
  118. package/dist/client/locales/zh-TW.d.ts +5 -0
  119. package/dist/client/locales/zh-TW.d.ts.map +1 -0
  120. package/dist/client/locales/zh-TW.js +876 -0
  121. package/dist/client/locales/zh-TW.js.map +1 -0
  122. package/dist/client/pages/app-page.d.ts.map +1 -1
  123. package/dist/client/pages/app-page.js +14 -8
  124. package/dist/client/pages/app-page.js.map +1 -1
  125. package/dist/client/pages/apps-outlet.d.ts +8 -0
  126. package/dist/client/pages/apps-outlet.d.ts.map +1 -0
  127. package/dist/client/pages/apps-outlet.js +9 -0
  128. package/dist/client/pages/apps-outlet.js.map +1 -0
  129. package/dist/client/pages/environment-page.d.ts.map +1 -1
  130. package/dist/client/pages/environment-page.js +112 -24
  131. package/dist/client/pages/environment-page.js.map +1 -1
  132. package/dist/client/pages/environments-page.d.ts +8 -3
  133. package/dist/client/pages/environments-page.d.ts.map +1 -1
  134. package/dist/client/pages/environments-page.js +58 -10
  135. package/dist/client/pages/environments-page.js.map +1 -1
  136. package/dist/client/pages/new-app-page.d.ts +2 -4
  137. package/dist/client/pages/new-app-page.d.ts.map +1 -1
  138. package/dist/client/pages/new-app-page.js +29 -19
  139. package/dist/client/pages/new-app-page.js.map +1 -1
  140. package/dist/client/pages/new-app-redirect.d.ts +7 -0
  141. package/dist/client/pages/new-app-redirect.d.ts.map +1 -0
  142. package/dist/client/pages/new-app-redirect.js +8 -0
  143. package/dist/client/pages/new-app-redirect.js.map +1 -0
  144. package/dist/client/pages/request-page.js +1 -1
  145. package/dist/client/pages.d.ts +6 -2
  146. package/dist/client/pages.d.ts.map +1 -1
  147. package/dist/client/pages.js +8 -4
  148. package/dist/client/pages.js.map +1 -1
  149. package/dist/client/routes.d.ts.map +1 -1
  150. package/dist/client/routes.js +37 -10
  151. package/dist/client/routes.js.map +1 -1
  152. package/dist/database/migrations/.manifest.json +10 -2
  153. package/dist/database/migrations/202610020001_rel_create_tables.js +1 -1
  154. package/dist/database/migrations/202610020001_rel_create_tables.js.map +1 -1
  155. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts +4 -0
  156. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts.map +1 -0
  157. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js +51 -0
  158. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js.map +1 -0
  159. package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts +8 -0
  160. package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts.map +1 -0
  161. package/dist/database/migrations/202611020001_rel_environment_purpose.js +30 -0
  162. package/dist/database/migrations/202611020001_rel_environment_purpose.js.map +1 -0
  163. package/dist/server/access/caller.d.ts +6 -0
  164. package/dist/server/access/caller.d.ts.map +1 -1
  165. package/dist/server/access/caller.js +36 -0
  166. package/dist/server/access/caller.js.map +1 -1
  167. package/dist/server/artifact-config.d.ts +9 -0
  168. package/dist/server/artifact-config.d.ts.map +1 -0
  169. package/dist/server/artifact-config.js +49 -0
  170. package/dist/server/artifact-config.js.map +1 -0
  171. package/dist/server/composition.d.ts +6 -0
  172. package/dist/server/composition.d.ts.map +1 -1
  173. package/dist/server/composition.js +7 -1
  174. package/dist/server/composition.js.map +1 -1
  175. package/dist/server/config.d.ts +18 -2
  176. package/dist/server/config.d.ts.map +1 -1
  177. package/dist/server/drivers/host/driver.d.ts +13 -2
  178. package/dist/server/drivers/host/driver.d.ts.map +1 -1
  179. package/dist/server/drivers/host/driver.js +427 -81
  180. package/dist/server/drivers/host/driver.js.map +1 -1
  181. package/dist/server/drivers/types.d.ts +79 -1
  182. package/dist/server/drivers/types.d.ts.map +1 -1
  183. package/dist/server/drivers/types.js +6 -0
  184. package/dist/server/drivers/types.js.map +1 -1
  185. package/dist/server/index.d.ts +1 -1
  186. package/dist/server/index.d.ts.map +1 -1
  187. package/dist/server/index.js.map +1 -1
  188. package/dist/server/locales/de-DE.d.ts +5 -0
  189. package/dist/server/locales/de-DE.d.ts.map +1 -0
  190. package/dist/server/locales/de-DE.js +133 -0
  191. package/dist/server/locales/de-DE.js.map +1 -0
  192. package/dist/server/locales/es-ES.d.ts +5 -0
  193. package/dist/server/locales/es-ES.d.ts.map +1 -0
  194. package/dist/server/locales/es-ES.js +133 -0
  195. package/dist/server/locales/es-ES.js.map +1 -0
  196. package/dist/server/locales/fr-FR.d.ts +5 -0
  197. package/dist/server/locales/fr-FR.d.ts.map +1 -0
  198. package/dist/server/locales/fr-FR.js +133 -0
  199. package/dist/server/locales/fr-FR.js.map +1 -0
  200. package/dist/server/locales/id-ID.d.ts +5 -0
  201. package/dist/server/locales/id-ID.d.ts.map +1 -0
  202. package/dist/server/locales/id-ID.js +133 -0
  203. package/dist/server/locales/id-ID.js.map +1 -0
  204. package/dist/server/locales/index.d.ts.map +1 -1
  205. package/dist/server/locales/index.js +10 -0
  206. package/dist/server/locales/index.js.map +1 -1
  207. package/dist/server/locales/ja-JP.d.ts +5 -0
  208. package/dist/server/locales/ja-JP.d.ts.map +1 -0
  209. package/dist/server/locales/ja-JP.js +133 -0
  210. package/dist/server/locales/ja-JP.js.map +1 -0
  211. package/dist/server/locales/ko-KR.d.ts +5 -0
  212. package/dist/server/locales/ko-KR.d.ts.map +1 -0
  213. package/dist/server/locales/ko-KR.js +133 -0
  214. package/dist/server/locales/ko-KR.js.map +1 -0
  215. package/dist/server/locales/pt-BR.d.ts +5 -0
  216. package/dist/server/locales/pt-BR.d.ts.map +1 -0
  217. package/dist/server/locales/pt-BR.js +133 -0
  218. package/dist/server/locales/pt-BR.js.map +1 -0
  219. package/dist/server/locales/ru-RU.d.ts +5 -0
  220. package/dist/server/locales/ru-RU.d.ts.map +1 -0
  221. package/dist/server/locales/ru-RU.js +133 -0
  222. package/dist/server/locales/ru-RU.js.map +1 -0
  223. package/dist/server/locales/vi-VN.d.ts +5 -0
  224. package/dist/server/locales/vi-VN.d.ts.map +1 -0
  225. package/dist/server/locales/vi-VN.js +133 -0
  226. package/dist/server/locales/vi-VN.js.map +1 -0
  227. package/dist/server/locales/zh-TW.d.ts +5 -0
  228. package/dist/server/locales/zh-TW.d.ts.map +1 -0
  229. package/dist/server/locales/zh-TW.js +133 -0
  230. package/dist/server/locales/zh-TW.js.map +1 -0
  231. package/dist/server/providers/releases.d.ts.map +1 -1
  232. package/dist/server/providers/releases.js +12 -5
  233. package/dist/server/providers/releases.js.map +1 -1
  234. package/dist/server/routes/api.d.ts +1 -1
  235. package/dist/server/routes/api.d.ts.map +1 -1
  236. package/dist/server/routes/api.js +120 -20
  237. package/dist/server/routes/api.js.map +1 -1
  238. package/dist/server/routes/index.d.ts.map +1 -1
  239. package/dist/server/routes/index.js +10 -2
  240. package/dist/server/routes/index.js.map +1 -1
  241. package/dist/server/routes/schemas.d.ts +7 -2
  242. package/dist/server/routes/schemas.d.ts.map +1 -1
  243. package/dist/server/routes/schemas.js +320 -89
  244. package/dist/server/routes/schemas.js.map +1 -1
  245. package/dist/server/services/environments.d.ts +2 -2
  246. package/dist/server/services/environments.d.ts.map +1 -1
  247. package/dist/server/services/environments.js +21 -1
  248. package/dist/server/services/environments.js.map +1 -1
  249. package/dist/server/services/releases.d.ts +69 -8
  250. package/dist/server/services/releases.d.ts.map +1 -1
  251. package/dist/server/services/releases.js +574 -128
  252. package/dist/server/services/releases.js.map +1 -1
  253. package/dist/server/services/validation.d.ts +2 -0
  254. package/dist/server/services/validation.d.ts.map +1 -1
  255. package/dist/server/services/validation.js +8 -0
  256. package/dist/server/services/validation.js.map +1 -1
  257. package/dist/server/tokens.d.ts +15 -2
  258. package/dist/server/tokens.d.ts.map +1 -1
  259. package/dist/server/tokens.js.map +1 -1
  260. package/dist/shared/releases.d.ts +94 -3
  261. package/dist/shared/releases.d.ts.map +1 -1
  262. package/dist/shared/releases.js +37 -0
  263. package/dist/shared/releases.js.map +1 -1
  264. package/package.json +18 -18
  265. package/dist/client/pages/apps-page.d.ts +0 -4
  266. package/dist/client/pages/apps-page.d.ts.map +0 -1
  267. package/dist/client/pages/apps-page.js +0 -116
  268. package/dist/client/pages/apps-page.js.map +0 -1
@@ -19,7 +19,8 @@ import { createDiagnosticLogger, } from '@nocobase/logging';
19
19
  import { stringify as stringifyYaml } from 'yaml';
20
20
  import { ACCESS_NAMESPACE } from '../../../shared/access.js';
21
21
  import { writeTextAtomic } from '../../services/config-file.js';
22
- import { expandPublicUrl, } from '../types.js';
22
+ export { UNRESTORED_APP_ERROR } from '../types.js';
23
+ import { expandPublicUrl, UNRESTORED_APP_ERROR, } from '../types.js';
23
24
  /** The backend of Apps that run in the Host process. */
24
25
  export const IN_PROCESS_BACKEND = 'in-process';
25
26
  export const DOCKER_BACKEND = 'docker';
@@ -61,6 +62,14 @@ export function createHostDriver(options) {
61
62
  const inProcess = options.hosts[IN_PROCESS_BACKEND];
62
63
  if (!inProcess)
63
64
  throw new Error('The Host driver needs the in-process Host.');
65
+ for (const [name, value] of Object.entries({
66
+ restartMaxWaitMs: inProcess.restartMaxWaitMs,
67
+ restartDrainMs: inProcess.restartDrainMs,
68
+ })) {
69
+ if (value !== undefined &&
70
+ (!Number.isInteger(value) || value < 0 || value > 2_147_483_647))
71
+ throw new Error(`Invalid ${name}: expected a non-negative timer interval`);
72
+ }
64
73
  const scopes = new Map();
65
74
  /** The latest revision sent per scope; the Host refuses an older one. */
66
75
  const revisions = new Map();
@@ -72,7 +81,23 @@ export function createHostDriver(options) {
72
81
  let runtimeChurn = 0;
73
82
  let inFlight = 0;
74
83
  let restartScheduled = null;
84
+ let rotationPreparing = null;
85
+ let readyRestoring = null;
75
86
  let churnTimer = null;
87
+ let restartDeadline = null;
88
+ let checkingChurn = false;
89
+ let recheckChurn = false;
90
+ let retryCheckAt = 0;
91
+ const capacityWaiting = new Set();
92
+ const batchBusy = () => [...scopes.values()].some((scope) => scope.host === inProcess &&
93
+ scope.deploymentBatchBusy?.(capacityWaiting));
94
+ let hostGeneration = 0;
95
+ let rotationSince = null;
96
+ let capacityRotation = false;
97
+ const rotationWaiters = new Set();
98
+ const heapRatio = inProcess.restartHeapRatio ?? (inProcess.restartAfterChurn ? 0.55 : 0);
99
+ if (!Number.isFinite(heapRatio) || heapRatio < 0 || heapRatio >= 1)
100
+ throw new Error('Invalid Host restart heap ratio');
76
101
  const hostOf = (backend) => {
77
102
  const host = options.hosts[backend];
78
103
  if (!host)
@@ -164,18 +189,11 @@ export function createHostDriver(options) {
164
189
  : {}),
165
190
  };
166
191
  };
167
- const track = (scope, spec) => {
168
- if (spec.activation === 'onDemand')
169
- scope.onDemand.add(spec.appId);
170
- else
171
- scope.onDemand.delete(spec.appId);
172
- };
173
192
  /** Sends one environment's complete desired set as a new revision of its scope. */
174
193
  const sendSet = async (scope) => {
175
194
  const management = await managementOf(scope.host);
176
195
  const specs = await scope.desired();
177
- for (const spec of specs)
178
- track(scope, spec);
196
+ scope.desiredAppIds = new Set(specs.map((spec) => spec.appId));
179
197
  const deployments = specs.map((spec) => toHostSpec(scope, spec));
180
198
  const send = async (revision) => {
181
199
  revisions.set(scope.environmentId, revision);
@@ -191,8 +209,22 @@ export function createHostDriver(options) {
191
209
  await send(first.revision + 1);
192
210
  };
193
211
  const scheduleRestore = (scope) => {
212
+ if (scope.restoring)
213
+ return scope.restoring;
194
214
  const current = sendSet(scope)
195
- .catch((error) => diagnostic.error(`Failed to restore the Apps of environment ${scope.environmentId}`, error))
215
+ .then(() => {
216
+ scope.restoreFailed = false;
217
+ scope.restoreError = null;
218
+ })
219
+ .catch((error) => {
220
+ scope.restoreFailed = true;
221
+ scope.restoreError =
222
+ error instanceof Error ? error.message : String(error);
223
+ if (scopes.get(scope.environmentId) === scope)
224
+ watchChurn(true);
225
+ diagnostic.error(`Failed to restore the Apps of environment ${scope.environmentId}`, error);
226
+ throw error;
227
+ })
196
228
  .finally(() => {
197
229
  if (scope.restoring === current)
198
230
  scope.restoring = null;
@@ -201,28 +233,49 @@ export function createHostDriver(options) {
201
233
  return current;
202
234
  };
203
235
  const restoreHost = async (host) => {
236
+ const failures = [];
204
237
  for (const scope of scopes.values())
205
238
  if (scope.host === host)
206
- await scheduleRestore(scope);
239
+ await scheduleRestore(scope).catch((error) => {
240
+ failures.push(error);
241
+ });
242
+ // A broken environment must not prevent the remaining environments from being restored.
243
+ if (failures.length)
244
+ throw failures[0];
207
245
  };
208
246
  const watchReady = (host) => {
209
247
  if (watched.has(host))
210
248
  return;
211
249
  watched.add(host);
212
250
  host.controller.onReady(() => {
213
- void restoreHost(host);
251
+ if (host === inProcess && !restartScheduled) {
252
+ hostGeneration += 1;
253
+ churn = 0;
254
+ runtimeChurn = 0;
255
+ if (restartDeadline)
256
+ clearTimeout(restartDeadline);
257
+ restartDeadline = null;
258
+ rotationSince = null;
259
+ capacityRotation = false;
260
+ }
261
+ if (!restartScheduled) {
262
+ const restoring = restoreHost(host);
263
+ if (host === inProcess)
264
+ readyRestoring = restoring;
265
+ void restoring
266
+ .catch(() => undefined)
267
+ .finally(() => {
268
+ if (readyRestoring === restoring)
269
+ readyRestoring = null;
270
+ });
271
+ }
214
272
  });
215
273
  };
216
274
  // --- Recycling the in-process Host (`restartAfterChurn`) ---------------------------------------------------------
217
- /** Each of the Host's open scopes with its status. */
218
- const statusesOf = async (host) => {
219
- const management = await managementOf(host);
220
- return await Promise.all([...scopes.values()]
221
- .filter((scope) => scope.host === host)
222
- .map(async (scope) => [
223
- scope,
224
- await management.getStatus({ scope: scope.environmentId }),
225
- ]));
275
+ const finishRotation = (ok) => {
276
+ for (const resolve of rotationWaiters)
277
+ resolve(ok);
278
+ rotationWaiters.clear();
226
279
  };
227
280
  const restartHost = async (reason) => {
228
281
  const { controller } = inProcess;
@@ -230,10 +283,28 @@ export function createHostDriver(options) {
230
283
  throw new Error('This Host runtime cannot be restarted by the driver.');
231
284
  restartScheduled ??= (async () => {
232
285
  try {
233
- await controller.restart(reason);
286
+ hostGeneration += 1;
287
+ if (restartDeadline)
288
+ clearTimeout(restartDeadline);
289
+ restartDeadline = null;
290
+ await controller.restart(reason, {
291
+ shutdownTimeoutMs: inProcess.restartDrainMs ?? 30_000,
292
+ });
234
293
  churn = 0;
235
294
  runtimeChurn = 0;
295
+ rotationSince = null;
296
+ capacityRotation = false;
236
297
  await restoreHost(inProcess);
298
+ finishRotation(true);
299
+ }
300
+ catch (error) {
301
+ // If restart failed before the child exited, serving traffic can resume. New revisions remain paused.
302
+ const service = await managementOf(inProcess).catch(() => null);
303
+ await service
304
+ ?.prepareRotation?.({ resume: true })
305
+ .catch(() => undefined);
306
+ finishRotation(false);
307
+ throw error;
237
308
  }
238
309
  finally {
239
310
  restartScheduled = null;
@@ -241,61 +312,190 @@ export function createHostDriver(options) {
241
312
  })();
242
313
  await restartScheduled;
243
314
  };
244
- /** Whether an on-demand App runs or starts, which a restart would cut off. */
245
- const onDemandBusy = (statuses) => statuses.some(([scope, status]) => status.deployments.some((deployment) => {
246
- const state = observed(deployment).state;
247
- return (scope.onDemand.has(deployment.appId) &&
248
- (state === 'running' || state === 'starting'));
249
- }));
250
- /**
251
- * Restarts the in-process Host when the churn reached `restartAfterChurn`, nothing is in flight and no on-demand App
252
- * is in use; otherwise a later check tries again.
253
- */
254
315
  const maybeRestart = async () => {
255
- const limit = inProcess.restartAfterChurn;
256
316
  const { controller } = inProcess;
257
- if (!limit || !controller.restart || restartScheduled)
317
+ if (restartScheduled || rotationPreparing)
258
318
  return;
259
- if (controller.getInfo().status !== 'ready')
319
+ if (controller.getInfo().status !== 'ready' || Date.now() < retryCheckAt)
260
320
  return;
261
- const statuses = await statusesOf(inProcess).catch(() => null);
262
- const counters = statuses?.find(([, status]) => status.counters)?.[1]
263
- .counters;
264
- if (counters)
265
- runtimeChurn = (counters.idleStops ?? 0) + (counters.dormancies ?? 0);
321
+ if ((!inProcess.restartAfterChurn && !heapRatio && !capacityRotation) ||
322
+ !controller.restart)
323
+ return;
324
+ if (batchBusy() && rotationSince === null && !capacityRotation)
325
+ return;
326
+ const generation = hostGeneration;
327
+ const service = await managementOf(inProcess);
328
+ // The unfiltered status includes resident, staging and unscoped Apps sharing the process.
329
+ const status = await service.getStatus();
330
+ if (restartScheduled || generation !== hostGeneration)
331
+ return;
332
+ const counters = status.counters;
333
+ runtimeChurn =
334
+ (counters?.idleStops ?? 0) +
335
+ (counters?.dormancies ?? 0) +
336
+ (counters?.capacityStops ?? 0) +
337
+ (counters?.startupTimeouts ?? 0);
266
338
  const total = churn + runtimeChurn;
267
- if (total < limit || inFlight > 0)
339
+ const memory = counters?.memory;
340
+ const threshold = total > 0 &&
341
+ (capacityRotation ||
342
+ (inProcess.restartAfterChurn && total >= inProcess.restartAfterChurn) ||
343
+ (heapRatio > 0 &&
344
+ memory &&
345
+ memory.heapUsed >= memory.heapLimit * heapRatio));
346
+ if (!threshold && rotationSince === null)
347
+ return;
348
+ rotationSince ??= Date.now();
349
+ const wait = inProcess.restartMaxWaitMs ?? 5 * 60_000;
350
+ const expired = Date.now() - rotationSince >= wait;
351
+ if (!expired && !restartDeadline) {
352
+ restartDeadline = setTimeout(() => {
353
+ restartDeadline = null;
354
+ checkChurn();
355
+ }, Math.max(1, wait - (Date.now() - rotationSince)));
356
+ restartDeadline.unref?.();
357
+ }
358
+ if (expired)
359
+ await service.prepareRotation?.({ pauseOnly: true });
360
+ if (inFlight > 0 ||
361
+ batchBusy() ||
362
+ [...scopes.values()].some((scope) => scope.host === inProcess && scope.restoring !== null) ||
363
+ (counters?.operationsInFlight ?? 0) > 0)
364
+ return;
365
+ const busy = status.deployments.some((deployment) => deployment.lifecycle?.state === 'starting' ||
366
+ (!expired && (deployment.app?.activeRequests ?? 0) > 0) ||
367
+ (!expired && (deployment.app?.activeConnections ?? 0) > 0));
368
+ if (busy)
268
369
  return;
269
- if (statuses && onDemandBusy(statuses))
370
+ // Before the deadline reserve only an idle child. At the deadline gate ingress first, then give existing
371
+ // responses a bounded drain. The old proxy target remains available to return 503 + Retry-After while draining.
372
+ if (!service.prepareRotation)
270
373
  return;
271
- await restartHost(`${total} applications replaced, removed, stopped or made dormant`);
374
+ const preparing = service.prepareRotation({
375
+ ignoreConnections: expired,
376
+ ...(expired
377
+ ? { drainTimeoutMs: inProcess.restartDrainMs ?? 30_000 }
378
+ : {}),
379
+ });
380
+ rotationPreparing = preparing;
381
+ let reserved;
382
+ try {
383
+ reserved = await preparing;
384
+ }
385
+ catch (error) {
386
+ await service.prepareRotation({ resume: true }).catch(() => undefined);
387
+ throw error;
388
+ }
389
+ finally {
390
+ if (rotationPreparing === preparing)
391
+ rotationPreparing = null;
392
+ }
393
+ if (!reserved)
394
+ return;
395
+ // New tracked work waits for the handoff. New control-plane dispatches have not entered the Host yet;
396
+ // the batch was settled before the handoff began. Existing operations remain protected by the child recheck.
397
+ if (inFlight > 0 || generation !== hostGeneration) {
398
+ await service.prepareRotation({ resume: true });
399
+ return;
400
+ }
401
+ await restartHost(`${total} applications deployed, replaced, removed, stopped or made dormant; recycling Host memory`);
272
402
  };
273
403
  const checkChurn = () => {
274
- void maybeRestart().catch((error) => diagnostic.error('Failed to restart the App Host', error));
404
+ if (checkingChurn) {
405
+ recheckChurn = true;
406
+ return;
407
+ }
408
+ checkingChurn = true;
409
+ void maybeRestart()
410
+ .catch((error) => {
411
+ retryCheckAt = Date.now() + (options.churnCheckIntervalMs ?? 60_000);
412
+ diagnostic.error('Failed to recycle the App Host', error);
413
+ })
414
+ .finally(() => {
415
+ checkingChurn = false;
416
+ // Retry failed restores after evaluating rotation, never as a foreground management prerequisite. A pending
417
+ // restore remains protected work, including the interval spent reading its desired set before Host submission.
418
+ if (!inFlight &&
419
+ !batchBusy() &&
420
+ !rotationPreparing &&
421
+ !restartScheduled &&
422
+ inProcess.controller.getInfo().status === 'ready' &&
423
+ Date.now() >= retryCheckAt)
424
+ for (const scope of scopes.values())
425
+ if (scope.restoreFailed && !scope.restoring)
426
+ void scheduleRestore(scope).catch(() => undefined);
427
+ if (recheckChurn) {
428
+ recheckChurn = false;
429
+ queueMicrotask(checkChurn);
430
+ }
431
+ });
275
432
  };
276
433
  const countChurn = (scope) => {
277
434
  if (scope.host !== inProcess)
278
435
  return;
279
436
  churn += 1;
280
- checkChurn();
281
437
  };
282
- const watchChurn = () => {
283
- if (churnTimer || !inProcess.restartAfterChurn)
438
+ const watchChurn = (forRecovery = false) => {
439
+ if (churnTimer ||
440
+ (!forRecovery && !inProcess.restartAfterChurn && !heapRatio))
284
441
  return;
285
442
  churnTimer = setInterval(checkChurn, options.churnCheckIntervalMs ?? 60_000);
286
443
  churnTimer.unref?.();
287
444
  };
445
+ /** Several failed admissions share one rotation, never hold inFlight while waiting for it. */
446
+ const recycleForCapacity = async (signal) => {
447
+ if (!inProcess.controller.restart || signal.aborted)
448
+ return false;
449
+ const status = await (await managementOf(inProcess)).getStatus();
450
+ const counters = status.counters;
451
+ runtimeChurn =
452
+ (counters?.idleStops ?? 0) +
453
+ (counters?.dormancies ?? 0) +
454
+ (counters?.capacityStops ?? 0) +
455
+ (counters?.startupTimeouts ?? 0);
456
+ if (churn + runtimeChurn <= 0)
457
+ return false;
458
+ capacityRotation = true;
459
+ return new Promise((resolve) => {
460
+ const done = (ok) => {
461
+ clearTimeout(timer);
462
+ rotationWaiters.delete(done);
463
+ signal.removeEventListener('abort', cancelled);
464
+ resolve(ok);
465
+ };
466
+ const cancelled = () => done(false);
467
+ signal.addEventListener('abort', cancelled, { once: true });
468
+ const timer = setTimeout(() => done(false), (inProcess.restartMaxWaitMs ?? 300_000) +
469
+ 2 * (inProcess.restartDrainMs ?? 30_000) +
470
+ (options.churnCheckIntervalMs ?? 60_000));
471
+ rotationWaiters.add(done);
472
+ if (signal.aborted)
473
+ cancelled();
474
+ else
475
+ checkChurn();
476
+ });
477
+ };
288
478
  const tracked = async (scope, work) => {
289
479
  if (scope.host !== inProcess)
290
480
  return await work();
481
+ if (rotationPreparing)
482
+ await rotationPreparing.catch(() => false);
291
483
  if (restartScheduled)
292
484
  await restartScheduled.catch(() => undefined);
485
+ if (readyRestoring)
486
+ await readyRestoring.catch(() => undefined);
487
+ // A pending replay may still hold an older desired set. Let it finish before submitting any mutation.
488
+ // Failure leaves management usable; only the background poll initiates another recovery attempt.
489
+ if (scope.restoring)
490
+ await scope.restoring.catch(() => undefined);
293
491
  inFlight += 1;
294
492
  try {
295
493
  return await work();
296
494
  }
297
495
  finally {
298
496
  inFlight -= 1;
497
+ if (inFlight === 0)
498
+ queueMicrotask(checkChurn);
299
499
  }
300
500
  };
301
501
  const capabilitiesOf = (backend) => HOST_BACKEND_CAPABILITIES[backend] ??
@@ -343,6 +543,10 @@ export function createHostDriver(options) {
343
543
  churn: () => churn + runtimeChurn,
344
544
  restartHost: (reason = 'restart requested') => restartHost(reason),
345
545
  proxyTarget() {
546
+ // Only the initial replay after the child becomes ready gates ingress. Failed environments and their
547
+ // background retries must not take healthy applications on this shared Host offline.
548
+ if (restartScheduled || readyRestoring)
549
+ return null;
346
550
  const info = inProcess.controller.getInfo();
347
551
  return info.status === 'ready' && info.targetUrl
348
552
  ? new URL(info.targetUrl)
@@ -361,11 +565,15 @@ export function createHostDriver(options) {
361
565
  scope: scopeOf(environment, settings),
362
566
  publicUrl: environment.publicUrl,
363
567
  desired: () => context.desired(),
364
- onDemand: new Set(),
365
568
  restoring: null,
569
+ restoreFailed: false,
570
+ restoreError: null,
571
+ desiredAppIds: new Set(),
572
+ deploymentBatchBusy: (excluding) => context.deploymentBatchBusy?.(excluding) ?? false,
366
573
  };
367
574
  scopes.set(environment.id, scope);
368
575
  watchReady(host);
576
+ // Recovery is retried by the background poll. A broken desired set must not block management or repair.
369
577
  const management = () => managementOf(host);
370
578
  const statusOf = async (appId) => {
371
579
  const status = await (await management()).getStatus({ scope: environment.id, appIds: [appId] });
@@ -380,7 +588,9 @@ export function createHostDriver(options) {
380
588
  error: null,
381
589
  };
382
590
  };
591
+ const pendingDeployments = new Map();
383
592
  return {
593
+ deploymentSettled: checkChurn,
384
594
  async check() {
385
595
  try {
386
596
  const url = (await host.controller.ensureStarted()).toString();
@@ -398,57 +608,122 @@ export function createHostDriver(options) {
398
608
  };
399
609
  }
400
610
  },
611
+ async cancel(deploymentId, appId, queuedOnly) {
612
+ const pending = pendingDeployments.get(deploymentId);
613
+ if (pending && pending.appId === appId && !pending.submitted) {
614
+ pending.cancelled = true;
615
+ pending.abortController.abort();
616
+ return 'cancelled';
617
+ }
618
+ return (await management()).cancelDeployment(deploymentId, appId, {
619
+ scope: scope.scope,
620
+ queuedOnly,
621
+ });
622
+ },
623
+ async progress(deploymentId, appId) {
624
+ if (pendingDeployments.get(deploymentId)?.appId === appId &&
625
+ capacityWaiting.has(deploymentId))
626
+ return { state: 'queued', queueAhead: 0, cancellable: true };
627
+ return (await management()).deploymentProgress(deploymentId, appId, {
628
+ scope: scope.scope,
629
+ });
630
+ },
401
631
  async apply(spec, onEvent) {
402
- track(scope, spec);
403
- let replacing = false;
632
+ const pending = {
633
+ appId: spec.appId,
634
+ submitted: false,
635
+ cancelled: false,
636
+ abortController: new AbortController(),
637
+ };
638
+ pendingDeployments.set(spec.deploymentId, pending);
639
+ let sequence = 0;
640
+ const forward = (entry) => {
641
+ onEvent?.({ ...entry, sequence: ++sequence });
642
+ };
404
643
  try {
405
- const status = await tracked(scope, async () => {
644
+ const submit = (retryCapacity) => tracked(scope, async () => {
406
645
  const service = await management();
407
- const before = await service
408
- .getStatus({ scope: environment.id, appIds: [spec.appId] })
409
- .catch(() => null);
410
- replacing = Boolean(before?.deployments.some((deployment) => deployment.app?.state === 'active'));
411
- // A deployment always starts the App once, so it proves the release runs.
412
- return await service.applyDeployment({
646
+ if (pending.cancelled)
647
+ throw Object.assign(new Error('Deployment cancelled.'), {
648
+ code: 'DEPLOYMENT_CANCELLED',
649
+ });
650
+ pending.submitted = true;
651
+ const status = await service.applyDeployment({
413
652
  ...toHostSpec(scope, spec, 'running', 'eager'),
414
653
  scope: scope.scope,
415
- }, onEvent ? (entry) => onEvent(entry) : undefined);
654
+ ...(retryCapacity ? { retryCapacity: true } : {}),
655
+ }, onEvent ? forward : undefined);
656
+ if (status.deployments.some((deployment) => deployment.appId === spec.appId &&
657
+ deployment.observedState !== 'failed'))
658
+ countChurn(scope);
659
+ return status;
416
660
  });
417
- if (replacing)
418
- countChurn(scope);
661
+ let status = await submit(false);
662
+ const capacity = status.deployments.find((deployment) => deployment.appId === spec.appId)?.errorCode === 'APP_CAPACITY_EXCEEDED';
663
+ if (capacity && scope.host === inProcess) {
664
+ pending.submitted = false;
665
+ capacityWaiting.add(spec.deploymentId);
666
+ forward({
667
+ phase: 'queued',
668
+ msg: 'Waiting for Host memory recycling',
669
+ queueAhead: 0,
670
+ cancellable: true,
671
+ });
672
+ try {
673
+ if (await recycleForCapacity(pending.abortController.signal)) {
674
+ capacityWaiting.delete(spec.deploymentId);
675
+ status = await submit(true);
676
+ }
677
+ }
678
+ finally {
679
+ capacityWaiting.delete(spec.deploymentId);
680
+ }
681
+ if (pending.cancelled)
682
+ throw Object.assign(new Error('Deployment cancelled.'), {
683
+ code: 'DEPLOYMENT_CANCELLED',
684
+ });
685
+ }
419
686
  const deployed = status.deployments.find((deployment) => deployment.appId === spec.appId);
420
687
  if (!deployed)
421
688
  return failed(spec, 'The Host did not report the deployment.');
689
+ scope.desiredAppIds.add(spec.appId);
422
690
  return outcomeOf(deployed, spec.deploymentId);
423
691
  }
424
692
  catch (error) {
693
+ if (error.code === 'DEPLOYMENT_CANCELLED')
694
+ return {
695
+ ...failed(spec, 'Deployment cancelled.'),
696
+ state: 'cancelled',
697
+ };
425
698
  return failed(spec, error instanceof Error ? error.message : String(error));
426
699
  }
700
+ finally {
701
+ pendingDeployments.delete(spec.deploymentId);
702
+ }
427
703
  },
428
704
  async start(spec) {
429
- track(scope, spec);
430
- await (await management()).startDeployment({
705
+ await tracked(scope, async () => (await management()).startDeployment({
431
706
  ...toHostSpec(scope, spec, 'running'),
432
707
  scope: scope.scope,
433
- });
708
+ }));
434
709
  return await statusOf(spec.appId);
435
710
  },
436
711
  async stop(appId) {
437
- await (await management()).stopDeployment(appId);
712
+ await tracked(scope, async () => (await management()).stopDeployment(appId));
438
713
  return await statusOf(appId);
439
714
  },
440
715
  async restart(appId) {
441
- await (await management()).restartApp(appId);
716
+ await tracked(scope, async () => (await management()).restartApp(appId));
442
717
  return await statusOf(appId);
443
718
  },
444
719
  async remove(appId, { purgeData }) {
445
- await tracked(scope, async () => (await management()).removeDeployment(appId, { purgeData }));
446
- scope.onDemand.delete(appId);
447
- countChurn(scope);
720
+ await tracked(scope, async () => {
721
+ await (await management()).removeDeployment(appId, { purgeData });
722
+ scope.desiredAppIds.delete(appId);
723
+ countChurn(scope);
724
+ });
448
725
  },
449
726
  async status(appIds) {
450
- if (scope.restoring)
451
- await Promise.race([scope.restoring, delay(5_000)]);
452
727
  const status = await (await management()).getStatus({
453
728
  scope: environment.id,
454
729
  ...(appIds ? { appIds: [...appIds] } : {}),
@@ -456,6 +731,18 @@ export function createHostDriver(options) {
456
731
  const result = new Map();
457
732
  for (const deployment of status.deployments)
458
733
  result.set(deployment.appId, observed(deployment));
734
+ // A failed restore left these Apps off the Host until a background retry succeeds.
735
+ if (scope.restoreFailed && !scope.restoring && appIds)
736
+ for (const appId of appIds)
737
+ if (!result.has(appId))
738
+ result.set(appId, {
739
+ state: 'failed',
740
+ version: null,
741
+ deploymentId: null,
742
+ startedAt: null,
743
+ error: UNRESTORED_APP_ERROR,
744
+ unrestored: true,
745
+ });
459
746
  if ((scope.restoring || !status.scope) && appIds)
460
747
  for (const appId of appIds)
461
748
  if (!result.has(appId))
@@ -469,11 +756,61 @@ export function createHostDriver(options) {
469
756
  return result;
470
757
  },
471
758
  async restore() {
472
- await host.controller.ensureStarted();
473
- await scheduleRestore(scope);
759
+ await tracked(scope, async () => {
760
+ await host.controller.ensureStarted();
761
+ await scheduleRestore(scope);
762
+ });
474
763
  },
475
764
  async reloadConfig(appId, content) {
476
- await (await management()).publishAppConfig(appId, content);
765
+ await tracked(scope, async () => (await management()).publishAppConfig(appId, content));
766
+ },
767
+ async occupancy() {
768
+ if (host !== inProcess)
769
+ return null;
770
+ // The unfiltered status: every scope sharing the process, and Apps deployed without one.
771
+ const status = await (await management()).getStatus();
772
+ const failedScopes = [...scopes.values()].filter((other) => other.host === inProcess &&
773
+ other.restoreFailed &&
774
+ !other.restoring);
775
+ const present = new Set(status.deployments.map((deployment) => `${deployment.scopeId ?? ''}\u0000${deployment.appId}`));
776
+ // A failed restore leaves an environment's Apps off the Host until a background retry succeeds: they are
777
+ // still deployed, and failed, as `status()` reports them. Apps still on the Host keep their own state.
778
+ const missing = failedScopes.flatMap((other) => [...other.desiredAppIds]
779
+ .filter((appId) => !present.has(`${other.environmentId}\u0000${appId}`))
780
+ .map((appId) => ({
781
+ state: 'failed',
782
+ version: null,
783
+ deploymentId: null,
784
+ startedAt: null,
785
+ error: UNRESTORED_APP_ERROR,
786
+ unrestored: true,
787
+ appId,
788
+ environmentId: other.environmentId,
789
+ })));
790
+ return {
791
+ id: IN_PROCESS_BACKEND,
792
+ memory: status.counters?.memory ?? null,
793
+ maxActive: status.counters?.maxActiveApps ?? null,
794
+ recycling: restartScheduled ||
795
+ readyRestoring ||
796
+ inProcess.controller.getInfo().status !== 'ready'
797
+ ? 'restarting'
798
+ : rotationSince !== null || status.counters?.rotationPending
799
+ ? 'pending'
800
+ : null,
801
+ apps: [
802
+ ...status.deployments.map((deployment) => ({
803
+ ...observed(deployment),
804
+ appId: deployment.appId,
805
+ environmentId: deployment.scopeId ?? null,
806
+ })),
807
+ ...missing,
808
+ ],
809
+ failedEnvironments: failedScopes.map((other) => ({
810
+ environmentId: other.environmentId,
811
+ error: other.restoreError,
812
+ })),
813
+ };
477
814
  },
478
815
  async logs(appId, query) {
479
816
  return await (await management()).readAppLogs(appId, query);
@@ -524,6 +861,10 @@ function observed(item) {
524
861
  item.lifecycle?.lastFailure?.error ??
525
862
  null),
526
863
  lastAccessedAt: item.lifecycle?.lastAccessedAt ?? null,
864
+ ...(item.lifecycle?.holds?.length ? { holds: item.lifecycle.holds } : {}),
865
+ ...(item.lifecycle?.waiting && !active
866
+ ? { waiting: item.lifecycle.waiting }
867
+ : {}),
527
868
  };
528
869
  }
529
870
  /** How a deployment ended, as the plugin records it: `running` succeeded, anything else failed. */
@@ -535,7 +876,10 @@ function outcomeOf(item, deploymentId) {
535
876
  ...status,
536
877
  state: 'failed',
537
878
  deploymentId,
538
- error: item.error ?? status.error ?? 'The deployment failed.',
879
+ error: item.errorCode
880
+ ? `[${item.errorCode}] ${item.error ?? status.error ?? 'The deployment failed.'}`
881
+ : (item.error ?? status.error ?? 'The deployment failed.'),
882
+ ...(item.errorCode ? { errorCode: item.errorCode } : {}),
539
883
  };
540
884
  return { ...status, state: 'running', deploymentId, error: null };
541
885
  }
@@ -550,11 +894,14 @@ function operationOutcome(result) {
550
894
  lastAccessedAt: result.status?.lifecycle?.lastAccessedAt ?? null,
551
895
  };
552
896
  return {
553
- state: 'failed',
897
+ state: result.state === 'cancelled' ? 'cancelled' : 'failed',
898
+ ...(result.status?.errorCode ? { errorCode: result.status.errorCode } : {}),
554
899
  version: result.status?.version ?? null,
555
900
  deploymentId: result.operationId,
556
901
  startedAt: null,
557
- error: result.error ??
902
+ error: (result.state === 'interrupted'
903
+ ? 'Deployment was interrupted by a restart. Deploy the release again.'
904
+ : result.error) ??
558
905
  (result.state === 'running'
559
906
  ? `The deployment of ${result.appId} has not finished on the Host.`
560
907
  : 'The deployment failed.'),
@@ -597,16 +944,15 @@ function unionCapabilities(list) {
597
944
  images: list.some((item) => item.images === true),
598
945
  };
599
946
  }
600
- function delay(ms) {
601
- return new Promise((resolve) => {
602
- setTimeout(resolve, ms).unref?.();
603
- });
604
- }
605
947
  /** Writes a managed Host child's configuration file (`AppHostSupervisor` `configPath`) before it starts. */
606
948
  export async function writeHostConfig(configPath, config) {
607
949
  await writeTextAtomic(configPath, stringifyYaml({
608
950
  host: {
609
951
  mode: 'managed',
952
+ ...(config.memoryBudget ? { memoryBudget: config.memoryBudget } : {}),
953
+ ...(config.maxActiveApps !== undefined
954
+ ? { maxActiveApps: config.maxActiveApps }
955
+ : {}),
610
956
  ...(config.logging ? { logging: config.logging } : {}),
611
957
  server: { host: '127.0.0.1', port: 3000 },
612
958
  artifact: config.artifact.driver === 'fs'