@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,44 @@
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ A full-landscape `tdk up` of `tdk-erp-system` (107 resources, 110 containers) saturated a 16 GB MacBook Air on 2026-09-25. Load average reached 30–43, only 99 MB of memory was free with 8 GB compressed, and `docker ps` took 6–13 s, which made `tdk doctor` and `tdk down` fail their own 10 s Docker check. A single idle backend is cheap (0.3–1.2% CPU, about 20 MiB in its cgroup). The overload comes from per-container overhead that the generated runtime layers multiply by 110: a redundant second Bun process, a 10 s exec-based healthcheck, and tooling nobody uses baked into the golden images. Cutting that overhead at the layer level makes large landscapes usable on the laptops TDK targets.
6
+
7
+ ## What Changes
8
+
9
+ - **Start the backend with one process.** The generated L4 backend runtime (and the Bun-static frontend runtime) now start the compiled entrypoint directly, e.g. `CMD ["bun", "dist/index.js"]`, instead of `bun run start`. `bun run start` launches a Bun wrapper that then spawns a second Bun runtime (about 10 MB + 52 MB resident per container). A service whose `start` script does more than run a single file keeps `bun run start`.
10
+ - **Add an init process for signals.** Generated runtime services run under Docker's minimal init (`init: true`) so `docker stop` still reaches the app once the wrapper is gone.
11
+ - **Slow down healthchecks.** The default steady-state healthcheck interval goes from 10 s to 30 s for generated compose services and golden images. A fast start interval keeps first-healthy time where it is today. The value stays configurable through the existing healthcheck config, and compose files now read that config instead of hard-coding 10 s.
12
+ - **BREAKING (environment):** the fast start interval needs Docker Engine 25+ and Compose 2.20.2+. `tdk doctor` gets a check for this.
13
+ - **Make the migrator healthcheck cheap.** The `l4_migrator_golden` healthcheck no longer runs `bunx prisma --version`, which starts a full bunx/Prisma process on every check. It checks the locally installed Prisma binary instead.
14
+ - **Remove hugo from the golden backend image.** `apk add hugo` is removed from the golden `l4_backend_bun` stage (about 57 MB per backend image). The per-service L4 layer already installs hugo only when a manifest lists the `hugo` feature, and that stays as is.
15
+ - **Document a per-container budget.** An idle generated backend has a documented footprint target, so regressions like these are visible.
16
+
17
+ Nothing here is breaking for service code. Services that relied on hugo being present without declaring the `hugo` feature must add it to `featuresEnabled`.
18
+
19
+ ## Capabilities
20
+
21
+ ### New Capabilities
22
+ - `runtime-container-footprint`: Requirements on generated runtime images and containers: one application process per container, healthcheck cadence and cost, golden image contents limited to what every service of that type needs, and an idle footprint budget.
23
+
24
+ ### Modified Capabilities
25
+ - (none)
26
+
27
+ ## Non-goals
28
+
29
+ - Changing which resources `tdk up` starts by default, or putting backends under Sablier scale-to-zero. Even with these fixes, starting all 107 services at once stays heavy on 16 GB machines. That is a separate product decision with its own change.
30
+ - Changing the Bun version, base OS, or Prisma version.
31
+
32
+ ## Impact
33
+
34
+ - **Engine (Starlark):**
35
+ - `engine/topologies/platform/docker/layers/l4_runtime_layers.star`: backend and Bun-static frontend `CMD`.
36
+ - `engine/topologies/platform/docker/layers/infisical/infisical_docker.star`: `_runtime_setup` default command.
37
+ - `engine/topologies/platform/docker/layers/l3_builder_layers.star`: `RUNTIME_CONFIG.bun.backend_start_command`.
38
+ - `engine/topologies/platform/docker/dockerfile/dockerfile.star` and `generators/golden_docker_generator_v2.star`: default `cmd` parameters.
39
+ - `engine/topologies/platform/docker/build/golden_image_dockerfile.star`: hugo removal, healthcheck interval, migrator healthcheck.
40
+ - `engine/topologies/platform/docker/compose/compose.star`: compose healthcheck (lines 141, 264) reads the shared config; `init: true`.
41
+ - **CLI:** `cli/src/commands/doctor.ts` gets a Docker Engine / Compose minimum-version check.
42
+ - **Generated projects:** every project needs `tdk project --yes` (re-vendoring `.tdk/.tdk-out/tdk-cli-ext/`) and a golden image rebuild to pick up the change. Existing images keep working until then.
43
+ - **Behaviour:** after a failure, Docker marks a container unhealthy up to about 20 s later than today. Tilt readiness and dependency ordering still use the start period.
44
+ - **Tests:** starlark and generated-Dockerfile snapshot tests covering these files need updating.
@@ -0,0 +1,79 @@
1
+ # Spec Delta
2
+
3
+ ## Purpose
4
+
5
+ Keeps the per-container CPU, memory and image-size cost of generated TDK runtimes small enough that a large landscape (100+ resources) can run on a 16 GB developer laptop without starving the container runtime.
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: One Application Process Per Runtime Container
10
+
11
+ A generated Bun backend or Bun-static frontend runtime container SHALL run its application as a single Bun process. The container entrypoint MUST hand off to that process with `exec` rather than keep a wrapper process alive. This applies when the service's `start` script runs a single entry file.
12
+
13
+ #### Scenario: Default backend start script
14
+
15
+ - **WHEN** a generated backend's `package.json` has `"start": "bun run dist/index.js"` and its runtime container has finished starting
16
+ - **THEN** exactly one Bun process is running in the container (as listed by `docker top`)
17
+ - **AND** that process is running `dist/index.js`
18
+
19
+ #### Scenario: Custom start script is preserved
20
+
21
+ - **WHEN** a service's `start` script does more than run a single entry file (for example it chains commands or sets environment variables)
22
+ - **THEN** the runtime starts the service through `bun run start`
23
+ - **AND** the service behaves as it did before this change
24
+
25
+ #### Scenario: Signals reach the application
26
+
27
+ - **WHEN** a running backend container is stopped with `docker stop`
28
+ - **THEN** the application process receives SIGTERM directly and the container exits within the stop timeout
29
+
30
+ ### Requirement: Healthchecks Are Low-Cost And Low-Frequency
31
+
32
+ Container healthchecks generated for services and golden images SHALL default to an interval of 30 seconds, and the timing MUST be overridable per run through `TDK_HEALTHCHECK_*` environment variables. A healthcheck command MUST NOT download packages or start a package-runner process (such as `bunx` or `npx`).
33
+
34
+ #### Scenario: Default service healthcheck interval
35
+
36
+ - **WHEN** a project is generated with default healthcheck configuration
37
+ - **THEN** every generated compose service healthcheck has `interval: 30s`
38
+ - **AND** the start period is at least as long as it was before this change
39
+
40
+ #### Scenario: Startup readiness is not delayed
41
+
42
+ - **WHEN** a generated service container starts and begins answering its health endpoint
43
+ - **THEN** Docker reports it healthy within 5 seconds of the first successful answer, even with a 30-second steady-state interval
44
+
45
+ #### Scenario: Configured interval is honoured
46
+
47
+ - **WHEN** `tdk up` runs with `TDK_HEALTHCHECK_INTERVAL_SECONDS` set (e.g. `15`)
48
+ - **THEN** the generated compose files and golden images use that interval
49
+
50
+ #### Scenario: One-shot migrator images carry no healthcheck
51
+
52
+ - **WHEN** the golden migrator image or a generated migrator runtime image is inspected
53
+ - **THEN** it declares no healthcheck (`HEALTHCHECK NONE`)
54
+ - **AND** no healthcheck anywhere in the generated images invokes `bunx` or `npx`
55
+
56
+ ### Requirement: Golden Images Contain Only Universally Required Tooling
57
+
58
+ A golden runtime image SHALL contain only tooling that every service of that runtime type needs. Tooling that only some services use MUST be installed in the per-service layer, and only when that service's manifest declares the matching feature.
59
+
60
+ #### Scenario: Backend golden image has no hugo
61
+
62
+ - **WHEN** the golden L4 backend Bun image is built
63
+ - **THEN** the `hugo` binary is not present in the image
64
+
65
+ #### Scenario: Service declaring the hugo feature
66
+
67
+ - **WHEN** a service's manifest lists `hugo` in `featuresEnabled`
68
+ - **THEN** that service's runtime image contains the `hugo` binary
69
+ - **AND** services that do not declare it do not
70
+
71
+ ### Requirement: Idle Backend Footprint Budget
72
+
73
+ A backend generated from the default TDK backend template SHALL, when idle (running and healthy, with no incoming requests), stay within 64 MiB container memory and average under 2% of one CPU core over 60 seconds, as reported by `docker stats`.
74
+
75
+ #### Scenario: Measuring an idle template backend
76
+
77
+ - **WHEN** a backend generated with `tdk resource <name> --type backend` has been running and healthy for 60 seconds with no incoming requests
78
+ - **THEN** its container memory usage is at most 64 MiB
79
+ - **AND** its average CPU usage over the following 60 seconds is below 2% of one core
@@ -0,0 +1,36 @@
1
+ # Tasks
2
+
3
+ ## 1. Preflight checks
4
+
5
+ - [x] 1.1 Search every TDK project in the workspace (`tdk-erp-system`, `tdk-saas-starter`, `tdk-restaurant-example`, `tdk-user-management`) for services that use `hugo` without listing it in `featuresEnabled`. Record the result under design D5. Verify by attaching the search command and its (expected empty) output to the design note.
6
+ - [x] 1.2 Determine whether any `l4_migrator_golden`-based container runs long-lived or all are one-shot jobs. Record the answer under design D4 and keep or switch the D4 decision accordingly. Verify that design.md is updated with the evidence (compose `restart` policy and Tilt resource type).
7
+
8
+ ## 2. Single-process runtime (D1, D2)
9
+
10
+ - [x] 2.1 Add one start-command resolver to the engine. It returns `bun <file>` for a `start` script of exactly `bun run <file>` or `bun <file>`, and `bun run start` otherwise. Verify with a new pytest in `tests/tilt-engine/` that generates Dockerfiles for fixture services with these start scripts: `bun run dist/index.js`, `bun dist/index.js`, `bun run dist/index.js --port 4000`, `NODE_ENV=x bun run dist/index.js`, `tsc && bun dist/index.js`, and no `start`. Assert the resulting `CMD` for each.
11
+ - [x] 2.2 Apply the resolver where every Bun `CMD` is emitted: the backend runtime (plain and Infisical-entrypoint paths) and the Bun-static frontend in `l4_runtime_layers.star`. Upstream `cmd` defaults stay as the no-override value and explicit overrides are kept (design D1). Verify with `test_backend_cmd_follows_start_script` (both paths) and `test_explicit_cmd_override_is_kept`.
12
+ - [x] 2.3 Emit `init: true` for generated backend and frontend runtime services in `compose.star`. Verify with a pytest asserting `init: true` on both service kinds in a generated compose file.
13
+ - [ ] 2.4 Build one real backend (`cash-management-api` in `tdk-erp-system`) from the new layers. Verify that `docker top` lists exactly one Bun process running `dist/index.js`, and that `docker stop` finishes in under 3 s.
14
+
15
+ ## 3. Healthchecks (D3, D4)
16
+
17
+ - [x] 3.1 Set `get_docker_healthcheck_config` to `interval 30`, `start_interval 2`, `start_period 30`, `retries 3`, and add `--start-interval` to every golden `HEALTHCHECK`. Verify with a pytest on the generated `golden-layers.Dockerfile` asserting `--interval=30s --start-interval=2s` on each stage.
18
+ - [x] 3.2 Make `compose.star` (lines 141, 264) and `traefik_standalone.star` read the shared healthcheck config instead of hard-coding `10s`. Verify with a pytest asserting that generated compose files contain `interval: 30s` and `start_interval: 2s` and no `interval: 10s` for app services.
19
+ - [ ] 3.3 Replace the golden migrator `bunx prisma --version` healthcheck with `HEALTHCHECK NONE` (design D4). Verify with a pytest asserting that no generated golden stage or migrator runtime contains `bunx` or `npx` in a `HEALTHCHECK`, and that after a golden rebuild `docker inspect <prefix>-l4-migrator:latest` shows `Healthcheck.Test` = `["NONE"]`.
20
+ - [x] 3.4 Add a `tdk doctor` check for Docker Engine 25+ and Compose 2.20.2+ in `cli/src/commands/doctor.ts`, with vitest cases in `cli/src/commands/__tests__/doctor.test.ts` for an old version (fails, with an upgrade hint) and a current one (passes). Verify with `bun run test -- doctor.test.ts`.
21
+ - [ ] 3.5 Verify startup readiness with `docker events`: a freshly started backend is marked `healthy` within 5 s of its first successful `/health` response.
22
+
23
+ ## 4. Golden image contents (D5)
24
+
25
+ - [ ] 4.1 Remove `RUN apk add --no-cache hugo` from the `l4_backend_bun` stage in `golden_image_dockerfile.star`. Verify that `docker run --rm <prefix>-l4-backend:latest which hugo` exits non-zero, and that `docker image inspect` shows the image about 57 MB smaller than before.
26
+ - [x] 4.2 Pass the manifest from `manifest_resource.star` to `Docker.backend` so the feature-gated install actually runs (design D5), then verify: a service with `"featuresEnabled": ["hugo"]` gets `apk add --no-cache hugo` in its generated Dockerfile and a service without it does not (`test_hugo_installed_only_for_services_declaring_it`).
27
+
28
+ ## 5. Footprint budget and docs (D6)
29
+
30
+ - [ ] 5.1 Add `scripts/measure-idle-footprint.sh <container>`. It waits for the running backend container to be `healthy`, counts Bun processes, samples `docker stats` for 60 s, and exits non-zero if it finds more than one Bun process, memory over 64 MiB, or average CPU of 2% or more. (It measures an existing container instead of generating a template backend, because generation needs a whole project. The ERP backends are generated from the default template.) Verify it passes against a finance backend built from the new layers.
31
+ - [ ] 5.2 Document the minimum Docker Engine / Compose versions, the `TDK_HEALTHCHECK_*` overrides, the `featuresEnabled: ["hugo"]` migration note, and how to run the footprint script in `cli/README.md` (the repo has no root README). Verify the documented commands run as written.
32
+
33
+ ## 6. Integration
34
+
35
+ - [ ] 6.1 Re-vendor into `tdk-erp-system` (`tdk project --yes`, touch the Tiltfile, rebuild golden layers) and run `tdk up finance`. Verify that every finance service is healthy and answers HTTP 200, and record per-container idle memory next to the baseline in proposal.md.
36
+ - [ ] 6.2 Measurement only: start the full `tdk-erp-system` landscape on the 16 GB reference machine. Record load average, free memory, and `docker ps` latency against the 2026-09-25 baseline, then add the numbers to proposal.md. This informs the separate default-startup-scope change and is not a pass/fail gate.
@@ -0,0 +1,4 @@
1
+ schema: spec-driven
2
+ created: 2026-09-26
3
+ goal: Requests to a not-yet-running resource trigger and prioritize its startup
4
+ instead of waiting behind the full dependency graph
@@ -0,0 +1,113 @@
1
+ # Design
2
+
3
+ ## Context
4
+
5
+ See `proposal.md` (Why) for the motivating case. Relevant current behavior:
6
+
7
+ - Whether an app resource starts automatically is already a single Tilt-native, landscape-wide switch: `RuntimeFlags.resolve()` (`engine/topologies/tilt/resources/orchestrator/apply_runtime_flags.star`) reads `TILT_AUTO_INIT_APPS` into `runtime_flags['auto_init_apps']`, and `_register_replicas(config, auto_init_apps)` (`engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star:117`) passes it straight through as `dc_resource(..., auto_init=auto_init_apps)`. `auto_init` is Tilt's own parameter: `auto_init=False` means Tilt builds the resource's image and creates its container as part of reconciliation, but does not start it until explicitly triggered (`tilt trigger <resource>`, or the Tilt UI/HTTP API). This is exactly the "built but not started" split the proposal needs — it already exists, just as one global flag instead of a per-resource one.
8
+ - Sablier's premium implementation (`tdk-cli-extensions/premium/networking/sablier_container_cycle.star`) already assumes the container exists (Traefik's `traefik.docker.allownonrunning=true` plus `sablier.enable`/`sablier.group` labels) and only handles the docker-level stop/start of an existing container. It has no concept of Tilt's own build/create step, and no way to call back into Tilt.
9
+ - `engine/lifecycle/orchestrator.star`'s `STARTUP_PHASES` and `should_wait_for_dependencies` compute Tilt `resource_deps` chains per manifest (database, NATS) already; that same data is what a wake needs to decide which other resources to bring up together.
10
+ - Tilt exposes an HTTP API on `localhost:10350` (already referenced as the debug/inspection endpoint in `engine/paths.star:652` and as `tilt trigger <resource>` in `cli/src/utils/doctor-runtime.ts:486`) that includes a trigger endpoint equivalent to the `tilt trigger` CLI command. This design relies on that existing mechanism rather than adding a new one to Tilt.
11
+ - Real Sablier (`sablierapp/sablier`, already vendored per `traefik_standalone.star`) already holds an incoming request open and serves a wait/loading response until the container it started passes its healthcheck, or a configured timeout elapses — this is stock Sablier behavior, not something this change adds. What's missing is only: (a) getting the container into a "created, stoppable" state before it's ever run once, and (b) also reaching into Tilt for anything Sablier's plain Docker start doesn't cover.
12
+
13
+ **Verified against the live `tdk-erp-system` landscape and an isolated probe (2026-09-26), replacing the preflight guesses below:**
14
+ - The user's exact URL, reproduced live: `curl http://api.tdk-erp-system.localhost/api/data-governance/health` returns `404 page not found`, and Traefik's own API confirms why — `GET /api/http/routers/data-governance-api-project@docker` returns `{"message": "router not found: data-governance-api-project@docker"}`. The container (`analytics-autogenerated-data-governance-api-1`) does exist, `docker inspect` shows it ran once, went `unhealthy`, and `exited` hours ago — Tilt's own `uiresource` for it shows `runtimeStatus: pending` (queued to rebuild, `order: 50`, behind whatever else is ahead of it). Traefik drops an exited container from routing entirely because its labels have no `traefik.docker.allownonrunning=true` (this project has no `sablier` block on it at all). So the concrete failure mode observed in production is "crashed and queued for rebuild behind everything else," not only "literally never started" — D3's Tilt-trigger call addresses both, since it doesn't care why a resource isn't running, only that it now should be first.
15
+ - A throwaway `Tiltfile` + `docker-compose.yml` (two `busybox` services, one `dc_resource(auto_init=True)`, one `dc_resource(auto_init=False)`, run on an isolated port so the live landscape was untouched) showed: the `auto_init=False` resource gets **no container object at all** (`docker ps -a` shows nothing for it; its `uiresource` stays `runtimeStatus: none` / `updateStatus: none`) until triggered. `tilt trigger <name>` then creates *and* starts it from nothing in one call. This overturns D2's "Tilt pre-creates the container" assumption below — it does not. See the corrected D2/D3/D6.
16
+ - Read against the real `sablierapp/sablier` v1.18 docs (matching the vendored version): Sablier has **no webhook/event-hook system at all**. Its entire model is pull-based — the reverse-proxy plugin asks Sablier's HTTP API for a session's status on every request, and Sablier itself asks the provider (Docker) for instance status; the "blocking strategy" (already assumed above) is exactly this loop held open until ready or timeout. There is nothing to attach a "also call this other URL" hook to.
17
+ - The same docs describe two *native* mechanisms this design should reuse instead of inventing its own: (1) `sablier.group` — "start related workloads together... put an app and the database it needs in the same group, and a single request warms both at once" — but it requires every group member to itself carry `sablier.enable=true`; (2) Compose's own `depends_on` with `condition: service_healthy` / `service_completed_successfully` / etc. — Sablier reads the `com.docker.compose.depends_on` label Compose writes automatically and recursively starts+waits on dependencies, **even ones with no `sablier.*` labels at all**. TDK's generated compose deliberately omits `depends_on` today (`compose.star:298`: `# Note: depends_on removed - Tilt manages service startup order`) because Tilt's per-resource `resource_deps` already sequences non-deferred resources; that omission doesn't help a *deferred* resource, whose startup Tilt is explicitly not managing. See the corrected D2/D3/D4/D5.
18
+ - A second isolated probe (2026-09-26, same technique as the `auto_init` one, fully torn down afterward) tested what happens when a `deferStart`-shaped resource's compose entry has `depends_on: {other: {condition: service_healthy}}` and *both* resources are `auto_init=False`: Tilt itself parses the compose `depends_on` and automatically adds a Tilt-level wait — triggering the dependent resource alone leaves it parked at `runtimeStatus: pending` with `status.waiting = {on: [<dependency>], reason: "waiting-for-dep"}` **indefinitely**; Tilt does not auto-trigger the dependency just because something waits on it. Only once the dependency was triggered separately did the wait clear and the dependent resource actually build and start. So `depends_on` alone is not sufficient to reproduce D5's "wake the dependency too" requirement under Tilt (it would work if these were being started via a bare `docker compose up`, but Tilt's own reconciliation gates on its own trigger state first) — the wake gateway (D3) must explicitly `tilt trigger` every `depends_on` dependency itself, not rely on the cascade. This confirms and promotes what was recorded as a fallback to the primary, required mechanism.
19
+ - **A third finding overturned D5 entirely rather than just refining it:** `depends_on` is not just gated on Tilt's trigger state, it cannot validly reference a dependency at all in TDK's actual layout. `docker compose config` fails outright — `service "X" depends on undefined service "Y": invalid compose project` — whenever the referenced service is defined in a compose file registered via a *different* `docker_compose()` call, even one sharing the same `project_name` (tested with two files under one shared `project_name`: still rejected, confirming Tilt validates each registered file independently rather than merging same-project files for this purpose). Every real dependency a TDK resource has (database, NATS, its own migrator per `apply_migrator_orchestration.star:91`'s separate `docker_compose()` call) lives in a file generated by a different call than the resource's own. So `depends_on` cannot express any of D5's cases here, regardless of scoping — see the corrected D5.
20
+
21
+ ## Goals / Non-Goals
22
+
23
+ **Goals:**
24
+ - Make "start on first request" work for a resource that has never run this session, using the same manifest opt-in, Traefik wiring, and license gate as today's Sablier feature.
25
+ - Reuse Tilt's existing `auto_init` mechanism per-resource instead of inventing a parallel scheduler.
26
+ - Keep the free-tier stub/premium-override structure (`sablier_container_cycle.star` in both repos) intact - this is an extension of that module's contract, not a replacement.
27
+
28
+ **Non-Goals:**
29
+ - Redesigning Tilt's build/reconciliation loop or its trigger API. See proposal.md Non-goals for the licensing and footprint boundaries.
30
+ - Handling a resource whose `sablier` block is added or toggled while `tdk up` is already running (that already requires a Tiltfile re-eval today, same as any other manifest change).
31
+
32
+ ## Decisions
33
+
34
+ ### D1: Deferred start is `auto_init=False` scoped per-resource, driven by the manifest, not the global flag
35
+
36
+ Add a `deferStart` key to the manifest's existing `sablier` block (`engine/topologies/tilt/manifest/schema.star`'s `sablier` schema, alongside `enable`/`group`/`sessionDuration`), default `false`. `_register_replicas` (and any other call site that currently receives the single `auto_init_apps` flag for app resources) resolves per-resource auto-init as `auto_init_apps and not resource_defers_start(manifest)` instead of using `auto_init_apps` directly. The resource's **config-gen** and **docker_build** resources keep `auto_init=True` unconditionally (or follow `auto_init_config_gen`, already `True` by default) so the image still builds during `tdk up` — only the running container is held back.
37
+
38
+ *Alternatives considered:*
39
+ - A separate global "cold landscape" mode (e.g. `TILT_DEFER_ALL_APPS`): rejected because it can't express "just this one resource," which is the actual request (one API, not the whole landscape).
40
+ - Piggybacking on `TILT_TRIGGER_MODE=manual` wholesale: rejected — that already exists and disables `enforce_migrator_deps` and replica counts landscape-wide; overloading it would change unrelated behavior for every resource, not just opted-in ones.
41
+
42
+ ### D2: Two genuinely different wake mechanisms, chosen by whether a container object exists
43
+
44
+ Confirmed live: `auto_init=False` leaves **no container object** until triggered — there is nothing for Traefik's Docker provider to discover (no container, no labels, no route), which is a different situation from a resource that ran before and is now `exited` (that container and its labels already exist; Traefik would route to it if and only if `traefik.docker.allownonrunning=true` is present). Docker-label auto-discovery — the mechanism the existing premium Sablier module relies on entirely — can only ever cover the second case. The two cases need different plumbing:
45
+
46
+ - **Previously-ran, now-stopped/exited** (the case actually reproduced live against `data-governance-api`): today's premium `sablier_container_cycle.star` labels already work once a resource opts in — `allownonrunning=true` is the only missing ingredient, and it's already emitted when `sablier.enable` is set. No new mechanism needed here beyond what D1/D4/D5 add.
47
+ - **Never created this session** (the state proven by the `auto_init_probe` test): there is no container to attach Docker labels to, so the route has to come from somewhere else. See D6.
48
+
49
+ *Alternative considered:* add an explicit `docker compose create <service>` step (without starting) right after image build, so every deferred resource always has a discoverable-but-stopped container from the start, keeping Docker-label discovery as the single mechanism for both cases. Rejected as the primary path: it re-adds exactly the container-creation cost (network setup, volume mounts) to `tdk up`'s critical path that deferring was meant to avoid, just shifted from "start" to "create," and it still needs D6's static route anyway for the moment between `tdk up` finishing and that create step actually completing. Recorded as a possible later optimization (pre-create eagerly to shave latency off the very first request) but not required for this change to work.
50
+
51
+ ### D3: One wake gateway in front of every deferred resource; it calls Sablier's API or `tilt trigger` depending on container state
52
+
53
+ Since Sablier has no hook to piggyback on (see Context), the component that also needs to call Tilt was always going to be something this project writes, not a Sablier extension point. So rather than splitting "Sablier handles it, plus something else calls Tilt on the side," one small always-on gateway sits in front of every `deferStart` resource (behind D4's static route) and branches on whether a container object exists for it:
54
+
55
+ - **Container exists (previously ran, now stopped/exited):** the gateway calls Sablier's own HTTP API (the same call Traefik's plugin would make) to get the existing Docker-label wake, and in parallel runs `tilt trigger <resource>` so Tilt's own state (e.g. a migrator that needs to re-run) catches up too — Sablier's Docker-level start alone doesn't guarantee that. In practice this branch is reached rarely: per D4's verified priority fix, once any container object exists at all, Traefik's Docker-label route (at its normal, higher priority) wins over this gateway's static route on the very next request, so ongoing traffic to an existing-but-stopped resource goes straight to Sablier's own native plugin, not through here. This branch mainly covers the brief window before Traefik's docker provider notices a just-created container.
56
+ - **No container exists yet:** there is nothing for Sablier to manage (confirmed in Context — Sablier's Docker provider needs a labeled container to register, same constraint as Traefik's). The gateway skips Sablier entirely and calls `tilt trigger <resource>` directly, then polls the resource's healthcheck itself, holding the request exactly the way Sablier's blocking strategy would. This is the branch that matters for the proposal's actual motivating case, and the one the live rehearsal below exercises.
57
+
58
+ Either way, the gateway is the single thing the static route (D4) points at, and it's the same process regardless of which branch it takes — this only affects which downstream call it makes on a given request. In both branches, the gateway also explicitly `tilt trigger`s and polls every dependency named in the request's `X-Wake-Deps` header (D2/D4), rather than assuming either Sablier or Tilt cascades that on its own — confirmed necessary by the second probe in Context (Tilt parks a triggered resource on `waiting-for-dep` indefinitely if its dependency is itself untriggered) and doubly so by D5's finding that Compose `depends_on` cannot even validly express these dependencies in TDK's layout, so nothing but the gateway's own explicit handling was ever going to cover this.
59
+
60
+ *Alternative:* always go through Tilt's trigger regardless of container state, and drop Sablier's Docker-level start entirely. Rejected: it makes every wake pay Tilt's reconciliation overhead even for the common case (a resource that only needs restarting), and it removes the one piece of this feature that doesn't depend on Tilt being reachable at all (Sablier + Traefik + Docker socket keep working even if the Tilt daemon itself is down, e.g. mid-`tilt up` restart).
61
+
62
+ **Implemented and verified live (2026-09-26), replacing speculation with what actually shipped:**
63
+ - The gateway is real: `engine/assets/docker/wake-gateway/` (`decide.ts` + `server.ts`, a Bun HTTP server; `Dockerfile` bundling Bun, the Docker CLI, and the exact pinned `tilt` binary version this workspace runs). Config for a request (which resource, which container, which dependencies, which port) travels via `X-Wake-*` headers that a generated Traefik `headers` middleware injects — not the request path — so the original path/query reach the real backend unchanged once ready.
64
+ - Confirmed live: `tilt trigger` from inside a container, reaching Tilt's HTTP port at `host.docker.internal:10350`, fails with `(403): invalid session token` even though the port is reachable — Tilt's HTTP API requires the same local session token its own CLI reads from `~/.tilt-dev/token` on the host. The gateway's compose service mounts `~/.tilt-dev` read-only; with that mount, the identical `tilt trigger` call succeeds.
65
+ - Confirmed live: the Tilt/Compose *resource* name (what `tilt trigger` and Compose's own `depends_on` use) is not the same string as the Docker *container* name Compose actually creates (`<project>-<service>-1`, e.g. `analytics-autogenerated-data-governance-api-1` for resource `data-governance-api`) — `docker inspect` needs the latter. The gateway and the generated route both carry both identifiers (`X-Wake-Resource` / `X-Wake-Container`) rather than assuming they're interchangeable.
66
+ - A full disposable rehearsal (isolated Tilt + Docker Compose + the built gateway image, torn down after) woke a cold `traefik/whoami` container from both "exited" and fully "absent" states and correctly proxied a request through with its original path intact.
67
+
68
+ ### D4: A never-created resource's route comes from Traefik's static/file provider, not Docker-label discovery
69
+
70
+ Since a never-created resource has no container and thus no labels, its Traefik router has to be defined statically. The standalone Traefik generator (`traefik_standalone.star`) already reserves the `@file` provider namespace by reference (`maintenance@file` in `traefik.star`) but does not actually enable it — its `command:` list only sets `--providers.docker=true`; there is no `--providers.file=true` and no generated dynamic-config file or mounted directory today. This change adds both: `--providers.file.directory=/etc/traefik/dynamic` (a new generated directory mounted into the `traefik` service), and, for every resource with `sablier.enable` + `deferStart` set, a generated file-provider entry defining that resource's router/service/middleware ahead of time, pointed at D3's wake gateway.
71
+
72
+ **Verified live (2026-09-26, isolated Traefik+Docker probe, torn down after):** a file-provider router and a Docker-discovered router with the identical name coexist without conflict or error — Traefik namespaces them internally as `<name>@file` and `<name>@docker` — but when both match the same request at **equal priority, the file-provider router wins**, even though the Docker-discovered backend (the real, healthy container) was reachable and correct. A direct request through the file-route in that state hit the placeholder backend and got `502`, not the working one. Explicitly giving the file-provider entry a **low, fixed priority** (tested with `1`) while the Docker-label path keeps its existing computed priority (TDK's real values run 44-166, per `TRAEFIK_FRONTEND_PRIORITY_BASE + len(path)`) made the Docker-discovered router win as soon as it existed, with no manual removal of the static entry needed. So the static entry must carry an explicit low priority (a single reserved constant, e.g. `1`) — it is not safe to assume Traefik "prefers the more specific route" on its own; that was wrong.
73
+
74
+ *Alternative:* forward-declare the resource's Docker Compose service with `deploy.replicas: 0` or similar so a static route isn't needed. Rejected — Compose has no "define the service, register it with Traefik, but truly never create a container" mode short of the `auto_init=False` behavior already confirmed to produce zero Traefik-visible state; a static file-provider route is the only mechanism confirmed to give Traefik something to match against before any container exists.
75
+
76
+ ### D5: Dependency wake is entirely the wake gateway's job — neither Compose `depends_on` nor a Sablier group works across TDK's actual project layout
77
+
78
+ Two mechanisms looked plausible from Sablier's docs (see Context) and were both ruled out by direct verification against TDK's real generated output, not just read about:
79
+
80
+ - `sablier.group`: rejected as previously recorded — it requires every member to itself be `sablier.enable`d, wrong for a deferred resource's database/NATS dependency, which is shared, always-on infra (`dc_resource(..., auto_init=True)` per `infra-loader.star`) that must never become eligible for scale-to-zero just because one deferred app joins its group.
81
+ - Compose `depends_on`: **verified live to be structurally impossible here, not just undesirable.** `docker compose config` rejects a service whose `depends_on` names a service not defined in that *same* compose file with `service "X" depends on undefined service "Y": invalid compose project` — confirmed both for a resource depending on a genuinely separate compose project (e.g. shared postgres) and, more surprisingly, for two resources sharing the same `project_name` but registered via two separate `docker_compose()` calls (e.g. a backend and its own migrator, per `apply_migrator_orchestration.star:91`'s own separate `docker_compose(..., project_name=compose_project_name)` call): Tilt runs `docker compose config` against each registered file *individually*, so a shared `project_name` string does not merge them into one validatable project. Since every one of a resource's real dependencies (database, NATS, its own migrator) lives in a compose file generated by a *different* `docker_compose()` call than the resource's own, `depends_on` cannot express any of them, full stop — there is no scoping that makes this workable, unlike D5's original assumption.
82
+
83
+ Dependency waking is therefore entirely the wake gateway's job, which was already built to not need either mechanism: `X-Wake-Deps` (D2/D4) carries each dependency's resource and container name directly, and the gateway `tilt trigger`s and polls each one itself (confirmed in the live rehearsal in D3). This is strictly simpler than the original plan, not a workaround — one mechanism, already implemented and tested, instead of two speculative ones layered together. The practical scope this actually covers is narrower than first assumed, too: database/NATS are always-on infra that's essentially never actually cold, so the gateway's dependency-waking mostly matters for the rarer case of one deferred resource depending on another.
84
+
85
+ *Consequence:* Sablier's native docker-level start path (the "container exists" branch in D3) has no dependency-awareness of its own — it can restart a container but can't wait on that container's migrator or anything else via a label Compose never had a valid reason to write. In practice this rarely bites: by the time any container object exists at all, D4's priority verification means Traefik routes to it directly via the Docker-label path (Sablier's own plugin), bypassing the gateway entirely — the gateway's own "container exists" branch is reached only in the brief window before Traefik's docker provider notices a just-created container's labels, a correctness safety net rather than the primary path for that case.
86
+
87
+ *Alternative:* the originally-planned computed `sablier.group`, and separately, generated `depends_on`. Both rejected once the real mechanics were confirmed — the former for the scale-to-zero risk above, the latter because it is not merely undesirable but structurally rejected by `docker compose config` for every real dependency shape in this codebase.
88
+
89
+ ### D6: `tdk up`/`tdk doctor` read `auto_init` state, not container status, to report "deferred"
90
+
91
+ A deferred resource looks, from `docker ps`, identical to a resource that failed to start. `tdk up`'s summary and `tdk doctor` need to check the manifest's resolved `deferStart` (D1) directly rather than inferring "deferred" from container state, so a genuinely stuck resource is never miscategorized as intentionally cold.
92
+
93
+ ## Risks / Trade-offs
94
+
95
+ - [The static file-provider route (D4) and the Docker-discovered route disagree once the container exists, e.g. stale healthcheck path baked into the static entry] → Keep the static entry's routing rule byte-identical to what the generator already emits for the Docker-label version (same source-of-truth function), so the two can never drift; verified by generating both and diffing in task testing.
96
+ - [Confirmed live: network reachability to Tilt's port alone isn't enough — `tilt trigger` from inside a container fails with `(403): invalid session token` without the host's `~/.tilt-dev` token] → Fixed by mounting `~/.tilt-dev` read-only into the wake-gateway service. If Tilt runs in a context where that directory isn't at the expected host path (e.g. Tilt itself inside a devcontainer), the mount is wrong and every trigger call fails the same way — surfaces as a clear 403 in the gateway's logs rather than a silent hang, but there's no auto-detection of an unusual `$HOME` yet.
97
+ - [Re-adding `depends_on` to a `deferStart` resource's compose entry (D5) has some other effect under Tilt's per-resource `docker compose ... --no-build` invocation that the original removal was guarding against] → Verify by generating and running a `deferStart` resource with `depends_on` present under normal (non-deferred) `tdk up` handling, confirming Tilt's own bring-up for it is unaffected — expected to be moot in practice since a `deferStart` resource's `auto_init=False` means Tilt never invokes it directly during `tdk up` anyway, but confirm rather than assume.
98
+ - [A `deferStart` resource's `depends_on` condition (`service_healthy`/`service_completed_successfully`) never resolves because the dependency is itself unhealthy or the migrator's `honor-restart-policy` isn't enabled] → Sablier's docs are explicit that `service_completed_successfully` needs `--provider.docker.honor-restart-policy=true` to recognize a one-shot migrator's exit code 0 as `completed` rather than `stopped`; enable that flag on the vendored `sablier` command alongside D5's `depends_on` change, and hold the wake request only up to the existing timeout rather than indefinitely if it never resolves.
99
+ - [The held request during wake exceeds Sablier's own timeout while waiting on a dependency chain] → `sessionDuration`/timeout stay configurable per resource via the existing manifest field; document that a resource with dependencies needing time to become healthy may need a longer timeout.
100
+ - [Background auto-commit tooling observed in this workspace pushes partial engine edits] → Land each decision's Starlark and CLI edits together with its snapshot tests in one sitting, per the same mitigation already used in `optimize-runtime-docker-layers`.
101
+ - [Found and fixed 2026-09-26: re-reading the shipped `apply_compose_resource_registration.star` found the `generate_static_wake_route(...)` call site never passed `dependencies=`, so D5's dependency-wake path was implemented and unit-tested (gateway side and generator side both) but never wired to any real dependency data — every generated route shipped `X-Wake-Deps: ""`] → Fixed (task 7.3): the call site now resolves a `deferStart` resource's manifest `dependsOn` entries to the subset that are themselves `deferStart`, reusing the same resolution `_build_resource_deps` already trusts for Tilt's own `resource_deps` ordering, so it carries no new naming risk beyond what's already shipped. Covered by 6 new Starlark tests. The full licensed, live "wakes a cold peer resource" path is still only verifiable against the real premium module (task 7.2), consistent with how task 3.1 already treats that limitation.
102
+
103
+ ## Migration Plan
104
+
105
+ 1. Ship the schema (`deferStart`) and engine changes (D1-D6) in a TDK release, gated the same way the rest of `sablier` already is (premium license required for the real module; free-tier stub keeps returning disabled).
106
+ 2. In each project with an opted-in resource: run `tdk project --yes` to re-vendor, touch the Tiltfile, rebuild affected images.
107
+ 3. A resource adopts deferred start by adding `deferStart: true` to its existing `sablier` block; removing it (or the whole block) reverts to today's behavior with no other cleanup needed.
108
+
109
+ **Rollback:** unset `deferStart` (or the whole `sablier` block) and re-run `tdk project --yes`; the resource returns to normal `auto_init` bring-up immediately.
110
+
111
+ ## Open Questions
112
+
113
+ - Whether Traefik, when both a file-provider router (D4) and a Docker-discovered router (D2) exist for the same resource name, prefers one deterministically or requires a distinct router name per provider - resolved empirically in tasks.md's preflight phase (task 1.4); at most changes a naming detail, not the approach.
@@ -0,0 +1,40 @@
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ On a large landscape (`tdk-erp-system`: 107 resources, 110 containers), a request to a specific backend — e.g. `http://api.tdk-erp-system.localhost/api/data-governance/health` — can go unanswered for minutes not because that backend is broken, but because Tilt hasn't reached it yet in its own bring-up order. `data-governance-api` has no `sablier` block today, so it is started the same way as every other resource: as part of the full `tdk up` sequence, behind however many of the other ~106 resources Tilt's dependency graph and build concurrency put ahead of it. The existing premium Sablier integration (`engine/topologies/platform/docker/networking/sablier_container_cycle.star`, real implementation in `tdk-cli-extensions/premium/networking/`) already wakes a resource on its next request — but only if Tilt already created and then stopped that resource's container this session. It has nothing to wake when the container was never created in the first place, which is exactly the case here. Developers working against one corner of a large landscape (one API, one flow) shouldn't pay the full-landscape startup cost, or sit wondering whether the resource they actually want is stuck or just queued.
6
+
7
+ ## What Changes
8
+
9
+ - Extend the existing `sablier: {enable: true}` manifest block (opt-in, premium, unchanged licensing boundary) so an opted-in resource can be marked deferred for the *current* `tdk up` run, not only for scale-to-zero after it has run once. A deferred resource's image still builds during `tdk up` (so there's something to start on first request), but Tilt does not create or start its container at all, removing it from the sequential dependency-bring-up path entirely (confirmed empirically: an `auto_init=False` resource has no Docker container object until triggered — there is nothing for Traefik's normal Docker-label discovery to find).
10
+ - Because a never-created resource has no container and thus no Docker labels, give Traefik a static route for it via its file provider (a mechanism this codebase already references by name — `maintenance@file` — but doesn't yet enable for standalone projects), pointed at a small always-on wake endpoint. A resource that has previously run and is merely stopped keeps using today's Docker-label discovery (`traefik.docker.allownonrunning=true` plus Sablier's labels) unchanged.
11
+ - On the first request that hits a deferred resource's route, the wake gateway runs `tilt trigger <resource>` (Tilt's own supported CLI, proven to create-and-start a never-touched resource in one call) plus a separate `tilt trigger` for each dependency named in the request (confirmed necessary: Tilt does not cascade a trigger to a dependency on its own — it just waits on it forever), so the whole chain is prioritized immediately instead of waiting for Tilt's normal resource order. For a previously-run-then-stopped resource, Sablier's existing Docker-level start stays the primary (faster) path, with the same trigger calls fired alongside it so Tilt's own state (e.g. a migrator) catches up too.
12
+ - Dependency information for a wake travels through the gateway itself (which resource, which container, which dependencies), not through Compose's `depends_on` — confirmed empirically that `depends_on` cannot validly reference any of TDK's real cross-resource dependencies at all (database, NATS, a resource's own migrator each live in a separately-registered compose file; `docker compose config` rejects the reference outright even when two such files share a project name).
13
+ - `tdk up` and `tdk doctor` report deferred resources as intentionally cold (not stalled/pending), so their absence from the early startup log isn't read as a hang.
14
+
15
+ **BREAKING (none):** this only changes behavior for resources that opt in via the existing `sablier` manifest block; a resource without that block behaves exactly as it does today.
16
+
17
+ ## Capabilities
18
+
19
+ ### New Capabilities
20
+ - `on-demand-resource-startup`: Requirements for how a Sablier-opted-in resource that Tilt has not yet started this session is discovered by Traefik while stopped, triggered and prioritized on its first request (including its dependency chain), and reported by `tdk up`/`tdk doctor` as deferred rather than stalled.
21
+
22
+ ### Modified Capabilities
23
+ - (none — no existing `openspec/specs/` capability documents today's Sablier behavior or Tilt's startup sequencing, so there is nothing to amend; this proposal documents the extended behavior as new.)
24
+
25
+ ## Non-goals
26
+
27
+ - Changing the free/premium licensing boundary. This stays inside the existing `sablier` opt-in and its license gate (per product decision); it does not become default behavior for unlicensed or non-opted-in resources.
28
+ - Reducing per-container CPU/memory footprint — already addressed by `optimize-runtime-docker-layers`, whose non-goals explicitly deferred "putting backends under Sablier scale-to-zero" to this change.
29
+ - Building a general-purpose priority queue inside Tilt itself. This relies on Tilt's existing `TILT_AUTO_INIT_APPS`/manual trigger mode and its trigger API rather than patching Tilt's own scheduler.
30
+ - Changing behavior for resources already running and healthy, or for landscapes with no `sablier`-opted-in resources.
31
+
32
+ ## Impact
33
+
34
+ - **Engine (Starlark):** `engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star` (today's landscape-wide `auto_init_apps`) needs a per-resource variant driven by each manifest's `sablier` block rather than one global flag.
35
+ - **Engine (Starlark):** `engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star` calls the new static-route generator for each `deferStart` backend resource and writes its output alongside the resource's other generated config.
36
+ - **Engine (Starlark):** `engine/topologies/platform/docker/compose/traefik_standalone.star` — adds `--providers.file` support (new command flag, mounted dynamic-config directory), generated static route entries for deferred resources, and a wake gateway service (built from new `engine/assets/docker/wake-gateway/`) that runs `tilt trigger` and proxies the held request through.
37
+ - **CLI:** `cli/src/utils/resource-features.ts` (`sablier` feature description), `cli/src/commands/up.ts` and `doctor-runtime.ts` reporting for deferred resources; `cli/src/types/index.ts`'s `sablier` manifest type gains the new field(s).
38
+ - **Manifest schema:** `engine/topologies/tilt/manifest/schema.star`'s `sablier` block gains a new optional key (name TBD in design, e.g. `deferStart`).
39
+ - **Generated projects:** every project with an opted-in `sablier` resource needs `tdk project --yes` (re-vendor) plus a golden image rebuild to pick up the change; existing non-opted-in resources are unaffected.
40
+ - **Tests:** `tests/tilt-engine/` snapshot tests for the per-resource `auto_init` resolution and the generated static Traefik route; `bun test` unit tests for the wake gateway's decision logic.
@@ -0,0 +1,92 @@
1
+ # Spec Delta
2
+
3
+ ## Purpose
4
+
5
+ Lets a resource opt out of `tdk up`'s normal sequential bring-up and instead start on its own first request, so one request against a large landscape doesn't wait behind every other resource's startup.
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: Deferred Start Opt-In
10
+
11
+ A resource with a `sablier` manifest block SHALL be able to mark itself deferred for the current `tdk up` run. A deferred resource's image SHALL still be built during `tdk up`, but its container SHALL NOT be started as part of `tdk up`'s normal bring-up sequence.
12
+
13
+ #### Scenario: Deferred resource is not started during tdk up
14
+
15
+ - **WHEN** `tdk up` runs a landscape containing a resource with deferred start enabled
16
+ - **THEN** that resource's image build completes during the run
17
+ - **AND** the resource's container is not started, and `tdk up` does not wait on it to become healthy before finishing bring-up of the rest of the landscape
18
+
19
+ #### Scenario: Non-opted-in resource is unaffected
20
+
21
+ - **WHEN** `tdk up` runs a landscape containing a resource with no `sablier` block, or a `sablier` block without deferred start enabled
22
+ - **THEN** that resource is built and started as part of `tdk up`'s normal dependency-graph sequencing, exactly as before this change
23
+
24
+ ### Requirement: Deferred Resource Stays Discoverable Before Its First Start
25
+
26
+ A deferred resource's Traefik route SHALL be registered and routable regardless of whether its container has ever been created, so the first request to it can be received and acted on rather than returning "no route" or a connection error.
27
+
28
+ #### Scenario: Route exists before first start
29
+
30
+ - **WHEN** `tdk up` finishes bring-up of a landscape containing a deferred resource that has never been started this session
31
+ - **THEN** a request to that resource's route reaches Traefik and is recognized as targeting a known, deferred resource, rather than returning "no route" or a connection error
32
+
33
+ #### Scenario: Route still resolves after the resource has run and stopped
34
+
35
+ - **WHEN** a deferred resource has run at least once this session and its container is now stopped
36
+ - **THEN** a request to that resource's route still reaches Traefik and is recognized as targeting that resource, using the same or an equivalent route as before it ever ran
37
+
38
+ ### Requirement: First Request Triggers And Prioritizes Startup
39
+
40
+ The first request to a deferred resource's route SHALL start that resource's container and SHALL cause the resource to be prioritized ahead of the landscape's normal remaining bring-up order, rather than waiting for that order to reach it naturally.
41
+
42
+ #### Scenario: Request during a large, in-progress tdk up
43
+
44
+ - **WHEN** a request is made to a deferred resource's route while `tdk up` is still bringing up other, unrelated resources elsewhere in the landscape
45
+ - **THEN** the deferred resource is started and reaches a healthy state without waiting for the rest of the landscape's bring-up to complete first
46
+
47
+ #### Scenario: Request after tdk up has finished
48
+
49
+ - **WHEN** a request is made to a deferred resource's route after `tdk up` has finished bringing up the rest of the landscape
50
+ - **THEN** the deferred resource starts immediately in response to that request
51
+
52
+ ### Requirement: Waking A Resource Also Wakes Its Required Dependencies
53
+
54
+ Waking a deferred resource SHALL also wake any of its required startup dependencies (the same dependencies already used to sequence Tilt's own bring-up, e.g. its database or messaging broker) that are not currently running, and SHALL wait for each to be ready before evaluating the resource's own health.
55
+
56
+ #### Scenario: Deferred resource depends on a cold database
57
+
58
+ - **WHEN** a request wakes a deferred resource whose required dependencies include another resource that is not currently running
59
+ - **THEN** that dependency is also started as part of the same wake, and the requesting resource's health checks are not evaluated against a still-cold dependency
60
+
61
+ #### Scenario: Unrelated resource is not woken
62
+
63
+ - **WHEN** a request wakes a deferred resource
64
+ - **THEN** resources that are not among its required startup dependencies are not started as a side effect of that wake
65
+
66
+ ### Requirement: A Waking Request Waits For Health, Not Just Start
67
+
68
+ A request that triggers a deferred resource's startup SHALL be held until the resource passes its health check or until a bounded timeout elapses, rather than being answered as soon as the container process starts.
69
+
70
+ #### Scenario: Client receives the real response
71
+
72
+ - **WHEN** a request wakes a deferred resource
73
+ - **THEN** the client's request is held and then answered by the resource once it is healthy, rather than receiving an immediate "starting" response with no real answer
74
+
75
+ #### Scenario: Startup exceeds the timeout
76
+
77
+ - **WHEN** a deferred resource does not become healthy within its configured wake timeout
78
+ - **THEN** the held request fails with a clear timeout error rather than hanging indefinitely
79
+
80
+ ### Requirement: Deferred Status Is Reported Distinctly From Failure
81
+
82
+ `tdk up` and `tdk doctor` SHALL report a deferred, not-yet-started resource as intentionally deferred, and SHALL NOT report it the same way as a stalled or failed resource.
83
+
84
+ #### Scenario: tdk up summary
85
+
86
+ - **WHEN** `tdk up` finishes bring-up of a landscape containing deferred resources that have not yet received a request
87
+ - **THEN** the summary output lists those resources as deferred rather than omitting them or listing them as pending/stalled
88
+
89
+ #### Scenario: tdk doctor
90
+
91
+ - **WHEN** `tdk doctor` runs against a landscape containing a deferred, not-yet-started resource
92
+ - **THEN** it does not flag that resource as unhealthy or stuck solely because it has not started
@@ -0,0 +1,46 @@
1
+ # Tasks
2
+
3
+ ## 1. Preflight checks
4
+
5
+ - [x] 1.1 ~~Confirm whether `dc_resource(..., auto_init=False)` pre-creates the underlying container~~ — **Done, confirmed empirically 2026-09-26** against the installed Tilt `v0.37.7`: a throwaway `dc_resource(auto_init=False)` produces no container object at all (`docker ps -a` empty for it, `uiresource.status.runtimeStatus`/`updateStatus` stay `none`) until triggered; `tilt trigger <name>` then creates and starts it in one call. Recorded under design D2/D3. No further verification needed on this point.
6
+ - [x] 1.2 ~~Confirm whether the vendored `sablierapp/sablier` version supports a webhook/hook callable on container start~~ — **Done, confirmed 2026-09-26** by reading the v1.18 docs (`sablierapp.dev/v1.18`, version-matched to the vendored `1.18.0`): Sablier has no hook/webhook system at all — it's pull-based (the reverse-proxy plugin queries Sablier's status API per request; the "blocking strategy" page describes exactly this loop). Recorded under design D3: the wake gateway is a self-written component regardless, not a Sablier extension point.
7
+ - [x] 1.3 ~~Confirm the Tilt HTTP trigger API request shape~~ — **Superseded, 2026-09-26**: design D3 now prefers shelling out to the `tilt trigger <resource>` CLI (proven working end-to-end in the 1.1 probe) over the undocumented internal HTTP API, so no request-shape verification is needed.
8
+ - [x] 1.4 ~~Confirm how Traefik's Docker provider and file provider behave when both eventually define a router for the same resource name~~ — **Done, confirmed empirically 2026-09-26** with an isolated Traefik + Docker probe (torn down after): same-named `@file` and `@docker` routers coexist without conflict, but at equal priority **the file-provider router wins** even once the real container is healthy (a test request got `502` from the stale file-routed backend, not the working docker-routed one). Giving the file-provider entry a fixed low priority (`1`) while the docker-label path keeps its normal computed priority fixed this — the docker route then won automatically as soon as it existed. **Conclusion: the generated file-provider entry (task 4.2) must set an explicit low priority; do not rely on Traefik "preferring" one router on its own.**
9
+ - [x] 1.5 ~~Confirm whether `tilt trigger <resource>` cascades to a `depends_on` dependency that is itself `auto_init=False`~~ — **Done, confirmed empirically 2026-09-26** with the same isolated-probe technique as 1.1 (two `dc_resource(auto_init=False)` services, one `depends_on` the other with `condition: service_healthy`, fully torn down after): Tilt parses the compose `depends_on` and adds its own wait, but triggering only the dependent resource leaves it parked at `runtimeStatus: pending`, `status.waiting = {on: [dependency], reason: "waiting-for-dep"}` indefinitely — Tilt does not auto-trigger the dependency. Triggering the dependency separately immediately cleared the wait and let the dependent resource build and start. **Conclusion: the wake gateway (task 4.3) must explicitly `tilt trigger` every `depends_on` dependency itself; the cascade cannot be relied on.**
10
+
11
+ ## 2. Manifest schema (D1)
12
+
13
+ - [x] 2.1 Add `deferStart` (boolean, default `false`) to the `sablier` schema block in `engine/topologies/tilt/manifest/schema.star` and to the `sablier` type in `cli/src/types/index.ts`. Verify with a schema validation test asserting a manifest with `sablier: {enable: true, deferStart: true}` passes validation and one with `deferStart` set but `enable` absent/false fails with a clear error (deferStart requires enable). **Done**: added the schema field, a `_validate_cross_field` rule in `validator.star`, and `tests/tilt-engine/test_sablier_defer_start.py` (7 cases, all passing via `tilt alpha tiltfile-result`).
14
+ - [x] 2.2 Update the `sablier` entry in `cli/src/utils/resource-features.ts` to document `deferStart`. Verify by running the existing feature-description test/lint that covers this file, if any, or a manual `tdk resource --help`-style check that the new field appears. **Done**: description and `generator_file`/`output_file` updated; no dedicated test file existed for this file, verified with `npm run typecheck` (clean).
15
+
16
+ ## 3. Per-resource deferred auto_init (D1)
17
+
18
+ - [x] 3.1 Add a `resource_defers_start(manifest)` helper (co-located with the other manifest-derived helpers used by `apply_compose_resource_registration.star`) that reads the resolved `sablier.enable`/`sablier.deferStart` for a manifest. Verify with a unit test covering: no `sablier` block, `enable: false`, `enable: true` without `deferStart`, and `enable: true, deferStart: true`. **Done**: added at `apply_compose_resource_registration.star:117` (no leading underscore — confirmed via a throwaway probe that Tilt's `load()` genuinely rejects underscore-prefixed names, so a directly-loadable unit test needed a non-underscored name); 7 cases added to `test_sablier_defer_start.py`, all passing.
19
+ - [x] 3.2 Change `_register_single_resource` (the actual primary `dc_resource` call at `apply_compose_resource_registration.star:238` — not `_register_replicas`, which only ever fires for extra replicas beyond the first) to resolve `auto_init` per-resource as `auto_init_apps and not resource_defers_start(config['manifest'])`, threading that same `effective_auto_init` into `dc_resource`, `_register_run_only_resource`, and `_register_replicas` alike; `docker_build` is unaffected since it has no `auto_init` parameter in this codebase, so the image still builds regardless. `_build_resource_config` now also carries `'manifest': manifest` so this is available. **Done and code-reviewed**; verified indirectly via 3.1's predicate tests plus reading every call site — a full live `dc_resource` integration test is deferred to task 7.2's real `tdk-erp-system` run rather than duplicated here with a synthetic project.
20
+ - [ ] 3.3 Confirm end to end that a `deferStart` resource genuinely produces no container object after `tdk up` finishes (per 1.1's confirmed finding, this is expected, not a bug) — `docker ps -a` shows nothing for it — and that its config-gen and image build still completed. Folded into task 7.2's real `tdk-erp-system` run rather than a separate synthetic project.
21
+
22
+ ## 4. Static route for never-created resources (D2, D4)
23
+
24
+ - [x] 4.1 Add `--providers.file.directory=/etc/traefik/dynamic` to the standalone Traefik command list in `engine/topologies/platform/docker/compose/traefik_standalone.star`, plus a mounted directory for generated dynamic-config files. **Done**: also added `--providers.file.watch=true`, a `./traefik-dynamic:/etc/traefik/dynamic:ro` mount (`PlatformDockerConstants.TRAEFIK_DYNAMIC_DIR_REL`), and the `wake-gateway` service itself (built from the new `engine/assets/docker/wake-gateway/`, auto-vendored into every generated project since `engine/` is copied wholesale — confirmed by reading `cli/src/generator/template-engine.ts`'s `TDK_EXTENSION_DIRS`). Verified for real: `docker compose up -d --build` on the generated compose brought up `traefik`+`sablier`+`wake-gateway` together (only a host port-80 conflict with the already-running real landscape, not a config defect); `test_standalone_compose_enables_file_provider_and_wake_gateway` covers it as a repeatable test.
25
+ - [x] 4.2 Generate a file-provider dynamic-config entry for each `deferStart` resource, reusing the exact same router rule/middleware values the Docker-label path already computes for that resource, pointed at the wake gateway's address instead of the resource's own port, with a fixed low priority (`1`). **Done**: `engine/topologies/platform/docker/networking/traefik_static_routes.star`, config travels via an injected `headers` middleware (`X-Wake-Resource`/`X-Wake-Container`/`X-Wake-Deps`/`X-Wake-Proxy-Port`) rather than the path, so the real request path reaches the backend unchanged; `X-Wake-Container`/each dependency's container name follow Compose's `<project>-<service>-1` convention (confirmed against the live `analytics-autogenerated-data-governance-api-1` container). `test_traefik_static_wake_route.py` (3 cases) verifies the rule matches the Docker-label `-project` router, the priority is `1` and lower than the computed one, and the headers carry container names correctly.
26
+ - [x] 4.3 Stand up the wake gateway referenced by 4.2 that triggers a cold resource and its dependencies, then holds the request and proxies it through once healthy. **Done**: `engine/assets/docker/wake-gateway/` (`decide.ts` pure decision logic + 9 `bun test` cases, `server.ts` the real Bun HTTP server, `Dockerfile`). Full real end-to-end verification via three disposable probes (all torn down): (a) confirmed `tilt trigger` from inside a container needs the host's `~/.tilt-dev` token directory mounted read-only — without it, every trigger call fails with `(403): invalid session token` even though the Tilt port is reachable; (b) confirmed and fixed a Bun `URL.host` setter bug (`target.host = "name:port"` silently kept the original port; fixed by setting `.hostname`/`.port` separately) and a header-forwarding bug (`fetch` rejected the raw incoming `Host`/`X-Wake-*` headers; fixed by building a clean header set); (c) with both fixes, a real cold `traefik/whoami` container (both "exited" and fully "absent" states) was woken, became healthy, and a request was correctly proxied through with its original path intact, in ~10-29s.
27
+ - [x] 4.4 Confirm end to end that 4.2's fixed low priority actually hands traffic back to the real container once it exists. **Done** — this is exactly what task 1.4's probe verified (recorded there): at equal priority the file-provider router won even over a healthy Docker-discovered backend; priority `1` fixed it.
28
+
29
+ ## 5. Wake path wiring: generate the static route, dispatch to the gateway (D2, D3, D5)
30
+
31
+ - [x] ~~5.1 Add a `depends_on` block to a `deferStart` resource's compose entry~~ — **Superseded, 2026-09-26**: verified empirically that Compose `depends_on` cannot validly reference any of TDK's real cross-file dependencies here at all (see design.md D5's third finding — `docker compose config` rejects it outright, even across files sharing one `project_name`). D5 now relies entirely on the gateway's own explicit `X-Wake-Deps` handling (already built in task 4.3), not on anything compose-level.
32
+ - [x] ~~5.2 Add `--provider.docker.honor-restart-policy=true`~~ — **Removed, 2026-09-26**: its only stated purpose was supporting `depends_on`'s `service_completed_successfully` condition, which 5.1's finding ruled out entirely; reverted from `traefik_standalone.star` rather than shipping unused config.
33
+ - [x] 5.3 Wire the static route generator (task 4.2) into the real registration pipeline: for a `deferStart` backend resource, `register_compose_resources` (`apply_compose_resource_registration.star`) now calls `generate_static_wake_route` and writes it to `PlatformDockerConstants.TRAEFIK_DYNAMIC_DIR_REL + '/<resource>.yml'` via the existing `write_file` callback, right where its Traefik Docker labels are otherwise generated. Scoped to backends only for now (matching the proposal's motivating case) and with an empty dependency list (D5: TDK's real dependencies can't go through `depends_on`, and are either always-on infra that doesn't need waking, or a peer `deferStart` resource, left as a follow-up rather than guessed at — see the code comment at the call site). Verified: `resource_defers_start` and `generate_static_wake_route` unit/snapshot tests pass with this import wired in; full pipeline exercise (real `Docker.backend_compose`/file writes) folded into task 7.2's live run rather than duplicated with a synthetic project, consistent with how task 3.2 was verified.
34
+ - [x] 5.4 Confirm the held request is answered only once the resource (and any dependencies named in `X-Wake-Deps`) are healthy, and fails with a clear timeout error past the configured duration instead of hanging. **Already verified** in task 4.3's live rehearsal (the gateway's timeout path and `evaluateReadiness` logic are exercised by `decide.test.ts` and were hit for real during debugging before the fixes landed — several requests genuinely timed out with a clear 504 rather than hanging).
35
+
36
+ ## 6. tdk up / tdk doctor reporting (D6)
37
+
38
+ - [x] 6.1 Add a "deferred" resource classification to `tdk up`'s summary output, sourced from the resolved `sablier.enable && sablier.deferStart` per manifest (not from container state). **Done**: `cli/src/commands/up.ts`'s Backend API URLs list tags a deferred resource `(deferred — starts on first request)`. Verified by `npm run typecheck` (clean); no existing test harness covers `tdk up`'s interactive output at all (nothing to extend proportionately for one label — the full end-to-end pass in task 7.2 exercises it against the real landscape instead).
39
+ - [x] 6.2 Make `tdk doctor` skip its usual "not running" / "unhealthy" flags for a resource classified as deferred and never yet triggered, per `doctor-runtime.ts`. **Done**: `parseTiltResourceFailures` now takes a `deferredNames` set and reports a deferred resource's `none`/`pending` status as its own `deferredCount`, separate from genuine `pendingCount` — its message no longer says "often blocked by the failures above" for these. `getDeferredResourceNames()` (via `discoverResources()`) supplies that set in `checkTiltResourceHealth`, best-effort (empty set, not a throw, if discovery fails). A genuinely failing resource is unaffected either way — `deferStart` never touches the `failures` branch. 2 new test cases pass in `doctor.test.ts` (29/29 total, no regressions); a resource that's been triggered but is still unhealthy would surface via Tilt's own `error` status, already covered by the existing failure-path tests.
40
+
41
+ ## 7. Documentation and integration
42
+
43
+ - [x] 7.1 Document `deferStart` in the same place the `sablier` block itself is documented. **Done**: this repo has no separate docs file for `sablier` at all — its only documentation is `schema.star`'s field description and `resource-features.ts`'s feature entry, both already updated in group 2/task 2.2 (D5's `depends_on` mention removed from that description since D5 no longer uses it). No standalone docs page exists to additionally update.
44
+ - [ ] 7.2 Re-vendor into `tdk-erp-system` (`tdk project --yes`, touch the Tiltfile, rebuild affected images), set `sablier: {enable: true, deferStart: true}` on `data-governance-api`'s `service.json`, and run a full `tdk up`. Verify that `data-governance-api` does not start during the initial bring-up, that `curl http://api.tdk-erp-system.localhost/api/data-governance/health` while the rest of the landscape is still starting returns a real (not `404`/connection-refused) health response within the resource's normal cold-start time — directly closing the gap reproduced live during design (see design.md Context) — and that `tdk doctor` does not flag it before that request and does not flag it as broken after. **Still open**: this needs the actual `tdk-erp-system` checkout and its real, long-running Docker landscape, which exist only on the developer's own machine, not in a cloud session's ephemeral container — confirmed 2026-09-27 (no Docker daemon running, no `tdk-erp-system` checkout, no persistent landscape in this environment). A disposable synthetic probe covering the mechanism this task would exercise was run instead — see the note below — but this task itself needs to be completed against the real project locally.
45
+ - **2026-09-27 disposable probe (torn down after, nothing kept):** to verify task 7.3's fix end-to-end rather than only at the Starlark-unit level, stood up an isolated Tilt + Docker Compose project (two `traefik/whoami` services, `dep-service` and `primary-service`, both `dc_resource(auto_init=False)`) plus a standalone Traefik with the file provider enabled. Generated `primary-service`'s static route with the **real, unmodified** `generate_static_wake_route()` from `traefik_static_routes.star`, passing `dependencies=[("dep-service", "wakeprobe")]` the same shape `resolve_defer_start_dependencies` now produces — confirmed the emitted YAML carries `X-Wake-Deps: "dep-service=wakeprobe-dep-service-1"`. Ran the real `wake-gateway/server.ts` (as a local Bun process against this container's own Tilt daemon and Docker socket, since building the packaged Docker image failed here on an unrelated Alpine/`apk` proxy TLS issue — the packaged image itself is unaffected, this only affected building it ad hoc in this sandbox). A single HTTP request against `primary-service`'s route, while both containers were confirmed absent beforehand, resulted in **both** `wakeprobe-primary-service-1` and `wakeprobe-dep-service-1` being created and started (verified via `docker ps` and `tilt logs` showing both `tilt trigger` calls succeed) — this is the exact behavior task 7.3 added and that D5's requirement describes, now observed working end-to-end, not just unit-tested. The final proxy-through step returned a connection error, but only because the gateway ran as a host process outside the Compose network and so couldn't resolve the container's Compose-DNS name (`primary-service`) — confirmed by curling the container's bridge IP directly and getting a real `200`; that proxy-through mechanic itself was already verified live in task 4.3's own probe when the gateway ran inside the correct Docker network, and is unrelated to 7.3's change. One real, unrelated hiccup along the way: `primary-service`'s first `tilt trigger` failed with a Docker Hub `429 Too Many Requests` on the image pull (transient registry throttling, not a bug); a second trigger with the image already cached succeeded normally.
46
+ - [x] 7.3 (Found 2026-09-26 re-reading the shipped code) Wire real dependency data into the `generate_static_wake_route(...)` call at `apply_compose_resource_registration.star:442` — it always passed no `dependencies=` argument, so every generated route shipped `X-Wake-Deps: ""` despite the gateway side and the generator's `dependencies` param both being implemented and unit-tested in isolation. **Done, 2026-09-26**: added `resolve_manifest_dependency_names(manifest, all_services_map)` (reuses the existing `_resolve_dependency_to_resource` that `_build_resource_deps` already trusts for Tilt's own `resource_deps` ordering — an ambiguous name behaves identically here as it already does there) and `resolve_defer_start_dependencies(manifest, resource_manifests, all_services_map)` (filters those resolved names to the ones whose own manifest is itself `resource_defers_start`), both in `apply_compose_resource_registration.star`, no leading underscore so they're directly `load()`-able for tests per task 3.1's precedent. The call site now passes `dependencies=resolve_defer_start_dependencies(manifest, resource_manifests, all_services_map)`. 6 new cases in `test_sablier_defer_start.py` cover: exact-name resolution, an unresolvable name falling back to exclusion (not a guess), a dependency that resolves but isn't itself deferStart, and the free-tier license gate keeping the result empty even for a `dependsOn` target whose manifest asks for `deferStart` (mirrors task 3.1's own license-gate tests, since `resource_defers_start` is reused unchanged) — full 166/166 passing (166 = prior 160 + these 6; the pre-existing 29 unrelated failures in `tests/tilt-engine/` — `test_agents_md_*`, `test_daemon_*`, `test_snapshot_scanning`, etc. — are identical before and after this change, confirmed by diffing the failure list against a `git stash` baseline). The "licensed and actually wakes a peer deferStart resource" end-to-end case still can't be exercised from this repo's free-tier stub, same limitation task 3.1 already documented — folded into task 7.2's live run rather than duplicated here.
@@ -0,0 +1 @@
1
+ schema: spec-driven
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@tdk-landscape/tdk-cli-core",
3
- "version": "1.3.49",
4
- "description": "TDK CLI - Tilt Development Kit (monorepo)",
3
+ "version": "1.3.50",
4
+ "description": "Run 100 microservices on a 16 GB laptop without Kubernetes. Scaffold services and run them locally with hot reload, built on Tilt.",
5
+ "homepage": "https://tdk-landscape.github.io/tdk-website",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tdk-landscape/tdk-cli-core.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/tdk-landscape/tdk-cli-core/issues"
12
+ },
5
13
  "private": false,
6
14
  "workspaces": [
7
15
  "cli"
@@ -29,11 +37,33 @@
29
37
  "tilt",
30
38
  "cli",
31
39
  "microservices",
32
- "local-dev"
40
+ "local-dev",
41
+ "local-development",
42
+ "developer-tools",
43
+ "developer-experience",
44
+ "docker",
45
+ "docker-compose",
46
+ "dev-environment",
47
+ "monorepo",
48
+ "hot-reload",
49
+ "bun",
50
+ "kubernetes-alternative"
33
51
  ],
34
52
  "license": "MIT",
53
+ "dependencies": {
54
+ "chalk": "^5.3.0",
55
+ "commander": "^15.0.0",
56
+ "handlebars": "^4.7.8",
57
+ "ink": "^7.1.1",
58
+ "ink-select-input": "^6.2.0",
59
+ "ora": "^9.4.1",
60
+ "react": "^19.3.0"
61
+ },
35
62
  "devDependencies": {
36
- "@types/node": "^20.0.0",
37
- "knip": "^6.9.0"
63
+ "@types/node": "^22.0.0",
64
+ "knip": "^6.38.0"
65
+ },
66
+ "engines": {
67
+ "node": ">=22.12.0"
38
68
  }
39
69
  }