@akanjs/devkit 3.0.0-beta.8 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DevBootLatch } from "./devBootLatch";
3
+
4
+ const latch = (waitMs = 60_000) => {
5
+ const booted: number[] = [];
6
+ return { booted, latch: new DevBootLatch(() => booted.push(Date.now()), { waitMs }) };
7
+ };
8
+
9
+ describe("DevBootLatch", () => {
10
+ test("opens once the app serves and its boot builds have settled, in either order", () => {
11
+ const readyFirst = latch();
12
+ readyFirst.latch.ready();
13
+ expect(readyFirst.booted).toHaveLength(0);
14
+ readyFirst.latch.armed();
15
+ expect(readyFirst.booted).toHaveLength(1);
16
+
17
+ const armedFirst = latch();
18
+ armedFirst.latch.armed();
19
+ expect(armedFirst.booted).toHaveLength(0);
20
+ armedFirst.latch.ready();
21
+ expect(armedFirst.booted).toHaveLength(1);
22
+ });
23
+
24
+ test("opens once per session, however often the app restarts or the builder re-arms", () => {
25
+ const { booted, latch: boot } = latch();
26
+ boot.armed();
27
+ boot.ready();
28
+ boot.ready();
29
+ boot.armed();
30
+ expect(booted).toHaveLength(1);
31
+ });
32
+
33
+ test("opens after its wait past ready when the builder never reports", async () => {
34
+ const { booted, latch: boot } = latch(20);
35
+ boot.ready();
36
+ await Bun.sleep(60);
37
+ expect(booted).toHaveLength(1);
38
+ });
39
+ });
@@ -0,0 +1,39 @@
1
+ //* An app's boot is over for the supervisor once it serves and its builder's boot builds have settled (the SSR
2
+ //* registry's, and CSR's when the env arms it): those workers are a boot's largest processes, so the next boot wave
3
+ //* starts here, while the browser opens on the ready that comes first.
4
+ export class DevBootLatch {
5
+ readonly #onBooted: () => void;
6
+ readonly #waitMs: number;
7
+ #ready = false;
8
+ #armed = false;
9
+ #booted = false;
10
+ #timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ /** `waitMs` bounds the wait past ready: a builder that never reports (killed mid-boot) must not stall the waves. */
13
+ constructor(onBooted: () => void, { waitMs = 30_000 }: { waitMs?: number } = {}) {
14
+ this.#onBooted = onBooted;
15
+ this.#waitMs = waitMs;
16
+ }
17
+
18
+ ready(): void {
19
+ this.#ready = true;
20
+ if (!this.#armed && !this.#timer) {
21
+ this.#timer = setTimeout(() => this.armed(), this.#waitMs);
22
+ this.#timer.unref?.();
23
+ }
24
+ this.#release();
25
+ }
26
+
27
+ armed(): void {
28
+ this.#armed = true;
29
+ this.#release();
30
+ }
31
+
32
+ #release(): void {
33
+ if (this.#booted || !this.#ready || !this.#armed) return;
34
+ this.#booted = true;
35
+ if (this.#timer) clearTimeout(this.#timer);
36
+ this.#timer = null;
37
+ this.#onBooted();
38
+ }
39
+ }
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
3
+ //? The source itself, not the package: the published akanjs exports no such subpath, and tests are not bundled.
4
+ import { DevBuildRecovery } from "../../../akanjs/server/artifact/devBuildRecovery";
3
5
  import {
4
6
  backendRestartReasonFromMessage,
5
7
  buildStatusReplaySequence,
@@ -17,6 +19,7 @@ import {
17
19
  resolveIdleSuspendMs,
18
20
  shouldAbandonBackendRecovery,
19
21
  shouldHoldForReturningBuilder,
22
+ shouldKeepBuildFailure,
20
23
  shouldMarkBuildPhaseRecovered,
21
24
  shouldQueueBuildStatusReplay,
22
25
  shouldRefreshConfigOnIdleWake,
@@ -28,6 +31,13 @@ import {
28
31
  shouldWarnBuilderRssCeilingTight,
29
32
  } from "./devHostPolicy";
30
33
 
34
+ const status = (phase: BuildPhase, generation: number, ok: boolean): DevBuildStatus => ({
35
+ generation,
36
+ phase,
37
+ ok,
38
+ files: [],
39
+ });
40
+
31
41
  const invalidateWithActions = (actions: DevChangeAction[]): Extract<BuilderMessage, { type: "invalidate" }> => ({
32
42
  type: "invalidate",
33
43
  kinds: ["code"],
@@ -158,8 +168,6 @@ describe("holding requests for a returning builder", () => {
158
168
  });
159
169
 
160
170
  test("holds through the drain too, not only after the process is gone", () => {
161
- // The window this decision originally missed: the builder is still alive and refusing, which is
162
- // the same gap as a restart from anyone waiting on a page.
163
171
  expect(shouldHoldForReturningBuilder({ status: "recycling", heldCount: 0 })).toBe(true);
164
172
  });
165
173
 
@@ -196,8 +204,6 @@ describe("builder rss recycle", () => {
196
204
  expect(decide({ ceilingBytes: null })).toBe("unbounded");
197
205
  });
198
206
 
199
- // Rebooting on a generation whose build failed strands the dev server: the replacement hits the
200
- // same compile error and exits before builder-ready.
201
207
  test("defers while the current generation has a failing build", () => {
202
208
  expect(decide({ buildFailed: true })).toBe("build-failed");
203
209
  });
@@ -208,34 +214,25 @@ describe("builder rss recycle", () => {
208
214
  expect(decide({ msSinceLastRecycle: 5_000, minIntervalMs: 1_000 })).toBe("recycle");
209
215
  });
210
216
 
211
- // Says so, and keeps enforcing: a page load is two route builds, so an app whose builds sit over the
212
- // ceiling reaches this on its first navigation — which is normal work, not a reason to drop the only
213
- // bound the builder has.
214
217
  test("mentions a tight ceiling rather than acting on it", () => {
215
218
  expect(shouldWarnBuilderRssCeilingTight(1)).toBe(false);
216
219
  expect(shouldWarnBuilderRssCeilingTight(2)).toBe(true);
217
220
  expect(shouldWarnBuilderRssCeilingTight(1, 1)).toBe(true);
218
221
  });
219
222
 
220
- // The one case recycling cannot fix, measured on the replacement before it has built anything: every
221
- // future replacement lands on the same floor, so the loop would only ever cost boot builds.
222
223
  test("gives up only when a fresh builder is already over the ceiling", () => {
223
224
  expect(isRssCeilingUnreachable(ceiling + 1, ceiling)).toBe(true);
224
225
  expect(isRssCeilingUnreachable(ceiling - 1, ceiling)).toBe(false);
225
- // An unreadable rss is no information, and no ceiling is nothing to be unreachable.
226
226
  expect(isRssCeilingUnreachable(null, ceiling)).toBe(false);
227
227
  expect(isRssCeilingUnreachable(ceiling + 1, null)).toBe(false);
228
228
  });
229
229
 
230
- // Measured on Linux: the builder peaked at 522MiB and settled at 214MiB with no help, so a 400MiB
231
- // ceiling recycled a process that was already back under it. The armed sample is always the peak.
232
230
  describe("settle check before committing", () => {
233
231
  test("waits when the builder is only modestly over the ceiling", () => {
234
232
  expect(decideBuilderRssSettle({ rssBytes: 522, ceilingBytes: 400 })).toBe("wait-and-recheck");
235
233
  expect(decideBuilderRssSettle({ rssBytes: 401, ceilingBytes: 400 })).toBe("wait-and-recheck");
236
234
  });
237
235
 
238
- // No purge is going to rescue a builder this far over, so waiting only delays the inevitable.
239
236
  test("recycles immediately once far enough past the ceiling", () => {
240
237
  expect(decideBuilderRssSettle({ rssBytes: 600, ceilingBytes: 400 })).toBe("recycle-now");
241
238
  expect(decideBuilderRssSettle({ rssBytes: 900, ceilingBytes: 400 })).toBe("recycle-now");
@@ -268,7 +265,6 @@ describe("dev idle suspend", () => {
268
265
  expect(decide({ restartPending: true })).toBe("restart-pending");
269
266
  });
270
267
 
271
- // A wake would boot straight back into the same compile error, and the developer is mid-fix anyway.
272
268
  test("never suspends on a red build", () => {
273
269
  expect(decide({ buildFailed: true })).toBe("build-failed");
274
270
  });
@@ -288,15 +284,15 @@ describe("dev idle suspend", () => {
288
284
  expect(resolveIdleSuspendMs("1500")).toBe(1_500);
289
285
  });
290
286
 
291
- test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
292
- const status = (phase: BuildPhase, ok: boolean): DevBuildStatus => ({ generation: 1, phase, ok, files: [] });
287
+ test("blocks a suspend on a failure in any phase but a route's, not just the newest generation", () => {
293
288
  expect(hasAnyBuildFailure(new Map())).toBe(false);
294
- expect(hasAnyBuildFailure(new Map([["scan", status("scan", true)]]))).toBe(false);
289
+ expect(hasAnyBuildFailure(new Map([["route", status("route", 1, false)]]))).toBe(false);
290
+ expect(hasAnyBuildFailure(new Map([["scan", status("scan", 1, true)]]))).toBe(false);
295
291
  expect(
296
292
  hasAnyBuildFailure(
297
293
  new Map([
298
- ["scan", status("scan", true)],
299
- ["pages", status("pages", false)],
294
+ ["scan", status("scan", 1, true)],
295
+ ["pages", status("pages", 1, false)],
300
296
  ]),
301
297
  ),
302
298
  ).toBe(true);
@@ -332,8 +328,6 @@ describe("recycled builder state announcements", () => {
332
328
  },
333
329
  });
334
330
 
335
- // Both identities are content hashes, so a recycle with no concurrent edit reproduces them exactly
336
- // and must not reload the backend — that would refresh every browser on a memory recycle.
337
331
  test("suppresses an unchanged pages announcement and relays a moved one", () => {
338
332
  expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-abc.js"))).toBe(false);
339
333
  expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-def.js"))).toBe(true);
@@ -355,13 +349,6 @@ describe("recycled builder state announcements", () => {
355
349
  });
356
350
 
357
351
  describe("build status helpers", () => {
358
- const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
359
- generation,
360
- phase,
361
- ok,
362
- files: [],
363
- });
364
-
365
352
  test("tracks recovery by phase without unrelated phases masking failures", () => {
366
353
  const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
367
354
  ["pages", status("pages", 10, false)],
@@ -372,6 +359,67 @@ describe("build status helpers", () => {
372
359
  expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
373
360
  });
374
361
 
362
+ test("an ok of the failure's own generation neither recovers nor replaces it, except a backend's", () => {
363
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
364
+ ["ssr", status("ssr", 7, false)],
365
+ ["backend", status("backend", 7, false)],
366
+ ]);
367
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("ssr", 7, true))).toBe(false);
368
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, true))).toBe(true);
369
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 8, true))).toBe(false);
370
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, false))).toBe(false);
371
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
372
+ });
373
+
374
+ test("the same route built again recovers at the failure's generation or later, and another route's ok never does", () => {
375
+ const routeA = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/a" });
376
+ const routeB = (ok: boolean, generation = 4) => ({ ...status("route", generation, ok), scope: "/:lang/b" });
377
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", routeA(false)]]);
378
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true))).toBe(true);
379
+ expect(shouldKeepBuildFailure(previousByPhase, routeA(true))).toBe(false);
380
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true))).toBe(false);
381
+ expect(shouldKeepBuildFailure(previousByPhase, routeB(true))).toBe(true);
382
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeB(true, 5))).toBe(false);
383
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, routeA(true, 5))).toBe(true);
384
+ expect(shouldKeepBuildFailure(previousByPhase, routeA(true, 3))).toBe(true);
385
+ });
386
+
387
+ test("judges recovery as the backend does (akanjs DevBuildRecovery), case for case", () => {
388
+ const at = (phase: BuildPhase, generation: number, ok: boolean, scope?: string): DevBuildStatus => ({
389
+ ...status(phase, generation, ok),
390
+ ...(scope ? { scope } : {}),
391
+ });
392
+ const cases: [DevBuildStatus | undefined, DevBuildStatus][] = [];
393
+ for (const phase of ["backend", "route", "scan", "pages", "ssr"] as BuildPhase[])
394
+ for (const [previousOk, ok] of [
395
+ [false, true],
396
+ [true, true],
397
+ [false, false],
398
+ ])
399
+ for (const generation of [3, 4, 5])
400
+ for (const [previousScope, scope] of [
401
+ [undefined, undefined],
402
+ ["/a", "/a"],
403
+ ["/a", "/b"],
404
+ ])
405
+ cases.push([at(phase, 4, previousOk, previousScope), at(phase, generation, ok, scope)]);
406
+ cases.push([undefined, at("pages", 4, true)]);
407
+ for (const [previous, next] of cases) {
408
+ const previousByPhase = new Map<BuildPhase, DevBuildStatus>(previous ? [[previous.phase, previous]] : []);
409
+ expect([previous, next, shouldMarkBuildPhaseRecovered(previousByPhase, next)]).toEqual([
410
+ previous,
411
+ next,
412
+ DevBuildRecovery.recovers(previous, next),
413
+ ]);
414
+ }
415
+ });
416
+
417
+ test("a builder that came back clears no config change still on hold, or one that failed to apply", () => {
418
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["scan", status("scan", 42, false)]]);
419
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(false);
420
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 43, true))).toBe(true);
421
+ });
422
+
375
423
  test("creates backend build-status payloads for lifecycle test hooks", () => {
376
424
  expect(
377
425
  createBackendBuildStatus({
@@ -441,13 +489,6 @@ describe("backend recovery abandonment", () => {
441
489
  });
442
490
 
443
491
  describe("hasBuildFailureForGeneration", () => {
444
- const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
445
- generation,
446
- phase,
447
- ok,
448
- files: [],
449
- });
450
-
451
492
  test("detects a failing phase recorded for the same generation", () => {
452
493
  const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
453
494
  ["csr", status("csr", 3, false)],
@@ -464,6 +505,11 @@ describe("hasBuildFailureForGeneration", () => {
464
505
  expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
465
506
  });
466
507
 
508
+ test("leaves a route's failure out: nothing a replacement builder boots builds a route", () => {
509
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["route", status("route", 42, false)]]);
510
+ expect(hasBuildFailureForGeneration(statusByPhase, 42)).toBe(false);
511
+ });
512
+
467
513
  test("treats unknown generations and green boards as healthy", () => {
468
514
  const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
469
515
  expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
@@ -11,11 +11,7 @@ import type { IncrementalBuilderStatus } from "../incrementalBuilder";
11
11
 
12
12
  const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
13
13
 
14
- /**
15
- * How many requests may wait for a builder that is coming back. Generous — a page load asks for
16
- * several routes — but finite, so a builder that never returns cannot grow this without bound. Past
17
- * it, requests are failed as they were before, which is the behaviour this limit falls back to.
18
- */
14
+ // A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
19
15
  const HELD_BUILDER_REQUEST_LIMIT = 64;
20
16
 
21
17
  export const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
@@ -25,8 +21,6 @@ const BUILDER_TIGHT_RSS_REPORT_LIMIT = 2;
25
21
  // Far enough above the ceiling that no purge would rescue it; recycle without waiting.
26
22
  const BUILDER_RSS_HARD_MULTIPLE = 1.5;
27
23
 
28
- // A sandbox between user turns pays for a watcher that is watching nothing change. Suspending build
29
- // capacity after this long returns the builder's residency until the next edit or route request.
30
24
  const DEV_IDLE_SUSPEND_MS = 300_000;
31
25
 
32
26
  // A wake that immediately suspends again would flap around whatever woke it.
@@ -34,7 +28,6 @@ const DEV_IDLE_MIN_UPTIME_MS = 30_000;
34
28
 
35
29
  export const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
36
30
 
37
- /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
38
31
  const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
39
32
 
40
33
  const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
@@ -53,10 +46,6 @@ export const shouldRestartBackendByDevPlan = (
53
46
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
54
47
  message.devPlan?.actions.includes("restart-builder") ?? false;
55
48
 
56
- /**
57
- * A backend that keeps dying isn't going to heal by retrying the same code; after this many
58
- * consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
59
- */
60
49
  export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
61
50
  attempts >= maxAttempts;
62
51
 
@@ -69,19 +58,23 @@ export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, {
69
58
 
70
59
  export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
71
60
 
72
- /**
73
- * What a dev host tells a supervising process about itself. Reported rather than scraped: the parent
74
- * gets one pipe of interleaved child output, in which "this app is up" is indistinguishable from a line
75
- * that merely mentions it.
76
- */
61
+ /** Reported, not scraped: in interleaved child output "this app is up" reads like any line mentioning it. */
77
62
  export type DevHostState = "starting" | "ready" | "restarting" | "recovering" | "suspended" | "failed" | "stopped";
78
63
 
79
- export interface DevHostEvent {
64
+ export interface DevHostStateEvent {
80
65
  app: string;
81
66
  state: DevHostState;
82
67
  detail?: string;
83
68
  }
84
69
 
70
+ /** Sent once: the app serves and its boot builds have settled, which is what the next boot wave waits for. */
71
+ export interface DevHostBootEvent {
72
+ app: string;
73
+ booted: true;
74
+ }
75
+
76
+ export type DevHostEvent = DevHostStateEvent | DevHostBootEvent;
77
+
85
78
  const devHostStateByBackendState = {
86
79
  starting: "starting",
87
80
  ready: "ready",
@@ -91,11 +84,7 @@ const devHostStateByBackendState = {
91
84
  stopped: "stopped",
92
85
  } as const satisfies { [key in BackendLifecycleState]: DevHostState };
93
86
 
94
- /**
95
- * The backend's own lifecycle collapsed to what a supervisor can act on. `stopped` is the one state
96
- * that is ambiguous on its own — a planned shutdown and a crash loop that ran out of retries both land
97
- * there — so the give-up flag is what separates "gone" from "broken".
98
- */
87
+ /** `stopped` is both a planned shutdown and an exhausted crash loop; `gaveUp` maps the latter to `failed`. */
99
88
  export const devHostStateOf = (state: BackendLifecycleState, gaveUp: boolean): DevHostState =>
100
89
  state === "stopped" && gaveUp ? "failed" : devHostStateByBackendState[state];
101
90
 
@@ -131,14 +120,29 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
131
120
  );
132
121
  };
133
122
 
123
+ //? The backend's rule (akanjs `DevBuildRecovery`), copied: no exported akanjs subpath reaches it from the CLI bundle.
134
124
  export const shouldMarkBuildPhaseRecovered = (
135
125
  previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
136
126
  status: DevBuildStatus,
137
127
  ): boolean => {
138
128
  const previous = previousByPhase.get(status.phase);
139
- return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
129
+ if (!previous || previous.ok || !status.ok) return false;
130
+ const generation = generationValue(status.generation);
131
+ if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
132
+ return status.scope === previous.scope && generation >= previous.generation;
133
+ if (generation !== previous.generation) return generation > previous.generation;
134
+ return status.phase === "backend";
140
135
  };
141
136
 
137
+ /** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
138
+ export const shouldKeepBuildFailure = (
139
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
140
+ status: DevBuildStatus,
141
+ ): boolean =>
142
+ status.ok &&
143
+ previousByPhase.get(status.phase)?.ok === false &&
144
+ !shouldMarkBuildPhaseRecovered(previousByPhase, status);
145
+
142
146
  export const createBackendBuildStatus = ({
143
147
  generation,
144
148
  ok,
@@ -192,34 +196,22 @@ export const shouldReplaceLastGoodMessage = (
192
196
  export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
193
197
  !backendReady || pendingReplayCount > 0;
194
198
 
195
- /**
196
- * Recycling the builder/backend on a generation whose build already failed is guaranteed to strand
197
- * the dev server: the rebooted builder hits the same compile error and exits before builder-ready.
198
- * Failing phase statuses for a generation arrive over IPC before that generation's invalidate, so
199
- * the host can check them here and defer the recycle until a healthy batch lands.
200
- */
199
+ /** Failing statuses arrive before their generation's invalidate; recycling on one strands the dev server. */
201
200
  export const hasBuildFailureForGeneration = (
202
201
  statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
203
202
  generation: number | undefined,
204
203
  ): boolean => {
205
204
  if (typeof generation !== "number") return false;
206
205
  for (const status of statusByPhase.values()) {
207
- if (!status.ok && status.generation === generation) return true;
206
+ //? Not a route's: a replacement builder or a restarted dev host boots without building one, so it cannot hit it.
207
+ if (!status.ok && status.phase !== "route" && status.generation === generation) return true;
208
208
  }
209
209
  return false;
210
210
  };
211
211
 
212
212
  export type BuilderRssRecycleDecision = "unbounded" | "below-ceiling" | "build-failed" | "too-soon" | "recycle";
213
213
 
214
- /**
215
- * Whether an over-ceiling builder should be replaced now.
216
- *
217
- * `Bun.build` never returns its native arenas, so the builder's RSS only comes back when the process
218
- * exits. Recycling it is therefore the only bound available — but it costs a boot build, so the two
219
- * cases where a recycle cannot help are excluded: a generation whose build already failed (the
220
- * replacement would hit the same compile error), and a recycle so soon after the last one that the
221
- * ceiling is evidently unreachable for this app.
222
- */
214
+ /** `Bun.build` never returns its native arenas, so recycling the builder is the only bound on its RSS. */
223
215
  export const decideBuilderRssRecycle = ({
224
216
  rssBytes,
225
217
  ceilingBytes,
@@ -242,11 +234,6 @@ export const decideBuilderRssRecycle = ({
242
234
 
243
235
  export type BuilderRssSettleDecision = "recycle-now" | "wait-and-recheck";
244
236
 
245
- /**
246
- * Whether an armed recycle should wait out the allocator's purge window before committing. A builder
247
- * far enough over the ceiling is not going to be rescued by a purge, so waiting there only delays a
248
- * recycle that has to happen.
249
- */
250
237
  export const decideBuilderRssSettle = ({
251
238
  rssBytes,
252
239
  ceilingBytes,
@@ -267,15 +254,7 @@ export type IdleSuspendDecision =
267
254
  | "too-soon"
268
255
  | "suspend";
269
256
 
270
- /**
271
- * Whether the dev host may drop its build capacity now. Every "no" here is a case where suspending
272
- * would either lose work or produce a wake that immediately re-suspends:
273
- *
274
- * - a red build means the developer is mid-fix and about to save again, and a wake would boot straight
275
- * back into the same error via the degraded-boot path
276
- * - a pending restart/recovery already has its own plan for the builder
277
- * - `too-soon` keeps a wake from flapping around whatever triggered it
278
- */
257
+ /** A red build means a save is imminent, and a wake would boot straight back into the same error. */
279
258
  export const decideIdleSuspend = ({
280
259
  enabled,
281
260
  suspended,
@@ -313,28 +292,14 @@ export const resolveIdleSuspendMs = (raw: string | undefined): number | null =>
313
292
  return Math.round(parsed);
314
293
  };
315
294
 
316
- /** Any red phase blocks a suspend, unlike the rss recycle which only cares about one generation. */
295
+ //? Not a route's: a broken route builds again on its next request, which wakes a suspended builder anyway.
317
296
  export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
318
- [...statusByPhase.values()].some((status) => !status.ok);
297
+ [...statusByPhase.values()].some((status) => !status.ok && status.phase !== "route");
319
298
 
320
- /**
321
- * A config change while suspended cannot be applied by restarting the builder alone — the dev host
322
- * itself has to re-read the config, which is the same path an ordinary config save takes.
323
- */
324
299
  export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
325
300
  !!batch && batch.kinds.has("config");
326
301
 
327
- /**
328
- * Whether a request that arrived while the builder was away should wait for the one coming back.
329
- *
330
- * A recycle or a crash-restart is a gap, not a failure — the request that lands in it is the page a
331
- * developer is waiting on. A stopped builder is a different thing: nothing is bringing it back, so
332
- * waiting would only delay the error.
333
- *
334
- * `recycling` is in here for the same reason as `restarting`, and was the hole this decision shipped
335
- * with: a draining builder is still alive, so the request reached it and came back refused while the
336
- * host still thought there was nothing to wait for.
337
- */
302
+ // `recycling` too: a draining builder is alive and refuses; nothing brings a `stopped` one back, so it fails fast.
338
303
  const RETURNING_BUILDER_STATUSES = new Set<IncrementalBuilderStatus>(["starting", "recycling", "restarting"]);
339
304
  export const shouldHoldForReturningBuilder = ({
340
305
  status,
@@ -346,37 +311,17 @@ export const shouldHoldForReturningBuilder = ({
346
311
  limit?: number;
347
312
  }): boolean => RETURNING_BUILDER_STATUSES.has(status) && heldCount < limit;
348
313
 
349
- /**
350
- * Whether a builder that is over the ceiling again this soon after being replaced is worth saying so
351
- * about, once. Not a reason to stop enforcing the ceiling: the minimum interval already bounds what
352
- * this costs at one recycle per interval, and dropping the bound is how a container gets OOM-killed.
353
- *
354
- * This used to disable the ceiling for the session, on a count that a single page load reaches — two
355
- * route builds, two reports, both inside the interval. That is normal work on any app whose builds sit
356
- * above the ceiling, which is the same app the ceiling was derived for.
357
- */
314
+ /** Warn only, never stop enforcing: dropping the ceiling is how a container gets OOM-killed. */
358
315
  export const shouldWarnBuilderRssCeilingTight = (
359
316
  reportsSinceRecycle: number,
360
317
  limit = BUILDER_TIGHT_RSS_REPORT_LIMIT,
361
318
  ): boolean => reportsSinceRecycle >= limit;
362
319
 
363
- /**
364
- * Whether recycling can ever bring this builder under the ceiling.
365
- *
366
- * Measured on a replacement the moment it is ready, before it has built anything on demand: that is
367
- * the floor every future replacement lands on, so a floor already over the ceiling is the one case
368
- * where the recycle loop is pure cost. It is also the case the escape hatch was always described as
369
- * being for — the previous rule inferred it from report timing and caught ordinary work instead.
370
- */
320
+ /** Takes a fresh replacement's RSS: the floor every future replacement lands on. */
371
321
  export const isRssCeilingUnreachable = (freshRssBytes: number | null, ceilingBytes: number | null): boolean =>
372
322
  freshRssBytes !== null && ceilingBytes !== null && freshRssBytes >= ceilingBytes;
373
323
 
374
- /**
375
- * Whether a recycled builder's re-announced boot artifact actually differs from what the backend
376
- * already has. Both payload identities are content hashes — `pages-[hash].js` and
377
- * `<name>-[hash].css` — so an unchanged recycle produces identical ones and needs no reload. Only a
378
- * save that raced the recycle moves them, and that is the case worth pushing.
379
- */
324
+ /** Both identities are content hashes (`pages-[hash].js`, `<name>-[hash].css`), unchanged by a clean recycle. */
380
325
  export const shouldRelayRecycledFrontendState = (
381
326
  current:
382
327
  | Extract<BuilderMessage, { type: "pages-updated" }>
@@ -409,7 +354,6 @@ const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChange
409
354
  };
410
355
  };
411
356
 
412
- /** A deferred recycle accumulates every batch it skipped so the eventual restart covers them all. */
413
357
  export const mergeInvalidateMessages = (
414
358
  current: Extract<BuilderMessage, { type: "invalidate" }>,
415
359
  next: Extract<BuilderMessage, { type: "invalidate" }>,
@@ -429,14 +373,9 @@ export const buildStatusReplaySequence = (
429
373
  latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
430
374
  ): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
431
375
 
432
- /** `(mtimeMs, size)` per file — what a save moves, and what a rebuild of identical content does not. */
376
+ /** `mtimeMs:size` per file. */
433
377
  export type SourceFingerprints = ReadonlyMap<string, string>;
434
378
 
435
- /**
436
- * Which of the files in `before` are no longer stamped the way they were.
437
- *
438
- * Only files present in `before` are compared. The question this answers is which *running* code went
439
- * stale while nothing was watching, and a file that did not exist then is not running anywhere.
440
- */
379
+ /** Only files in `before`: a file that did not exist then is not running anywhere. */
441
380
  export const filesChangedSince = (before: SourceFingerprints, after: SourceFingerprints): string[] =>
442
381
  [...before].filter(([file, stamp]) => after.get(file) !== stamp).map(([file]) => file);
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { AkanBin } from "./akanBin";
4
+
5
+ const owner = "apps/portal/akan.config.ts";
6
+ const sha256 = "A".repeat(64);
7
+
8
+ describe("AkanBin.parse", () => {
9
+ test("takes a download checked by its hash, or a file relative to the declaring folder", () => {
10
+ expect(
11
+ AkanBin.parse(
12
+ {
13
+ ffmpeg: {
14
+ "linux-x64": { url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz", sha256, file: "./ffmpeg\\bin/ffmpeg" },
15
+ "darwin-arm64": { path: "tools/ffmpeg" },
16
+ },
17
+ },
18
+ owner,
19
+ "/repo/apps/portal",
20
+ ),
21
+ ).toEqual({
22
+ ffmpeg: {
23
+ "linux-x64": {
24
+ url: "https://files.test/ffmpeg-linux64-lgpl.tar.xz",
25
+ sha256: "a".repeat(64),
26
+ file: "ffmpeg/bin/ffmpeg",
27
+ },
28
+ "darwin-arm64": { path: path.resolve("/repo/apps/portal/tools/ffmpeg") },
29
+ },
30
+ });
31
+ expect(AkanBin.parse(undefined, owner, "/repo/apps/portal")).toEqual({});
32
+ });
33
+
34
+ test("names the declaration that is wrong", () => {
35
+ const parse = (bin: unknown) => () => AkanBin.parse(bin, owner, "/repo/apps/portal");
36
+ expect(parse(["ffmpeg"])).toThrow(`${owner}: bin maps each executable's name`);
37
+ expect(parse({ "../ffmpeg": {} })).toThrow("bin.../ffmpeg is not a file name");
38
+ expect(parse({ ffmpeg: { "darwin-universal": { path: "x" } } })).toThrow(
39
+ "bin.ffmpeg.darwin-universal is not one of darwin-arm64",
40
+ );
41
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256, path: "x" } } })).toThrow(
42
+ "takes either url and sha256, or path",
43
+ );
44
+ expect(parse({ ffmpeg: { "linux-x64": { url: "ftp://x.test/ffmpeg", sha256 } } })).toThrow(
45
+ "url must be an http(s) address",
46
+ );
47
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg", sha256: "abc" } } })).toThrow(
48
+ "sha256 must be the downloaded file's 64-digit hex digest",
49
+ );
50
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", sha256 } } })).toThrow("a path source downloads nothing");
51
+ expect(parse({ ffmpeg: { "linux-x64": { url: "https://x.test/ffmpeg.zip", sha256 } } })).toThrow(
52
+ "names an archive: file picks the executable inside it",
53
+ );
54
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", file: "bin/ffmpeg" } } })).toThrow(
55
+ "file picks the executable out of an archive",
56
+ );
57
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg.tar.gz", file: "../ffmpeg" } } })).toThrow(
58
+ "file must be a path inside the archive",
59
+ );
60
+ expect(parse({ ffmpeg: { "linux-x64": { path: "ffmpeg", mode: 755 } } })).toThrow("unknown key mode");
61
+ });
62
+ });