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

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 (92) hide show
  1. package/.github/workflows/ci.yml +13 -0
  2. package/.github/workflows/release-binaries.yml +76 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +54 -17
  5. package/cli/README.md +16 -43
  6. package/cli/biome.json +0 -4
  7. package/cli/dist/commands/help.d.ts.map +1 -1
  8. package/cli/dist/commands/help.js +22 -39
  9. package/cli/dist/commands/help.js.map +1 -1
  10. package/cli/dist/commands/project.d.ts +6 -0
  11. package/cli/dist/commands/project.d.ts.map +1 -1
  12. package/cli/dist/commands/project.js +16 -0
  13. package/cli/dist/commands/project.js.map +1 -1
  14. package/cli/dist/commands/up.d.ts.map +1 -1
  15. package/cli/dist/commands/up.js +5 -0
  16. package/cli/dist/commands/up.js.map +1 -1
  17. package/cli/dist/commands/upgrade.d.ts +3 -0
  18. package/cli/dist/commands/upgrade.d.ts.map +1 -1
  19. package/cli/dist/commands/upgrade.js +43 -1
  20. package/cli/dist/commands/upgrade.js.map +1 -1
  21. package/cli/dist/utils/constants.d.ts +1 -1
  22. package/cli/dist/utils/constants.d.ts.map +1 -1
  23. package/cli/dist/utils/constants.js +1 -1
  24. package/cli/dist/utils/constants.js.map +1 -1
  25. package/cli/dist/utils/doctor-runtime.js +5 -5
  26. package/cli/dist/utils/doctor-runtime.js.map +1 -1
  27. package/cli/dist/utils/prompt.d.ts.map +1 -1
  28. package/cli/dist/utils/prompt.js +6 -1
  29. package/cli/dist/utils/prompt.js.map +1 -1
  30. package/cli/dist/utils/service-urls.js +1 -1
  31. package/cli/dist/utils/service-urls.js.map +1 -1
  32. package/cli/package.json +2 -1
  33. package/cli/shared-platform-engineering/docker-templates/infisical-entrypoint.sh +2 -2
  34. package/cli/src/commands/__tests__/doctor.test.ts +8 -8
  35. package/cli/src/commands/__tests__/upgrade.test.ts +90 -3
  36. package/cli/src/commands/help.ts +22 -39
  37. package/cli/src/commands/project.ts +20 -0
  38. package/cli/src/commands/up.ts +10 -0
  39. package/cli/src/commands/upgrade.ts +52 -1
  40. package/cli/src/utils/constants.ts +1 -1
  41. package/cli/src/utils/doctor-runtime.ts +5 -5
  42. package/cli/src/utils/prompt.ts +8 -1
  43. package/cli/src/utils/service-urls.ts +1 -1
  44. package/discovery/identity-management-backend/service.yaml +2 -2
  45. package/discovery/identity-management-frontend/service.yaml +1 -1
  46. package/discovery/registry.star +3 -1
  47. package/discovery/services/platform/orchestrator/orchestrator-glue-backend/service.yaml +2 -2
  48. package/docs/demo.svg +217 -0
  49. package/engine/assets/docker/golden-layers.Dockerfile +18 -18
  50. package/engine/assets/docker/wake-gateway/Dockerfile +4 -3
  51. package/engine/assets/docker/wake-gateway/decide.test.ts +72 -1
  52. package/engine/assets/docker/wake-gateway/decide.ts +51 -2
  53. package/engine/assets/docker/wake-gateway/server.ts +165 -28
  54. package/engine/assets/scripts/mac-m1-resource-monitor.sh +1 -1
  55. package/engine/scripts/debug-container.sh +8 -3
  56. package/engine/spec.master +2 -2
  57. package/engine/topologies/platform/docker/compose/traefik_standalone.star +23 -1
  58. package/engine/topologies/platform/docker/layers/prisma/prisma_build.star +1 -1
  59. package/engine/topologies/platform/docker/networking/traefik_static_routes.star +62 -8
  60. package/engine/topologies/tilt/common/utils.star +1 -1
  61. package/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +89 -12
  62. package/engine/topologies/tilt/resources/orchestrator/builders/typescript.star +20 -0
  63. package/install.sh +19 -242
  64. package/openspec/changes/prioritized-cold-start/design.md +27 -3
  65. package/openspec/changes/prioritized-cold-start/proposal.md +1 -0
  66. package/openspec/changes/prioritized-cold-start/specs/on-demand-resource-startup/spec.md +9 -3
  67. package/openspec/changes/prioritized-cold-start/tasks.md +24 -1
  68. package/package.json +1 -1
  69. package/platform/services/platform/proxy/service.yaml +1 -1
  70. package/scripts/record-demo.sh +43 -0
  71. package/scripts/release-binaries.sh +8 -5
  72. package/shared-platform-engineering/docker-templates/infisical-entrypoint.sh +2 -2
  73. package/tests/tilt-engine/test_sablier_defer_start.py +41 -0
  74. package/tests/tilt-engine/test_standalone_traefik.py +14 -0
  75. package/tests/tilt-engine/test_traefik_static_wake_route.py +68 -0
  76. package/openspec/changes/archive/2026-04-29-scan-repo-gaps-fix/codebase-audit-report.md +0 -62
  77. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/.openspec.yaml +0 -5
  78. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/design.md +0 -94
  79. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/proposal.md +0 -46
  80. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/ai-slop-cleanup/CLEANUP_REPORT_8.md +0 -174
  81. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/ai-slop-cleanup/CRITICAL_ASSESSMENT_8.md +0 -160
  82. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/circular-deps/CRITICAL_ASSESSMENT_4.md +0 -381
  83. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/dead-code/CRITICAL_ASSESSMENT_3.md +0 -316
  84. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/deduplication/CRITICAL_ASSESSMENT_1.md +0 -290
  85. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/deduplication/IMPLEMENTATION_SUMMARY_1.md +0 -233
  86. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/defensive-cleanup/CRITICAL_ASSESSMENT_6.md +0 -835
  87. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/legacy-cleanup/CRITICAL_ASSESSMENT_7.md +0 -254
  88. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/legacy-cleanup/IMPLEMENTATION_SUMMARY_7.md +0 -187
  89. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/strong-typing/CRITICAL_ASSESSMENT_5.md +0 -269
  90. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/type-consolidation/CRITICAL_ASSESSMENT_2.md +0 -312
  91. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/specs/type-consolidation/IMPLEMENTATION_SUMMARY.md +0 -170
  92. package/openspec/changes/archive/2026-05-04-comprehensive-cleanup-final/tasks.md +0 -137
@@ -7,7 +7,7 @@
7
7
  # 2. Or update service.json
8
8
  #
9
9
  # Generation Source: GoldenImage.generate_layered_dockerfile()
10
- # Service: beauty-crm-l1-l4
10
+ # Service: tdk-project-l1-l4
11
11
  # Type: GOLDEN LAYERED IMAGES | L1-L4 Pre-built Layers
12
12
  ###############################################################################
13
13
 
@@ -15,14 +15,14 @@
15
15
  # 🏗️ GOLDEN LAYERED IMAGES - Multi-Stage Base Images
16
16
  # =============================================================================
17
17
  # This Dockerfile builds 8 golden images:
18
- # - beauty-crm-l1:latest - OS base + Bun runtime
19
- # - beauty-crm-l2:latest - Dependencies installed
20
- # - beauty-crm-l3-backend:latest - Backend build tools (Prisma, no Infisical CLI)
21
- # - beauty-crm-l3-frontend:latest - Frontend build tools (no Prisma)
22
- # - beauty-crm-l3-migrator:latest - Migrator build tools (Prisma, no Infisical CLI)
23
- # - beauty-crm-l4-backend:latest - Backend production runtime
24
- # - beauty-crm-l4-frontend:latest - Frontend production runtime (Nginx)
25
- # - beauty-crm-l4-migrator:latest - Migrator production runtime
18
+ # - tdk-project-l1:latest - OS base + Bun runtime
19
+ # - tdk-project-l2:latest - Dependencies installed
20
+ # - tdk-project-l3-backend:latest - Backend build tools (Prisma, no Infisical CLI)
21
+ # - tdk-project-l3-frontend:latest - Frontend build tools (no Prisma)
22
+ # - tdk-project-l3-migrator:latest - Migrator build tools (Prisma, no Infisical CLI)
23
+ # - tdk-project-l4-backend:latest - Backend production runtime
24
+ # - tdk-project-l4-frontend:latest - Frontend production runtime (Nginx)
25
+ # - tdk-project-l4-migrator:latest - Migrator production runtime
26
26
  #
27
27
  # Services use these as base images to skip heavy dependency installation.
28
28
  #
@@ -38,7 +38,7 @@
38
38
  # =============================================================================
39
39
  FROM oven/bun:1.3.11-alpine AS l1_golden
40
40
 
41
- LABEL layer="l1" description="OS base with runtime environment" maintainer="beauty-crm"
41
+ LABEL layer="l1" description="OS base with runtime environment" maintainer="tdk-project"
42
42
 
43
43
  # System dependencies for all services
44
44
  RUN apk add --no-cache ca-certificates tzdata curl file bash openssl
@@ -57,7 +57,7 @@ WORKDIR /app
57
57
  # =============================================================================
58
58
  FROM l1_golden AS l2_golden
59
59
 
60
- LABEL layer="l2" description="Base with common dependencies installed" maintainer="beauty-crm"
60
+ LABEL layer="l2" description="Base with common dependencies installed" maintainer="tdk-project"
61
61
 
62
62
  # Install minimal debugging tools (removed: vim, python3, make, g++ = -67MB)
63
63
  RUN apk add --no-cache curl netcat-openbsd jq bind-tools
@@ -74,7 +74,7 @@ RUN echo "Bun version: $(bun --version)"
74
74
  # =============================================================================
75
75
  FROM l2_golden AS l3_backend_golden
76
76
 
77
- LABEL layer="l3-backend" description="Backend build tools with Prisma" maintainer="beauty-crm"
77
+ LABEL layer="l3-backend" description="Backend build tools with Prisma" maintainer="tdk-project"
78
78
 
79
79
  # Install Prisma CLI globally (pinned for deterministic builds)
80
80
  RUN bun add -g prisma@7.5.0 @prisma/client@7.5.0
@@ -94,7 +94,7 @@ RUN echo "Prisma version: $(bunx prisma --version | head -1)"
94
94
  # =============================================================================
95
95
  FROM l2_golden AS l3_frontend_golden
96
96
 
97
- LABEL layer="l3-frontend" description="Frontend build tools without Prisma" maintainer="beauty-crm"
97
+ LABEL layer="l3-frontend" description="Frontend build tools without Prisma" maintainer="tdk-project"
98
98
 
99
99
  # Frontend doesn't need Prisma, just verify Bun
100
100
  RUN echo "Bun version: $(bun --version)"
@@ -105,7 +105,7 @@ RUN echo "Bun version: $(bun --version)"
105
105
  # =============================================================================
106
106
  FROM l2_golden AS l3_migrator_golden
107
107
 
108
- LABEL layer="l3-migrator" description="Migrator build tools with Prisma" maintainer="beauty-crm"
108
+ LABEL layer="l3-migrator" description="Migrator build tools with Prisma" maintainer="tdk-project"
109
109
 
110
110
  # Install Prisma CLI globally (required for migrations, pinned)
111
111
  RUN bun add -g prisma@7.5.0 @prisma/client@7.5.0
@@ -125,7 +125,7 @@ RUN echo "Prisma version: $(bunx prisma --version | head -1)"
125
125
  # =============================================================================
126
126
  FROM l1_golden AS l4_backend_bun
127
127
 
128
- LABEL layer="l4-backend-bun" description="Backend production runtime (Bun)" maintainer="beauty-crm"
128
+ LABEL layer="l4-backend-bun" description="Backend production runtime (Bun)" maintainer="tdk-project"
129
129
 
130
130
  ENV NODE_ENV=production
131
131
 
@@ -150,7 +150,7 @@ WORKDIR /app
150
150
  # =============================================================================
151
151
  FROM node:22-alpine AS l4_backend_node
152
152
 
153
- LABEL layer="l4-backend-node" description="Backend production runtime (Node.js - lightweight)" maintainer="beauty-crm"
153
+ LABEL layer="l4-backend-node" description="Backend production runtime (Node.js - lightweight)" maintainer="tdk-project"
154
154
 
155
155
  ENV NODE_ENV=production
156
156
 
@@ -180,7 +180,7 @@ FROM l4_backend_bun AS l4_backend_golden
180
180
  # =============================================================================
181
181
  FROM nginx:alpine AS l4_frontend_golden
182
182
 
183
- LABEL layer="l4-frontend" description="Frontend production runtime with Nginx" maintainer="beauty-crm"
183
+ LABEL layer="l4-frontend" description="Frontend production runtime with Nginx" maintainer="tdk-project"
184
184
 
185
185
  ENV NODE_ENV=production
186
186
 
@@ -198,7 +198,7 @@ WORKDIR /usr/share/nginx/html
198
198
  # =============================================================================
199
199
  FROM l1_golden AS l4_migrator_golden
200
200
 
201
- LABEL layer="l4-migrator" description="Migrator runtime with Prisma" maintainer="beauty-crm"
201
+ LABEL layer="l4-migrator" description="Migrator runtime with Prisma" maintainer="tdk-project"
202
202
 
203
203
  ENV NODE_ENV=production
204
204
 
@@ -1,8 +1,9 @@
1
1
  # Wake gateway (see openspec/changes/prioritized-cold-start): fronts every
2
2
  # `sablier.deferStart` resource's Traefik static route, triggers cold
3
3
  # containers via `tilt trigger` and/or Sablier, then holds the request until
4
- # healthy. Needs the Docker CLI (client only, talks to the mounted
5
- # /var/run/docker.sock) and the real `tilt` CLI (D3: shells out to the
4
+ # healthy. Needs the Docker CLI plus its Compose plugin (client only, talks
5
+ # to the mounted /var/run/docker.sock; compose starts an already-built image
6
+ # directly, ahead of Tilt's build queue) and the real `tilt` CLI (D3: shells out to the
6
7
  # documented `tilt trigger` command rather than depending on Tilt's
7
8
  # undocumented internal HTTP API).
8
9
 
@@ -13,7 +14,7 @@ FROM oven/bun:1.3.11-alpine AS base
13
14
  # directly (not via tilt-dev's install.sh, which shells out to `sudo` -- not
14
15
  # present in this minimal image, and unnecessary since we're already root).
15
16
  ARG TILT_VERSION=0.37.7
16
- RUN apk add --no-cache docker-cli curl gcompat \
17
+ RUN apk add --no-cache docker-cli docker-cli-compose curl gcompat \
17
18
  && arch="$(uname -m)" \
18
19
  && case "$arch" in aarch64) tilt_arch=arm64 ;; x86_64) tilt_arch=x86_64 ;; *) echo "unsupported arch: $arch" >&2; exit 1 ;; esac \
19
20
  && curl -fsSL "https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.${tilt_arch}.tar.gz" \
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { decideWakeAction, evaluateReadiness } from "./decide";
2
+ import {
3
+ COMPOSE_START_TIMEOUT_MS,
4
+ EARLY_RESPONSE_MS,
5
+ MAX_EARLY_RESPONSE_MS,
6
+ RETRY_AFTER_SECONDS,
7
+ TILT_TRIGGER_TIMEOUT_MS,
8
+ decideWakeAction,
9
+ earlyResponseMs,
10
+ evaluateReadiness,
11
+ wakeTimeoutMs,
12
+ } from "./decide";
3
13
 
4
14
  describe("decideWakeAction", () => {
5
15
  test("never-created resource: tilt trigger only, no Sablier start", () => {
@@ -56,6 +66,31 @@ describe("decideWakeAction", () => {
56
66
  ]);
57
67
  });
58
68
 
69
+ test("never-created resource with compose details: started directly, not queued in Tilt", () => {
70
+ const action = decideWakeAction({
71
+ resourceContainerName: "data-governance-api",
72
+ resourceState: "absent",
73
+ dependencies: [{ name: "peer-api", state: "absent" }],
74
+ hasComposeInvocation: true,
75
+ });
76
+ expect(action.useComposeStart).toBe(true);
77
+ // Dependencies carry no compose details, so they still go through Tilt.
78
+ expect(action.tiltTriggerNames).toEqual(["peer-api"]);
79
+ expect(action.useSablierStart).toBe(false);
80
+ });
81
+
82
+ test("existing (exited) container keeps the Sablier path even with compose details", () => {
83
+ const action = decideWakeAction({
84
+ resourceContainerName: "data-governance-api",
85
+ resourceState: "exited",
86
+ dependencies: [],
87
+ hasComposeInvocation: true,
88
+ });
89
+ expect(action.useComposeStart).toBe(false);
90
+ expect(action.tiltTriggerNames).toEqual(["data-governance-api"]);
91
+ expect(action.useSablierStart).toBe(true);
92
+ });
93
+
59
94
  test("running dependency is not re-triggered", () => {
60
95
  const action = decideWakeAction({
61
96
  resourceContainerName: "data-governance-api",
@@ -66,6 +101,42 @@ describe("decideWakeAction", () => {
66
101
  });
67
102
  });
68
103
 
104
+ describe("wakeTimeoutMs", () => {
105
+ test("a direct compose start only waits for boot + healthcheck", () => {
106
+ expect(wakeTimeoutMs(true)).toBe(COMPOSE_START_TIMEOUT_MS);
107
+ });
108
+
109
+ test("the tilt trigger path allows for an image build, under Bun's 255s idle cap", () => {
110
+ expect(wakeTimeoutMs(false)).toBe(TILT_TRIGGER_TIMEOUT_MS);
111
+ expect(TILT_TRIGGER_TIMEOUT_MS).toBeLessThan(255_000);
112
+ });
113
+
114
+ test("an explicit X-Wake-Timeout-Ms wins", () => {
115
+ expect(wakeTimeoutMs(false, 5_000)).toBe(5_000);
116
+ });
117
+ });
118
+
119
+ describe("earlyResponseMs", () => {
120
+ test("holds a waking request for EARLY_RESPONSE_MS by default", () => {
121
+ expect(earlyResponseMs()).toBe(EARLY_RESPONSE_MS);
122
+ });
123
+
124
+ test("the default answers well before Bun's 255s idle cap drops the connection", () => {
125
+ expect(EARLY_RESPONSE_MS).toBeLessThan(255_000);
126
+ });
127
+
128
+ test("an explicit X-Wake-Respond-Within-Ms wins, but is capped under Bun's idle limit", () => {
129
+ expect(earlyResponseMs(5_000)).toBe(5_000);
130
+ expect(earlyResponseMs(600_000)).toBe(MAX_EARLY_RESPONSE_MS);
131
+ expect(MAX_EARLY_RESPONSE_MS).toBeLessThan(255_000);
132
+ });
133
+
134
+ test("tells the caller to retry soon", () => {
135
+ expect(RETRY_AFTER_SECONDS).toBeGreaterThan(0);
136
+ expect(RETRY_AFTER_SECONDS * 1000).toBeLessThan(EARLY_RESPONSE_MS);
137
+ });
138
+ });
139
+
69
140
  describe("evaluateReadiness", () => {
70
141
  test("ready once every required name reports healthy", () => {
71
142
  const outcome = evaluateReadiness(["a", "b"], { a: true, b: true });
@@ -20,11 +20,26 @@ export interface WakeRequestSnapshot {
20
20
  resourceContainerName: string;
21
21
  resourceState: ContainerState;
22
22
  dependencies: DependencySnapshot[];
23
+ /**
24
+ * Whether the route carried enough compose detail (project, files, image)
25
+ * for the gateway to create the resource's container itself.
26
+ */
27
+ hasComposeInvocation?: boolean;
23
28
  }
24
29
 
25
30
  export interface WakeAction {
26
31
  /** Resources to `tilt trigger` (task 1.5: Tilt never cascades this on its own). */
27
32
  tiltTriggerNames: string[];
33
+ /**
34
+ * Whether to create/start the resource's container directly with
35
+ * `docker compose up --no-build` instead of `tilt trigger`. `tilt trigger`
36
+ * only queues the resource: with Tilt's build slots busy (e.g. 100+
37
+ * resources rebuilding after a re-vendor), a triggered resource waited
38
+ * behind them and the request timed out (observed live against
39
+ * tdk-erp-system, see tasks.md 7.2). server.ts falls back to `tilt trigger`
40
+ * when no image exists yet to start from.
41
+ */
42
+ useComposeStart: boolean;
28
43
  /**
29
44
  * Whether to also try Sablier's own Docker-level start for the resource.
30
45
  * Only meaningful when the resource's container already exists (D2/D3):
@@ -39,8 +54,9 @@ export interface WakeAction {
39
54
  */
40
55
  export function decideWakeAction(snapshot: WakeRequestSnapshot): WakeAction {
41
56
  const tiltTriggerNames: string[] = [];
57
+ const useComposeStart = snapshot.resourceState === "absent" && snapshot.hasComposeInvocation === true;
42
58
 
43
- if (snapshot.resourceState !== "running") {
59
+ if (snapshot.resourceState !== "running" && !useComposeStart) {
44
60
  tiltTriggerNames.push(snapshot.resourceContainerName);
45
61
  }
46
62
  for (const dep of snapshot.dependencies) {
@@ -51,7 +67,40 @@ export function decideWakeAction(snapshot: WakeRequestSnapshot): WakeAction {
51
67
 
52
68
  const useSablierStart = snapshot.resourceState === "exited" || snapshot.resourceState === "created";
53
69
 
54
- return { tiltTriggerNames, useSablierStart };
70
+ return { tiltTriggerNames, useComposeStart, useSablierStart };
71
+ }
72
+
73
+ /** Wait budget when the gateway starts an existing image itself (boot + healthcheck only). */
74
+ export const COMPOSE_START_TIMEOUT_MS = 180_000;
75
+ /**
76
+ * Wait budget when the resource has to go through `tilt trigger` (image build
77
+ * included). Kept under Bun.serve's 255s idleTimeout cap in server.ts.
78
+ */
79
+ export const TILT_TRIGGER_TIMEOUT_MS = 240_000;
80
+
81
+ /** The wait budget for a wake: an explicit X-Wake-Timeout-Ms wins, else by start path. */
82
+ export function wakeTimeoutMs(startedViaCompose: boolean, overrideMs?: number): number {
83
+ if (overrideMs && overrideMs > 0) return overrideMs;
84
+ return startedViaCompose ? COMPOSE_START_TIMEOUT_MS : TILT_TRIGGER_TIMEOUT_MS;
85
+ }
86
+
87
+ /**
88
+ * How long a waking request is held before the gateway answers
89
+ * `503` + `Retry-After` while the start keeps going in the background.
90
+ * Found live (task 7.2): during a full `tdk up` bring-up the Docker daemon
91
+ * took ~5 min to start one container, and Bun.serve's 255s idle cap dropped
92
+ * the held connection with no response at all.
93
+ */
94
+ export const EARLY_RESPONSE_MS = 60_000;
95
+ /** Largest hold allowed: stays under Bun.serve's 255s idle cap. */
96
+ export const MAX_EARLY_RESPONSE_MS = 240_000;
97
+ /** The `Retry-After` value sent with the early 503. */
98
+ export const RETRY_AFTER_SECONDS = 10;
99
+
100
+ /** How long to hold a request: an explicit X-Wake-Respond-Within-Ms wins, capped under Bun's idle limit. */
101
+ export function earlyResponseMs(overrideMs?: number): number {
102
+ if (overrideMs && overrideMs > 0) return Math.min(overrideMs, MAX_EARLY_RESPONSE_MS);
103
+ return EARLY_RESPONSE_MS;
55
104
  }
56
105
 
57
106
  export interface WaitOutcome {
@@ -10,11 +10,10 @@
10
10
  * injects (set by the file-provider entry generated in
11
11
  * traefik_static_routes.star) rather than the path, so the original request
12
12
  * path/query reach the real backend unchanged once it's ready:
13
- * X-Wake-Resource Tilt/Compose resource name (for `tilt trigger`,
14
- * and the proxy target -- Compose's embedded DNS
15
- * resolves a service by this name too).
13
+ * X-Wake-Resource Tilt/Compose resource name (for `tilt trigger` and
14
+ * `docker compose up <service>`).
16
15
  * X-Wake-Container The actual Docker container name (for `docker
17
- * inspect` state/health -- NOT the same string as
16
+ * inspect` state/health and the proxy target -- NOT the same string as
18
17
  * the resource name; Compose derives it as
19
18
  * <project>-<service>-<replica>, confirmed empirically
20
19
  * against a live TDK container, e.g.
@@ -23,15 +22,33 @@
23
22
  * X-Wake-Deps Comma-separated "resourceName=containerName" pairs
24
23
  * for required dependencies (task 1.5: Tilt never
25
24
  * cascades a trigger to these on its own).
25
+ * X-Wake-Compose-Project The resource's Compose project (e.g. "analytics-autogenerated").
26
+ * X-Wake-Compose-Files Comma-separated absolute compose file paths, the
27
+ * same ones Tilt's `<resource>-run-only` resource
28
+ * passes. The project root is mounted into this
29
+ * container at the same absolute path, so relative
30
+ * paths inside those files resolve exactly as for Tilt.
31
+ * X-Wake-Env-File Absolute path of the project's .env (optional).
32
+ * X-Wake-Image The image the compose service declares
33
+ * (<service>_<resource>:dev).
26
34
  * X-Wake-Timeout-Ms, X-Wake-Proxy-Port, X-Wake-Session-Duration
35
+ * X-Wake-Respond-Within-Ms How long to hold the request before answering
36
+ * 503 + Retry-After while the start continues in the
37
+ * background (default decide.ts EARLY_RESPONSE_MS).
27
38
  */
28
- import { decideWakeAction, evaluateReadiness, type ContainerState } from "./decide";
39
+ import {
40
+ RETRY_AFTER_SECONDS,
41
+ decideWakeAction,
42
+ earlyResponseMs,
43
+ evaluateReadiness,
44
+ wakeTimeoutMs,
45
+ type ContainerState,
46
+ } from "./decide";
29
47
 
30
48
  const TILT_HOST = process.env.TILT_HOST || "host.docker.internal";
31
49
  const TILT_PORT = process.env.TILT_PORT || "10350";
32
50
  const SABLIER_URL = process.env.SABLIER_URL || "http://sablier:10000";
33
51
  const POLL_INTERVAL_MS = 1000;
34
- const DEFAULT_TIMEOUT_MS = 60_000;
35
52
 
36
53
  async function run(cmd: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
37
54
  const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
@@ -66,7 +83,52 @@ async function isHealthy(name: string): Promise<boolean> {
66
83
  }
67
84
 
68
85
  async function tiltTrigger(name: string): Promise<void> {
69
- await run(["tilt", "trigger", name, "--host", TILT_HOST, "--port", TILT_PORT]);
86
+ const { code, stderr } = await run(["tilt", "trigger", name, "--host", TILT_HOST, "--port", TILT_PORT]);
87
+ console.log(`tilt trigger ${name}: ${code === 0 ? "queued" : `failed (${stderr.trim()})`}`);
88
+ }
89
+
90
+ interface ComposeInvocation {
91
+ project: string;
92
+ files: string[];
93
+ envFile: string;
94
+ image: string;
95
+ }
96
+
97
+ /**
98
+ * Make sure the compose service's declared image exists locally. Tilt tags its
99
+ * own builds `<repo>:tilt-<hash>` and injects that tag when it runs compose, so
100
+ * the declared `<repo>:dev` tag may be missing even though the resource was
101
+ * built; retag Tilt's newest build in that case. False when nothing was ever
102
+ * built -- only `tilt trigger` (which builds) can wake the resource then.
103
+ */
104
+ async function ensureImage(image: string): Promise<boolean> {
105
+ if ((await run(["docker", "image", "inspect", "--format", "{{.Id}}", image])).code === 0) return true;
106
+ const repo = image.split(":")[0];
107
+ // `docker images` lists newest first.
108
+ const { code, stdout } = await run(["docker", "images", repo, "--format", "{{.Tag}}"]);
109
+ if (code !== 0) return false;
110
+ const tiltTag = stdout.split("\n").map((t) => t.trim()).find((t) => t.startsWith("tilt-"));
111
+ if (!tiltTag) return false;
112
+ return (await run(["docker", "tag", `${repo}:${tiltTag}`, image])).code === 0;
113
+ }
114
+
115
+ /**
116
+ * Create and start the resource's container directly, with the same
117
+ * `docker compose ... up -d --no-build` its Tilt `-run-only` resource runs,
118
+ * bypassing Tilt's build queue (see decide.ts useComposeStart).
119
+ */
120
+ async function composeStart(compose: ComposeInvocation, service: string): Promise<boolean> {
121
+ if (!(await ensureImage(compose.image))) {
122
+ console.log(`compose start ${service}: no local image for ${compose.image}, falling back to tilt trigger`);
123
+ return false;
124
+ }
125
+ const args = ["docker", "compose", "-p", compose.project];
126
+ for (const file of compose.files) args.push("-f", file);
127
+ if (compose.envFile) args.push("--env-file", compose.envFile);
128
+ args.push("up", "-d", "--no-build", "--no-deps", service);
129
+ const { code, stderr } = await run(args);
130
+ console.log(`compose start ${service}: ${code === 0 ? "started" : `failed (${stderr.trim()})`}`);
131
+ return code === 0;
70
132
  }
71
133
 
72
134
  /**
@@ -110,39 +172,111 @@ function parseRequest(req: Request) {
110
172
  const resource = req.headers.get("X-Wake-Resource") || "";
111
173
  const container = req.headers.get("X-Wake-Container") || resource;
112
174
  const deps = parseDeps(req.headers.get("X-Wake-Deps") || "");
113
- const timeoutMs = Number(req.headers.get("X-Wake-Timeout-Ms")) || DEFAULT_TIMEOUT_MS;
175
+ const timeoutOverrideMs = Number(req.headers.get("X-Wake-Timeout-Ms")) || undefined;
176
+ const respondWithinOverrideMs = Number(req.headers.get("X-Wake-Respond-Within-Ms")) || undefined;
114
177
  const proxyPort = req.headers.get("X-Wake-Proxy-Port") || "80";
115
178
  const sessionDuration = req.headers.get("X-Wake-Session-Duration") || "10m";
116
- return { resource, container, deps, timeoutMs, proxyPort, sessionDuration };
179
+ const composeFiles = (req.headers.get("X-Wake-Compose-Files") || "").split(",").filter(Boolean);
180
+ const compose: ComposeInvocation = {
181
+ project: req.headers.get("X-Wake-Compose-Project") || "",
182
+ files: composeFiles,
183
+ envFile: req.headers.get("X-Wake-Env-File") || "",
184
+ image: req.headers.get("X-Wake-Image") || "",
185
+ };
186
+ const hasCompose = Boolean(compose.project && compose.files.length && compose.image);
187
+ return {
188
+ resource,
189
+ container,
190
+ deps,
191
+ timeoutOverrideMs,
192
+ respondWithinOverrideMs,
193
+ proxyPort,
194
+ sessionDuration,
195
+ compose,
196
+ hasCompose,
197
+ };
198
+ }
199
+
200
+ type WakeParams = ReturnType<typeof parseRequest>;
201
+ type WakeOutcome = { ready: true } | { ready: false; timeoutMs: number };
202
+
203
+ // One wake per resource at a time: a caller that got an early 503 and retries,
204
+ // or a second caller arriving meanwhile, joins the wake already in progress
205
+ // instead of starting the container again.
206
+ const inflight = new Map<string, Promise<WakeOutcome>>();
207
+
208
+ function wakeOnce(params: WakeParams): Promise<WakeOutcome> {
209
+ let wake = inflight.get(params.resource);
210
+ if (!wake) {
211
+ wake = runWake(params).finally(() => inflight.delete(params.resource));
212
+ inflight.set(params.resource, wake);
213
+ }
214
+ return wake;
117
215
  }
118
216
 
119
217
  async function handleWake(req: Request): Promise<Response> {
120
- const { resource, container, deps, timeoutMs, proxyPort, sessionDuration } = parseRequest(req);
121
- if (!resource) {
218
+ const params = parseRequest(req);
219
+ if (!params.resource) {
122
220
  return new Response("wake gateway: missing X-Wake-Resource header (set by the generated Traefik static route)", { status: 400 });
123
221
  }
124
222
 
223
+ const holdMs = earlyResponseMs(params.respondWithinOverrideMs);
224
+ const outcome = await Promise.race([wakeOnce(params), Bun.sleep(holdMs).then(() => null)]);
225
+ if (outcome === null) {
226
+ console.log(`wake ${params.resource}: still starting after ${holdMs}ms, answering 503`);
227
+ return new Response(
228
+ `wake gateway: ${params.resource} is starting; retry in ${RETRY_AFTER_SECONDS}s`,
229
+ { status: 503, headers: { "Retry-After": String(RETRY_AFTER_SECONDS) } },
230
+ );
231
+ }
232
+ if (outcome.ready) {
233
+ return proxyThrough(req, params.container, params.proxyPort);
234
+ }
235
+ return new Response(
236
+ `wake gateway: timed out after ${outcome.timeoutMs}ms waiting for ${params.resource} and its dependencies to become healthy`,
237
+ { status: 504 },
238
+ );
239
+ }
240
+
241
+ async function runWake(params: WakeParams): Promise<WakeOutcome> {
242
+ const { resource, container, deps, timeoutOverrideMs, sessionDuration, compose, hasCompose } = params;
125
243
  const resourceState = await containerState(container);
126
244
  const dependencies = await Promise.all(
127
245
  deps.map(async (dep) => ({ name: dep.resourceName, state: await containerState(dep.containerName) })),
128
246
  );
129
247
 
130
- const action = decideWakeAction({ resourceContainerName: resource, resourceState, dependencies });
248
+ const action = decideWakeAction({
249
+ resourceContainerName: resource,
250
+ resourceState,
251
+ dependencies,
252
+ hasComposeInvocation: hasCompose,
253
+ });
254
+ console.log(
255
+ `wake ${resource}: state=${resourceState} compose=${action.useComposeStart} ` +
256
+ `trigger=[${action.tiltTriggerNames.join(",")}] sablier=${action.useSablierStart}`,
257
+ );
258
+
259
+ // Dependencies (and the resource itself when it can't be started directly)
260
+ // go through tilt trigger (task 1.5: Tilt never cascades this on its own).
261
+ const tiltNames = [...action.tiltTriggerNames];
262
+ let startedViaCompose = false;
263
+ if (action.useComposeStart) {
264
+ startedViaCompose = await composeStart(compose, resource);
265
+ if (!startedViaCompose) tiltNames.unshift(resource);
266
+ }
267
+ const timeoutMs = wakeTimeoutMs(startedViaCompose, timeoutOverrideMs);
131
268
  const deadline = Date.now() + timeoutMs;
132
269
 
133
- // tilt trigger runs for everything cold (task 1.5: Tilt never cascades this
134
- // on its own); Sablier's blocking call, when applicable, both starts AND
135
- // waits for the resource itself in one round trip.
270
+ // Sablier's blocking call, when applicable, both starts AND waits for the
271
+ // resource itself in one round trip.
136
272
  const [, resourceReadyViaSablier] = await Promise.all([
137
- Promise.all(action.tiltTriggerNames.map(tiltTrigger)),
273
+ Promise.all(tiltNames.map(tiltTrigger)),
138
274
  action.useSablierStart
139
275
  ? sablierBlockingWait(resource, sessionDuration, timeoutMs)
140
276
  : Promise.resolve(false),
141
277
  ]);
142
278
 
143
279
  const requiredNames = [resource, ...deps.map((d) => d.resourceName)];
144
- const containerByName: Record<string, string> = { [resource]: container };
145
- for (const dep of deps) containerByName[dep.resourceName] = dep.containerName;
146
280
 
147
281
  while (Date.now() < deadline) {
148
282
  const healthy: Record<string, boolean> = {};
@@ -152,17 +286,12 @@ async function handleWake(req: Request): Promise<Response> {
152
286
  for (const dep of deps) {
153
287
  healthy[dep.resourceName] = await isHealthy(dep.containerName);
154
288
  }
155
- const outcome = evaluateReadiness(requiredNames, healthy);
156
- if (outcome.ready) {
157
- return proxyThrough(req, resource, proxyPort);
289
+ if (evaluateReadiness(requiredNames, healthy).ready) {
290
+ return { ready: true };
158
291
  }
159
292
  await Bun.sleep(POLL_INTERVAL_MS);
160
293
  }
161
-
162
- return new Response(
163
- `wake gateway: timed out after ${timeoutMs}ms waiting for ${resource} and its dependencies to become healthy`,
164
- { status: 504 },
165
- );
294
+ return { ready: false, timeoutMs };
166
295
  }
167
296
 
168
297
  // Headers that must not be forwarded verbatim to the upstream fetch: Host
@@ -174,11 +303,19 @@ const STRIPPED_REQUEST_HEADERS = new Set([
174
303
  "x-wake-container",
175
304
  "x-wake-deps",
176
305
  "x-wake-timeout-ms",
306
+ "x-wake-respond-within-ms",
177
307
  "x-wake-proxy-port",
178
308
  "x-wake-session-duration",
309
+ "x-wake-compose-project",
310
+ "x-wake-compose-files",
311
+ "x-wake-env-file",
312
+ "x-wake-image",
179
313
  ]);
180
314
 
181
- async function proxyThrough(req: Request, resource: string, port: string): Promise<Response> {
315
+ // Proxies to the container name rather than the Compose service name: every
316
+ // TDK project's resources share the traefik-public network, where a bare
317
+ // service name is only an alias and could collide across projects.
318
+ async function proxyThrough(req: Request, container: string, port: string): Promise<Response> {
182
319
  // The original path/query reach the gateway untouched (config travels via
183
320
  // X-Wake-* headers, not the path), so they're forwarded as-is.
184
321
  const target = new URL(req.url);
@@ -186,7 +323,7 @@ async function proxyThrough(req: Request, resource: string, port: string): Promi
186
323
  // Set hostname/port separately, not via `.host` -- confirmed empirically
187
324
  // that `target.host = "name:port"` silently keeps the URL's original port
188
325
  // when one is already present instead of replacing it.
189
- target.hostname = resource;
326
+ target.hostname = container;
190
327
  target.port = port;
191
328
 
192
329
  const headers = new Headers();
@@ -84,7 +84,7 @@ while IFS= read -r container; do
84
84
 
85
85
  printf "%-20s\t%s%%\t%s\t%s\n" "$CONTAINER_DISPLAY" "$CPU_PERCENT" "$MEMORY_USAGE" "$STATUS"
86
86
  fi
87
- done <<< "$BEAUTY_CONTAINERS"
87
+ done <<< "$PROJECT_CONTAINERS"
88
88
 
89
89
  echo ""
90
90
  echo -e "${BLUE}📈 Resource Summary:${NC}"
@@ -13,6 +13,11 @@ YELLOW='\033[1;33m'
13
13
  BLUE='\033[0;34m'
14
14
  NC='\033[0m' # No Color
15
15
 
16
+ # Container name filter: project name from .tdk/project.json (override with TDK_PROJECT_NAME)
17
+ PROJECT_FILTER="${TDK_PROJECT_NAME:-$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' .tdk/project.json 2>/dev/null | head -1)}"
18
+ PROJECT_FILTER="${PROJECT_FILTER:-tdk-project}"
19
+ PROJECT_FILTER="${PROJECT_FILTER//-/[-_]}"
20
+
16
21
  # Help text
17
22
  show_help() {
18
23
  cat << EOF
@@ -68,11 +73,11 @@ EOF
68
73
  # List containers
69
74
  list_containers() {
70
75
  echo -e "${GREEN}=== Running Containers ===${NC}"
71
- docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -i "beauty"
76
+ docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -iE "$PROJECT_FILTER"
72
77
 
73
78
  echo ""
74
79
  echo -e "${YELLOW}=== Recently Exited Containers ===${NC}"
75
- docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -i "beauty" | grep -i "exited"
80
+ docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -iE "$PROJECT_FILTER" | grep -i "exited"
76
81
  }
77
82
 
78
83
  # Get container ID from service name
@@ -122,7 +127,7 @@ run_command() {
122
127
  # Show failed containers
123
128
  show_failed() {
124
129
  echo -e "${RED}=== Failed/Exited Containers ===${NC}"
125
- docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -i "beauty" | grep -iE "(exited|error)"
130
+ docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep -iE "$PROJECT_FILTER" | grep -iE "(exited|error)"
126
131
 
127
132
  echo ""
128
133
  echo -e "${YELLOW}To inspect logs of a failed container:${NC}"
@@ -62,7 +62,7 @@
62
62
 
63
63
  # PROJECT_ROOT is automatically set by Tiltfile based on where you run `tilt up`.
64
64
  # You don't need to set it. Example:
65
- # - If you run in /home/dev/beauty-crm, PROJECT_ROOT = "/home/dev/beauty-crm"
65
+ # - If you run in /home/dev/tdk-project, PROJECT_ROOT = "/home/dev/tdk-project"
66
66
  # - All other paths are built from this root.
67
67
  #
68
68
  # This file is manually maintained to match the directory structure.
@@ -563,7 +563,7 @@ FEATURES = struct(
563
563
 
564
564
  CONSTANTS = struct(
565
565
  # System identity - EXPLICIT
566
- SYSTEM_NAME = "beauty-crm-tilt-platform",
566
+ SYSTEM_NAME = "tdk-project-tilt-platform",
567
567
  SYSTEM_VERSION = "2.0.0",
568
568
 
569
569
  # Runtimes - EXPLICIT supported options