@akanjs/devkit 3.0.0-beta.3 → 3.0.0-beta.30
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.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +2 -2
- package/README.md +3 -2
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +404 -141
- package/akanConfig/akanConfig.ts +317 -274
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +21 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +269 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +204 -0
- package/mobile/desktopServerStage.ts +138 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +151 -0
- package/mobile/nativeApp.ts +405 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +414 -0
- package/mobile/nativeConfig.ts +296 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +30 -25
- package/subspace.ts +81 -115
- package/subspaceConfig.ts +11 -9
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- 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([["
|
|
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);
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/** `
|
|
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
|
+
});
|