@tdk-landscape/tdk-cli-core 1.3.49 → 1.3.50

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 (309) hide show
  1. package/.claude/skills/layer-autoresearch/SKILL.md +48 -0
  2. package/.claude/skills/tdk-troubleshoot/SKILL.md +222 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +40 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +18 -0
  6. package/.github/dependabot.yml +33 -0
  7. package/.github/pull_request_template.md +5 -0
  8. package/.github/workflows/ci.yml +187 -0
  9. package/.github/workflows/release-binaries.yml +19 -9
  10. package/.github/zizmor.yml +9 -0
  11. package/CODE_OF_CONDUCT.md +9 -0
  12. package/CONTRIBUTING.md +42 -0
  13. package/LICENSE +21 -0
  14. package/README.md +149 -0
  15. package/SECURITY.md +17 -0
  16. package/benchmarks/results/container-scale-2026-09-25T20-54-49-736Z.json +45 -0
  17. package/benchmarks/results/container-scale-2026-09-25T21-01-20-540Z.json +150 -0
  18. package/benchmarks/results/container-scale-2026-09-25T21-15-01-441Z.json +73 -0
  19. package/benchmarks/results/container-scale-2026-09-25T22-09-01-630Z.json +77 -0
  20. package/benchmarks/results/container-scale-2026-09-25T22-18-28-371Z.json +202 -0
  21. package/cli/README.md +25 -0
  22. package/cli/biome.json +1 -1
  23. package/cli/dist/cli.d.ts +3 -0
  24. package/cli/dist/cli.d.ts.map +1 -0
  25. package/cli/dist/cli.js +64 -0
  26. package/cli/dist/cli.js.map +1 -0
  27. package/cli/dist/commands/completion.d.ts +3 -0
  28. package/cli/dist/commands/completion.d.ts.map +1 -0
  29. package/cli/dist/commands/completion.js +294 -0
  30. package/cli/dist/commands/completion.js.map +1 -0
  31. package/cli/dist/commands/config.d.ts +3 -0
  32. package/cli/dist/commands/config.d.ts.map +1 -0
  33. package/cli/dist/commands/config.js +231 -0
  34. package/cli/dist/commands/config.js.map +1 -0
  35. package/cli/dist/commands/doctor.d.ts +19 -0
  36. package/cli/dist/commands/doctor.d.ts.map +1 -0
  37. package/cli/dist/commands/doctor.js +745 -0
  38. package/cli/dist/commands/doctor.js.map +1 -0
  39. package/cli/dist/commands/down.d.ts +3 -0
  40. package/cli/dist/commands/down.d.ts.map +1 -0
  41. package/cli/dist/commands/down.js +32 -0
  42. package/cli/dist/commands/down.js.map +1 -0
  43. package/cli/dist/commands/help.d.ts +2 -0
  44. package/cli/dist/commands/help.d.ts.map +1 -0
  45. package/cli/dist/commands/help.js +138 -0
  46. package/cli/dist/commands/help.js.map +1 -0
  47. package/cli/dist/commands/networks.d.ts +3 -0
  48. package/cli/dist/commands/networks.d.ts.map +1 -0
  49. package/cli/dist/commands/networks.js +260 -0
  50. package/cli/dist/commands/networks.js.map +1 -0
  51. package/cli/dist/commands/project.d.ts +3 -0
  52. package/cli/dist/commands/project.d.ts.map +1 -0
  53. package/cli/dist/commands/project.js +370 -0
  54. package/cli/dist/commands/project.js.map +1 -0
  55. package/cli/dist/commands/projects.d.ts +3 -0
  56. package/cli/dist/commands/projects.d.ts.map +1 -0
  57. package/cli/dist/commands/projects.js +71 -0
  58. package/cli/dist/commands/projects.js.map +1 -0
  59. package/cli/dist/commands/resource.d.ts +69 -0
  60. package/cli/dist/commands/resource.d.ts.map +1 -0
  61. package/cli/dist/commands/resource.js +579 -0
  62. package/cli/dist/commands/resource.js.map +1 -0
  63. package/cli/dist/commands/resources.d.ts +3 -0
  64. package/cli/dist/commands/resources.d.ts.map +1 -0
  65. package/cli/dist/commands/resources.js +73 -0
  66. package/cli/dist/commands/resources.js.map +1 -0
  67. package/cli/dist/commands/stack.d.ts +3 -0
  68. package/cli/dist/commands/stack.d.ts.map +1 -0
  69. package/cli/dist/commands/stack.js +97 -0
  70. package/cli/dist/commands/stack.js.map +1 -0
  71. package/cli/dist/commands/stacks.d.ts +3 -0
  72. package/cli/dist/commands/stacks.d.ts.map +1 -0
  73. package/cli/dist/commands/stacks.js +43 -0
  74. package/cli/dist/commands/stacks.js.map +1 -0
  75. package/cli/dist/commands/status.d.ts +3 -0
  76. package/cli/dist/commands/status.d.ts.map +1 -0
  77. package/cli/dist/commands/status.js +71 -0
  78. package/cli/dist/commands/status.js.map +1 -0
  79. package/cli/dist/commands/ui.d.ts +3 -0
  80. package/cli/dist/commands/ui.d.ts.map +1 -0
  81. package/cli/dist/commands/ui.js +445 -0
  82. package/cli/dist/commands/ui.js.map +1 -0
  83. package/cli/dist/commands/up.d.ts +3 -0
  84. package/cli/dist/commands/up.d.ts.map +1 -0
  85. package/cli/dist/commands/up.js +124 -0
  86. package/cli/dist/commands/up.js.map +1 -0
  87. package/cli/dist/commands/upgrade.d.ts +11 -0
  88. package/cli/dist/commands/upgrade.d.ts.map +1 -0
  89. package/cli/dist/commands/upgrade.js +496 -0
  90. package/cli/dist/commands/upgrade.js.map +1 -0
  91. package/cli/dist/commands/version.d.ts +3 -0
  92. package/cli/dist/commands/version.d.ts.map +1 -0
  93. package/cli/dist/commands/version.js +9 -0
  94. package/cli/dist/commands/version.js.map +1 -0
  95. package/cli/dist/components/Accessible.d.ts +8 -0
  96. package/cli/dist/components/Accessible.d.ts.map +1 -0
  97. package/cli/dist/components/Accessible.js +10 -0
  98. package/cli/dist/components/Accessible.js.map +1 -0
  99. package/cli/dist/components/BaseTooltip.d.ts +4 -0
  100. package/cli/dist/components/BaseTooltip.d.ts.map +1 -0
  101. package/cli/dist/components/BaseTooltip.js +28 -0
  102. package/cli/dist/components/BaseTooltip.js.map +1 -0
  103. package/cli/dist/components/DetailPanel.d.ts +4 -0
  104. package/cli/dist/components/DetailPanel.d.ts.map +1 -0
  105. package/cli/dist/components/DetailPanel.js +18 -0
  106. package/cli/dist/components/DetailPanel.js.map +1 -0
  107. package/cli/dist/components/FileTree.d.ts +4 -0
  108. package/cli/dist/components/FileTree.d.ts.map +1 -0
  109. package/cli/dist/components/FileTree.js +47 -0
  110. package/cli/dist/components/FileTree.js.map +1 -0
  111. package/cli/dist/components/ResourceSelectInput.d.ts +4 -0
  112. package/cli/dist/components/ResourceSelectInput.d.ts.map +1 -0
  113. package/cli/dist/components/ResourceSelectInput.js +7 -0
  114. package/cli/dist/components/ResourceSelectInput.js.map +1 -0
  115. package/cli/dist/components/ResourceTable.d.ts +4 -0
  116. package/cli/dist/components/ResourceTable.d.ts.map +1 -0
  117. package/cli/dist/components/ResourceTable.js +18 -0
  118. package/cli/dist/components/ResourceTable.js.map +1 -0
  119. package/cli/dist/components/TabBar.d.ts +4 -0
  120. package/cli/dist/components/TabBar.d.ts.map +1 -0
  121. package/cli/dist/components/TabBar.js +17 -0
  122. package/cli/dist/components/TabBar.js.map +1 -0
  123. package/cli/dist/components/Tooltip.d.ts +12 -0
  124. package/cli/dist/components/Tooltip.d.ts.map +1 -0
  125. package/cli/dist/components/Tooltip.js +12 -0
  126. package/cli/dist/components/Tooltip.js.map +1 -0
  127. package/cli/dist/components/index.d.ts +8 -0
  128. package/cli/dist/components/index.d.ts.map +1 -0
  129. package/cli/dist/components/index.js +8 -0
  130. package/cli/dist/components/index.js.map +1 -0
  131. package/cli/dist/config/platform-standards.d.ts +159 -0
  132. package/cli/dist/config/platform-standards.d.ts.map +1 -0
  133. package/cli/dist/config/platform-standards.js +181 -0
  134. package/cli/dist/config/platform-standards.js.map +1 -0
  135. package/cli/dist/generator/extension-fetch.d.ts +25 -0
  136. package/cli/dist/generator/extension-fetch.d.ts.map +1 -0
  137. package/cli/dist/generator/extension-fetch.js +259 -0
  138. package/cli/dist/generator/extension-fetch.js.map +1 -0
  139. package/cli/dist/generator/template-engine.d.ts +62 -0
  140. package/cli/dist/generator/template-engine.d.ts.map +1 -0
  141. package/cli/dist/generator/template-engine.js +623 -0
  142. package/cli/dist/generator/template-engine.js.map +1 -0
  143. package/cli/dist/index.d.ts +11 -0
  144. package/cli/dist/index.d.ts.map +1 -0
  145. package/cli/dist/index.js +11 -0
  146. package/cli/dist/index.js.map +1 -0
  147. package/cli/dist/types/index.d.ts +257 -0
  148. package/cli/dist/types/index.d.ts.map +1 -0
  149. package/cli/dist/types/index.js +17 -0
  150. package/cli/dist/types/index.js.map +1 -0
  151. package/cli/dist/utils/cache.d.ts +19 -0
  152. package/cli/dist/utils/cache.d.ts.map +1 -0
  153. package/cli/dist/utils/cache.js +64 -0
  154. package/cli/dist/utils/cache.js.map +1 -0
  155. package/cli/dist/utils/command-helpers.d.ts +17 -0
  156. package/cli/dist/utils/command-helpers.d.ts.map +1 -0
  157. package/cli/dist/utils/command-helpers.js +41 -0
  158. package/cli/dist/utils/command-helpers.js.map +1 -0
  159. package/cli/dist/utils/constants.d.ts +53 -0
  160. package/cli/dist/utils/constants.d.ts.map +1 -0
  161. package/cli/dist/utils/constants.js +115 -0
  162. package/cli/dist/utils/constants.js.map +1 -0
  163. package/cli/dist/utils/discovery-context.d.ts +4 -0
  164. package/cli/dist/utils/discovery-context.d.ts.map +1 -0
  165. package/cli/dist/utils/discovery-context.js +39 -0
  166. package/cli/dist/utils/discovery-context.js.map +1 -0
  167. package/cli/dist/utils/docker.d.ts +4 -0
  168. package/cli/dist/utils/docker.d.ts.map +1 -0
  169. package/cli/dist/utils/docker.js +19 -0
  170. package/cli/dist/utils/docker.js.map +1 -0
  171. package/cli/dist/utils/doctor-runtime.d.ts +81 -0
  172. package/cli/dist/utils/doctor-runtime.d.ts.map +1 -0
  173. package/cli/dist/utils/doctor-runtime.js +498 -0
  174. package/cli/dist/utils/doctor-runtime.js.map +1 -0
  175. package/cli/dist/utils/env-validator.d.ts +15 -0
  176. package/cli/dist/utils/env-validator.d.ts.map +1 -0
  177. package/cli/dist/utils/env-validator.js +109 -0
  178. package/cli/dist/utils/env-validator.js.map +1 -0
  179. package/cli/dist/utils/errors.d.ts +31 -0
  180. package/cli/dist/utils/errors.d.ts.map +1 -0
  181. package/cli/dist/utils/errors.js +140 -0
  182. package/cli/dist/utils/errors.js.map +1 -0
  183. package/cli/dist/utils/file-helpers.d.ts +14 -0
  184. package/cli/dist/utils/file-helpers.d.ts.map +1 -0
  185. package/cli/dist/utils/file-helpers.js +45 -0
  186. package/cli/dist/utils/file-helpers.js.map +1 -0
  187. package/cli/dist/utils/formatting.d.ts +30 -0
  188. package/cli/dist/utils/formatting.d.ts.map +1 -0
  189. package/cli/dist/utils/formatting.js +164 -0
  190. package/cli/dist/utils/formatting.js.map +1 -0
  191. package/cli/dist/utils/paths.d.ts +3 -0
  192. package/cli/dist/utils/paths.d.ts.map +1 -0
  193. package/cli/dist/utils/paths.js +41 -0
  194. package/cli/dist/utils/paths.js.map +1 -0
  195. package/cli/dist/utils/port-assignment.d.ts +6 -0
  196. package/cli/dist/utils/port-assignment.d.ts.map +1 -0
  197. package/cli/dist/utils/port-assignment.js +81 -0
  198. package/cli/dist/utils/port-assignment.js.map +1 -0
  199. package/cli/dist/utils/project-features.d.ts +23 -0
  200. package/cli/dist/utils/project-features.d.ts.map +1 -0
  201. package/cli/dist/utils/project-features.js +98 -0
  202. package/cli/dist/utils/project-features.js.map +1 -0
  203. package/cli/dist/utils/project-templates.d.ts +7 -0
  204. package/cli/dist/utils/project-templates.d.ts.map +1 -0
  205. package/cli/dist/utils/project-templates.js +24 -0
  206. package/cli/dist/utils/project-templates.js.map +1 -0
  207. package/cli/dist/utils/prompt.d.ts +28 -0
  208. package/cli/dist/utils/prompt.d.ts.map +1 -0
  209. package/cli/dist/utils/prompt.js +79 -0
  210. package/cli/dist/utils/prompt.js.map +1 -0
  211. package/cli/dist/utils/resource-features.d.ts +29 -0
  212. package/cli/dist/utils/resource-features.d.ts.map +1 -0
  213. package/cli/dist/utils/resource-features.js +175 -0
  214. package/cli/dist/utils/resource-features.js.map +1 -0
  215. package/cli/dist/utils/service-urls.d.ts +30 -0
  216. package/cli/dist/utils/service-urls.d.ts.map +1 -0
  217. package/cli/dist/utils/service-urls.js +93 -0
  218. package/cli/dist/utils/service-urls.js.map +1 -0
  219. package/cli/dist/utils/services.d.ts +18 -0
  220. package/cli/dist/utils/services.d.ts.map +1 -0
  221. package/cli/dist/utils/services.js +292 -0
  222. package/cli/dist/utils/services.js.map +1 -0
  223. package/cli/dist/utils/stack-features.d.ts +22 -0
  224. package/cli/dist/utils/stack-features.d.ts.map +1 -0
  225. package/cli/dist/utils/stack-features.js +13 -0
  226. package/cli/dist/utils/stack-features.js.map +1 -0
  227. package/cli/dist/utils/tar.d.ts +7 -0
  228. package/cli/dist/utils/tar.d.ts.map +1 -0
  229. package/cli/dist/utils/tar.js +21 -0
  230. package/cli/dist/utils/tar.js.map +1 -0
  231. package/cli/dist/utils/tilt.d.ts +20 -0
  232. package/cli/dist/utils/tilt.d.ts.map +1 -0
  233. package/cli/dist/utils/tilt.js +95 -0
  234. package/cli/dist/utils/tilt.js.map +1 -0
  235. package/cli/dist/utils/validation.d.ts +16 -0
  236. package/cli/dist/utils/validation.d.ts.map +1 -0
  237. package/cli/dist/utils/validation.js +58 -0
  238. package/cli/dist/utils/validation.js.map +1 -0
  239. package/cli/package.json +11 -12
  240. package/cli/src/commands/__tests__/always-enabled-infra.test.ts +2 -8
  241. package/cli/src/commands/__tests__/doctor.test.ts +181 -8
  242. package/cli/src/commands/__tests__/phases-model.test.ts +2 -4
  243. package/cli/src/commands/__tests__/project-resource-features-e2e.test.ts +4 -24
  244. package/cli/src/commands/__tests__/saas-starter-integration.test.ts +8 -10
  245. package/cli/src/commands/__tests__/stack-features-e2e.test.ts +10 -4
  246. package/cli/src/commands/__tests__/upgrade.test.ts +23 -13
  247. package/cli/src/commands/doctor.ts +136 -0
  248. package/cli/src/commands/help.ts +13 -3
  249. package/cli/src/commands/project.ts +11 -14
  250. package/cli/src/commands/projects.ts +2 -2
  251. package/cli/src/commands/up.ts +13 -4
  252. package/cli/src/commands/upgrade.ts +11 -4
  253. package/cli/src/config/platform-standards.test.ts +6 -6
  254. package/cli/src/generator/__tests__/extension-fetch.test.ts +36 -14
  255. package/cli/src/generator/extension-fetch.ts +12 -5
  256. package/cli/src/generator/template-engine.ts +19 -21
  257. package/cli/src/types/index.ts +2 -0
  258. package/cli/src/utils/__tests__/tilt.test.ts +29 -0
  259. package/cli/src/utils/constants.ts +4 -0
  260. package/cli/src/utils/doctor-runtime.ts +67 -27
  261. package/cli/src/utils/env-validator.ts +2 -2
  262. package/cli/src/utils/project-templates.ts +2 -1
  263. package/cli/src/utils/prompt.ts +2 -4
  264. package/cli/src/utils/resource-features.ts +10 -4
  265. package/cli/src/utils/services.ts +2 -1
  266. package/cli/src/utils/tilt.ts +20 -0
  267. package/engine/assets/docker/wake-gateway/Dockerfile +26 -0
  268. package/engine/assets/docker/wake-gateway/decide.test.ts +87 -0
  269. package/engine/assets/docker/wake-gateway/decide.ts +75 -0
  270. package/engine/assets/docker/wake-gateway/server.ts +220 -0
  271. package/engine/topologies/platform/docker/build/golden_image_dockerfile.star +13 -23
  272. package/engine/topologies/platform/docker/compose/compose.star +7 -14
  273. package/engine/topologies/platform/docker/compose/compose_helpers.star +1 -1
  274. package/engine/topologies/platform/docker/compose/traefik_standalone.star +44 -6
  275. package/engine/topologies/platform/docker/config/healthcheck.star +56 -0
  276. package/engine/topologies/platform/docker/constants.star +10 -0
  277. package/engine/topologies/platform/docker/layers/l1_base_layers.star +1 -1
  278. package/engine/topologies/platform/docker/layers/l4_runtime_layers.star +55 -20
  279. package/engine/topologies/platform/docker/networking/traefik_static_routes.star +107 -0
  280. package/engine/topologies/tilt/generators/typescript/backend_tsconfig.star +2 -2
  281. package/engine/topologies/tilt/generators/typescript/frontend_tsconfig.star +1 -1
  282. package/engine/topologies/tilt/manifest/schema.star +6 -0
  283. package/engine/topologies/tilt/manifest/validator.star +16 -0
  284. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +107 -8
  285. package/engine/topologies/tilt/resources/orchestrator/builders/typescript.star +12 -6
  286. package/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +2 -1
  287. package/knip.json +1 -1
  288. package/openspec/changes/optimize-runtime-docker-layers/.openspec.yaml +3 -0
  289. package/openspec/changes/optimize-runtime-docker-layers/design.md +81 -0
  290. package/openspec/changes/optimize-runtime-docker-layers/proposal.md +44 -0
  291. package/openspec/changes/optimize-runtime-docker-layers/specs/runtime-container-footprint/spec.md +79 -0
  292. package/openspec/changes/optimize-runtime-docker-layers/tasks.md +36 -0
  293. package/openspec/changes/prioritized-cold-start/.openspec.yaml +4 -0
  294. package/openspec/changes/prioritized-cold-start/design.md +113 -0
  295. package/openspec/changes/prioritized-cold-start/proposal.md +40 -0
  296. package/openspec/changes/prioritized-cold-start/specs/on-demand-resource-startup/spec.md +92 -0
  297. package/openspec/changes/prioritized-cold-start/tasks.md +46 -0
  298. package/openspec/config.yaml +1 -0
  299. package/package.json +35 -5
  300. package/scripts/autoresearch/layer-eval.sh +65 -0
  301. package/scripts/benchmark/README.md +25 -0
  302. package/scripts/benchmark/container-scale.ts +503 -0
  303. package/scripts/benchmark/pre-release-gate.sh +9 -0
  304. package/scripts/measure-idle-footprint.sh +58 -0
  305. package/tests/tilt-engine/test_runtime_container_footprint.py +162 -0
  306. package/tests/tilt-engine/test_sablier_defer_start.py +196 -0
  307. package/tests/tilt-engine/test_standalone_traefik.py +38 -0
  308. package/tests/tilt-engine/test_traefik_static_wake_route.py +119 -0
  309. package/bun.lock +0 -538
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # Ground-truth evaluation for L1-L4 layer experiments. DO NOT EDIT during an autoresearch run.
3
+ # Re-vendors this engine into the ERP project, regenerates Dockerfiles through Tilt, rebuilds the
4
+ # golden layers and a fixed sample of 20 real ERP services, runs the container benchmark and the
5
+ # feature tests, then prints one summary block.
6
+ set -uo pipefail
7
+ CORE="$(cd "$(dirname "$0")/../.." && pwd)"
8
+ ERP="${TDK_BENCH_PROJECT:-$CORE/../tdk-erp-system}"
9
+ SAMPLE="${LAYER_EVAL_SAMPLE:-20}"
10
+ TDK_CLI="${TDK_CLI:-$CORE/cli/bin/tdk.js}"
11
+ PREFIX="$(python3 -c "import json;print(json.load(open('$ERP/.tdk/project.json'))['project']['name'])")"
12
+ t() { date +%s; }
13
+
14
+ cd "$ERP"
15
+ TDK_EXTENSION_SOURCE="$CORE" node "$TDK_CLI" project --yes >/dev/null 2>&1 || { echo "status: crash (tdk project)"; exit 1; }
16
+ tilt alpha tiltfile-result -f .tdk/.tdk-out/Tiltfile >/dev/null 2>&1 || { echo "status: crash (Tiltfile evaluation)"; exit 1; }
17
+
18
+ s=$(t)
19
+ for target in l1_golden:l1 l2_golden:l2 l3_backend_golden:l3-backend l3_frontend_golden:l3-frontend l4_backend_bun:l4-backend l4_frontend_golden:l4-frontend; do
20
+ docker build -q -f .tdk/.tdk-out/golden-layers.Dockerfile --target "${target%%:*}" -t "$PREFIX-${target##*:}:latest" . >/dev/null 2>&1 \
21
+ || { echo "status: crash (golden ${target%%:*})"; exit 1; }
22
+ done
23
+ golden_build_s=$(( $(t) - s ))
24
+
25
+ s=$(t)
26
+ ls services/*/*/service.json | sort | head -n "$SAMPLE" | while read -r manifest; do
27
+ dir="$(dirname "$manifest")"; svc="$(basename "$dir")"; stack="$(basename "$(dirname "$dir")")"
28
+ echo "docker build -q -f $dir/.autogenerated/Dockerfile.app.autogenerated --target production -t ${stack}_${svc}:dev . >/dev/null 2>&1 || echo FAIL $svc"
29
+ done | xargs -P 4 -I{} sh -c '{}' | tee /tmp/layer-eval-build.log
30
+ grep -q FAIL /tmp/layer-eval-build.log && { echo "status: crash (service build: $(grep FAIL /tmp/layer-eval-build.log | head -3 | tr '\n' ' '))"; exit 1; }
31
+ service_build_s=$(( $(t) - s ))
32
+
33
+ cd "$CORE"
34
+ bun scripts/benchmark/container-scale.ts --synthetic --tiers "$SAMPLE" --gate "$SAMPLE" --settle 15 > /tmp/layer-eval-bench.log 2>&1
35
+ result="$(ls -t benchmarks/results/container-scale-*.json | head -1)"
36
+ tests=pass
37
+ python3 -m pytest tests/tilt-engine/test_runtime_container_footprint.py -q -p no:cacheprovider >/tmp/layer-eval-tests.log 2>&1 || tests=fail
38
+
39
+ python3 - "$result" "$ERP" "$SAMPLE" "$PREFIX" "$golden_build_s" "$service_build_s" "$tests" <<'PY'
40
+ import json, subprocess, sys, glob, os
41
+ result, erp, sample, prefix, gb, sb, tests = sys.argv[1:]
42
+ r = json.load(open(result))["results"][0]
43
+ def size(img):
44
+ out = subprocess.run(["docker", "image", "inspect", img, "--format", "{{.Size}}"], capture_output=True, text=True).stdout.strip()
45
+ return int(out) / 1048576 if out else 0
46
+ base = size(f"{prefix}-l4-backend:latest")
47
+ svcs = sorted(glob.glob(f"{erp}/services/*/*/service.json"))[: int(sample)]
48
+ backend = [f"{os.path.basename(os.path.dirname(os.path.dirname(p)))}_{os.path.basename(os.path.dirname(p))}:dev" for p in svcs
49
+ if json.load(open(p)).get("appType") != "frontend"]
50
+ sizes = [size(i) for i in backend]
51
+ ok = r["allHealthySeconds"] is not None and r["crashed"] == 0 and tests == "pass"
52
+ print("---")
53
+ print(f"mem_per_service_mib: {r['avgMemMiB']}")
54
+ print(f"service_image_mib: {sum(sizes)/len(sizes):.1f}")
55
+ print(f"unique_per_service_mib: {sum(sizes)/len(sizes) - base:.1f}")
56
+ print(f"l4_backend_mib: {base:.1f}")
57
+ print(f"all_healthy_s: {r['allHealthySeconds']}")
58
+ print(f"crashed: {r['crashed']}")
59
+ print(f"docker_ps_ms: {r['dockerPsMs']}")
60
+ print(f"cpu_total_pct: {r['totalCpuPct']}")
61
+ print(f"golden_build_s: {gb}")
62
+ print(f"service_build_s: {sb}")
63
+ print(f"tests: {tests}")
64
+ print(f"status: {'ok' if ok else 'invalid'}")
65
+ PY
@@ -0,0 +1,25 @@
1
+ # Container scale benchmark
2
+
3
+ Release gate for the north star: **100 ERP services on a 16 GB machine without removing features**.
4
+
5
+ ```bash
6
+ bun scripts/benchmark/container-scale.ts # tiers 5,10,20,50,80,100, gate on 100
7
+ bun scripts/benchmark/container-scale.ts --tiers 5,10 --gate 10
8
+ bun scripts/benchmark/container-scale.ts --legacy-healthcheck # old 10s probes, for comparison
9
+ ```
10
+
11
+ It uses the ERP images that `tdk up` already built in `../tdk-erp-system` (override with `--project` or `TDK_BENCH_PROJECT`). For each tier it starts N real service containers with the generated runtime settings (`--init`, 512 MiB / 0.5 CPU limits, healthchecks against `/health`). It then records:
12
+
13
+ - launch time, first-healthy and all-healthy time
14
+ - crashes and OOM kills, with each container's exit code and last log line
15
+ - total and average memory, CPU
16
+ - `docker ps` latency (`tdk doctor` fails above 10 s)
17
+ - p50 `/health` latency and host load
18
+
19
+ Containers are removed after each tier. Results are printed as a table and saved to `benchmarks/results/*.json`.
20
+
21
+ **Gate:** at the gate tier, every container must be healthy within the timeout with no crashes. Total memory must fit `(Docker VM memory - 512 MiB) / 100` per service, and `docker ps` must answer in under 10 s. The exit code is 1 when the gate fails.
22
+
23
+ **Git hook (opt-in):** call `scripts/benchmark/pre-release-gate.sh` from `pre-push` or `pre-commit`. It does nothing unless `TDK_BENCH_GATE=1`.
24
+
25
+ Rebuild the ERP images after engine changes (`tdk project --yes && tdk up` in the ERP project) so the benchmark measures the current layers. The header line reports what share of images use a single-process `CMD`.
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Container scale benchmark / release gate.
4
+ *
5
+ * Starts N real ERP service containers (images built by `tdk up` in the ERP
6
+ * project) for each tier, measures time-to-healthy, crashes, memory, CPU and
7
+ * Docker responsiveness, tears them down, and gates on the north star:
8
+ * 100 services on a 16 GB machine.
9
+ *
10
+ * bun scripts/benchmark/container-scale.ts # 5,10,20,50,80,100
11
+ * bun scripts/benchmark/container-scale.ts --tiers 5,10 --gate 10
12
+ * bun scripts/benchmark/container-scale.ts --legacy-healthcheck # 10s probes, for comparison
13
+ *
14
+ * Exit code 1 when the gate tier fails.
15
+ */
16
+ import { execSync, spawnSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { basename, dirname, join, relative, resolve } from "node:path";
19
+
20
+ const REPO_ROOT = resolve(import.meta.dir, "..", "..");
21
+ const PREFIX = "tdkbench-";
22
+
23
+ interface Options {
24
+ mode: "compose" | "synthetic";
25
+ project: string;
26
+ tiers: number[];
27
+ gate: number;
28
+ timeoutSeconds: number;
29
+ interval: number;
30
+ startInterval: number;
31
+ settleSeconds: number;
32
+ }
33
+
34
+ function parseArgs(): Options {
35
+ const args = process.argv.slice(2);
36
+ const get = (flag: string, fallback: string): string => {
37
+ const i = args.indexOf(flag);
38
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
39
+ };
40
+ const legacy = args.includes("--legacy-healthcheck");
41
+ const tiers = get("--tiers", "5,10,20,50,80,100").split(",").map(Number).filter((n) => n > 0);
42
+ return {
43
+ mode: args.includes("--synthetic") ? "synthetic" : "compose",
44
+ project: resolve(get("--project", process.env.TDK_BENCH_PROJECT ?? join(REPO_ROOT, "..", "tdk-erp-system"))),
45
+ tiers,
46
+ gate: Number(get("--gate", String(Math.max(...tiers)))),
47
+ timeoutSeconds: Number(get("--timeout", "240")),
48
+ interval: legacy ? 10 : Number(get("--interval", "30")),
49
+ startInterval: legacy ? 0 : Number(get("--start-interval", "2")),
50
+ settleSeconds: Number(get("--settle", "15")),
51
+ };
52
+ }
53
+
54
+ function sh(command: string, timeoutMs = 120_000): string {
55
+ return execSync(command, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }).trim();
56
+ }
57
+
58
+ function shOk(command: string, timeoutMs = 120_000): string {
59
+ try {
60
+ return sh(command, timeoutMs);
61
+ } catch {
62
+ return "";
63
+ }
64
+ }
65
+
66
+ interface Service {
67
+ name: string;
68
+ stack: string;
69
+ image: string;
70
+ port: number;
71
+ isFrontend: boolean;
72
+ singleProcess: boolean;
73
+ imageMiB: number;
74
+ }
75
+
76
+ function discoverServices(project: string): Service[] {
77
+ const images = new Set(shOk("docker images --format '{{.Repository}}:{{.Tag}}'").split("\n"));
78
+ const services: Service[] = [];
79
+ const manifests = [...new Bun.Glob("services/*/*/service.json").scanSync({ cwd: project })].sort();
80
+ for (const relative of manifests) {
81
+ const manifestPath = join(project, relative);
82
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
83
+ const name = basename(dirname(manifestPath));
84
+ const stack = manifest.stack ?? basename(dirname(dirname(manifestPath)));
85
+ const image = `${stack}_${name}:dev`;
86
+ if (!images.has(image)) continue;
87
+ const [cmd, bytes] = shOk(`docker image inspect ${image} --format '{{json .Config.Cmd}}|{{.Size}}'`).split("|");
88
+ services.push({
89
+ name,
90
+ stack,
91
+ image,
92
+ port: Number(manifest.port ?? 4000),
93
+ isFrontend: manifest.appType === "frontend",
94
+ singleProcess: !(cmd ?? "").includes('"run","start"'),
95
+ imageMiB: Math.round(Number(bytes ?? 0) / 1048576),
96
+ });
97
+ }
98
+ return services;
99
+ }
100
+
101
+ function dbPassword(project: string): string {
102
+ const envPath = join(project, ".env");
103
+ if (!existsSync(envPath)) return "";
104
+ const line = readFileSync(envPath, "utf-8").split("\n").find((l) => l.startsWith("DB_PASSWORD="));
105
+ return line ? line.slice("DB_PASSWORD=".length).trim() : "";
106
+ }
107
+
108
+ function projectName(project: string): string {
109
+ const pj = join(project, ".tdk", "project.json");
110
+ const name = existsSync(pj) ? JSON.parse(readFileSync(pj, "utf-8")).project?.name : basename(project);
111
+ return String(name).replace(/-/g, "_");
112
+ }
113
+
114
+ function toMiB(raw: string): number {
115
+ const n = Number.parseFloat(raw);
116
+ if (/GiB$/.test(raw)) return n * 1024;
117
+ if (/KiB$/.test(raw)) return n / 1024;
118
+ if (/MiB$/.test(raw)) return n;
119
+ if (/[0-9]B$/.test(raw)) return n / 1048576;
120
+ return n;
121
+ }
122
+
123
+ function timed<T>(fn: () => T): [T, number] {
124
+ const start = performance.now();
125
+ const value = fn();
126
+ return [value, Math.round(performance.now() - start)];
127
+ }
128
+
129
+ function hostLoad(): number {
130
+ return Number.parseFloat(shOk("sysctl -n vm.loadavg").replace(/[{}]/g, "").trim().split(/\s+/)[0] ?? "0");
131
+ }
132
+
133
+ function hostFreeMiB(): number {
134
+ const out = shOk("vm_stat");
135
+ const page = Number(out.match(/page size of (\d+)/)?.[1] ?? 16384);
136
+ const pages = (key: string) => Number(out.match(new RegExp(`${key}:\\s+(\\d+)`))?.[1] ?? 0);
137
+ return Math.round(((pages("Pages free") + pages("Pages speculative")) * page) / 1048576);
138
+ }
139
+
140
+ function removeBenchContainers(): void {
141
+ const ids = shOk(`docker ps -aq --filter name=${PREFIX}`).split("\n").filter(Boolean);
142
+ if (ids.length) shOk(`docker rm -f ${ids.join(" ")}`, 300_000);
143
+ }
144
+
145
+ interface TierResult {
146
+ tier: number;
147
+ launchSeconds: number;
148
+ firstHealthySeconds: number | null;
149
+ allHealthySeconds: number | null;
150
+ healthy: number;
151
+ unhealthy: number;
152
+ starting: number;
153
+ crashed: number;
154
+ oomKilled: number;
155
+ crashes: { name: string; exitCode: string; oom: boolean; lastLog: string }[];
156
+ totalMemMiB: number;
157
+ avgMemMiB: number;
158
+ totalCpuPct: number;
159
+ dockerPsMs: number;
160
+ requestMsP50: number | null;
161
+ loadAvg1m: number;
162
+ hostFreeMiB: number;
163
+ singleProcessShare: number;
164
+ }
165
+
166
+ function runTier(n: number, services: Service[], opts: Options, network: string, password: string, project: string): TierResult {
167
+ removeBenchContainers();
168
+ const picked = Array.from({ length: n }, (_, i) => ({ svc: services[i % services.length], name: `${PREFIX}${i}` }));
169
+ const healthFlags = (probe: string) =>
170
+ [
171
+ `--health-cmd '${probe}'`,
172
+ `--health-interval ${opts.interval}s`,
173
+ opts.startInterval > 0 ? `--health-start-interval ${opts.startInterval}s` : "",
174
+ "--health-timeout 5s --health-start-period 30s --health-retries 3",
175
+ ].join(" ");
176
+
177
+ const t0 = performance.now();
178
+ for (const { svc, name } of picked) {
179
+ const probe = svc.isFrontend ? "curl -fs http://localhost/ || exit 1" : `curl -fs http://localhost:${svc.port}/health || exit 1`;
180
+ const env = svc.isFrontend
181
+ ? ""
182
+ : `-e PORT=${svc.port} -e NODE_ENV=development -e DATABASE_URL=postgresql://${project}:${password}@${project}_postgres:5432/${project}_${svc.stack} -e TILT_DATABASE_URL=postgresql://${project}:${password}@${project}_postgres:5432/${project}_${svc.stack} -e AUTO_MIGRATE=false`;
183
+ const res = spawnSync(
184
+ "sh",
185
+ ["-c", `docker run -d --name ${name} --init --memory 512m --cpus 0.5 --network ${network} ${env} ${healthFlags(probe)} ${svc.image}`],
186
+ { encoding: "utf-8" },
187
+ );
188
+ if (res.status !== 0) console.error(` ! ${name} (${svc.name}) failed to start: ${res.stderr.trim().split("\n").pop()}`);
189
+ }
190
+ const launchSeconds = (performance.now() - t0) / 1000;
191
+
192
+ let firstHealthy: number | null = null;
193
+ let allHealthy: number | null = null;
194
+ const deadline = t0 + opts.timeoutSeconds * 1000;
195
+ let states: string[] = [];
196
+ while (performance.now() < deadline) {
197
+ states = shOk(`docker ps -a --filter name=${PREFIX} --format '{{.Status}}'`, 60_000).split("\n").filter(Boolean);
198
+ const healthyCount = states.filter((s) => s.includes("(healthy)")).length;
199
+ const elapsed = (performance.now() - t0) / 1000;
200
+ if (healthyCount > 0 && firstHealthy === null) firstHealthy = elapsed;
201
+ if (healthyCount === n) {
202
+ allHealthy = elapsed;
203
+ break;
204
+ }
205
+ if (states.length === n && states.every((s) => !s.includes("health: starting") && !s.startsWith("Created"))) break;
206
+ Bun.sleepSync(1000);
207
+ }
208
+
209
+ Bun.sleepSync(opts.settleSeconds * 1000);
210
+ states = shOk(`docker ps -a --filter name=${PREFIX} --format '{{.Names}}\t{{.Status}}'`, 60_000).split("\n").filter(Boolean);
211
+ const exitedNames = states.filter((s) => /\tExited|\tRestarting|\tDead/.test(s)).map((s) => s.split("\t")[0]);
212
+ const crashes = exitedNames.map((name) => {
213
+ const inspect = shOk(`docker inspect ${name} --format '{{.State.ExitCode}}|{{.State.OOMKilled}}'`).split("|");
214
+ const lastLog = shOk(`docker logs --tail 3 ${name} 2>&1`).split("\n").filter(Boolean).pop() ?? "";
215
+ const svc = picked.find((p) => p.name === name)?.svc.name ?? name;
216
+ return { name: `${name} (${svc})`, exitCode: inspect[0] ?? "?", oom: inspect[1] === "true", lastLog: lastLog.slice(0, 160) };
217
+ });
218
+
219
+ const stats = shOk(`docker stats --no-stream --format '{{.Name}} {{.CPUPerc}} {{.MemUsage}}' $(docker ps -q --filter name=${PREFIX})`, 180_000)
220
+ .split("\n")
221
+ .filter(Boolean)
222
+ .map((l) => l.split(/\s+/))
223
+ .map(([, cpu, mem]) => ({ cpu: Number.parseFloat(cpu ?? "0"), mem: toMiB(mem ?? "0") }));
224
+ const totalMem = stats.reduce((a, s) => a + s.mem, 0);
225
+ const [, dockerPsMs] = timed(() => shOk("docker ps -q", 60_000));
226
+
227
+ const running = picked.filter((p) => !exitedNames.includes(p.name)).slice(0, 10);
228
+ const latencies = running
229
+ .map(({ svc, name }) => {
230
+ const url = svc.isFrontend ? "http://localhost/" : `http://localhost:${svc.port}/health`;
231
+ const out = shOk(`docker exec ${name} curl -s -o /dev/null -w '%{http_code} %{time_total}' ${url}`, 30_000).split(" ");
232
+ return out[0] === "200" ? Number.parseFloat(out[1] ?? "0") * 1000 : null;
233
+ })
234
+ .filter((v): v is number => v !== null)
235
+ .sort((a, b) => a - b);
236
+
237
+ const result: TierResult = {
238
+ tier: n,
239
+ launchSeconds: round(launchSeconds),
240
+ firstHealthySeconds: firstHealthy === null ? null : round(firstHealthy),
241
+ allHealthySeconds: allHealthy === null ? null : round(allHealthy),
242
+ healthy: states.filter((s) => s.includes("(healthy)")).length,
243
+ unhealthy: states.filter((s) => s.includes("(unhealthy)")).length,
244
+ starting: states.filter((s) => s.includes("health: starting")).length,
245
+ crashed: crashes.length,
246
+ oomKilled: crashes.filter((c) => c.oom).length,
247
+ crashes,
248
+ totalMemMiB: Math.round(totalMem),
249
+ avgMemMiB: stats.length ? round(totalMem / stats.length) : 0,
250
+ totalCpuPct: round(stats.reduce((a, s) => a + s.cpu, 0)),
251
+ dockerPsMs,
252
+ requestMsP50: latencies.length ? round(latencies[Math.floor(latencies.length / 2)]) : null,
253
+ loadAvg1m: hostLoad(),
254
+ hostFreeMiB: hostFreeMiB(),
255
+ singleProcessShare: round(picked.filter((p) => p.svc.singleProcess).length / n),
256
+ };
257
+ removeBenchContainers();
258
+ return result;
259
+ }
260
+
261
+ // ---- compose mode: start services exactly like Tilt (generated compose, real infra, Traefik) ----
262
+
263
+ function ensureExternalNetworks(composeArgs: string): void {
264
+ const config = shOk(`docker compose ${composeArgs} config --format json`, 120_000);
265
+ if (!config) return;
266
+ for (const net of Object.values(JSON.parse(config).networks ?? {}) as { name?: string; external?: boolean }[]) {
267
+ if (net.external && net.name && !shOk(`docker network inspect ${net.name} --format '{{.Name}}'`)) shOk(`docker network create ${net.name}`);
268
+ }
269
+ }
270
+
271
+ function startInfra(project: string, root: string): string[] {
272
+ const started: string[] = [];
273
+ const envFile = join(root, ".env");
274
+ const infra = [
275
+ { container: `${project}_postgres`, file: join(root, "services", "platform", "database-management", "docker-compose.yml") },
276
+ { container: `${project}_traefik`, file: join(root, ".tdk", ".tdk-out", "docker-compose.traefik.yml") },
277
+ ];
278
+ for (const { container, file } of infra) {
279
+ if (shOk(`docker ps -q --filter name=^${container}$`)) continue;
280
+ if (shOk(`docker ps -aq --filter name=^${container}$`)) shOk(`docker start ${container}`);
281
+ else {
282
+ const args = `-f ${file} --env-file ${envFile}`;
283
+ ensureExternalNetworks(args);
284
+ shOk(`docker compose ${args} up -d`, 300_000);
285
+ }
286
+ started.push(container);
287
+ }
288
+ for (let i = 0; i < 60 && !shOk(`docker exec ${project}_postgres pg_isready -U ${project}`); i++) Bun.sleepSync(1000);
289
+ return started;
290
+ }
291
+
292
+ function composeArgs(root: string, stack: string): string {
293
+ return `-p ${stack}-autogenerated -f ${join(root, "services", stack, ".autogenerated", "docker-compose.app.autogenerated.yml")} --env-file ${join(root, ".env")}`;
294
+ }
295
+
296
+ function routeRequest(container: string): { code: string; ms: number } | null {
297
+ const labels = JSON.parse(shOk(`docker inspect ${container} --format '{{json .Config.Labels}}'`) || "{}") as Record<string, string>;
298
+ const ruleKey = Object.keys(labels).find((k) => /^traefik\.http\.routers\.[^.]+\.rule$/.test(k));
299
+ if (!ruleKey) return null;
300
+ const rule = labels[ruleKey] ?? "";
301
+ const host = rule.match(/Host\(`([^`]+)`\)/)?.[1];
302
+ const prefix = rule.match(/PathPrefix\(`([^`]+)`\)/)?.[1] ?? "";
303
+ const healthKey = Object.keys(labels).find((k) => k.endsWith(".loadbalancer.healthcheck.path"));
304
+ const path = `${prefix.replace(/\/$/, "")}${healthKey ? labels[healthKey] : "/"}`;
305
+ if (!host) return null;
306
+ const out = shOk(`curl -s -o /dev/null -m 10 -w '%{http_code} %{time_total}' -H 'Host: ${host}' 'http://127.0.0.1:80${path}'`, 20_000).split(" ");
307
+ return { code: out[0] ?? "000", ms: round(Number.parseFloat(out[1] ?? "0") * 1000) };
308
+ }
309
+
310
+ function runTierCompose(n: number, services: Service[], opts: Options, project: string): TierResult & { routed: Record<string, number>; restarts: number } {
311
+ const picked = services.slice(0, Math.min(n, services.length));
312
+ const byStack = new Map<string, string[]>();
313
+ for (const svc of picked) byStack.set(svc.stack, [...(byStack.get(svc.stack) ?? []), svc.name]);
314
+ for (const stack of byStack.keys()) {
315
+ shOk(`docker exec ${project}_postgres createdb -U ${project} -O ${project} ${project}_${stack}`);
316
+ ensureExternalNetworks(composeArgs(opts.project, stack));
317
+ }
318
+ const names = picked.map((svc) => `${svc.stack}-autogenerated-${svc.name}-1`);
319
+
320
+ // Launch every stack's `compose up` in parallel, same as Tilt does across resources.
321
+ // NOTE: do not use Bun.spawn() + a Bun.sleepSync() poll loop here - sleepSync blocks
322
+ // Bun's event loop, so a spawned child's exit is never observed and the poll spins
323
+ // forever even after the real process has finished. Shelling out one blocking
324
+ // spawnSync (with the stack commands backgrounded and `wait`ed inside the shell)
325
+ // avoids that: the parent shell, not our JS, is what waits for real completion.
326
+ const launchScript = [...byStack]
327
+ .map(([stack, svcs]) => `docker compose ${composeArgs(opts.project, stack)} up -d --no-build ${svcs.join(" ")} &`)
328
+ .join("\n") + "\nwait\n";
329
+ const t0 = performance.now();
330
+ const launch = spawnSync("sh", ["-c", launchScript], { encoding: "utf-8", timeout: opts.timeoutSeconds * 1000 });
331
+ const launchSeconds = (performance.now() - t0) / 1000;
332
+ if (launch.status !== 0) console.error(` ! compose up failed (exit ${launch.status}): ${(launch.stderr ?? "").slice(0, 500)}`);
333
+
334
+ const status = () => {
335
+ const rows = shOk(`docker ps -a --format '{{.Names}}\t{{.Status}}'`, 60_000).split("\n").map((l) => l.split("\t"));
336
+ return new Map(rows.filter(([name]) => names.includes(name ?? "")).map(([name, st]) => [name ?? "", st ?? ""]));
337
+ };
338
+ let firstHealthy: number | null = null;
339
+ let allHealthy: number | null = null;
340
+ const deadline = t0 + opts.timeoutSeconds * 1000;
341
+ while (performance.now() < deadline) {
342
+ const st = [...status().values()];
343
+ const healthyCount = st.filter((s) => s.includes("(healthy)") || (s.startsWith("Up") && !s.includes("health"))).length;
344
+ const elapsed = (performance.now() - t0) / 1000;
345
+ if (healthyCount > 0 && firstHealthy === null) firstHealthy = elapsed;
346
+ if (healthyCount === picked.length) {
347
+ allHealthy = elapsed;
348
+ break;
349
+ }
350
+ Bun.sleepSync(1000);
351
+ }
352
+ Bun.sleepSync(opts.settleSeconds * 1000);
353
+
354
+ const final = status();
355
+ const states = [...final.values()];
356
+ const crashes = [...final]
357
+ .filter(([name, st]) => /^(Exited|Restarting|Dead)/.test(st) || Number(shOk(`docker inspect ${name} --format '{{.RestartCount}}'`)) > 0)
358
+ .map(([name]) => {
359
+ const [exitCode, oom, restarts] = shOk(`docker inspect ${name} --format '{{.State.ExitCode}}|{{.State.OOMKilled}}|{{.RestartCount}}'`).split("|");
360
+ const lastLog = shOk(`docker logs --tail 3 ${name} 2>&1`).split("\n").filter(Boolean).pop() ?? "";
361
+ return { name: `${name} restarts=${restarts}`, exitCode: exitCode ?? "?", oom: oom === "true", lastLog: lastLog.slice(0, 160) };
362
+ });
363
+ const running = [...final].filter(([, st]) => st.startsWith("Up")).map(([name]) => name);
364
+ const stats = shOk(`docker stats --no-stream --format '{{.CPUPerc}} {{.MemUsage}}' ${running.join(" ")}`, 180_000)
365
+ .split("\n")
366
+ .filter(Boolean)
367
+ .map((l) => l.split(/\s+/))
368
+ .map(([cpu, mem]) => ({ cpu: Number.parseFloat(cpu ?? "0"), mem: toMiB(mem ?? "0") }));
369
+ const totalMem = stats.reduce((a, x) => a + x.mem, 0);
370
+ const [, dockerPsMs] = timed(() => shOk("docker ps -q", 60_000));
371
+
372
+ const routed: Record<string, number> = {};
373
+ const latencies: number[] = [];
374
+ for (const name of running) {
375
+ const r = routeRequest(name);
376
+ const code = r?.code ?? "no-route";
377
+ routed[code] = (routed[code] ?? 0) + 1;
378
+ if (r && r.code === "200") latencies.push(r.ms);
379
+ }
380
+ latencies.sort((a, b) => a - b);
381
+
382
+ const result = {
383
+ tier: n,
384
+ launchSeconds: round(launchSeconds),
385
+ firstHealthySeconds: firstHealthy === null ? null : round(firstHealthy),
386
+ allHealthySeconds: allHealthy === null ? null : round(allHealthy),
387
+ healthy: states.filter((st) => st.includes("(healthy)")).length,
388
+ unhealthy: states.filter((st) => st.includes("(unhealthy)")).length,
389
+ starting: states.filter((st) => st.includes("health: starting")).length,
390
+ crashed: crashes.length,
391
+ oomKilled: crashes.filter((c) => c.oom).length,
392
+ crashes,
393
+ totalMemMiB: Math.round(totalMem),
394
+ avgMemMiB: stats.length ? round(totalMem / stats.length) : 0,
395
+ totalCpuPct: round(stats.reduce((a, x) => a + x.cpu, 0)),
396
+ dockerPsMs,
397
+ requestMsP50: latencies.length ? latencies[Math.floor(latencies.length / 2)] ?? null : null,
398
+ loadAvg1m: hostLoad(),
399
+ hostFreeMiB: hostFreeMiB(),
400
+ singleProcessShare: round(picked.filter((svc) => svc.singleProcess).length / picked.length),
401
+ routed,
402
+ restarts: crashes.length,
403
+ };
404
+ for (const [stack, svcs] of byStack) shOk(`docker compose ${composeArgs(opts.project, stack)} rm -sf ${svcs.join(" ")}`, 300_000);
405
+ return result;
406
+ }
407
+
408
+ function round(v: number): number {
409
+ return Math.round(v * 10) / 10;
410
+ }
411
+
412
+ function main(): void {
413
+ const opts = parseArgs();
414
+ const project = projectName(opts.project);
415
+ const network = `${project}_database`;
416
+ const dockerMemMiB = Math.round(Number(shOk("docker info --format '{{.MemTotal}}'")) / 1048576);
417
+ const hostMemGiB = Math.round(Number(shOk("sysctl -n hw.memsize")) / 1073741824);
418
+
419
+ if (shOk("pgrep -f 'tilt up'")) console.warn("⚠ Tilt is running - builds in the background will skew results.");
420
+ shOk(`docker network inspect ${network} || docker network create ${network}`);
421
+ const startedInfra = opts.mode === "compose" ? startInfra(project, opts.project) : [];
422
+ if (opts.mode === "synthetic") shOk(`docker start ${project}_postgres`);
423
+ const services = discoverServices(opts.project);
424
+ if (services.length === 0) {
425
+ console.error(`No built ERP images found for ${opts.project}. Run \`tdk up\` there once to build them.`);
426
+ process.exit(2);
427
+ }
428
+ const baselineContainers = shOk("docker ps -q").split("\n").filter(Boolean).length;
429
+ const singleShare = services.filter((s) => s.singleProcess).length / services.length;
430
+
431
+ // North star math: 100 services must fit in the Docker VM with ~512 MiB left for infra.
432
+ const imageSizes = (list: Service[]) => {
433
+ const sizes = list.map((s) => s.imageMiB).sort((a, b) => a - b);
434
+ return sizes.length ? { count: sizes.length, min: sizes[0], avg: Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length), max: sizes[sizes.length - 1] } : null;
435
+ };
436
+ const imageStats = {
437
+ backend: imageSizes(services.filter((s) => !s.isFrontend)),
438
+ frontend: imageSizes(services.filter((s) => s.isFrontend)),
439
+ backendSingleProcess: imageSizes(services.filter((s) => !s.isFrontend && s.singleProcess)),
440
+ backendWrapper: imageSizes(services.filter((s) => !s.isFrontend && !s.singleProcess)),
441
+ };
442
+ const diskUsage = shOk("docker system df --format '{{.Type}} {{.Size}}'").split("\n")[0] ?? "";
443
+ const perServiceBudgetMiB = Math.floor((dockerMemMiB - 512) / 100);
444
+ console.log(`Host ${hostMemGiB} GB, Docker VM ${dockerMemMiB} MiB, ${services.length} distinct ERP images (${Math.round(singleShare * 100)}% single-process CMD), ${baselineContainers} other containers running`);
445
+ console.log(`North star budget: (${dockerMemMiB} - 512 infra) / 100 = ${perServiceBudgetMiB} MiB per service`);
446
+ const fmt = (x: ReturnType<typeof imageSizes>) => (x ? `${x.count} × ${x.min}/${x.avg}/${x.max} MiB (min/avg/max)` : "none");
447
+ console.log(`Images - backend new layers: ${fmt(imageStats.backendSingleProcess)}, backend old layers: ${fmt(imageStats.backendWrapper)}, frontend: ${fmt(imageStats.frontend)}; Docker disk: ${diskUsage}`);
448
+ console.log(`Mode: ${opts.mode === "compose" ? "compose (generated compose files, real Postgres + Traefik, requests routed through Traefik)" : "synthetic (docker run, /health only)"}`);
449
+ console.log(`Healthcheck: interval ${opts.interval}s, start-interval ${opts.startInterval || "off"}\n`);
450
+
451
+ const results: TierResult[] = [];
452
+ for (const tier of opts.tiers) {
453
+ process.stdout.write(`▶ tier ${tier}... `);
454
+ const r = opts.mode === "compose" ? runTierCompose(tier, services, opts, project) : runTier(tier, services, opts, network, dbPassword(opts.project), project);
455
+ results.push(r);
456
+ const routed = "routed" in r ? ` | via Traefik: ${JSON.stringify(r.routed)}` : "";
457
+ console.log(`all healthy: ${r.allHealthySeconds ?? "NO"}s, crashed ${r.crashed}, mem ${r.totalMemMiB} MiB, docker ps ${r.dockerPsMs} ms${routed}`);
458
+ }
459
+
460
+ const header = "| tier | launch s | 1st healthy s | all healthy s | healthy | unhealthy | starting | crashed | OOM | mem MiB | avg MiB | CPU % | docker ps ms | req p50 ms | load 1m | host free MiB |";
461
+ const rows = results.map(
462
+ (r) =>
463
+ `| ${r.tier} | ${r.launchSeconds} | ${r.firstHealthySeconds ?? "-"} | ${r.allHealthySeconds ?? "TIMEOUT"} | ${r.healthy} | ${r.unhealthy} | ${r.starting} | ${r.crashed} | ${r.oomKilled} | ${r.totalMemMiB} | ${r.avgMemMiB} | ${r.totalCpuPct} | ${r.dockerPsMs} | ${r.requestMsP50 ?? "-"} | ${r.loadAvg1m} | ${r.hostFreeMiB} |`,
464
+ );
465
+ console.log(`\n${header}\n|${"---|".repeat(16)}\n${rows.join("\n")}`);
466
+ for (const r of results.filter((x) => x.crashes.length)) {
467
+ console.log(`\nCrashes at tier ${r.tier}:`);
468
+ for (const c of r.crashes.slice(0, 15)) console.log(` - ${c.name} exit=${c.exitCode}${c.oom ? " OOMKilled" : ""} :: ${c.lastLog}`);
469
+ }
470
+
471
+ const gate = results.find((r) => r.tier === opts.gate);
472
+ const checks = gate
473
+ ? [
474
+ [`all ${gate.tier} healthy within ${opts.timeoutSeconds}s`, gate.allHealthySeconds !== null],
475
+ ["no crashes / OOM kills", gate.crashed === 0],
476
+ [`memory ${gate.totalMemMiB} MiB <= ${perServiceBudgetMiB * gate.tier} MiB budget`, gate.totalMemMiB <= perServiceBudgetMiB * gate.tier],
477
+ [`docker ps ${gate.dockerPsMs} ms < 10000 ms (tdk doctor timeout)`, gate.dockerPsMs < 10_000],
478
+ ]
479
+ : [];
480
+ if (gate && "routed" in gate) {
481
+ const routed = gate.routed as Record<string, number>;
482
+ const total = Object.values(routed).reduce((a, b) => a + b, 0);
483
+ checks.push([`requests through Traefik: ${routed["200"] ?? 0}/${total} returned 200`, (routed["200"] ?? 0) === total]);
484
+ }
485
+ const smallest = results[0];
486
+ console.log(`\nWebsite promise "full boot in under 5 seconds": tier ${smallest?.tier} all healthy in ${smallest?.allHealthySeconds ?? "TIMEOUT"}s (info only)`);
487
+ console.log(`Gate (tier ${opts.gate}):`);
488
+ for (const [label, ok] of checks) console.log(` ${ok ? "✓" : "✗"} ${label}`);
489
+ const passed = checks.length > 0 && checks.every(([, ok]) => ok);
490
+ for (const container of startedInfra) shOk(`docker stop ${container}`);
491
+
492
+ const outDir = join(REPO_ROOT, "benchmarks", "results");
493
+ mkdirSync(outDir, { recursive: true });
494
+ const outFile = join(outDir, `container-scale-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
495
+ writeFileSync(
496
+ outFile,
497
+ JSON.stringify({ date: new Date().toISOString(), hostMemGiB, dockerMemMiB, perServiceBudgetMiB, options: { ...opts, project: relative(REPO_ROOT, opts.project) }, images: services.length, imageStats, diskUsage, singleProcessImageShare: singleShare, results, gate: { tier: opts.gate, passed } }, null, 2),
498
+ );
499
+ console.log(`\n${passed ? "PASS" : "FAIL"} - results saved to ${outFile}`);
500
+ process.exit(passed ? 0 : 1);
501
+ }
502
+
503
+ main();
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ # Opt-in container scale gate for git hooks. No-op unless TDK_BENCH_GATE=1.
3
+ # TDK_BENCH_GATE=1 git push # run full gate (tiers up to 100)
4
+ # TDK_BENCH_GATE=1 TDK_BENCH_TIERS=5,10,20 git commit ...
5
+ set -euo pipefail
6
+ [ "${TDK_BENCH_GATE:-0}" = "1" ] || exit 0
7
+ cd "$(dirname "$0")/../.."
8
+ tiers="${TDK_BENCH_TIERS:-5,10,20,50,80,100}"
9
+ exec bun scripts/benchmark/container-scale.ts --tiers "$tiers" --gate "${tiers##*,}"
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Checks an idle generated backend container against the runtime footprint budget
3
+ # (openspec: runtime-container-footprint): one Bun process, <= 64 MiB, < 2% CPU.
4
+ #
5
+ # Usage: scripts/measure-idle-footprint.sh <container> [sample_seconds]
6
+ set -euo pipefail
7
+
8
+ container="${1:?usage: $0 <container> [sample_seconds]}"
9
+ sample_seconds="${2:-60}"
10
+ max_mem_mib="${MAX_MEM_MIB:-64}"
11
+ max_cpu_pct="${MAX_CPU_PCT:-2}"
12
+
13
+ echo "Waiting for ${container} to be healthy..."
14
+ for _ in $(seq 1 90); do
15
+ health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")"
16
+ [ "$health" = "healthy" ] || [ "$health" = "none" ] && break
17
+ sleep 2
18
+ done
19
+ if [ "$health" != "healthy" ] && [ "$health" != "none" ]; then
20
+ echo "FAIL: ${container} is ${health}, not healthy" >&2
21
+ exit 1
22
+ fi
23
+
24
+ bun_processes="$(docker top "$container" -o args | tail -n +2 | grep -c '^bun ' || true)"
25
+ echo "Bun processes: ${bun_processes}"
26
+ docker top "$container" -o pid,rss,args | sed 's/^/ /'
27
+
28
+ to_mib() {
29
+ awk -v v="$1" 'BEGIN {
30
+ n = v + 0
31
+ if (v ~ /GiB$/) n *= 1024
32
+ else if (v ~ /KiB$/) n /= 1024
33
+ else if (v ~ /[0-9]B$/) n /= 1048576
34
+ printf "%.2f", n
35
+ }'
36
+ }
37
+
38
+ samples=0 cpu_total=0 mem_peak=0
39
+ end=$((SECONDS + sample_seconds))
40
+ while [ "$SECONDS" -lt "$end" ]; do
41
+ line="$(docker stats --no-stream --format '{{.CPUPerc}} {{.MemUsage}}' "$container")"
42
+ cpu="${line%%\%*}"
43
+ mem="$(to_mib "$(echo "$line" | awk '{print $2}')")"
44
+ cpu_total="$(awk -v a="$cpu_total" -v b="$cpu" 'BEGIN { printf "%.4f", a + b }')"
45
+ mem_peak="$(awk -v a="$mem_peak" -v b="$mem" 'BEGIN { print (b > a ? b : a) }')"
46
+ samples=$((samples + 1))
47
+ sleep 5
48
+ done
49
+ cpu_avg="$(awk -v t="$cpu_total" -v n="$samples" 'BEGIN { printf "%.2f", t / n }')"
50
+
51
+ echo "Samples: ${samples} over ${sample_seconds}s | peak memory: ${mem_peak} MiB (max ${max_mem_mib}) | avg CPU: ${cpu_avg}% (max <${max_cpu_pct}%)"
52
+
53
+ failed=0
54
+ [ "$bun_processes" -eq 1 ] || { echo "FAIL: expected 1 Bun process, found ${bun_processes}" >&2; failed=1; }
55
+ awk -v m="$mem_peak" -v max="$max_mem_mib" 'BEGIN { exit !(m <= max) }' || { echo "FAIL: memory over budget" >&2; failed=1; }
56
+ awk -v c="$cpu_avg" -v max="$max_cpu_pct" 'BEGIN { exit !(c < max) }' || { echo "FAIL: CPU over budget" >&2; failed=1; }
57
+ [ "$failed" -eq 0 ] && echo "PASS: ${container} is within the idle footprint budget"
58
+ exit "$failed"