@msn-control/liftoff 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/templates.js CHANGED
@@ -1,20 +1,82 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { addGenAiExtensionArtifacts } from './genai-templates.js';
3
+ import { addStandardStackArtifacts, renderStandardDockerfile, renderStandardEnv } from './standard-templates.js';
2
4
  import { liftoffVersion } from './version.js';
3
5
  const contentHash = (content) => `sha256:${createHash('sha256').update(content, 'utf8').digest('hex')}`;
6
+ const DEFAULT_FUNCTION_WORKER_QUEUE_NAME = 'events';
7
+ export const AZURE_NAME_LIMITS = {
8
+ resourceGroup: 90,
9
+ containerRegistry: 50,
10
+ identity: 128,
11
+ containerAppEnvironment: 60,
12
+ backendContainerApp: 32,
13
+ frontendContainerApp: 32,
14
+ functionServicePlan: 40,
15
+ functionApp: 60,
16
+ postgres: 63,
17
+ redis: 63,
18
+ storage: 24,
19
+ serviceBus: 50,
20
+ communication: 63,
21
+ keyVault: 24
22
+ };
23
+ const boundedToken = (value, length) => value.slice(0, length).replace(/-+$/g, '') || 'app';
24
+ export function buildAzureResourceNames(plan, environment, resourceSuffix) {
25
+ const workload = boundedToken(plan.safeProjectName, 12);
26
+ const compactWorkload = boundedToken(plan.safeProjectName.replace(/-/g, ''), 8);
27
+ return {
28
+ resourceGroup: `rg-${workload}-${environment}`,
29
+ containerRegistry: `acr${compactWorkload}${resourceSuffix}`,
30
+ identity: `id-${workload}-${environment}`,
31
+ containerAppEnvironment: `cae-${workload}-${environment}`,
32
+ backendContainerApp: `ca-${workload}-be-${environment}`,
33
+ frontendContainerApp: `ca-${workload}-fe-${environment}`,
34
+ functionServicePlan: `asp-${workload}-fn-${environment}`,
35
+ functionApp: `func-${workload}-${environment}-${resourceSuffix}`,
36
+ postgres: `psql-${workload}-${environment}-${resourceSuffix}`,
37
+ redis: `redis-${workload}-${environment}-${resourceSuffix}`,
38
+ storage: `st${compactWorkload}${resourceSuffix}`,
39
+ serviceBus: `sb-${workload}-${environment}-${resourceSuffix}`,
40
+ communication: `acs-${workload}-${environment}-${resourceSuffix}`,
41
+ keyVault: `kv-${compactWorkload}-${resourceSuffix}`
42
+ };
43
+ }
44
+ function stableResourceSuffix(plan, environment) {
45
+ return createHash('sha256')
46
+ .update(`${plan.safeProjectName}:${environment}`, 'utf8')
47
+ .digest('hex')
48
+ .slice(0, 12);
49
+ }
4
50
  const pyModule = (value) => value.replace(/-/g, '_');
5
51
  const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
6
- const hasFunctionWorker = (plan) => plan.provider.id === 'azure' && plan.pattern.worker;
7
- const functionWorkerName = (plan) => `${plan.pattern.id}-worker`;
52
+ const sourceString = (value) => JSON.stringify(value);
53
+ const scriptSourceString = (value) => sourceString(value).replaceAll('<', '\\u003c');
54
+ const escapeHtml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
55
+ const genAiPattern = (plan) => {
56
+ if (plan.projectType.id !== 'genai' || !plan.pattern) {
57
+ throw new Error('GenAI template rendering requires a GenAI pattern.');
58
+ }
59
+ return plan.pattern;
60
+ };
61
+ const hasFunctionWorker = (plan) => plan.projectType.id === 'genai' && plan.provider.id === 'azure' && genAiPattern(plan).worker;
62
+ const functionWorkerName = (plan) => `${genAiPattern(plan).id}-worker`;
8
63
  export function buildArtifacts(plan) {
9
64
  const artifacts = [];
10
65
  const add = (logicalName, category, pathParts, content) => {
11
66
  artifacts.push({ logicalName, category, pathParts, content: ensureTrailingNewline(content) });
12
67
  };
13
68
  addBaseArtifacts(add, plan);
14
- addBackendArtifacts(add, plan);
15
- addDatabaseArtifacts(add, plan);
16
- addPatternArtifacts(add, plan);
17
- addFunctionArtifacts(add, plan);
69
+ if (plan.projectType.id === 'genai') {
70
+ addGenAiExtensionArtifacts(add, plan, {
71
+ backend: addBackendArtifacts,
72
+ database: addDatabaseArtifacts,
73
+ pattern: addPatternArtifacts,
74
+ functions: addFunctionArtifacts
75
+ });
76
+ }
77
+ else {
78
+ addStandardStackArtifacts(add, plan);
79
+ }
18
80
  addEnvironmentArtifacts(add, plan);
19
81
  addDockerArtifacts(add, plan);
20
82
  addInfrastructureArtifacts(add, plan);
@@ -38,7 +100,9 @@ export function buildManifest(plan, artifacts) {
38
100
  liftoffVersion,
39
101
  project: {
40
102
  name: plan.projectName,
41
- pattern: plan.pattern.id,
103
+ projectType: plan.projectType.id,
104
+ apiStack: plan.apiStack.id,
105
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
42
106
  cloud: plan.provider.id,
43
107
  region: plan.region.slug,
44
108
  frontend: plan.includeFrontend,
@@ -60,7 +124,9 @@ function addBaseArtifacts(add, plan) {
60
124
  add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
61
125
  add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
62
126
  projectName: plan.projectName,
63
- pattern: plan.pattern.id,
127
+ projectType: plan.projectType.id,
128
+ apiStack: plan.apiStack.id,
129
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
64
130
  cloud: plan.provider.id,
65
131
  region: plan.region.slug,
66
132
  includeFrontend: plan.includeFrontend,
@@ -68,10 +134,10 @@ function addBaseArtifacts(add, plan) {
68
134
  specWorkflow: plan.specWorkflow.id
69
135
  }, null, 2));
70
136
  add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
71
- add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
137
+ add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
72
138
  }
73
139
  function addBackendArtifacts(add, plan) {
74
- const routeModule = pyModule(plan.pattern.id);
140
+ const routeModule = pyModule(genAiPattern(plan).id);
75
141
  add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
76
142
  add('backend-package', 'backend', ['backend', '__init__.py'], '');
77
143
  add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
@@ -89,6 +155,8 @@ function addBackendArtifacts(add, plan) {
89
155
  add('backend-observability', 'backend', ['backend', 'observability', 'tracing.py'], renderTracing());
90
156
  add('backend-observability-package', 'backend', ['backend', 'observability', '__init__.py'], '');
91
157
  add('backend-test-health', 'backend-test', ['backend', 'tests', 'test_health.py'], renderBackendHealthTest());
158
+ add('backend-test-messaging', 'backend-test', ['backend', 'tests', 'test_messaging.py'], renderMessagingTest());
159
+ add('backend-test-tracing', 'backend-test', ['backend', 'tests', 'test_tracing.py'], renderTracingTest());
92
160
  }
93
161
  function addDatabaseArtifacts(add, plan) {
94
162
  add('database-alembic-ini', 'database', ['database', 'alembic.ini'], renderAlembicIni());
@@ -97,20 +165,22 @@ function addDatabaseArtifacts(add, plan) {
97
165
  add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
98
166
  }
99
167
  function addPatternArtifacts(add, plan) {
100
- const routeModule = pyModule(plan.pattern.id);
168
+ const pattern = genAiPattern(plan);
169
+ const routeModule = pyModule(pattern.id);
101
170
  add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
102
- add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${plan.pattern.id}.md`], renderPromptTemplate(plan));
171
+ add('pattern-agent-test', 'backend-test', ['backend', 'tests', `test_${routeModule}_orchestration.py`], renderPatternAgentTest(plan));
172
+ add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
103
173
  add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
104
174
  add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
105
- if (plan.pattern.id === 'rag') {
175
+ if (pattern.id === 'rag') {
106
176
  add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
107
177
  add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
108
178
  }
109
- if (plan.pattern.worker) {
179
+ if (pattern.worker) {
110
180
  add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
111
181
  add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
112
182
  }
113
- if (plan.pattern.id === 'fine-tuned') {
183
+ if (pattern.id === 'fine-tuned') {
114
184
  add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
115
185
  }
116
186
  }
@@ -132,7 +202,7 @@ function addFunctionArtifacts(add, plan) {
132
202
  }
133
203
  function addEnvironmentArtifacts(add, plan) {
134
204
  for (const environment of plan.environments) {
135
- add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], renderBackendEnv(plan, environment.id));
205
+ add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], plan.projectType.id === 'genai' ? renderBackendEnv(plan, environment.id) : renderStandardEnv(plan, environment.id));
136
206
  if (hasFunctionWorker(plan)) {
137
207
  add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
138
208
  }
@@ -177,12 +247,135 @@ function addFrontendArtifacts(add, plan) {
177
247
  add('frontend-index', 'frontend', ['frontend', 'index.html'], renderFrontendIndex(plan));
178
248
  add('frontend-main', 'frontend', ['frontend', 'src', 'main.ts'], renderFrontendMain());
179
249
  add('frontend-app', 'frontend', ['frontend', 'src', 'App.vue'], renderFrontendApp(plan));
250
+ add('frontend-env-example', 'frontend', ['frontend', '.env.example'], 'VITE_API_BASE_URL=http://localhost:8000');
180
251
  add('frontend-styles', 'frontend', ['frontend', 'src', 'styles.css'], renderFrontendStyles());
181
252
  add('frontend-vite-config', 'frontend', ['frontend', 'vite.config.ts'], renderFrontendViteConfig());
182
253
  add('frontend-tailwind-config', 'frontend', ['frontend', 'tailwind.config.ts'], renderFrontendTailwindConfig());
183
254
  add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
184
255
  }
256
+ function renderDirectBuildAndTestGuide(plan) {
257
+ let backendCommands;
258
+ if (plan.projectType.id === 'genai' || plan.apiStack.id === 'python-fastapi') {
259
+ backendCommands = `python -m venv .venv
260
+ . .venv/bin/activate
261
+ python -m pip install -e "./backend[test]"
262
+ (cd backend && python -m pytest -q)`;
263
+ }
264
+ else if (plan.apiStack.id === 'node-fastify') {
265
+ backendCommands = `cd backend
266
+ npm install
267
+ npm run build
268
+ npm test`;
269
+ }
270
+ else {
271
+ backendCommands = `cd backend
272
+ go test ./...`;
273
+ }
274
+ const frontendCommands = plan.includeFrontend ? `
275
+
276
+ Build the frontend without a running backend:
277
+
278
+ \`\`\`bash
279
+ cp frontend/.env.example frontend/.env
280
+ cd frontend
281
+ npm install
282
+ npm run build
283
+ \`\`\`
284
+ ` : '';
285
+ const functionCommands = hasFunctionWorker(plan) ? `
286
+
287
+ Run the Function worker unit tests from the same Python virtual environment:
288
+
289
+ \`\`\`bash
290
+ cd functions/${functionWorkerName(plan)}
291
+ python -m pip install -r requirements.txt
292
+ python -m pytest -q
293
+ \`\`\`
294
+ ` : '';
295
+ return `## Direct Build And Test
296
+
297
+ \`\`\`bash
298
+ ${backendCommands}
299
+ \`\`\`
300
+
301
+ On Windows, activate Python virtual environments with \`.venv\\Scripts\\activate\`.
302
+ ${frontendCommands}${functionCommands}`;
303
+ }
304
+ function renderGeneratedConfigurationGuide(plan) {
305
+ const frontendConfiguration = plan.includeFrontend
306
+ ? '\n- `frontend/.env` configures `VITE_API_BASE_URL`; the production build does not contact the backend.'
307
+ : '';
308
+ if (plan.projectType.id === 'standard') {
309
+ return `## Runtime Configuration
310
+
311
+ Copy \`.env.example\` to \`.env\` before running outside Docker Compose. The backend requires \`DATABASE_URL\` and \`REDIS_URL\`. \`CORS_ALLOWED_ORIGINS\` is a comma-separated allowlist and defaults to the local frontend at \`http://localhost:5173\`.${frontendConfiguration}
312
+ `;
313
+ }
314
+ return `## Starter Integration Configuration
315
+
316
+ Copy \`.env.example\` to \`.env\`, then configure only the integrations you use:
317
+
318
+ - \`PYDANTIC_AI_MODEL\` is required when production orchestration is invoked. If it is absent, the agent raises an explicit configuration error rather than returning a placeholder answer.
319
+ - Redis Streams uses \`REDIS_URL\` and \`REDIS_STREAM_NAME\`.
320
+ - Azure Service Bus uses \`SERVICE_BUS_QUEUE_NAME\` plus either \`SERVICE_BUS_CONNECTION_STRING\` or \`SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE\`; set \`AZURE_CLIENT_ID\` when selecting a user-assigned managed identity.
321
+ - Langfuse requires both \`LANGFUSE_PUBLIC_KEY\` and \`LANGFUSE_SECRET_KEY\`, with optional \`LANGFUSE_HOST\`. Without both keys, tracing is explicitly disabled and no remote trace ID is reported.${frontendConfiguration}
322
+ - \`CORS_ALLOWED_ORIGINS\` is a comma-separated frontend-origin allowlist and defaults to \`http://localhost:5173\`.
323
+ `;
324
+ }
325
+ function renderGeneratedUpdateGuide() {
326
+ return `## Safe Liftoff Updates
327
+
328
+ \`liftoff update\` is a read-only drift check; \`liftoff update --apply\` writes only preflighted changes. An occupied destination with different user bytes is reported and skipped, while an identical destination is adopted without rewriting it. Use \`--force\` only after reviewing each conflict.
329
+
330
+ Liftoff rejects malformed, traversal, absolute, drive-qualified, UNC, separator-containing, or symlink-escaping manifest paths before artifact access. If the manifest is unsafe or malformed, restore \`liftoff.manifest.json\` from version control or regenerate the project with a matching Liftoff version; do not hand-edit unsafe paths. Run \`liftoff <command> --help\` for command-specific syntax because unknown flags, subcommands, values, and extra arguments fail before any write.
331
+ `;
332
+ }
185
333
  function renderRootReadme(plan) {
334
+ if (plan.projectType.id === 'standard') {
335
+ return `# ${plan.projectName}
336
+
337
+ Generated by Mission Control Liftoff.
338
+
339
+ ## Stack
340
+
341
+ - Project type: Standard application
342
+ - API: ${plan.apiStack.label}
343
+ - Database tooling: ${plan.apiStack.databaseTooling}
344
+ - API reference: Scalar with OpenAPI
345
+ - Cloud: ${plan.provider.label} (${plan.region.slug})
346
+ - Infrastructure: OpenTofu
347
+ - Database: PostgreSQL
348
+ - Cache and local messaging: Redis
349
+ - Local development: Docker Compose
350
+ ${plan.includeFrontend ? '- Frontend: Vue 3 with Tailwind\n' : ''}
351
+ ## Local Development
352
+
353
+ \`\`\`bash
354
+ docker compose up --build
355
+ \`\`\`
356
+
357
+ The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
358
+
359
+ ${renderGeneratedConfigurationGuide(plan)}
360
+ ${renderDirectBuildAndTestGuide(plan)}
361
+ ${renderGeneratedUpdateGuide()}
362
+ ## Infrastructure
363
+
364
+ \`\`\`bash
365
+ cd infrastructure/opentofu/azure
366
+ tofu init
367
+ tofu plan -var-file=environments/dev.tfvars
368
+ tofu apply -var-file=environments/dev.tfvars
369
+ \`\`\`
370
+
371
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
372
+
373
+ ## Spec-Driven Workflow
374
+
375
+ Selected workflow: ${plan.specWorkflow.label}.
376
+ `;
377
+ }
378
+ const pattern = genAiPattern(plan);
186
379
  const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
187
380
  ` : '';
188
381
  const functionsSection = hasFunctionWorker(plan) ? `
@@ -197,10 +390,10 @@ Generated by Mission Control Liftoff.
197
390
  ## Stack
198
391
 
199
392
  - Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
200
- - Pattern: ${plan.pattern.label}
393
+ - Pattern: ${pattern.label}
201
394
  - Cloud: ${plan.provider.label} (${plan.region.slug})
202
395
  - Infrastructure: OpenTofu
203
- - Database: PostgreSQL with Alembic migrations${plan.pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
396
+ - Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
204
397
  - Cache and local messaging: Redis
205
398
  - Observability: Langfuse
206
399
  - Local development: Docker Compose
@@ -215,6 +408,9 @@ docker compose --profile observability up --build
215
408
 
216
409
  The backend API is available on port 8000. Scalar is exposed at \`/scalar\`.
217
410
 
411
+ ${renderGeneratedConfigurationGuide(plan)}
412
+ ${renderDirectBuildAndTestGuide(plan)}
413
+ ${renderGeneratedUpdateGuide()}
218
414
  ## Infrastructure
219
415
 
220
416
  \`\`\`bash
@@ -224,6 +420,8 @@ tofu plan -var-file=environments/dev.tfvars
224
420
  tofu apply -var-file=environments/dev.tfvars
225
421
  \`\`\`
226
422
 
423
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
424
+
227
425
  ## Spec-Driven Workflow
228
426
 
229
427
  Selected workflow: ${plan.specWorkflow.label}.
@@ -244,16 +442,29 @@ migration/legacy/
244
442
  `;
245
443
  }
246
444
  function renderEnvExample(plan) {
445
+ if (plan.projectType.id === 'standard') {
446
+ return renderStandardEnv(plan);
447
+ }
448
+ const pattern = genAiPattern(plan);
247
449
  return `APP_ENV=dev
248
450
  APP_NAME=${plan.safeProjectName}
249
- GENAI_PATTERN=${plan.pattern.id}
451
+ GENAI_PATTERN=${pattern.id}
250
452
  CLOUD_PROVIDER=${plan.provider.id}
251
453
  AZURE_REGION=${plan.region.slug}
252
454
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
253
455
  REDIS_URL=redis://redis:6379/0
456
+ REDIS_STREAM_NAME=liftoff-events
254
457
  MESSAGING_TRANSPORT=redis-streams
458
+ SERVICE_BUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
459
+ SERVICE_BUS_CONNECTION_STRING=
460
+ SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE=
461
+ AZURE_CLIENT_ID=
255
462
  BLOB_ENDPOINT=http://azurite:10000/devstoreaccount1
463
+ CORS_ALLOWED_ORIGINS=http://localhost:5173
464
+ PYDANTIC_AI_MODEL=
256
465
  LANGFUSE_HOST=http://langfuse:3000
466
+ LANGFUSE_PUBLIC_KEY=
467
+ LANGFUSE_SECRET_KEY=
257
468
  `;
258
469
  }
259
470
  function renderBackendDockerfile() {
@@ -285,14 +496,16 @@ dependencies = [
285
496
  "uvicorn[standard]>=0.30",
286
497
  "pydantic>=2.7",
287
498
  "pydantic-settings>=2.3",
288
- "pydantic-ai>=0.0.13",
499
+ "pydantic-ai-slim[openai]==1.107.1",
289
500
  "scalar-fastapi>=1.0",
290
501
  "sqlalchemy[asyncio]>=2.0",
291
502
  "asyncpg>=0.29",
503
+ "psycopg[binary]>=3.2",
292
504
  "alembic>=1.13",
293
505
  "redis>=5.0",
294
- "langfuse>=2.39",
506
+ "langfuse==2.60.10",
295
507
  "azure-servicebus>=7.12",
508
+ "azure-identity>=1.17",
296
509
  "azure-storage-blob>=12.20",
297
510
  "azure-communication-email>=1.0"
298
511
  ]
@@ -304,6 +517,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
304
517
  requires = ["setuptools>=70"]
305
518
  build-backend = "setuptools.build_meta"
306
519
 
520
+ [tool.setuptools]
521
+ packages = []
522
+
307
523
  [tool.pytest.ini_options]
308
524
  pythonpath = [".."]
309
525
  testpaths = ["tests"]
@@ -311,6 +527,7 @@ testpaths = ["tests"]
311
527
  }
312
528
  function renderFastApiMain(plan, routeModule) {
313
529
  return `from fastapi import FastAPI
530
+ from fastapi.middleware.cors import CORSMiddleware
314
531
 
315
532
  try:
316
533
  from scalar_fastapi import get_scalar_api_reference
@@ -323,6 +540,16 @@ from backend.config.settings import get_settings
323
540
 
324
541
  settings = get_settings()
325
542
  app = FastAPI(title=settings.app_name, version="0.1.0")
543
+ app.add_middleware(
544
+ CORSMiddleware,
545
+ allow_origins=[
546
+ origin.strip()
547
+ for origin in settings.cors_allowed_origins.split(",")
548
+ if origin.strip()
549
+ ],
550
+ allow_methods=["*"],
551
+ allow_headers=["*"],
552
+ )
326
553
 
327
554
  app.include_router(health.router)
328
555
  app.include_router(${routeModule}.router)
@@ -352,16 +579,17 @@ def ready():
352
579
  `;
353
580
  }
354
581
  function renderPatternRoutes(plan) {
355
- const moduleName = pyModule(plan.pattern.id);
582
+ const pattern = genAiPattern(plan);
583
+ const moduleName = pyModule(pattern.id);
356
584
  const agentName = `${moduleName}_agent`;
357
- const prefix = plan.pattern.routePrefix;
358
- if (plan.pattern.id === 'streaming') {
585
+ const prefix = pattern.routePrefix;
586
+ if (pattern.id === 'streaming') {
359
587
  return `from fastapi import APIRouter
360
588
  from fastapi.responses import StreamingResponse
361
589
 
362
590
  from backend.orchestration.agents.${agentName} import stream_response
363
591
 
364
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
592
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
365
593
 
366
594
 
367
595
  @router.get("")
@@ -369,7 +597,7 @@ def stream(prompt: str):
369
597
  return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
370
598
  `;
371
599
  }
372
- if (plan.pattern.id === 'rag') {
600
+ if (pattern.id === 'rag') {
373
601
  return `from fastapi import APIRouter
374
602
  from pydantic import BaseModel
375
603
 
@@ -396,13 +624,13 @@ async def ingest(request: IngestionRequest):
396
624
  return await enqueue_ingestion(request.source_uri)
397
625
  `;
398
626
  }
399
- const bodyClass = `${titleCase(plan.pattern.id).replace(/\s/g, '')}Request`;
627
+ const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
400
628
  return `from fastapi import APIRouter
401
629
  from pydantic import BaseModel
402
630
 
403
631
  from backend.orchestration.agents.${agentName} import run_${moduleName}
404
632
 
405
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
633
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
406
634
 
407
635
 
408
636
  class ${bodyClass}(BaseModel):
@@ -428,6 +656,7 @@ async def get_current_user() -> CurrentUser:
428
656
  `;
429
657
  }
430
658
  function renderSettings(plan) {
659
+ const pattern = genAiPattern(plan);
431
660
  return `from functools import lru_cache
432
661
  from pydantic_settings import BaseSettings, SettingsConfigDict
433
662
 
@@ -435,16 +664,25 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
435
664
  class Settings(BaseSettings):
436
665
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
437
666
 
438
- app_name: str = "${plan.projectName}"
667
+ app_name: str = ${sourceString(plan.projectName)}
439
668
  app_env: str = "dev"
440
- genai_pattern: str = "${plan.pattern.id}"
669
+ genai_pattern: str = "${pattern.id}"
441
670
  cloud_provider: str = "${plan.provider.id}"
442
671
  azure_region: str = "${plan.region.slug}"
443
672
  database_url: str
444
673
  redis_url: str
674
+ redis_stream_name: str = "liftoff-events"
445
675
  messaging_transport: str = "redis-streams"
676
+ service_bus_queue_name: str = "${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}"
677
+ service_bus_connection_string: str | None = None
678
+ service_bus_fully_qualified_namespace: str | None = None
679
+ azure_client_id: str | None = None
446
680
  blob_endpoint: str | None = None
681
+ cors_allowed_origins: str = "http://localhost:5173"
682
+ pydantic_ai_model: str | None = None
447
683
  langfuse_host: str | None = None
684
+ langfuse_public_key: str | None = None
685
+ langfuse_secret_key: str | None = None
448
686
 
449
687
 
450
688
  @lru_cache
@@ -453,18 +691,66 @@ def get_settings() -> Settings:
453
691
  `;
454
692
  }
455
693
  function renderModelConfig(plan) {
456
- return `from pydantic import BaseModel, Field
694
+ const pattern = genAiPattern(plan);
695
+ return `import os
696
+ from dataclasses import dataclass
697
+ from typing import Protocol
698
+
699
+
700
+ class ModelConfigurationError(RuntimeError):
701
+ pass
702
+
703
+
704
+ class AgentRunner(Protocol):
705
+ async def run(self, prompt: str) -> str:
706
+ ...
457
707
 
458
708
 
459
- class ModelConfig(BaseModel):
460
- provider: str = Field(default="azure-openai")
461
- deployment_name: str = Field(default="gpt-4.1")
462
- embedding_deployment_name: str = Field(default="text-embedding-3-large")
463
- pattern: str = Field(default="${plan.pattern.id}")
709
+ @dataclass(frozen=True)
710
+ class ModelConfig:
711
+ model_name: str
712
+ pattern: str = "${pattern.id}"
713
+
714
+ @classmethod
715
+ def from_environment(cls) -> "ModelConfig":
716
+ model_name = os.getenv("PYDANTIC_AI_MODEL", "").strip()
717
+ if not model_name:
718
+ raise ModelConfigurationError(
719
+ "PYDANTIC_AI_MODEL is required before invoking production GenAI orchestration. "
720
+ "Use a PydanticAI model name such as 'openai:gpt-4.1-mini'."
721
+ )
722
+ return cls(model_name=model_name)
723
+
724
+
725
+ class PydanticAgentRunner:
726
+ def __init__(self, config: ModelConfig):
727
+ from pydantic_ai import Agent
728
+
729
+ self._agent = Agent(config.model_name)
730
+
731
+ async def run(self, prompt: str) -> str:
732
+ result = await self._agent.run(prompt)
733
+ output = getattr(result, "output", None)
734
+ if output is None:
735
+ output = getattr(result, "data", None)
736
+ if output is None:
737
+ raise RuntimeError("PydanticAI returned a result without output data.")
738
+ return str(output)
739
+
740
+
741
+ def build_agent_runner(config: ModelConfig | None = None) -> AgentRunner:
742
+ return PydanticAgentRunner(config or ModelConfig.from_environment())
464
743
  `;
465
744
  }
466
745
  function renderMessagingBoundary() {
467
- return `from typing import Protocol
746
+ return `import json
747
+ import os
748
+ from collections.abc import Callable
749
+ from typing import Any, Protocol
750
+
751
+
752
+ class MessagingConfigurationError(RuntimeError):
753
+ pass
468
754
 
469
755
 
470
756
  class MessagePublisher(Protocol):
@@ -472,32 +758,202 @@ class MessagePublisher(Protocol):
472
758
  ...
473
759
 
474
760
 
761
+ class RedisStreamClient(Protocol):
762
+ async def xadd(self, name: str, fields: dict[str, str]) -> Any:
763
+ ...
764
+
765
+
766
+ class ServiceBusSender(Protocol):
767
+ async def __aenter__(self) -> "ServiceBusSender":
768
+ ...
769
+
770
+ async def __aexit__(self, exc_type, exc, traceback) -> None:
771
+ ...
772
+
773
+ async def send_messages(self, message: Any) -> None:
774
+ ...
775
+
776
+
777
+ class ServiceBusClient(Protocol):
778
+ def get_queue_sender(self, *, queue_name: str) -> ServiceBusSender:
779
+ ...
780
+
781
+
782
+ def _serialize(topic: str, payload: dict) -> str:
783
+ return json.dumps({"topic": topic, "payload": payload}, separators=(",", ":"), sort_keys=True)
784
+
785
+
475
786
  class RedisStreamPublisher:
787
+ def __init__(self, client: RedisStreamClient, stream_name: str):
788
+ self._client = client
789
+ self._stream_name = stream_name
790
+
476
791
  async def publish(self, topic: str, payload: dict) -> None:
477
- # Implement with redis.asyncio in application code.
478
- return None
792
+ await self._client.xadd(
793
+ self._stream_name,
794
+ {"topic": topic, "payload": _serialize(topic, payload)},
795
+ )
479
796
 
480
797
 
481
798
  class AzureServiceBusPublisher:
482
- async def publish(self, topic: str, payload: dict) -> None:
483
- # Implement with azure-servicebus in cloud configuration.
484
- return None
799
+ def __init__(
800
+ self,
801
+ client: ServiceBusClient,
802
+ queue_name: str,
803
+ message_factory: Callable[[str], Any] | None = None,
804
+ ):
805
+ self._client = client
806
+ self._queue_name = queue_name
807
+ self._message_factory = message_factory or self._default_message_factory
808
+
809
+ @staticmethod
810
+ def _default_message_factory(body: str) -> Any:
811
+ from azure.servicebus import ServiceBusMessage
812
+
813
+ return ServiceBusMessage(body)
485
814
 
815
+ async def publish(self, topic: str, payload: dict) -> None:
816
+ message = self._message_factory(_serialize(topic, payload))
817
+ async with self._client.get_queue_sender(queue_name=self._queue_name) as sender:
818
+ await sender.send_messages(message)
819
+
820
+
821
+ def build_message_publisher(
822
+ transport: str,
823
+ *,
824
+ redis_client: RedisStreamClient | None = None,
825
+ service_bus_client: ServiceBusClient | None = None,
826
+ message_factory: Callable[[str], Any] | None = None,
827
+ ) -> MessagePublisher:
828
+ if transport == "redis-streams":
829
+ stream_name = os.getenv("REDIS_STREAM_NAME", "liftoff-events").strip()
830
+ if not stream_name:
831
+ raise MessagingConfigurationError("REDIS_STREAM_NAME must not be empty.")
832
+ if redis_client is None:
833
+ redis_url = os.getenv("REDIS_URL", "").strip()
834
+ if not redis_url:
835
+ raise MessagingConfigurationError("REDIS_URL is required for redis-streams messaging.")
836
+ from redis.asyncio import Redis
837
+
838
+ redis_client = Redis.from_url(redis_url, decode_responses=True)
839
+ return RedisStreamPublisher(redis_client, stream_name)
486
840
 
487
- def build_message_publisher(transport: str) -> MessagePublisher:
488
841
  if transport == "azure-service-bus":
489
- return AzureServiceBusPublisher()
490
- return RedisStreamPublisher()
842
+ queue_name = os.getenv("SERVICE_BUS_QUEUE_NAME", "").strip()
843
+ if not queue_name:
844
+ raise MessagingConfigurationError(
845
+ "SERVICE_BUS_QUEUE_NAME is required for azure-service-bus messaging."
846
+ )
847
+ if service_bus_client is None:
848
+ connection_string = os.getenv("SERVICE_BUS_CONNECTION_STRING", "").strip()
849
+ namespace = os.getenv("SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE", "").strip()
850
+ from azure.servicebus.aio import ServiceBusClient as AzureServiceBusClient
851
+
852
+ if connection_string:
853
+ service_bus_client = AzureServiceBusClient.from_connection_string(connection_string)
854
+ elif namespace:
855
+ from azure.identity.aio import DefaultAzureCredential
856
+
857
+ client_id = os.getenv("AZURE_CLIENT_ID", "").strip() or None
858
+ credential = DefaultAzureCredential(managed_identity_client_id=client_id)
859
+ service_bus_client = AzureServiceBusClient(namespace, credential)
860
+ else:
861
+ raise MessagingConfigurationError(
862
+ "Set SERVICE_BUS_CONNECTION_STRING or SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE "
863
+ "for azure-service-bus messaging."
864
+ )
865
+ return AzureServiceBusPublisher(service_bus_client, queue_name, message_factory)
866
+
867
+ raise MessagingConfigurationError(
868
+ f"Unsupported MESSAGING_TRANSPORT '{transport}'. "
869
+ "Expected 'redis-streams' or 'azure-service-bus'."
870
+ )
491
871
  `;
492
872
  }
493
873
  function renderTracing() {
494
- return `from contextlib import asynccontextmanager
874
+ return `import os
875
+ from contextlib import asynccontextmanager
876
+ from dataclasses import dataclass
877
+ from typing import Any, AsyncContextManager, Protocol
878
+
879
+
880
+ class TracingConfigurationError(RuntimeError):
881
+ pass
882
+
883
+
884
+ @dataclass
885
+ class TraceHandle:
886
+ enabled: bool
887
+ trace_id: str | None
888
+ output: Any = None
889
+
890
+ def set_output(self, output: Any) -> None:
891
+ self.output = output
892
+
893
+
894
+ class Tracer(Protocol):
895
+ def trace(self, name: str, input_data: Any = None) -> AsyncContextManager[TraceHandle]:
896
+ ...
495
897
 
496
898
 
497
- @asynccontextmanager
498
- async def trace_llm_operation(name: str):
499
- # Wire Langfuse callbacks here when model credentials are configured.
500
- yield {"trace": name}
899
+ class DisabledTracer:
900
+ @asynccontextmanager
901
+ async def trace(self, name: str, input_data: Any = None):
902
+ del name, input_data
903
+ yield TraceHandle(enabled=False, trace_id=None)
904
+
905
+
906
+ class LangfuseTracer:
907
+ def __init__(self, client: Any):
908
+ self._client = client
909
+
910
+
911
+ @asynccontextmanager
912
+ async def trace(self, name: str, input_data: Any = None):
913
+ remote_trace = self._client.trace(name=name, input=input_data)
914
+ remote_id = getattr(remote_trace, "id", None)
915
+ handle = TraceHandle(
916
+ enabled=True,
917
+ trace_id=str(remote_id) if remote_id is not None else None,
918
+ )
919
+ try:
920
+ yield handle
921
+ except Exception as error:
922
+ remote_trace.update(level="ERROR", status_message=str(error))
923
+ raise
924
+ else:
925
+ remote_trace.update(output=handle.output)
926
+
927
+
928
+ def build_tracer(
929
+ *,
930
+ client: Any = None,
931
+ public_key: str | None = None,
932
+ secret_key: str | None = None,
933
+ host: str | None = None,
934
+ ) -> Tracer:
935
+ if client is not None:
936
+ return LangfuseTracer(client)
937
+
938
+ resolved_public_key = public_key or os.getenv("LANGFUSE_PUBLIC_KEY", "").strip()
939
+ resolved_secret_key = secret_key or os.getenv("LANGFUSE_SECRET_KEY", "").strip()
940
+ if not resolved_public_key and not resolved_secret_key:
941
+ return DisabledTracer()
942
+ if not resolved_public_key or not resolved_secret_key:
943
+ raise TracingConfigurationError(
944
+ "LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be configured together."
945
+ )
946
+
947
+ from langfuse import Langfuse
948
+
949
+ resolved_host = host or os.getenv("LANGFUSE_HOST", "").strip() or None
950
+ kwargs = {
951
+ "public_key": resolved_public_key,
952
+ "secret_key": resolved_secret_key,
953
+ }
954
+ if resolved_host:
955
+ kwargs["host"] = resolved_host
956
+ return LangfuseTracer(Langfuse(**kwargs))
501
957
  `;
502
958
  }
503
959
  function renderBackendHealthTest() {
@@ -511,29 +967,192 @@ def test_health():
511
967
  response = client.get("/health")
512
968
  assert response.status_code == 200
513
969
  assert response.json()["status"] == "ok"
970
+
971
+
972
+ def test_cors_preflight_for_local_frontend():
973
+ response = TestClient(app).options(
974
+ "/health",
975
+ headers={
976
+ "Origin": "http://localhost:5173",
977
+ "Access-Control-Request-Method": "GET",
978
+ },
979
+ )
980
+ assert response.status_code == 200
981
+ assert response.headers["access-control-allow-origin"] == "http://localhost:5173"
982
+ `;
983
+ }
984
+ function renderMessagingTest() {
985
+ return `import asyncio
986
+ import json
987
+
988
+ from backend.orchestration.tools.messaging import build_message_publisher
989
+
990
+
991
+ class FakeRedisClient:
992
+ def __init__(self):
993
+ self.calls = []
994
+
995
+ async def xadd(self, name, fields):
996
+ self.calls.append((name, fields))
997
+
998
+
999
+ class FakeSender:
1000
+ def __init__(self):
1001
+ self.messages = []
1002
+
1003
+ async def __aenter__(self):
1004
+ return self
1005
+
1006
+ async def __aexit__(self, exc_type, exc, traceback):
1007
+ return None
1008
+
1009
+ async def send_messages(self, message):
1010
+ self.messages.append(message)
1011
+
1012
+
1013
+ class FakeServiceBusClient:
1014
+ def __init__(self, sender):
1015
+ self.sender = sender
1016
+ self.queue_names = []
1017
+
1018
+ def get_queue_sender(self, *, queue_name):
1019
+ self.queue_names.append(queue_name)
1020
+ return self.sender
1021
+
1022
+
1023
+ def test_redis_stream_publisher_uses_xadd(monkeypatch):
1024
+ monkeypatch.setenv("REDIS_STREAM_NAME", "orchestration-events")
1025
+ client = FakeRedisClient()
1026
+ publisher = build_message_publisher("redis-streams", redis_client=client)
1027
+
1028
+ asyncio.run(publisher.publish("rag.ingest", {"source_uri": "az://document"}))
1029
+
1030
+ stream_name, fields = client.calls[0]
1031
+ assert stream_name == "orchestration-events"
1032
+ assert fields["topic"] == "rag.ingest"
1033
+ assert json.loads(fields["payload"]) == {
1034
+ "payload": {"source_uri": "az://document"},
1035
+ "topic": "rag.ingest",
1036
+ }
1037
+
1038
+
1039
+ def test_service_bus_publisher_uses_async_sender(monkeypatch):
1040
+ monkeypatch.setenv("SERVICE_BUS_QUEUE_NAME", "orchestration-jobs")
1041
+ sender = FakeSender()
1042
+ client = FakeServiceBusClient(sender)
1043
+ publisher = build_message_publisher(
1044
+ "azure-service-bus",
1045
+ service_bus_client=client,
1046
+ message_factory=lambda body: body,
1047
+ )
1048
+
1049
+ asyncio.run(publisher.publish("workflow.run", {"job_id": "job-1"}))
1050
+
1051
+ assert client.queue_names == ["orchestration-jobs"]
1052
+ assert json.loads(sender.messages[0]) == {
1053
+ "payload": {"job_id": "job-1"},
1054
+ "topic": "workflow.run",
1055
+ }
1056
+ `;
1057
+ }
1058
+ function renderTracingTest() {
1059
+ return `import asyncio
1060
+
1061
+ import pytest
1062
+
1063
+ from backend.observability.tracing import (
1064
+ TracingConfigurationError,
1065
+ build_tracer,
1066
+ )
1067
+
1068
+
1069
+ class FakeRemoteTrace:
1070
+ id = "trace-123"
1071
+
1072
+ def __init__(self):
1073
+ self.updates = []
1074
+
1075
+ def update(self, **values):
1076
+ self.updates.append(values)
1077
+
1078
+
1079
+ class FakeLangfuse:
1080
+ def __init__(self):
1081
+ self.calls = []
1082
+ self.remote_trace = FakeRemoteTrace()
1083
+
1084
+ def trace(self, **values):
1085
+ self.calls.append(values)
1086
+ return self.remote_trace
1087
+
1088
+
1089
+ def test_unconfigured_tracing_is_explicitly_disabled(monkeypatch):
1090
+ monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
1091
+ monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
1092
+
1093
+ async def scenario():
1094
+ async with build_tracer().trace("offline") as trace:
1095
+ assert trace.enabled is False
1096
+ assert trace.trace_id is None
1097
+
1098
+ asyncio.run(scenario())
1099
+
1100
+
1101
+ def test_configured_tracing_updates_langfuse_operation():
1102
+ client = FakeLangfuse()
1103
+
1104
+ async def scenario():
1105
+ async with build_tracer(client=client).trace(
1106
+ "agent.run",
1107
+ {"prompt": "hello"},
1108
+ ) as trace:
1109
+ assert trace.enabled is True
1110
+ assert trace.trace_id == "trace-123"
1111
+ trace.set_output({"answer": "world"})
1112
+
1113
+ asyncio.run(scenario())
1114
+ assert client.calls == [{"name": "agent.run", "input": {"prompt": "hello"}}]
1115
+ assert client.remote_trace.updates == [{"output": {"answer": "world"}}]
1116
+
1117
+
1118
+ def test_partial_langfuse_configuration_fails(monkeypatch):
1119
+ monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
1120
+ monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
1121
+ with pytest.raises(TracingConfigurationError, match="configured together"):
1122
+ build_tracer()
514
1123
  `;
515
1124
  }
516
1125
  function renderAlembicIni() {
517
1126
  return `[alembic]
518
- script_location = migrations
1127
+ script_location = %(here)s/migrations
519
1128
  sqlalchemy.url = driver://user:pass@localhost/dbname
520
1129
  `;
521
1130
  }
522
1131
  function renderAlembicEnv() {
523
- return `from alembic import context
1132
+ return `import os
1133
+
1134
+ from alembic import context
1135
+ from sqlalchemy import create_engine
524
1136
 
525
1137
 
526
1138
  def run_migrations_online():
527
- context.configure(url=context.get_x_argument(as_dictionary=True).get("database_url"))
528
- with context.begin_transaction():
529
- context.run_migrations()
1139
+ database_url = os.environ.get("DATABASE_URL")
1140
+ if not database_url:
1141
+ raise RuntimeError("DATABASE_URL is required to run migrations")
1142
+ database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
1143
+ database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
1144
+ connectable = create_engine(database_url)
1145
+ with connectable.connect() as connection:
1146
+ context.configure(connection=connection, target_metadata=None)
1147
+ with context.begin_transaction():
1148
+ context.run_migrations()
530
1149
 
531
1150
 
532
1151
  run_migrations_online()
533
1152
  `;
534
1153
  }
535
1154
  function renderInitialMigration(plan) {
536
- const vectorExtension = plan.pattern.id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
1155
+ const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
537
1156
  return `from alembic import op
538
1157
  import sqlalchemy as sa
539
1158
 
@@ -564,45 +1183,256 @@ function renderDatabaseSchema(plan) {
564
1183
  payload JSONB NOT NULL,
565
1184
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
566
1185
  );
567
- ${plan.pattern.id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
1186
+ ${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
568
1187
  }
569
1188
  function renderPatternAgent(plan) {
570
- const moduleName = pyModule(plan.pattern.id);
571
- if (plan.pattern.id === 'rag') {
572
- return `from backend.orchestration.tools.messaging import build_message_publisher
573
-
574
-
575
- async def answer_question(question: str) -> dict:
1189
+ const pattern = genAiPattern(plan);
1190
+ const moduleName = pyModule(pattern.id);
1191
+ if (pattern.id === 'rag') {
1192
+ return `import os
1193
+
1194
+ from backend.observability.tracing import Tracer, build_tracer
1195
+ from backend.orchestration.model_config import AgentRunner, build_agent_runner
1196
+ from backend.orchestration.tools.messaging import MessagePublisher, build_message_publisher
1197
+
1198
+
1199
+ async def _run_agent(
1200
+ operation: str,
1201
+ prompt: str,
1202
+ runner: AgentRunner | None,
1203
+ tracer: Tracer | None,
1204
+ ) -> str:
1205
+ selected_runner = runner or build_agent_runner()
1206
+ selected_tracer = tracer or build_tracer()
1207
+ async with selected_tracer.trace(operation, {"prompt": prompt}) as trace:
1208
+ output = await selected_runner.run(prompt)
1209
+ trace.set_output({"text": output})
1210
+ return output
1211
+
1212
+
1213
+ async def answer_question(
1214
+ question: str,
1215
+ *,
1216
+ runner: AgentRunner | None = None,
1217
+ tracer: Tracer | None = None,
1218
+ ) -> dict:
1219
+ answer = await _run_agent(
1220
+ "rag.query",
1221
+ f"Answer the question using retrieved evidence when available.\\nQuestion: {question}",
1222
+ runner,
1223
+ tracer,
1224
+ )
576
1225
  return {
577
- "answer": "Replace this placeholder with PydanticAI retrieval orchestration.",
1226
+ "answer": answer,
578
1227
  "question": question,
579
1228
  "citations": [],
580
1229
  }
581
1230
 
582
1231
 
583
- async def enqueue_ingestion(source_uri: str) -> dict:
584
- publisher = build_message_publisher("redis-streams")
585
- await publisher.publish("rag.ingest", {"source_uri": source_uri})
1232
+ async def enqueue_ingestion(
1233
+ source_uri: str,
1234
+ *,
1235
+ publisher: MessagePublisher | None = None,
1236
+ ) -> dict:
1237
+ selected_publisher = publisher or build_message_publisher(
1238
+ os.getenv("MESSAGING_TRANSPORT", "redis-streams")
1239
+ )
1240
+ await selected_publisher.publish("rag.ingest", {"source_uri": source_uri})
586
1241
  return {"status": "queued", "source_uri": source_uri}
587
1242
  `;
588
1243
  }
589
- if (plan.pattern.id === 'streaming') {
590
- return `async def stream_response(prompt: str):
591
- yield f"data: Starting response for {prompt}\\n\\n"
592
- yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
1244
+ if (pattern.id === 'streaming') {
1245
+ return `import json
1246
+
1247
+ from backend.observability.tracing import Tracer, build_tracer
1248
+ from backend.orchestration.model_config import AgentRunner, build_agent_runner
1249
+
1250
+
1251
+ async def stream_response(
1252
+ prompt: str,
1253
+ *,
1254
+ runner: AgentRunner | None = None,
1255
+ tracer: Tracer | None = None,
1256
+ ):
1257
+ selected_runner = runner or build_agent_runner()
1258
+ selected_tracer = tracer or build_tracer()
1259
+ async with selected_tracer.trace("streaming.run", {"prompt": prompt}) as trace:
1260
+ output = await selected_runner.run(
1261
+ f"Respond concisely and safely to this streaming request:\\n{prompt}"
1262
+ )
1263
+ trace.set_output({"text": output})
1264
+ yield f"data: {json.dumps({'text': output})}\\n\\n"
593
1265
  `;
594
1266
  }
595
- return `async def run_${moduleName}(input_text: str) -> dict:
1267
+ return `from backend.observability.tracing import Tracer, build_tracer
1268
+ from backend.orchestration.model_config import AgentRunner, build_agent_runner
1269
+
1270
+
1271
+ async def run_${moduleName}(
1272
+ input_text: str,
1273
+ *,
1274
+ runner: AgentRunner | None = None,
1275
+ tracer: Tracer | None = None,
1276
+ ) -> dict:
1277
+ selected_runner = runner or build_agent_runner()
1278
+ selected_tracer = tracer or build_tracer()
1279
+ prompt = (
1280
+ "Run the ${pattern.label} orchestration contract for this input:\\n"
1281
+ f"{input_text}"
1282
+ )
1283
+ async with selected_tracer.trace("${pattern.id}.run", {"input": input_text}) as trace:
1284
+ output = await selected_runner.run(prompt)
1285
+ trace.set_output({"result": output})
596
1286
  return {
597
- "result": "Replace this placeholder with ${plan.pattern.label} PydanticAI orchestration.",
1287
+ "result": output,
598
1288
  "input": input_text,
599
1289
  }
600
1290
  `;
601
1291
  }
1292
+ function renderPatternAgentTest(plan) {
1293
+ const pattern = genAiPattern(plan);
1294
+ const moduleName = pyModule(pattern.id);
1295
+ const agentModule = `backend.orchestration.agents.${moduleName}_agent`;
1296
+ if (pattern.id === 'rag') {
1297
+ return `import asyncio
1298
+
1299
+ import pytest
1300
+
1301
+ from ${agentModule} import answer_question, enqueue_ingestion
1302
+ from backend.observability.tracing import DisabledTracer
1303
+ from backend.orchestration.model_config import ModelConfigurationError
1304
+
1305
+
1306
+ class FakeRunner:
1307
+ async def run(self, prompt):
1308
+ assert "Question: What is Liftoff?" in prompt
1309
+ return "Liftoff is the generated orchestration starter."
1310
+
1311
+
1312
+ class FakePublisher:
1313
+ def __init__(self):
1314
+ self.messages = []
1315
+
1316
+ async def publish(self, topic, payload):
1317
+ self.messages.append((topic, payload))
1318
+
1319
+
1320
+ def test_rag_query_uses_injected_runner_without_network():
1321
+ result = asyncio.run(
1322
+ answer_question(
1323
+ "What is Liftoff?",
1324
+ runner=FakeRunner(),
1325
+ tracer=DisabledTracer(),
1326
+ )
1327
+ )
1328
+ assert result == {
1329
+ "answer": "Liftoff is the generated orchestration starter.",
1330
+ "question": "What is Liftoff?",
1331
+ "citations": [],
1332
+ }
1333
+
1334
+
1335
+ def test_rag_ingestion_uses_injected_publisher():
1336
+ publisher = FakePublisher()
1337
+ result = asyncio.run(
1338
+ enqueue_ingestion("az://documents/one.pdf", publisher=publisher)
1339
+ )
1340
+ assert result == {
1341
+ "status": "queued",
1342
+ "source_uri": "az://documents/one.pdf",
1343
+ }
1344
+ assert publisher.messages == [
1345
+ ("rag.ingest", {"source_uri": "az://documents/one.pdf"})
1346
+ ]
1347
+
1348
+
1349
+ def test_missing_model_configuration_is_explicit(monkeypatch):
1350
+ monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
1351
+ with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
1352
+ asyncio.run(answer_question("unconfigured"))
1353
+ `;
1354
+ }
1355
+ if (pattern.id === 'streaming') {
1356
+ return `import asyncio
1357
+
1358
+ import pytest
1359
+
1360
+ from ${agentModule} import stream_response
1361
+ from backend.observability.tracing import DisabledTracer
1362
+ from backend.orchestration.model_config import ModelConfigurationError
1363
+
1364
+
1365
+ class FakeRunner:
1366
+ async def run(self, prompt):
1367
+ assert "stream this" in prompt
1368
+ return "offline streamed answer"
1369
+
1370
+
1371
+ def test_streaming_uses_injected_runner_without_network():
1372
+ async def collect():
1373
+ return [
1374
+ chunk
1375
+ async for chunk in stream_response(
1376
+ "stream this",
1377
+ runner=FakeRunner(),
1378
+ tracer=DisabledTracer(),
1379
+ )
1380
+ ]
1381
+
1382
+ chunks = asyncio.run(collect())
1383
+ assert chunks == ['data: {"text": "offline streamed answer"}\\n\\n']
1384
+
1385
+
1386
+ def test_missing_model_configuration_is_explicit(monkeypatch):
1387
+ monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
1388
+
1389
+ async def collect():
1390
+ return [chunk async for chunk in stream_response("unconfigured")]
1391
+
1392
+ with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
1393
+ asyncio.run(collect())
1394
+ `;
1395
+ }
1396
+ return `import asyncio
1397
+
1398
+ import pytest
1399
+
1400
+ from ${agentModule} import run_${moduleName}
1401
+ from backend.observability.tracing import DisabledTracer
1402
+ from backend.orchestration.model_config import ModelConfigurationError
1403
+
1404
+
1405
+ class FakeRunner:
1406
+ async def run(self, prompt):
1407
+ assert "offline input" in prompt
1408
+ return "offline ${pattern.id} result"
1409
+
1410
+
1411
+ def test_${moduleName}_uses_injected_runner_without_network():
1412
+ result = asyncio.run(
1413
+ run_${moduleName}(
1414
+ "offline input",
1415
+ runner=FakeRunner(),
1416
+ tracer=DisabledTracer(),
1417
+ )
1418
+ )
1419
+ assert result == {
1420
+ "result": "offline ${pattern.id} result",
1421
+ "input": "offline input",
1422
+ }
1423
+
1424
+
1425
+ def test_missing_model_configuration_is_explicit(monkeypatch):
1426
+ monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
1427
+ with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
1428
+ asyncio.run(run_${moduleName}("unconfigured"))
1429
+ `;
1430
+ }
602
1431
  function renderPromptTemplate(plan) {
603
- return `# ${plan.pattern.label} Prompt
1432
+ const pattern = genAiPattern(plan);
1433
+ return `# ${pattern.label} Prompt
604
1434
 
605
- You are implementing a ${plan.pattern.label} generated by Mission Control Liftoff.
1435
+ You are implementing a ${pattern.label} generated by Mission Control Liftoff.
606
1436
 
607
1437
  Use PydanticAI orchestration and return outputs that match the API contract.
608
1438
  `;
@@ -628,8 +1458,9 @@ class PgVectorStore:
628
1458
  `;
629
1459
  }
630
1460
  function renderPatternWorker(plan) {
1461
+ const pattern = genAiPattern(plan);
631
1462
  return `async def run_worker() -> None:
632
- # Consume ${plan.pattern.label} jobs from the configured messaging boundary.
1463
+ # Consume ${pattern.label} jobs from the configured messaging boundary.
633
1464
  return None
634
1465
  `;
635
1466
  }
@@ -643,12 +1474,15 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
643
1474
  }
644
1475
  function renderFunctionWorkerReadme(plan) {
645
1476
  const workerName = functionWorkerName(plan);
1477
+ const pattern = genAiPattern(plan);
646
1478
  return `# ${workerName}
647
1479
 
648
- Azure Functions worker scaffold for ${plan.pattern.label}.
1480
+ Azure Functions worker scaffold for ${pattern.label}.
649
1481
 
650
1482
  This Function app uses the Python v2 decorator programming model and a Service Bus queue trigger. The trigger adapter should stay thin: decode the message, validate the envelope, and call shared code from \`backend/orchestration\` after that shared code is packaged with the Function app.
651
1483
 
1484
+ Deployed triggers use \`ServiceBusConnection__fullyQualifiedNamespace\` and \`ServiceBusConnection__clientId\` to select the same user-assigned identity that OpenTofu grants the Service Bus Data Receiver role. \`SERVICEBUS_QUEUE_NAME\` is populated from \`function_worker_queue_name\`. Function host storage uses the complete \`AzureWebJobsStorage\` connection setting.
1485
+
652
1486
  ## Local Development
653
1487
 
654
1488
  \`\`\`bash
@@ -656,6 +1490,7 @@ python -m venv .venv
656
1490
  source .venv/bin/activate
657
1491
  pip install -r requirements.txt
658
1492
  cp local.settings.example.json local.settings.json
1493
+ python -m pytest -q
659
1494
  func start
660
1495
  \`\`\`
661
1496
 
@@ -672,14 +1507,15 @@ function renderFunctionHostJson() {
672
1507
  }, null, 2);
673
1508
  }
674
1509
  function renderFunctionLocalSettings(plan) {
1510
+ const pattern = genAiPattern(plan);
675
1511
  return JSON.stringify({
676
1512
  IsEncrypted: false,
677
1513
  Values: {
678
1514
  AzureWebJobsStorage: 'UseDevelopmentStorage=true',
679
1515
  FUNCTIONS_WORKER_RUNTIME: 'python',
680
- SERVICEBUS_QUEUE_NAME: 'events',
1516
+ SERVICEBUS_QUEUE_NAME: DEFAULT_FUNCTION_WORKER_QUEUE_NAME,
681
1517
  ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
682
- GENAI_PATTERN: plan.pattern.id,
1518
+ GENAI_PATTERN: pattern.id,
683
1519
  SHARED_ORCHESTRATION_ROOT: '../../backend'
684
1520
  }
685
1521
  }, null, 2);
@@ -690,7 +1526,8 @@ pytest>=8.2
690
1526
  `;
691
1527
  }
692
1528
  function renderFunctionApp(plan) {
693
- const moduleName = pyModule(plan.pattern.id);
1529
+ const pattern = genAiPattern(plan);
1530
+ const moduleName = pyModule(pattern.id);
694
1531
  return `import json
695
1532
  import logging
696
1533
 
@@ -717,7 +1554,7 @@ def decode_message_payload(body: str) -> dict:
717
1554
  )
718
1555
  def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
719
1556
  payload = decode_message_payload(message.get_body().decode("utf-8"))
720
- logging.info("Received ${plan.pattern.id} worker message with keys: %s", sorted(payload.keys()))
1557
+ logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
721
1558
  # Keep this adapter thin; call backend.orchestration code from packaged shared modules.
722
1559
  `;
723
1560
  }
@@ -751,31 +1588,44 @@ local.settings.json
751
1588
  `;
752
1589
  }
753
1590
  function renderBackendEnv(plan, environment) {
1591
+ const pattern = genAiPattern(plan);
754
1592
  const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
755
1593
  return `APP_ENV=${environment}
756
1594
  APP_NAME=${plan.safeProjectName}
757
- GENAI_PATTERN=${plan.pattern.id}
1595
+ GENAI_PATTERN=${pattern.id}
758
1596
  CLOUD_PROVIDER=${plan.provider.id}
759
1597
  AZURE_REGION=${plan.region.slug}
760
1598
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
761
1599
  REDIS_URL=redis://redis:6379/0
1600
+ REDIS_STREAM_NAME=liftoff-events
762
1601
  MESSAGING_TRANSPORT=${transport}
1602
+ SERVICE_BUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
1603
+ SERVICE_BUS_CONNECTION_STRING=
1604
+ SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE=${environment === 'dev' ? '' : '<service-bus-namespace>.servicebus.windows.net'}
1605
+ AZURE_CLIENT_ID=
763
1606
  BLOB_ENDPOINT=
764
- LANGFUSE_HOST=
1607
+ CORS_ALLOWED_ORIGINS=http://localhost:5173
1608
+ PYDANTIC_AI_MODEL=
1609
+ LANGFUSE_HOST=${environment === 'dev' ? 'http://langfuse:3000' : ''}
1610
+ LANGFUSE_PUBLIC_KEY=
1611
+ LANGFUSE_SECRET_KEY=
765
1612
  `;
766
1613
  }
767
1614
  function renderFunctionsEnv(plan, environment) {
1615
+ const pattern = genAiPattern(plan);
768
1616
  return `APP_ENV=${environment}
769
1617
  APP_NAME=${plan.safeProjectName}
770
- GENAI_PATTERN=${plan.pattern.id}
1618
+ GENAI_PATTERN=${pattern.id}
771
1619
  FUNCTIONS_WORKER_RUNTIME=python
772
- SERVICEBUS_QUEUE_NAME=events
1620
+ SERVICEBUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
773
1621
  ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
774
- AzureWebJobsStorage__accountName=<storage-account-name>
1622
+ ServiceBusConnection__clientId=<managed-identity-client-id>
1623
+ AzureWebJobsStorage=<storage-connection-string>
775
1624
  SHARED_ORCHESTRATION_ROOT=../../backend
776
1625
  `;
777
1626
  }
778
1627
  function renderDockerCompose(plan) {
1628
+ const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
779
1629
  const frontendService = plan.includeFrontend ? `
780
1630
  frontend:
781
1631
  build:
@@ -785,13 +1635,17 @@ function renderDockerCompose(plan) {
785
1635
  depends_on:
786
1636
  - backend
787
1637
  ` : '';
1638
+ const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
788
1639
  return `services:
789
1640
  backend:
790
1641
  build:
791
1642
  context: .
792
1643
  dockerfile: Dockerfile
793
1644
  env_file:
794
- - ./environments/dev/backend.env
1645
+ - ./environments/${localEnvironment.id}/backend.env
1646
+ environment:
1647
+ MESSAGING_TRANSPORT: redis-streams
1648
+ BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
795
1649
  ports:
796
1650
  - "8000:8000"
797
1651
  depends_on:
@@ -801,7 +1655,7 @@ function renderDockerCompose(plan) {
801
1655
  - mailpit
802
1656
  ${frontendService}
803
1657
  postgres:
804
- image: pgvector/pgvector:pg16
1658
+ image: ${postgresImage}
805
1659
  environment:
806
1660
  POSTGRES_USER: postgres
807
1661
  POSTGRES_PASSWORD: postgres
@@ -825,7 +1679,7 @@ ${frontendService}
825
1679
  ports:
826
1680
  - "8025:8025"
827
1681
 
828
- langfuse:
1682
+ ${plan.projectType.id === 'genai' ? ` langfuse:
829
1683
  image: langfuse/langfuse:2
830
1684
  profiles:
831
1685
  - observability
@@ -835,6 +1689,7 @@ ${frontendService}
835
1689
  SALT: local-development-placeholder
836
1690
  ports:
837
1691
  - "3000:3000"
1692
+ ` : ''}
838
1693
  `;
839
1694
  }
840
1695
  function renderTofuVersions() {
@@ -856,6 +1711,13 @@ function renderTofuProviders() {
856
1711
  `;
857
1712
  }
858
1713
  function renderTofuVariables(plan) {
1714
+ const frontendVariables = plan.includeFrontend ? `
1715
+ variable "frontend_image" {
1716
+ type = string
1717
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
1718
+ description = "Frontend image. Replace the bootstrap image with the generated frontend image after pushing it to ACR."
1719
+ }
1720
+ ` : '';
859
1721
  const functionVariables = hasFunctionWorker(plan) ? `
860
1722
  variable "function_worker_queue_name" {
861
1723
  type = string
@@ -882,9 +1744,26 @@ variable "location" {
882
1744
 
883
1745
  variable "resource_suffix" {
884
1746
  type = string
885
- description = "Globally unique suffix for Azure resource names."
1747
+ description = "Twelve-character lowercase alphanumeric suffix for globally scoped Azure resource names."
1748
+
1749
+ validation {
1750
+ condition = can(regex("^[a-z0-9]{12}$", var.resource_suffix))
1751
+ error_message = "resource_suffix must contain exactly 12 lowercase letters or numbers."
1752
+ }
1753
+ }
1754
+
1755
+ variable "backend_image" {
1756
+ type = string
1757
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
1758
+ description = "Backend image. Replace the bootstrap image with the generated backend image after pushing it to ACR."
886
1759
  }
887
1760
 
1761
+ variable "backend_target_port" {
1762
+ type = number
1763
+ default = 80
1764
+ description = "Backend ingress port. Set to 8000 when switching from the bootstrap image to the generated backend."
1765
+ }
1766
+ ${frontendVariables}
888
1767
  variable "postgres_admin_password" {
889
1768
  type = string
890
1769
  sensitive = true
@@ -900,17 +1779,49 @@ ${functionVariables}
900
1779
  `;
901
1780
  }
902
1781
  function renderTofuMain(plan) {
1782
+ const functionPattern = hasFunctionWorker(plan) ? genAiPattern(plan) : undefined;
1783
+ const names = buildAzureResourceNames(plan, '${var.environment}', '${var.resource_suffix}');
1784
+ const queueName = hasFunctionWorker(plan)
1785
+ ? 'var.function_worker_queue_name'
1786
+ : JSON.stringify(DEFAULT_FUNCTION_WORKER_QUEUE_NAME);
1787
+ const projectIdentityEnv = plan.projectType.id === 'genai' ? `
1788
+ env {
1789
+ name = "GENAI_PATTERN"
1790
+ value = "${genAiPattern(plan).id}"
1791
+ }
1792
+ ` : `
1793
+ env {
1794
+ name = "API_STACK"
1795
+ value = "${plan.apiStack.id}"
1796
+ }
1797
+ `;
1798
+ const frontendCorsEnvironment = plan.includeFrontend ? `
1799
+ env {
1800
+ name = "CORS_ALLOWED_ORIGINS"
1801
+ value = "https://\${azurerm_container_app.frontend.ingress[0].fqdn}"
1802
+ }
1803
+ ` : '';
903
1804
  const frontendContainer = plan.includeFrontend ? `
904
1805
  resource "azurerm_container_app" "frontend" {
905
- name = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
1806
+ name = "${names.frontendContainerApp}"
906
1807
  container_app_environment_id = azurerm_container_app_environment.main.id
907
1808
  resource_group_name = azurerm_resource_group.main.name
908
1809
  revision_mode = "Single"
909
1810
 
1811
+ identity {
1812
+ type = "UserAssigned"
1813
+ identity_ids = [azurerm_user_assigned_identity.app.id]
1814
+ }
1815
+
1816
+ registry {
1817
+ server = azurerm_container_registry.main.login_server
1818
+ identity = azurerm_user_assigned_identity.app.id
1819
+ }
1820
+
910
1821
  template {
911
1822
  container {
912
1823
  name = "frontend"
913
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-frontend:latest"
1824
+ image = var.frontend_image
914
1825
  cpu = 0.25
915
1826
  memory = "0.5Gi"
916
1827
  }
@@ -924,11 +1835,13 @@ resource "azurerm_container_app" "frontend" {
924
1835
  latest_revision = true
925
1836
  }
926
1837
  }
1838
+
1839
+ depends_on = [azurerm_role_assignment.acr_pull]
927
1840
  }
928
1841
  ` : '';
929
1842
  const functionWorker = hasFunctionWorker(plan) ? `
930
1843
  resource "azurerm_service_plan" "functions" {
931
- name = "asp-\${local.name_prefix}-functions"
1844
+ name = "${names.functionServicePlan}"
932
1845
  resource_group_name = azurerm_resource_group.main.name
933
1846
  location = azurerm_resource_group.main.location
934
1847
  os_type = "Linux"
@@ -936,7 +1849,7 @@ resource "azurerm_service_plan" "functions" {
936
1849
  }
937
1850
 
938
1851
  resource "azurerm_linux_function_app" "worker" {
939
- name = "func-${plan.safeProjectName}-\${var.environment}-\${var.resource_suffix}"
1852
+ name = "${names.functionApp}"
940
1853
  resource_group_name = azurerm_resource_group.main.name
941
1854
  location = azurerm_resource_group.main.location
942
1855
  service_plan_id = azurerm_service_plan.functions.id
@@ -955,14 +1868,14 @@ resource "azurerm_linux_function_app" "worker" {
955
1868
  }
956
1869
 
957
1870
  app_settings = {
958
- APP_ENV = var.environment
959
- APP_NAME = "${plan.safeProjectName}"
960
- GENAI_PATTERN = "${plan.pattern.id}"
961
- FUNCTIONS_WORKER_RUNTIME = "python"
962
- SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
1871
+ APP_ENV = var.environment
1872
+ APP_NAME = "${plan.safeProjectName}"
1873
+ GENAI_PATTERN = "${functionPattern?.id}"
1874
+ FUNCTIONS_WORKER_RUNTIME = "python"
1875
+ SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
1876
+ ServiceBusConnection__clientId = azurerm_user_assigned_identity.app.client_id
963
1877
  ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
964
- AzureWebJobsStorage__accountName = azurerm_storage_account.main.name
965
- SHARED_ORCHESTRATION_ROOT = "../../backend"
1878
+ SHARED_ORCHESTRATION_ROOT = "../../backend"
966
1879
  }
967
1880
  }
968
1881
 
@@ -978,17 +1891,13 @@ resource "azurerm_role_assignment" "function_storage_blob_contributor" {
978
1891
  principal_id = azurerm_user_assigned_identity.app.principal_id
979
1892
  }
980
1893
  ` : '';
981
- return `locals {
982
- name_prefix = "${plan.safeProjectName}-\${var.environment}"
983
- }
984
-
985
- resource "azurerm_resource_group" "main" {
986
- name = "rg-\${local.name_prefix}"
1894
+ return `resource "azurerm_resource_group" "main" {
1895
+ name = "${names.resourceGroup}"
987
1896
  location = var.location
988
1897
  }
989
1898
 
990
1899
  resource "azurerm_container_registry" "main" {
991
- name = "acr\${replace(var.resource_suffix, "-", "")}"
1900
+ name = "${names.containerRegistry}"
992
1901
  resource_group_name = azurerm_resource_group.main.name
993
1902
  location = azurerm_resource_group.main.location
994
1903
  sku = "Basic"
@@ -996,19 +1905,25 @@ resource "azurerm_container_registry" "main" {
996
1905
  }
997
1906
 
998
1907
  resource "azurerm_user_assigned_identity" "app" {
999
- name = "id-\${local.name_prefix}"
1908
+ name = "${names.identity}"
1000
1909
  resource_group_name = azurerm_resource_group.main.name
1001
1910
  location = azurerm_resource_group.main.location
1002
1911
  }
1003
1912
 
1913
+ resource "azurerm_role_assignment" "acr_pull" {
1914
+ scope = azurerm_container_registry.main.id
1915
+ role_definition_name = "AcrPull"
1916
+ principal_id = azurerm_user_assigned_identity.app.principal_id
1917
+ }
1918
+
1004
1919
  resource "azurerm_container_app_environment" "main" {
1005
- name = "cae-\${local.name_prefix}"
1920
+ name = "${names.containerAppEnvironment}"
1006
1921
  resource_group_name = azurerm_resource_group.main.name
1007
1922
  location = azurerm_resource_group.main.location
1008
1923
  }
1009
1924
 
1010
1925
  resource "azurerm_container_app" "backend" {
1011
- name = "ca-${plan.safeProjectName}-backend-\${var.environment}"
1926
+ name = "${names.backendContainerApp}"
1012
1927
  container_app_environment_id = azurerm_container_app_environment.main.id
1013
1928
  resource_group_name = azurerm_resource_group.main.name
1014
1929
  revision_mode = "Single"
@@ -1018,27 +1933,90 @@ resource "azurerm_container_app" "backend" {
1018
1933
  identity_ids = [azurerm_user_assigned_identity.app.id]
1019
1934
  }
1020
1935
 
1936
+ registry {
1937
+ server = azurerm_container_registry.main.login_server
1938
+ identity = azurerm_user_assigned_identity.app.id
1939
+ }
1940
+
1941
+ secret {
1942
+ name = "database-url"
1943
+ value = "postgresql://liftoffadmin:\${urlencode(var.postgres_admin_password)}@\${azurerm_postgresql_flexible_server.main.fqdn}:5432/postgres?sslmode=require"
1944
+ }
1945
+
1946
+ secret {
1947
+ name = "redis-url"
1948
+ value = "rediss://:\${urlencode(azurerm_redis_cache.main.primary_access_key)}@\${azurerm_redis_cache.main.hostname}:\${azurerm_redis_cache.main.ssl_port}/0"
1949
+ }
1950
+
1021
1951
  template {
1022
1952
  container {
1023
1953
  name = "backend"
1024
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-backend:latest"
1954
+ image = var.backend_image
1025
1955
  cpu = 0.5
1026
1956
  memory = "1Gi"
1957
+
1958
+ env {
1959
+ name = "APP_ENV"
1960
+ value = var.environment
1961
+ }
1962
+
1963
+ env {
1964
+ name = "APP_NAME"
1965
+ value = "${plan.safeProjectName}"
1966
+ }
1967
+
1968
+ env {
1969
+ name = "PROJECT_TYPE"
1970
+ value = "${plan.projectType.id}"
1971
+ }
1972
+ ${projectIdentityEnv}
1973
+ env {
1974
+ name = "CLOUD_PROVIDER"
1975
+ value = "azure"
1976
+ }
1977
+
1978
+ env {
1979
+ name = "AZURE_REGION"
1980
+ value = var.location
1981
+ }
1982
+ ${frontendCorsEnvironment}
1983
+
1984
+ env {
1985
+ name = "DATABASE_URL"
1986
+ secret_name = "database-url"
1987
+ }
1988
+
1989
+ env {
1990
+ name = "REDIS_URL"
1991
+ secret_name = "redis-url"
1992
+ }
1993
+
1994
+ env {
1995
+ name = "MESSAGING_TRANSPORT"
1996
+ value = "azure-service-bus"
1997
+ }
1998
+
1999
+ env {
2000
+ name = "BLOB_ENDPOINT"
2001
+ value = azurerm_storage_account.main.primary_blob_endpoint
2002
+ }
1027
2003
  }
1028
2004
  }
1029
2005
 
1030
2006
  ingress {
1031
2007
  external_enabled = true
1032
- target_port = 8000
2008
+ target_port = var.backend_target_port
1033
2009
  traffic_weight {
1034
2010
  percentage = 100
1035
2011
  latest_revision = true
1036
2012
  }
1037
2013
  }
2014
+
2015
+ depends_on = [azurerm_role_assignment.acr_pull]
1038
2016
  }
1039
2017
  ${frontendContainer}
1040
2018
  resource "azurerm_postgresql_flexible_server" "main" {
1041
- name = "psql-\${local.name_prefix}-\${var.resource_suffix}"
2019
+ name = "${names.postgres}"
1042
2020
  resource_group_name = azurerm_resource_group.main.name
1043
2021
  location = azurerm_resource_group.main.location
1044
2022
  version = "16"
@@ -1048,8 +2026,16 @@ resource "azurerm_postgresql_flexible_server" "main" {
1048
2026
  sku_name = "B_Standard_B1ms"
1049
2027
  }
1050
2028
 
2029
+ resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
2030
+ count = var.enable_private_networking ? 0 : 1
2031
+ name = "AllowAzureServices"
2032
+ server_id = azurerm_postgresql_flexible_server.main.id
2033
+ start_ip_address = "0.0.0.0"
2034
+ end_ip_address = "0.0.0.0"
2035
+ }
2036
+
1051
2037
  resource "azurerm_redis_cache" "main" {
1052
- name = "redis-\${local.name_prefix}-\${var.resource_suffix}"
2038
+ name = "${names.redis}"
1053
2039
  location = azurerm_resource_group.main.location
1054
2040
  resource_group_name = azurerm_resource_group.main.name
1055
2041
  capacity = 0
@@ -1058,7 +2044,7 @@ resource "azurerm_redis_cache" "main" {
1058
2044
  }
1059
2045
 
1060
2046
  resource "azurerm_storage_account" "main" {
1061
- name = "st\${replace(var.resource_suffix, "-", "")}"
2047
+ name = "${names.storage}"
1062
2048
  resource_group_name = azurerm_resource_group.main.name
1063
2049
  location = azurerm_resource_group.main.location
1064
2050
  account_tier = "Standard"
@@ -1072,26 +2058,26 @@ resource "azurerm_storage_container" "documents" {
1072
2058
  }
1073
2059
 
1074
2060
  resource "azurerm_servicebus_namespace" "main" {
1075
- name = "sb-\${local.name_prefix}-\${var.resource_suffix}"
2061
+ name = "${names.serviceBus}"
1076
2062
  location = azurerm_resource_group.main.location
1077
2063
  resource_group_name = azurerm_resource_group.main.name
1078
2064
  sku = "Standard"
1079
2065
  }
1080
2066
 
1081
2067
  resource "azurerm_servicebus_queue" "events" {
1082
- name = "events"
2068
+ name = ${queueName}
1083
2069
  namespace_id = azurerm_servicebus_namespace.main.id
1084
2070
  }
1085
2071
  ${functionWorker}
1086
2072
 
1087
2073
  resource "azurerm_communication_service" "main" {
1088
- name = "acs-\${local.name_prefix}"
2074
+ name = "${names.communication}"
1089
2075
  resource_group_name = azurerm_resource_group.main.name
1090
2076
  data_location = "United States"
1091
2077
  }
1092
2078
 
1093
2079
  resource "azurerm_key_vault" "main" {
1094
- name = "kv-\${local.name_prefix}-\${var.resource_suffix}"
2080
+ name = "${names.keyVault}"
1095
2081
  location = azurerm_resource_group.main.location
1096
2082
  resource_group_name = azurerm_resource_group.main.name
1097
2083
  tenant_id = data.azurerm_client_config.current.tenant_id
@@ -1121,6 +2107,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
1121
2107
  ` : ''}${functionOutputs}output "container_registry" {
1122
2108
  value = azurerm_container_registry.main.login_server
1123
2109
  }
2110
+
2111
+ output "container_registry_name" {
2112
+ value = azurerm_container_registry.main.name
2113
+ }
1124
2114
  `;
1125
2115
  }
1126
2116
  function renderTofuLocalState() {
@@ -1145,36 +2135,109 @@ function renderTofuReadme(plan) {
1145
2135
  const functionSection = hasFunctionWorker(plan) ? `
1146
2136
  ## Azure Functions Worker
1147
2137
 
1148
- This project includes an Azure Functions worker under \`functions/${functionWorkerName(plan)}\`. The OpenTofu configuration provisions Function app hosting, app settings, managed identity access to Service Bus, and worker outputs for the selected environments.
2138
+ This project includes an Azure Functions worker under \`functions/${functionWorkerName(plan)}\`. The OpenTofu configuration attaches one user-assigned identity, grants its principal the Service Bus Data Receiver role, and selects it through \`ServiceBusConnection__clientId\` plus \`ServiceBusConnection__fullyQualifiedNamespace\`. \`function_worker_queue_name\` provisions the queue, configures \`SERVICEBUS_QUEUE_NAME\`, and drives the worker queue output. Function host storage uses the complete key-backed \`AzureWebJobsStorage\` connection setting.
1149
2139
  ` : '';
1150
2140
  return `# Azure OpenTofu
1151
2141
 
1152
2142
  Azure is the complete V1 provider for this Liftoff project.
1153
2143
 
2144
+ ## Bootstrap Infrastructure
2145
+
2146
+ The first apply uses a public bootstrap image so Azure Container Apps can start before the new ACR contains application images.
2147
+
1154
2148
  \`\`\`bash
1155
2149
  tofu init
1156
2150
  tofu plan -var-file=environments/${env}.tfvars
1157
2151
  tofu apply -var-file=environments/${env}.tfvars
1158
- tofu output
2152
+ \`\`\`
2153
+
2154
+ Build the generated backend in ACR, then replace the bootstrap image:
2155
+
2156
+ \`\`\`bash
2157
+ ACR_NAME="$(tofu output -raw container_registry_name)"
2158
+ az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
2159
+ ${plan.includeFrontend ? `BACKEND_URL="https://$(tofu output -raw backend_url)"
2160
+ az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest --build-arg VITE_API_BASE_URL="$BACKEND_URL" ../../../frontend
2161
+ ` : ''}\`\`\`
2162
+
2163
+ Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
2164
+
2165
+ \`\`\`hcl
2166
+ backend_image = "<login-server>/${plan.safeProjectName}-backend:latest"
2167
+ backend_target_port = 8000
2168
+ ${plan.includeFrontend ? `frontend_image = "<login-server>/${plan.safeProjectName}-frontend:latest"
2169
+ ` : ''}\`\`\`
2170
+
2171
+ \`\`\`bash
2172
+ tofu apply -var-file=environments/${env}.tfvars
1159
2173
  \`\`\`
1160
2174
 
1161
2175
  Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
2176
+ The default PostgreSQL firewall permits Azure-hosted services. Replace it with private networking before production; set \`enable_private_networking=true\` only when the required VNet, delegated subnet, and private DNS resources are added.
2177
+
2178
+ ## Azure Name Suffixes
2179
+
2180
+ Each environment tfvars file contains a deterministic 12-character lowercase alphanumeric \`resource_suffix\` used by globally scoped Azure names. If Azure reports that a name is already taken, replace that environment's suffix with another unique value matching \`^[a-z0-9]{12}$\`; \`tofu validate\` rejects invalid overrides before deployment.
1162
2181
  ${functionSection}
1163
2182
  `;
1164
2183
  }
1165
2184
  function renderTofuTfvars(plan, environment) {
1166
- const functionValues = hasFunctionWorker(plan) ? `function_worker_queue_name = "events"
1167
- functions_python_version = "3.12"
1168
- ` : '';
1169
- return `environment = "${environment}"
1170
- location = "${plan.region.slug}"
1171
- resource_suffix = "${plan.safeProjectName.replace(/-/g, '')}${environment}"
1172
- enable_private_networking = ${environment === 'prod' ? 'true' : 'false'}
1173
- ${functionValues}
1174
- `;
2185
+ const values = [
2186
+ ['environment', JSON.stringify(environment)],
2187
+ ['location', JSON.stringify(plan.region.slug)],
2188
+ ['resource_suffix', JSON.stringify(stableResourceSuffix(plan, environment))],
2189
+ ['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
2190
+ ['backend_target_port', '80'],
2191
+ ['enable_private_networking', 'false']
2192
+ ];
2193
+ if (plan.includeFrontend) {
2194
+ values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
2195
+ }
2196
+ if (hasFunctionWorker(plan)) {
2197
+ values.push(['function_worker_queue_name', JSON.stringify(DEFAULT_FUNCTION_WORKER_QUEUE_NAME)], ['functions_python_version', JSON.stringify('3.12')]);
2198
+ }
2199
+ const width = Math.max(...values.map(([key]) => key.length));
2200
+ return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
1175
2201
  }
1176
2202
  function renderOpenSpecConfig(plan) {
1177
2203
  const frontendRule = plan.includeFrontend ? '\n - Keep frontend code under frontend.' : '';
2204
+ if (plan.projectType.id === 'standard') {
2205
+ const backendRule = plan.apiStack.id === 'python-fastapi'
2206
+ ? 'Keep backend API code under backend/apis.'
2207
+ : plan.apiStack.id === 'node-fastify'
2208
+ ? 'Keep backend API code under backend/src.'
2209
+ : 'Keep the Go entrypoint under backend/cmd/api and reusable code under backend/internal.';
2210
+ return `schema: spec-driven
2211
+
2212
+ context: |
2213
+ Project generated by Mission Control Liftoff.
2214
+ Project type: Standard application.
2215
+ API stack: ${plan.apiStack.label}.
2216
+ Database tooling: ${plan.apiStack.databaseTooling}.
2217
+ API developer portal: Scalar.
2218
+ Infrastructure: OpenTofu.
2219
+ Primary cloud: Azure (${plan.region.slug}).
2220
+ Local development: Docker Compose.
2221
+ Database: PostgreSQL.
2222
+ Cache and local messaging: Redis.
2223
+ Environments: ${plan.environments.map((environment) => environment.id).join(', ')}.
2224
+
2225
+ rules:
2226
+ specs:
2227
+ - Requirements must describe observable product behavior.
2228
+ - Cloud behavior must identify environment differences for generated environments.
2229
+ design:
2230
+ - Use ${plan.apiStack.framework} for backend APIs.
2231
+ - Use ${plan.apiStack.databaseTooling} for database access and migrations.
2232
+ - Use OpenTofu for infrastructure changes.${frontendRule}
2233
+ - ${backendRule}
2234
+ - Keep database artifacts under database.
2235
+ tasks:
2236
+ - Include local Docker Compose verification.
2237
+ - Include OpenTofu validation for generated infrastructure.
2238
+ `;
2239
+ }
2240
+ const pattern = genAiPattern(plan);
1178
2241
  const functionsContext = hasFunctionWorker(plan) ? `
1179
2242
  Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
1180
2243
  const functionsRule = hasFunctionWorker(plan) ? `
@@ -1184,7 +2247,7 @@ function renderOpenSpecConfig(plan) {
1184
2247
 
1185
2248
  context: |
1186
2249
  Project generated by Mission Control Liftoff.
1187
- GenAI pattern: ${plan.pattern.label}
2250
+ GenAI pattern: ${pattern.label}
1188
2251
  Application framework: FastAPI + PydanticAI.
1189
2252
  API developer portal: Scalar.
1190
2253
  Infrastructure: OpenTofu.
@@ -1213,10 +2276,36 @@ ${functionsRule}
1213
2276
  `;
1214
2277
  }
1215
2278
  function renderSeedProposal(plan) {
2279
+ if (plan.projectType.id === 'standard') {
2280
+ return `## Why
2281
+
2282
+ Bootstrap the generated ${plan.apiStack.label} standard application baseline created by Mission Control Liftoff.
2283
+
2284
+ ## What Changes
2285
+
2286
+ - Establish the approved backend, infrastructure, local development, and governance baseline.
2287
+ - Capture follow-up product requirements through spec-driven changes.
2288
+
2289
+ ## Capabilities
2290
+
2291
+ ### New Capabilities
2292
+
2293
+ - \`${plan.apiStack.id}-application-baseline\`: Generated standard application baseline for this Liftoff project.
2294
+
2295
+ ### Modified Capabilities
2296
+
2297
+ - None.
2298
+
2299
+ ## Impact
2300
+
2301
+ - Generated ${plan.apiStack.label} backend, OpenTofu infrastructure, Docker Compose local development, and governance files.
2302
+ `;
2303
+ }
2304
+ const pattern = genAiPattern(plan);
1216
2305
  const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
1217
2306
  return `## Why
1218
2307
 
1219
- Bootstrap the generated ${plan.pattern.label} application baseline created by Mission Control Liftoff.
2308
+ Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
1220
2309
 
1221
2310
  ## What Changes
1222
2311
 
@@ -1228,7 +2317,7 @@ ${functionsChange}
1228
2317
 
1229
2318
  ### New Capabilities
1230
2319
 
1231
- - \`${plan.pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
2320
+ - \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
1232
2321
 
1233
2322
  ### Modified Capabilities
1234
2323
 
@@ -1240,10 +2329,38 @@ ${functionsChange}
1240
2329
  `;
1241
2330
  }
1242
2331
  function renderSeedDesign(plan) {
2332
+ if (plan.projectType.id === 'standard') {
2333
+ return `## Context
2334
+
2335
+ This standard project was generated with Liftoff using ${plan.apiStack.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
2336
+
2337
+ ## Goals / Non-Goals
2338
+
2339
+ **Goals:**
2340
+
2341
+ - Keep the generated baseline aligned to the approved Mission Control stack.
2342
+
2343
+ **Non-Goals:**
2344
+
2345
+ - Define domain-specific product behavior in the bootstrap change.
2346
+
2347
+ ## Decisions
2348
+
2349
+ - Use ${plan.apiStack.framework} for backend APIs.
2350
+ - Use ${plan.apiStack.databaseTooling} for PostgreSQL integration.
2351
+ - Use OpenTofu for Azure infrastructure.
2352
+ - Use Docker Compose for local development.
2353
+
2354
+ ## Risks / Trade-offs
2355
+
2356
+ - The baseline contains placeholders that product-specific changes should replace.
2357
+ `;
2358
+ }
2359
+ const pattern = genAiPattern(plan);
1243
2360
  const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
1244
2361
  return `## Context
1245
2362
 
1246
- This project was generated with Liftoff using ${plan.pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
2363
+ This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1247
2364
 
1248
2365
  ## Goals / Non-Goals
1249
2366
 
@@ -1276,6 +2393,28 @@ function renderSeedTasks() {
1276
2393
  `;
1277
2394
  }
1278
2395
  function renderSpecKitConstitution(plan) {
2396
+ if (plan.projectType.id === 'standard') {
2397
+ const backendLayout = plan.apiStack.id === 'python-fastapi'
2398
+ ? 'backend/apis'
2399
+ : plan.apiStack.id === 'node-fastify' ? 'backend/src' : 'backend/cmd/api and backend/internal';
2400
+ return `# Mission Control Liftoff Constitution
2401
+
2402
+ ## Principle 1: Approved Application Stack
2403
+ Generated backend services MUST use ${plan.apiStack.framework}, ${plan.apiStack.databaseTooling}, and Scalar for API documentation.
2404
+
2405
+ ## Principle 2: Standard Project Layout
2406
+ Backend APIs live under ${backendLayout}. Database artifacts live under database.${plan.includeFrontend ? ' Frontend code lives under frontend.' : ''}
2407
+
2408
+ ## Principle 3: Infrastructure As Code
2409
+ Cloud infrastructure MUST be defined with OpenTofu. Azure is the supported V1 provider.
2410
+
2411
+ ## Principle 4: Local Development Parity
2412
+ Projects MUST include Docker Compose for local development with PostgreSQL, Redis, local blob storage, and local messaging behavior.
2413
+
2414
+ ## Principle 5: Observability And Operations
2415
+ Services MUST use structured logging and environment-specific configuration for ${plan.environments.map((environment) => environment.id).join(', ')}.
2416
+ `;
2417
+ }
1279
2418
  const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
1280
2419
  return `# Mission Control Liftoff Constitution
1281
2420
 
@@ -1337,7 +2476,7 @@ function renderFrontendPackage(plan) {
1337
2476
  }, null, 2);
1338
2477
  }
1339
2478
  function renderFrontendIndex(plan) {
1340
- return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${plan.projectName}</title>`;
2479
+ return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${escapeHtml(plan.projectName)}</title>`;
1341
2480
  }
1342
2481
  function renderFrontendMain() {
1343
2482
  return `import { createApp } from 'vue';
@@ -1348,10 +2487,72 @@ createApp(App).mount('#app');
1348
2487
  `;
1349
2488
  }
1350
2489
  function renderFrontendApp(plan) {
2490
+ const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
2491
+ const apiContract = plan.projectType.id === 'standard'
2492
+ ? { route: '/api', method: 'GET', bodyField: '', queryParameter: '', requiresInput: false }
2493
+ : genAiPattern(plan).id === 'rag'
2494
+ ? { route: `${genAiPattern(plan).routePrefix}/query`, method: 'POST', bodyField: 'question', queryParameter: '', requiresInput: true }
2495
+ : genAiPattern(plan).id === 'streaming'
2496
+ ? { route: genAiPattern(plan).routePrefix, method: 'GET', bodyField: '', queryParameter: 'prompt', requiresInput: true }
2497
+ : { route: `${genAiPattern(plan).routePrefix}/run`, method: 'POST', bodyField: 'input', queryParameter: '', requiresInput: true };
1351
2498
  return `<script setup lang="ts">
1352
- const title = '${plan.projectName}';
1353
- const starter = '${plan.frontendStarter}';
1354
- const pattern = '${plan.pattern.label}';
2499
+ import { ref } from 'vue';
2500
+
2501
+ const title = ${scriptSourceString(plan.projectName)};
2502
+ const starter = ${scriptSourceString(plan.frontendStarter)};
2503
+ const descriptor = ${scriptSourceString(descriptor)};
2504
+ const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000').replace(/\\/+$/, '');
2505
+ const route = ${scriptSourceString(apiContract.route)};
2506
+ const method = ${scriptSourceString(apiContract.method)};
2507
+ const bodyField = ${scriptSourceString(apiContract.bodyField)};
2508
+ const queryParameter = ${scriptSourceString(apiContract.queryParameter)};
2509
+ const requiresInput = ${apiContract.requiresInput};
2510
+
2511
+ const input = ref('');
2512
+ const loading = ref(false);
2513
+ const result = ref('');
2514
+ const errorMessage = ref('');
2515
+
2516
+ async function submit(): Promise<void> {
2517
+ const value = input.value.trim();
2518
+ if (requiresInput && !value) {
2519
+ errorMessage.value = 'Enter a value before running the starter.';
2520
+ return;
2521
+ }
2522
+
2523
+ loading.value = true;
2524
+ result.value = '';
2525
+ errorMessage.value = '';
2526
+ try {
2527
+ const query = queryParameter
2528
+ ? '?' + queryParameter + '=' + encodeURIComponent(value)
2529
+ : '';
2530
+ const request: RequestInit = { method };
2531
+ if (method === 'POST') {
2532
+ request.headers = { 'Content-Type': 'application/json' };
2533
+ request.body = JSON.stringify({ [bodyField]: value });
2534
+ }
2535
+ const response = await fetch(apiBaseUrl + route + query, request);
2536
+ const responseText = await response.text();
2537
+ if (!response.ok) {
2538
+ throw new Error(
2539
+ 'Backend request failed (' + response.status + '): ' +
2540
+ (responseText || response.statusText)
2541
+ );
2542
+ }
2543
+ if ((response.headers.get('content-type') || '').includes('application/json')) {
2544
+ result.value = JSON.stringify(JSON.parse(responseText), null, 2);
2545
+ } else {
2546
+ result.value = responseText;
2547
+ }
2548
+ } catch (error) {
2549
+ errorMessage.value = error instanceof Error
2550
+ ? error.message
2551
+ : 'The backend request failed unexpectedly.';
2552
+ } finally {
2553
+ loading.value = false;
2554
+ }
2555
+ }
1355
2556
  </script>
1356
2557
 
1357
2558
  <template>
@@ -1360,12 +2561,30 @@ const pattern = '${plan.pattern.label}';
1360
2561
  <header>
1361
2562
  <p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
1362
2563
  <h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
1363
- <p class="mt-2 text-slate-600">{{ pattern }} starter</p>
2564
+ <p class="mt-2 text-slate-600">{{ descriptor }}</p>
1364
2565
  </header>
1365
2566
  <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
1366
2567
  <h2 class="text-xl font-semibold">{{ starter }}</h2>
1367
- <textarea class="mt-4 min-h-40 w-full rounded-md border border-slate-300 p-3" placeholder="Start with your application-specific input." />
1368
- <button class="mt-4 rounded-md bg-emerald-700 px-4 py-2 font-semibold text-white">Run</button>
2568
+ <p class="mt-2 text-sm text-slate-500">API: {{ apiBaseUrl }}{{ route }}</p>
2569
+ <textarea
2570
+ v-if="requiresInput"
2571
+ v-model="input"
2572
+ class="mt-4 min-h-40 w-full rounded-md border border-slate-300 p-3"
2573
+ :disabled="loading"
2574
+ placeholder="Enter input for the generated backend."
2575
+ />
2576
+ <button
2577
+ class="mt-4 rounded-md bg-emerald-700 px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
2578
+ :disabled="loading"
2579
+ type="button"
2580
+ @click="submit"
2581
+ >
2582
+ {{ loading ? 'Running...' : 'Run' }}
2583
+ </button>
2584
+ <p v-if="errorMessage" class="mt-4 rounded-md bg-red-50 p-3 text-red-800" role="alert">
2585
+ {{ errorMessage }}
2586
+ </p>
2587
+ <pre v-if="result" class="mt-4 overflow-auto rounded-md bg-slate-950 p-4 text-sm text-white" aria-live="polite">{{ result }}</pre>
1369
2588
  </section>
1370
2589
  </section>
1371
2590
  </main>
@@ -1401,6 +2620,8 @@ WORKDIR /app
1401
2620
  COPY package.json package-lock.json* ./
1402
2621
  RUN npm install
1403
2622
  COPY . .
2623
+ ARG VITE_API_BASE_URL=http://localhost:8000
2624
+ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
1404
2625
  RUN npm run build
1405
2626
 
1406
2627
  FROM nginx:1.27-alpine