thinkwork-cli 0.12.11 → 0.12.15

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 (474) hide show
  1. package/README.md +42 -38
  2. package/dist/{api-client-ASJWIG3Z.js → api-client-4VT3FWOH.js} +1 -1
  3. package/dist/{chunk-JEHV35EU.js → chunk-H7AB42ES.js} +340 -30
  4. package/dist/cli.js +22157 -9229
  5. package/dist/commands/enterprise/templates/deploy-repo/.github/workflows/deploy.yml +17 -6
  6. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +81 -9
  7. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +183 -21
  8. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +1 -2
  9. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +286 -0
  10. package/dist/drizzle/0000_petite_marvel_apes.sql +1188 -0
  11. package/dist/drizzle/0001_milky_surge.sql +103 -0
  12. package/dist/drizzle/0002_aberrant_mattie_franklin.sql +41 -0
  13. package/dist/drizzle/0003_exotic_ogun.sql +3 -0
  14. package/dist/drizzle/0004_glamorous_magneto.sql +2 -0
  15. package/dist/drizzle/0005_ancient_nocturne.sql +2 -0
  16. package/dist/drizzle/0006_new_colonel_america.sql +2 -0
  17. package/dist/drizzle/0007_unique_external_task_id.sql +31 -0
  18. package/dist/drizzle/0008_external_task_id_column.sql +43 -0
  19. package/dist/drizzle/0009_evaluations.sql +64 -0
  20. package/dist/drizzle/0010_eval_agent_template.sql +2 -0
  21. package/dist/drizzle/0011_eval_run_template.sql +2 -0
  22. package/dist/drizzle/0012_eval_seed_unique.sql +16 -0
  23. package/dist/drizzle/0013_motionless_white_queen.sql +140 -0
  24. package/dist/drizzle/0014_wiki_aggregation.sql +11 -0
  25. package/dist/drizzle/0015_pg_trgm_alias_title_indexes.sql +9 -0
  26. package/dist/drizzle/0016_wiki_schema_drops.sql +24 -0
  27. package/dist/drizzle/0017_wiki_places.sql +46 -0
  28. package/dist/drizzle/0018_agent_workspace_overlay.sql +26 -0
  29. package/dist/drizzle/0018_cool_firebird.sql +24 -0
  30. package/dist/drizzle/0018_skill_runs.sql +84 -0
  31. package/dist/drizzle/0019_condemned_dragon_man.sql +95 -0
  32. package/dist/drizzle/0019_webhook_skill_runs.sql +58 -0
  33. package/dist/drizzle/0020_crazy_the_anarchist.sql +9 -0
  34. package/dist/drizzle/0020_mutation_idempotency.sql +64 -0
  35. package/dist/drizzle/0021_crm_work_links.sql +76 -0
  36. package/dist/drizzle/0021_skill_runs_completion_hmac.sql +33 -0
  37. package/dist/drizzle/0022_add_billing_tables.sql +70 -0
  38. package/dist/drizzle/0022_seed_thinkwork_admin_permissions.sql +160 -0
  39. package/dist/drizzle/0022_stripe_billing_indexes.sql +48 -0
  40. package/dist/drizzle/0023_tenants_deactivation.sql +27 -0
  41. package/dist/drizzle/0024_tenant_mcp_admin_keys.sql +63 -0
  42. package/dist/drizzle/0025_v1_agent_architecture.sql +182 -0
  43. package/dist/drizzle/0026_thread_turns_add_kind.sql +28 -0
  44. package/dist/drizzle/0027_capability_catalog_and_manifests.sql +219 -0
  45. package/dist/drizzle/0028_aws_s3_extension.sql +26 -0
  46. package/dist/drizzle/0029_collapse_execution_types.sql +42 -0
  47. package/dist/drizzle/0030_retire_composition_primitives.sql +48 -0
  48. package/dist/drizzle/0031_thread_cleanup_drops.sql +131 -0
  49. package/dist/drizzle/0032_add_browser_automation_template_config.sql +24 -0
  50. package/dist/drizzle/0033_agent_operation_leases.sql +32 -0
  51. package/dist/drizzle/0034_agent_workspace_events.sql +116 -0
  52. package/dist/drizzle/0035_workspace_review_decisions.sql +19 -0
  53. package/dist/drizzle/0036_user_scoped_memory_wiki.sql +152 -0
  54. package/dist/drizzle/0037_user_profile_operating_model.sql +34 -0
  55. package/dist/drizzle/0042_agent_runtime_selector.sql +41 -0
  56. package/dist/drizzle/0043_wiki_search_tsv_tokenization.sql +48 -0
  57. package/dist/drizzle/0044_add_web_search_template_config.sql +7 -0
  58. package/dist/drizzle/0045_add_send_email_template_config.sql +7 -0
  59. package/dist/drizzle/0046_tenant_mcp_context_tools.sql +26 -0
  60. package/dist/drizzle/0047_add_context_engine_template_config.sql +7 -0
  61. package/dist/drizzle/0048_tenant_context_provider_settings.sql +22 -0
  62. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +29 -0
  63. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +170 -0
  64. package/dist/drizzle/0052_brain_v0_external_refs.sql +41 -0
  65. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +48 -0
  66. package/dist/drizzle/0054_wiki_compile_jobs_input.sql +28 -0
  67. package/dist/drizzle/0055_routines_stepfunctions_substrate.sql +202 -0
  68. package/dist/drizzle/0056_routine_step_events_dedup_idx.sql +25 -0
  69. package/dist/drizzle/0057_archive_legacy_python_routines.sql +36 -0
  70. package/dist/drizzle/0058_routines_visibility_and_owning_agent.sql +61 -0
  71. package/dist/drizzle/0061_routine_execution_asl_version_id.sql +29 -0
  72. package/dist/drizzle/0062_migrate_pi_to_flue.sql +110 -0
  73. package/dist/drizzle/0063_threads_session_data.sql +23 -0
  74. package/dist/drizzle/0064_tenant_credentials.sql +65 -0
  75. package/dist/drizzle/0067_thinkwork_computers_phase_one.sql +174 -0
  76. package/dist/drizzle/0068_drop_system_workflows_and_activation.sql +115 -0
  77. package/dist/drizzle/0069_compliance_schema.sql +364 -0
  78. package/dist/drizzle/0070_compliance_aurora_roles.sql +171 -0
  79. package/dist/drizzle/0072_threads_computer_ownership.sql +24 -0
  80. package/dist/drizzle/0073_compliance_tenant_anchor_state.sql +105 -0
  81. package/dist/drizzle/0074_compliance_event_hash_index.sql +49 -0
  82. package/dist/drizzle/0075_scheduled_jobs_computer_ownership.sql +27 -0
  83. package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +69 -0
  84. package/dist/drizzle/0077_computers_primary_agent_id.sql +38 -0
  85. package/dist/drizzle/0078_tenant_customize_catalog.sql +46 -0
  86. package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +53 -0
  87. package/dist/drizzle/0081_routines_catalog_slug.sql +42 -0
  88. package/dist/drizzle/0082_messages_parts_jsonb.sql +52 -0
  89. package/dist/drizzle/0083_computer_runbooks.sql +103 -0
  90. package/dist/drizzle/0084_artifacts_favorited_at.sql +32 -0
  91. package/dist/drizzle/0085_seed_thinkwork_computer_default_template.sql +46 -0
  92. package/dist/drizzle/0086_update_default_template_models.sql +19 -0
  93. package/dist/drizzle/0087_retire_oss_connectors.sql +77 -0
  94. package/dist/drizzle/0088_compliance_event_types_finance_pilot.sql +37 -0
  95. package/dist/drizzle/0089_remove_maniflow_eval_seeds.sql +81 -0
  96. package/dist/drizzle/0089_wiki_schema_extraction.sql +315 -0
  97. package/dist/drizzle/0090_brain_schema_extraction.sql +274 -0
  98. package/dist/drizzle/0091_drop_wiki_brain_compat_views.sql +118 -0
  99. package/dist/drizzle/0092_finish_oss_connector_retirement.sql +97 -0
  100. package/dist/drizzle/0093_eval_runs_scheduled_job_id.sql +23 -0
  101. package/dist/drizzle/0094_slack_workspace_app.sql +140 -0
  102. package/dist/drizzle/0095_eval_runs_computer_id.sql +22 -0
  103. package/dist/drizzle/0096_true_redteam_eval_seed_cleanup.sql +67 -0
  104. package/dist/drizzle/0097_shared_computers.sql +191 -0
  105. package/dist/drizzle/0098_business_ontology.sql +587 -0
  106. package/dist/drizzle/0099_thread_idle_learning.sql +145 -0
  107. package/dist/drizzle/0100_ontology_gated_brain_dynamic_subtypes.sql +7 -0
  108. package/dist/drizzle/0101_ontology_gated_unresolved_mentions_dynamic_subtypes.sql +7 -0
  109. package/dist/drizzle/0102_wiki_brain_owner_repair.sql +87 -0
  110. package/dist/drizzle/0103_brain_section_source_trigger_repair.sql +75 -0
  111. package/dist/drizzle/0104_expand_seed_ontology_for_user_memory.sql +166 -0
  112. package/dist/drizzle/0105_spaces_domain.sql +349 -0
  113. package/dist/drizzle/0106_expand_seed_ontology_for_triples.sql +173 -0
  114. package/dist/drizzle/0106_space_threads_participants.sql +197 -0
  115. package/dist/drizzle/0107_linked_task_mirror.sql +245 -0
  116. package/dist/drizzle/0108_message_mentions.sql +95 -0
  117. package/dist/drizzle/0109_space_owned_threads.sql +217 -0
  118. package/dist/drizzle/0110_thread_participant_read_state.sql +21 -0
  119. package/dist/drizzle/0111_agents_tenant_name_unique.sql +38 -0
  120. package/dist/drizzle/0112_recast_spaces_as_contextual_workrooms.sql +138 -0
  121. package/dist/drizzle/0113_agents_own_runtime_fields.sql +48 -0
  122. package/dist/drizzle/0114_migrate_templates_to_agents_and_spaces.sql +361 -0
  123. package/dist/drizzle/0115_remove_computer_eval_template_dependencies.sql +69 -0
  124. package/dist/drizzle/0116_backfill_thread_participants_access.sql +126 -0
  125. package/dist/drizzle/0117_space_access_mode.sql +40 -0
  126. package/dist/drizzle/0118_normalize_tenant_member_principal_type.sql +41 -0
  127. package/dist/drizzle/0119_space_knowledge_bases.sql +126 -0
  128. package/dist/drizzle/0120_space_automations.sql +58 -0
  129. package/dist/drizzle/0121_drop_computer_runbooks.sql +26 -0
  130. package/dist/drizzle/0122_space_email_triggers.sql +24 -0
  131. package/dist/drizzle/0123_single_platform_agent_and_overrides.sql +58 -0
  132. package/dist/drizzle/0124_thread_turns_finalized_at.sql +17 -0
  133. package/dist/drizzle/0125_drop_space_agent_assignments.sql +42 -0
  134. package/dist/drizzle/0126_migrate_flue_to_pi.sql +42 -0
  135. package/dist/drizzle/0127_runtime_type_observability.sql +28 -0
  136. package/dist/drizzle/0128_drop_eval_test_cases_agent_id.sql +37 -0
  137. package/dist/drizzle/0129_drop_code_factory_tables.sql +43 -0
  138. package/dist/drizzle/0129_eval_results_system_prompt.sql +30 -0
  139. package/dist/drizzle/0130_drop_teams_and_team_id_columns.sql +54 -0
  140. package/dist/drizzle/0131_drop_skill_catalog_and_tenant_skills.sql +33 -0
  141. package/dist/drizzle/0132_drop_computer_tables.sql +31 -0
  142. package/dist/drizzle/0133_drop_seed_view_tombstones.sql +79 -0
  143. package/dist/drizzle/0133_thread_turns_system_prompt.sql +22 -0
  144. package/dist/drizzle/0134_drop_dead_agent_runtime_tables.sql +53 -0
  145. package/dist/drizzle/0135_native_checklist_linked_tasks.sql +48 -0
  146. package/dist/drizzle/0136_goal_ledger.sql +55 -0
  147. package/dist/drizzle/0136_space_email_trigger_status.sql +66 -0
  148. package/dist/drizzle/0137_thread_participant_pins.sql +25 -0
  149. package/dist/drizzle/0138_users_cognito_sub.sql +30 -0
  150. package/dist/drizzle/0139_mobile_turn_client_id.sql +23 -0
  151. package/dist/drizzle/0140_workspace_folder_names.sql +36 -0
  152. package/dist/drizzle/0141_eval_runs_desktop_pi_provenance.sql +31 -0
  153. package/dist/drizzle/0142_pi_only_agent_runtime.sql +61 -0
  154. package/dist/drizzle/0143_add_web_extract_builtin_config.sql +28 -0
  155. package/dist/drizzle/0144_skill_catalog_index.sql +40 -0
  156. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +512 -0
  157. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +413 -0
  158. package/dist/drizzle/0147_knowledge_graph_scope_trigger_qualification.sql +211 -0
  159. package/dist/drizzle/0148_user_cost_attribution.sql +171 -0
  160. package/dist/drizzle/0149_managed_mcp_servers.sql +64 -0
  161. package/dist/drizzle/0149_user_model_approvals.sql +89 -0
  162. package/dist/drizzle/0150_bootstrap_first_admin_claim.sql +11 -0
  163. package/dist/drizzle/0151_managed_deployments.sql +91 -0
  164. package/dist/drizzle/0152_agent_profiles.sql +81 -0
  165. package/dist/drizzle/0153_message_mentions_agent_profile.sql +9 -0
  166. package/dist/drizzle/0154_customer_deployment_sessions.sql +61 -0
  167. package/dist/drizzle/0155_tenant_model_catalog.sql +152 -0
  168. package/dist/drizzle/0156_bootstrap_credential_leases.sql +52 -0
  169. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +79 -0
  170. package/dist/drizzle/0158_pending_user_questions.sql +80 -0
  171. package/dist/drizzle/0158_wiki_tenant_scope.sql +38 -0
  172. package/dist/drizzle/0159_agent_profiles_space_local.sql +49 -0
  173. package/dist/drizzle/0159_application_plugins.sql +193 -0
  174. package/dist/drizzle/0159_eval_scoring_version.sql +46 -0
  175. package/dist/drizzle/0160_compliance_event_types_plugins.sql +37 -0
  176. package/dist/drizzle/0160_eval_datasets.sql +75 -0
  177. package/dist/drizzle/0160_eval_results_thread_turn.sql +35 -0
  178. package/dist/drizzle/0161_eval_run_pinning.sql +54 -0
  179. package/dist/drizzle/0161_tenant_mcp_servers_plugin_source.sql +62 -0
  180. package/dist/drizzle/0162_eval_result_override.sql +49 -0
  181. package/dist/drizzle/0163_eval_replay_tool_allowlist.sql +50 -0
  182. package/dist/drizzle/0164_eval_replay_tool_override_mode.sql +52 -0
  183. package/dist/drizzle/0165_plugin_premium_entitlements.sql +153 -0
  184. package/dist/drizzle/0166_company_brain_substrate_contract.sql +386 -0
  185. package/dist/drizzle/0167_company_brain_artifact_manifest_runtime.sql +85 -0
  186. package/dist/drizzle/0168_eval_skill_gate.sql +44 -0
  187. package/dist/drizzle/0169_release_update_jobs.sql +67 -0
  188. package/dist/drizzle/0170_email_channel_plugin.sql +291 -0
  189. package/dist/drizzle/0171_linked_task_external_providers.sql +30 -0
  190. package/dist/drizzle/0172_email_channel_sendgrid_provider.sql +8 -0
  191. package/dist/drizzle/0173_auth_provider_resources.sql +83 -0
  192. package/dist/drizzle/0174_workos_auth_bridges.sql +37 -0
  193. package/dist/drizzle/0175_workos_auth_sessions.sql +38 -0
  194. package/dist/drizzle/0176_n8n_agent_step_runs.sql +107 -0
  195. package/dist/drizzle/0177_workflow_control_plane.sql +281 -0
  196. package/dist/drizzle/0178_workflow_backfill_existing_routines.sql +525 -0
  197. package/dist/drizzle/0179_tenant_goal_budget_settings.sql +21 -0
  198. package/dist/drizzle/0180_skill_drafts.sql +89 -0
  199. package/dist/drizzle/0181_skill_draft_publish_status.sql +21 -0
  200. package/dist/drizzle/0182_agent_loops.sql +260 -0
  201. package/dist/drizzle/0183_okf_artifact_manifests.sql +52 -0
  202. package/dist/drizzle/0184_skill_catalog_trust_cache.sql +35 -0
  203. package/dist/drizzle/0185_agent_loop_space.sql +16 -0
  204. package/dist/drizzle/0186_tenant_mcp_runtime_metadata.sql +10 -0
  205. package/dist/drizzle/0187_native_work_items.sql +220 -0
  206. package/dist/drizzle/0188_company_etl_plugin_rename.sql +99 -0
  207. package/dist/drizzle/0189_trace_cost_substrate.sql +247 -0
  208. package/dist/drizzle/0190_billing_export_reconciliation.sql +101 -0
  209. package/dist/drizzle/0191_open_engine_work_item_queue.sql +66 -0
  210. package/dist/drizzle/0192_work_item_labels.sql +44 -0
  211. package/dist/drizzle/0193_work_item_documents.sql +31 -0
  212. package/dist/drizzle/0194_memory_retain_attempts.sql +103 -0
  213. package/dist/drizzle/0194_work_item_comments.sql +35 -0
  214. package/dist/drizzle/0195_plugin_app_overlays.sql +45 -0
  215. package/dist/drizzle/0196_pi_extensions.sql +118 -0
  216. package/dist/drizzle/0197_eval_profiles.sql +150 -0
  217. package/dist/drizzle/0198_eval_runs_unstable.sql +27 -0
  218. package/dist/drizzle/0199_eval_runs_cost_partial.sql +27 -0
  219. package/dist/drizzle/meta/_journal.json +160 -0
  220. package/dist/plugins/README.md +162 -0
  221. package/dist/plugins/catalog/package.json +40 -0
  222. package/dist/plugins/catalog/scripts/build-catalog.ts +120 -0
  223. package/dist/plugins/catalog/scripts/generate-plugin-registry.ts +144 -0
  224. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +86 -0
  225. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +308 -0
  226. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +861 -0
  227. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +124 -0
  228. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +120 -0
  229. package/dist/plugins/catalog/src/catalog.ts +434 -0
  230. package/dist/plugins/catalog/src/contracts.ts +1498 -0
  231. package/dist/plugins/catalog/src/index.ts +4 -0
  232. package/dist/plugins/catalog/src/plugin-package.ts +171 -0
  233. package/dist/plugins/catalog/src/registry/generated-first-party.ts +76 -0
  234. package/dist/plugins/catalog/src/registry/index.ts +1 -0
  235. package/dist/plugins/catalog/tsconfig.json +8 -0
  236. package/dist/plugins/company-brain/README.md +37 -0
  237. package/dist/plugins/company-brain/package.json +33 -0
  238. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +11 -0
  239. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +458 -0
  240. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +539 -0
  241. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +312 -0
  242. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +449 -0
  243. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +655 -0
  244. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +474 -0
  245. package/dist/plugins/company-brain/src/api/cognee-client.ts +896 -0
  246. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +49 -0
  247. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +84 -0
  248. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +888 -0
  249. package/dist/plugins/company-brain/src/api/migration.ts +665 -0
  250. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +396 -0
  251. package/dist/plugins/company-brain/src/index.ts +62 -0
  252. package/dist/plugins/company-brain/src/manifest.ts +112 -0
  253. package/dist/plugins/company-brain/terraform/cognee/README.md +263 -0
  254. package/dist/plugins/company-brain/terraform/cognee/main.tf +766 -0
  255. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +144 -0
  256. package/dist/plugins/company-brain/terraform/cognee/variables.tf +402 -0
  257. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +562 -0
  258. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +74 -0
  259. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +447 -0
  260. package/dist/plugins/company-brain/test/api/migration.test.ts +465 -0
  261. package/dist/plugins/company-brain/test/manifest.test.ts +95 -0
  262. package/dist/plugins/company-brain/tsconfig.json +8 -0
  263. package/dist/plugins/company-data/README.md +49 -0
  264. package/dist/plugins/company-data/package.json +21 -0
  265. package/dist/plugins/company-data/src/index.ts +30 -0
  266. package/dist/plugins/company-data/src/manifest.ts +26 -0
  267. package/dist/plugins/company-data/test/manifest.test.ts +116 -0
  268. package/dist/plugins/company-data/tsconfig.json +8 -0
  269. package/dist/plugins/company-etl/README.md +46 -0
  270. package/dist/plugins/company-etl/package.json +21 -0
  271. package/dist/plugins/company-etl/src/index.ts +29 -0
  272. package/dist/plugins/company-etl/src/manifest.ts +26 -0
  273. package/dist/plugins/company-etl/test/manifest.test.ts +108 -0
  274. package/dist/plugins/company-etl/tsconfig.json +8 -0
  275. package/dist/plugins/email-channel/README.md +41 -0
  276. package/dist/plugins/email-channel/package.json +22 -0
  277. package/dist/plugins/email-channel/src/index.ts +36 -0
  278. package/dist/plugins/email-channel/src/manifest.ts +44 -0
  279. package/dist/plugins/email-channel/src/provider-contract.ts +36 -0
  280. package/dist/plugins/email-channel/test/manifest.test.ts +119 -0
  281. package/dist/plugins/email-channel/tsconfig.json +8 -0
  282. package/dist/plugins/lastmile/README.md +50 -0
  283. package/dist/plugins/lastmile/package.json +23 -0
  284. package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +661 -0
  285. package/dist/plugins/lastmile/src/api/tasks-adapter.ts +572 -0
  286. package/dist/plugins/lastmile/src/discovery.fixture.ts +64 -0
  287. package/dist/plugins/lastmile/src/index.ts +46 -0
  288. package/dist/plugins/lastmile/src/manifest.ts +139 -0
  289. package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +266 -0
  290. package/dist/plugins/lastmile/test/discovery.test.ts +88 -0
  291. package/dist/plugins/lastmile/tsconfig.json +8 -0
  292. package/dist/plugins/n8n/README.md +201 -0
  293. package/dist/plugins/n8n/n8n-app/README.md +33 -0
  294. package/dist/plugins/n8n/n8n-app/package.json +23 -0
  295. package/dist/plugins/n8n/n8n-app/src/application-config.ts +45 -0
  296. package/dist/plugins/n8n/n8n-app/src/front-components/thinkwork-workflows.front-component.tsx +970 -0
  297. package/dist/plugins/n8n/n8n-app/src/lib/n8n-app-data.ts +190 -0
  298. package/dist/plugins/n8n/n8n-app/src/vite-env.d.ts +7 -0
  299. package/dist/plugins/n8n/n8n-app/tsconfig.json +15 -0
  300. package/dist/plugins/n8n/package.json +35 -0
  301. package/dist/plugins/n8n/runtime/Dockerfile +18 -0
  302. package/dist/plugins/n8n/runtime/n8n-task-runners.json +14 -0
  303. package/dist/plugins/n8n/runtime/n8n-task-runners.json.template +14 -0
  304. package/dist/plugins/n8n/runtime/package-lock.json +13 -0
  305. package/dist/plugins/n8n/runtime/package.json +7 -0
  306. package/dist/plugins/n8n/scripts/sync-thinkwork-app.mjs +268 -0
  307. package/dist/plugins/n8n/smoke/README.md +95 -0
  308. package/dist/plugins/n8n/smoke/n8n-agent-step-bridge-smoke.mjs +866 -0
  309. package/dist/plugins/n8n/smoke/n8n-integrated-app-smoke.mjs +262 -0
  310. package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +409 -0
  311. package/dist/plugins/n8n/smoke/n8n-mcp-smoke.mjs +624 -0
  312. package/dist/plugins/n8n/src/deployment/image-build.ts +172 -0
  313. package/dist/plugins/n8n/src/deployment/managed-app.ts +429 -0
  314. package/dist/plugins/n8n/src/index.ts +79 -0
  315. package/dist/plugins/n8n/src/manifest.ts +403 -0
  316. package/dist/plugins/n8n/src/package-config.ts +186 -0
  317. package/dist/plugins/n8n/src/skills/n8n-workflow-operator/SKILL.md +76 -0
  318. package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
  319. package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
  320. package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/workflow-authoring.md +45 -0
  321. package/dist/plugins/n8n/terraform/n8n/README.md +113 -0
  322. package/dist/plugins/n8n/terraform/n8n/main.tf +918 -0
  323. package/dist/plugins/n8n/terraform/n8n/outputs.tf +124 -0
  324. package/dist/plugins/n8n/terraform/n8n/scripts/sync-database.py +250 -0
  325. package/dist/plugins/n8n/terraform/n8n/scripts/test_sync_database.py +55 -0
  326. package/dist/plugins/n8n/terraform/n8n/variables.tf +354 -0
  327. package/dist/plugins/n8n/test/image-build.test.ts +118 -0
  328. package/dist/plugins/n8n/test/integrated-app-contract.test.ts +353 -0
  329. package/dist/plugins/n8n/test/manifest.test.ts +278 -0
  330. package/dist/plugins/n8n/test/package-config.test.ts +57 -0
  331. package/dist/plugins/n8n/tsconfig.json +8 -0
  332. package/dist/plugins/sendgrid/README.md +30 -0
  333. package/dist/plugins/sendgrid/package.json +22 -0
  334. package/dist/plugins/sendgrid/src/index.ts +36 -0
  335. package/dist/plugins/sendgrid/src/manifest.ts +37 -0
  336. package/dist/plugins/sendgrid/src/provider-contract.ts +14 -0
  337. package/dist/plugins/sendgrid/test/manifest.test.ts +80 -0
  338. package/dist/plugins/sendgrid/tsconfig.json +8 -0
  339. package/dist/plugins/twenty/README.md +78 -0
  340. package/dist/plugins/twenty/package.json +27 -0
  341. package/dist/plugins/twenty/scripts/__tests__/sync-thinkwork-app.test.mjs +67 -0
  342. package/dist/plugins/twenty/scripts/sync-thinkwork-app.mjs +185 -0
  343. package/dist/plugins/twenty/scripts/wire-thinkwork-workflow.mjs +658 -0
  344. package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +359 -0
  345. package/dist/plugins/twenty/smoke/twenty-mcp-oauth-smoke.mjs +574 -0
  346. package/dist/plugins/twenty/src/api/cutover.ts +209 -0
  347. package/dist/plugins/twenty/src/deployment/managed-app.ts +253 -0
  348. package/dist/plugins/twenty/src/index.ts +55 -0
  349. package/dist/plugins/twenty/src/manifest.ts +259 -0
  350. package/dist/plugins/twenty/terraform/twenty/README.md +97 -0
  351. package/dist/plugins/twenty/terraform/twenty/main.tf +829 -0
  352. package/dist/plugins/twenty/terraform/twenty/outputs.tf +79 -0
  353. package/dist/plugins/twenty/terraform/twenty/variables.tf +268 -0
  354. package/dist/plugins/twenty/test/api/cutover.test.ts +183 -0
  355. package/dist/plugins/twenty/test/manifest.test.ts +463 -0
  356. package/dist/plugins/twenty/tsconfig.json +9 -0
  357. package/dist/plugins/twenty/twenty-app/.nvmrc +1 -0
  358. package/dist/plugins/twenty/twenty-app/README.md +148 -0
  359. package/dist/plugins/twenty/twenty-app/package.json +30 -0
  360. package/dist/plugins/twenty/twenty-app/src/application-config.ts +44 -0
  361. package/dist/plugins/twenty/twenty-app/src/constants/universal-identifiers.ts +18 -0
  362. package/dist/plugins/twenty/twenty-app/src/default-role.ts +24 -0
  363. package/dist/plugins/twenty/twenty-app/src/front-components/thinkwork-settings.front-component.tsx +299 -0
  364. package/dist/plugins/twenty/twenty-app/src/logic-functions/thinkwork-webhook.logic-function.ts +354 -0
  365. package/dist/plugins/twenty/twenty-app/tsconfig.json +14 -0
  366. package/dist/plugins/twenty/twenty-app/yarn.lock +4144 -0
  367. package/dist/plugins/workos-auth/package.json +22 -0
  368. package/dist/plugins/workos-auth/src/index.ts +30 -0
  369. package/dist/plugins/workos-auth/src/manifest.ts +40 -0
  370. package/dist/plugins/workos-auth/src/provider-contract.ts +39 -0
  371. package/dist/plugins/workos-auth/test/manifest.test.ts +94 -0
  372. package/dist/plugins/workos-auth/tsconfig.json +8 -0
  373. package/dist/scripts/bootstrap-workspace.sh +162 -0
  374. package/dist/scripts/post-deploy.sh +290 -0
  375. package/dist/terraform/examples/greenfield/main.tf +903 -75
  376. package/dist/terraform/examples/greenfield/terraform.tfvars.example +67 -8
  377. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +2 -2
  378. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +8 -9
  379. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +227 -0
  380. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +277 -0
  381. package/dist/terraform/modules/app/agentcore-memory/README.md +6 -6
  382. package/dist/terraform/modules/app/agentcore-memory/main.tf +1 -1
  383. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +40 -9
  384. package/dist/terraform/modules/app/agentcore-pi/README.md +61 -0
  385. package/dist/terraform/modules/app/agentcore-pi/main.tf +453 -0
  386. package/dist/terraform/modules/app/agentcore-pi/outputs.tf +38 -0
  387. package/dist/terraform/modules/app/agentcore-pi/variables.tf +145 -0
  388. package/dist/terraform/modules/app/agentcore-platform/main.tf +83 -0
  389. package/dist/terraform/modules/app/agentcore-runtime/main.tf +9 -11
  390. package/dist/terraform/modules/app/appsync-subscriptions/main.tf +5 -1
  391. package/dist/terraform/modules/app/customer-domain/main.tf +483 -0
  392. package/dist/terraform/modules/app/deployment-control-plane/README.md +47 -0
  393. package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +24 -0
  394. package/dist/terraform/modules/app/deployment-control-plane/main.tf +614 -0
  395. package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +54 -0
  396. package/dist/terraform/modules/app/deployment-control-plane/runner.py +4720 -0
  397. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +3026 -0
  398. package/dist/terraform/modules/app/deployment-control-plane/variables.tf +96 -0
  399. package/dist/terraform/modules/app/hindsight-memory/README.md +25 -23
  400. package/dist/terraform/modules/app/hindsight-memory/main.tf +26 -2
  401. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +17 -50
  402. package/dist/terraform/modules/app/lambda-api/handlers.tf +1290 -679
  403. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +933 -0
  404. package/dist/terraform/modules/app/lambda-api/main.tf +14 -527
  405. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +3 -19
  406. package/dist/terraform/modules/app/lambda-api/outputs.tf +50 -6
  407. package/dist/terraform/modules/app/lambda-api/runtime-config.tf +163 -0
  408. package/dist/terraform/modules/app/lambda-api/slack-app-secrets.tf +3 -1
  409. package/dist/terraform/modules/app/lambda-api/stripe-secrets.tf +3 -1
  410. package/dist/terraform/modules/app/lambda-api/variables.tf +240 -53
  411. package/dist/terraform/modules/app/lambda-api/workspace-events.tf +2 -25
  412. package/dist/terraform/modules/app/ses-email/main.tf +143 -30
  413. package/dist/terraform/modules/app/static-site/main.tf +4 -3
  414. package/dist/terraform/modules/app/www-dns/main.tf +45 -5
  415. package/dist/terraform/modules/app/www-dns/outputs.tf +10 -0
  416. package/dist/terraform/modules/app/www-dns/variables.tf +24 -0
  417. package/dist/terraform/modules/data/aurora-postgres/main.tf +15 -1
  418. package/dist/terraform/modules/data/compliance-exports-bucket/main.tf +1 -1
  419. package/dist/terraform/modules/data/compliance-exports-bucket/variables.tf +6 -0
  420. package/dist/terraform/modules/foundation/cognito/main.tf +167 -11
  421. package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
  422. package/dist/terraform/modules/foundation/cognito/variables.tf +110 -0
  423. package/dist/terraform/modules/foundation/vpc/main.tf +45 -3
  424. package/dist/terraform/modules/foundation/vpc/outputs.tf +20 -0
  425. package/dist/terraform/modules/foundation/vpc/variables.tf +18 -0
  426. package/dist/terraform/modules/thinkwork/README.md +71 -0
  427. package/dist/terraform/modules/thinkwork/main.tf +1325 -174
  428. package/dist/terraform/modules/thinkwork/outputs.tf +457 -24
  429. package/dist/terraform/modules/thinkwork/variables.tf +1169 -93
  430. package/dist/terraform/schema.graphql +82 -9
  431. package/dist/workspace-defaults/files/AGENTS.md +344 -0
  432. package/dist/workspace-defaults/files/CONTEXT.md +8 -0
  433. package/dist/workspace-defaults/files/GUARDRAILS.md +51 -0
  434. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +65 -0
  435. package/dist/workspace-defaults/files/ROUTER.md +24 -0
  436. package/dist/workspace-defaults/files/SPACE.md +62 -0
  437. package/dist/workspace-defaults/files/TOOLS.md +65 -0
  438. package/dist/workspace-defaults/files/USER.md +40 -0
  439. package/dist/workspace-defaults/files/mcp.json +3 -0
  440. package/dist/workspace-defaults/files/memory/contacts.md +13 -0
  441. package/dist/workspace-defaults/files/memory/lessons.md +13 -0
  442. package/dist/workspace-defaults/files/memory/preferences.md +12 -0
  443. package/dist/workspace-defaults/files/skills/.gitkeep +1 -0
  444. package/dist/workspace-defaults/files/skills/artifact-builder/SKILL.md +79 -0
  445. package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +166 -0
  446. package/dist/workspace-defaults/files/skills/automation-loop-designer/SKILL.md +120 -0
  447. package/dist/workspace-defaults/files/skills/skill-creator/LICENSE.txt +202 -0
  448. package/dist/workspace-defaults/files/skills/skill-creator/SKILL.md +485 -0
  449. package/dist/workspace-defaults/files/skills/skill-creator/UPSTREAM.json +124 -0
  450. package/dist/workspace-defaults/files/skills/skill-creator/agents/analyzer.md +274 -0
  451. package/dist/workspace-defaults/files/skills/skill-creator/agents/comparator.md +202 -0
  452. package/dist/workspace-defaults/files/skills/skill-creator/agents/grader.md +223 -0
  453. package/dist/workspace-defaults/files/skills/skill-creator/assets/eval_review.html +146 -0
  454. package/dist/workspace-defaults/files/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  455. package/dist/workspace-defaults/files/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  456. package/dist/workspace-defaults/files/skills/skill-creator/references/schemas.md +430 -0
  457. package/dist/workspace-defaults/files/skills/skill-creator/scripts/__init__.py +0 -0
  458. package/dist/workspace-defaults/files/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  459. package/dist/workspace-defaults/files/skills/skill-creator/scripts/generate_report.py +326 -0
  460. package/dist/workspace-defaults/files/skills/skill-creator/scripts/improve_description.py +247 -0
  461. package/dist/workspace-defaults/files/skills/skill-creator/scripts/package_skill.py +136 -0
  462. package/dist/workspace-defaults/files/skills/skill-creator/scripts/quick_validate.py +103 -0
  463. package/dist/workspace-defaults/files/skills/skill-creator/scripts/run_eval.py +310 -0
  464. package/dist/workspace-defaults/files/skills/skill-creator/scripts/run_loop.py +328 -0
  465. package/dist/workspace-defaults/files/skills/skill-creator/scripts/utils.py +47 -0
  466. package/package.json +1 -1
  467. package/dist/terraform/modules/app/agentcore-flue/README.md +0 -58
  468. package/dist/terraform/modules/app/agentcore-flue/main.tf +0 -323
  469. package/dist/terraform/modules/app/agentcore-flue/outputs.tf +0 -23
  470. package/dist/terraform/modules/app/agentcore-flue/variables.tf +0 -97
  471. package/dist/terraform/modules/app/computer-runtime/README.md +0 -15
  472. package/dist/terraform/modules/app/computer-runtime/main.tf +0 -406
  473. package/dist/terraform/modules/app/computer-runtime/outputs.tf +0 -75
  474. package/dist/terraform/modules/app/computer-runtime/variables.tf +0 -66
@@ -0,0 +1,4720 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import platform
6
+ import re
7
+ import secrets
8
+ import subprocess
9
+ import tarfile
10
+ import urllib.parse
11
+ import urllib.request
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from tempfile import TemporaryDirectory
15
+
16
+ WORK = Path("/tmp/thinkwork-platform-deploy")
17
+ RELEASE = WORK / "release"
18
+ SOURCE = WORK / "source"
19
+ TF = WORK / "terraform"
20
+ MANIFEST = RELEASE / "thinkwork-release.json"
21
+ CLOUDFLARE_PROVIDER_VERSION = "4.52.7"
22
+ CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256 = (
23
+ "904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13"
24
+ )
25
+ CLOUDFLARE_PROVIDER_LINUX_AMD64_URL = (
26
+ "https://github.com/cloudflare/terraform-provider-cloudflare/releases/download/"
27
+ f"v{CLOUDFLARE_PROVIDER_VERSION}/"
28
+ f"terraform-provider-cloudflare_{CLOUDFLARE_PROVIDER_VERSION}_linux_amd64.zip"
29
+ )
30
+ STARTED_AT = datetime.now(UTC).isoformat()
31
+ RELEASE_EVIDENCE = {}
32
+ CONTROLLER_EVIDENCE = {}
33
+ TERRAFORM_EVIDENCE = {}
34
+ FIRST_ADMIN_EVIDENCE = {}
35
+ MANAGED_APP_EVIDENCE = {}
36
+ RELEASE_MANIFEST_TRUST_POLICIES = {
37
+ "allow_unsigned_canary",
38
+ "require_signature",
39
+ }
40
+ # Migrations that intentionally re-run after seed data lands (idempotent;
41
+ # they backfill from seeded rows). Everything else is ledger-driven.
42
+ POST_SEED_MIGRATIONS = [
43
+ "0155_tenant_model_catalog.sql",
44
+ ]
45
+ MIGRATION_MARKER_KINDS = [
46
+ ("-- creates-column:", "column"),
47
+ ("-- creates-constraint:", "constraint"),
48
+ ("-- creates:", "object"),
49
+ ]
50
+
51
+
52
+ def run(args, **kwargs):
53
+ return subprocess.run(args, check=True, text=True, **kwargs)
54
+
55
+
56
+ def output(args, **kwargs):
57
+ return subprocess.check_output(args, text=True, **kwargs).strip()
58
+
59
+
60
+ def sha256_file(path):
61
+ digest = hashlib.sha256()
62
+ with open(path, "rb") as handle:
63
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
64
+ digest.update(chunk)
65
+ return digest.hexdigest()
66
+
67
+
68
+ def read_json_env(name, default):
69
+ value = os.environ.get(name)
70
+ if not value:
71
+ return default
72
+ return json.loads(value)
73
+
74
+
75
+ def download(url, destination):
76
+ destination.parent.mkdir(parents=True, exist_ok=True)
77
+ with urllib.request.urlopen(url, timeout=120) as response:
78
+ destination.write_bytes(response.read())
79
+
80
+
81
+ def configure_terraform_provider_mirror():
82
+ system = platform.system().lower()
83
+ machine = platform.machine().lower()
84
+ if system != "linux" or machine not in {"x86_64", "amd64"}:
85
+ return
86
+
87
+ mirror = WORK / "provider-mirror"
88
+ package = (
89
+ mirror
90
+ / "registry.terraform.io"
91
+ / "cloudflare"
92
+ / "cloudflare"
93
+ / f"terraform-provider-cloudflare_{CLOUDFLARE_PROVIDER_VERSION}_linux_amd64.zip"
94
+ )
95
+ if not package.exists() or sha256_file(package) != CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256:
96
+ download(CLOUDFLARE_PROVIDER_LINUX_AMD64_URL, package)
97
+
98
+ digest = sha256_file(package)
99
+ if digest != CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256:
100
+ raise RuntimeError(
101
+ "Cloudflare provider mirror digest mismatch: "
102
+ f"expected {CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256}, got {digest}"
103
+ )
104
+
105
+ terraformrc = WORK / "terraformrc"
106
+ terraformrc.write_text(
107
+ f"""
108
+ provider_installation {{
109
+ filesystem_mirror {{
110
+ path = "{mirror}"
111
+ include = ["registry.terraform.io/cloudflare/cloudflare"]
112
+ }}
113
+ direct {{
114
+ exclude = ["registry.terraform.io/cloudflare/cloudflare"]
115
+ }}
116
+ }}
117
+ """.lstrip(),
118
+ encoding="utf-8",
119
+ )
120
+ os.environ["TF_CLI_CONFIG_FILE"] = str(terraformrc)
121
+
122
+
123
+ def patch_downloaded_customer_domain_module():
124
+ module_path = (
125
+ TF
126
+ / ".terraform"
127
+ / "modules"
128
+ / "thinkwork"
129
+ / "terraform"
130
+ / "modules"
131
+ / "app"
132
+ / "customer-domain"
133
+ / "main.tf"
134
+ )
135
+ if not module_path.exists():
136
+ return
137
+ text = module_path.read_text()
138
+ if 'resource "aws_route53_record" "app_alias_a"' not in text:
139
+ return
140
+
141
+ def ensure_allow_overwrite(resource_name, record_type, source):
142
+ marker = f'resource "aws_route53_record" "{resource_name}" {{'
143
+ start = source.find(marker)
144
+ if start == -1:
145
+ return source
146
+ next_resource = source.find('\nresource "', start + len(marker))
147
+ end = next_resource if next_resource != -1 else len(source)
148
+ block = source[start:end]
149
+ if "allow_overwrite" in block:
150
+ return source
151
+ insert_after = block.find(f'type = "{record_type}"')
152
+ if insert_after == -1:
153
+ return source
154
+ line_end = block.find("\n", insert_after)
155
+ if line_end == -1:
156
+ return source
157
+ patched_block = block[: line_end + 1] + " allow_overwrite = true\n" + block[line_end + 1 :]
158
+ return source[:start] + patched_block + source[end:]
159
+
160
+ patched = ensure_allow_overwrite("app_alias_a", "A", text)
161
+ patched = ensure_allow_overwrite("app_alias_aaaa", "AAAA", patched)
162
+ if patched != text:
163
+ module_path.write_text(patched)
164
+
165
+
166
+ def stable_json_bytes(value):
167
+ return json.dumps(
168
+ value,
169
+ sort_keys=True,
170
+ separators=(",", ":"),
171
+ ensure_ascii=False,
172
+ ).encode("utf-8")
173
+
174
+
175
+ def release_manifest_sha256(manifest):
176
+ return hashlib.sha256(stable_json_bytes(manifest)).hexdigest()
177
+
178
+
179
+ def read_release_manifest(path=MANIFEST):
180
+ return json.loads(path.read_text(encoding="utf-8"))
181
+
182
+
183
+ def verify_release_manifest_digest(path, expected_sha256):
184
+ actual = sha256_file(path)
185
+ expected = (expected_sha256 or "").lower()
186
+ if expected and actual != expected:
187
+ raise RuntimeError(f"Release manifest digest mismatch: expected {expected}, got {actual}")
188
+ manifest = read_release_manifest(path)
189
+ return manifest
190
+
191
+
192
+ def release_manifest_trust_policy():
193
+ policy = os.environ.get(
194
+ "THINKWORK_RELEASE_MANIFEST_TRUST_POLICY",
195
+ "allow_unsigned_canary",
196
+ ).strip()
197
+ if not policy:
198
+ policy = "allow_unsigned_canary"
199
+ if policy not in RELEASE_MANIFEST_TRUST_POLICIES:
200
+ raise RuntimeError(
201
+ "Unsupported release manifest trust policy "
202
+ f"{policy!r}; expected one of {sorted(RELEASE_MANIFEST_TRUST_POLICIES)}"
203
+ )
204
+ return policy
205
+
206
+
207
+ def is_canary_release(manifest):
208
+ version = str(
209
+ manifest.get("release", {}).get("version")
210
+ or os.environ.get("THINKWORK_RELEASE_VERSION")
211
+ or ""
212
+ )
213
+ return "-canary" in version
214
+
215
+
216
+ def default_signature_url(manifest_url):
217
+ if manifest_url.endswith("/thinkwork-release.json"):
218
+ return manifest_url[: -len("thinkwork-release.json")] + "thinkwork-release.sig.json"
219
+ if manifest_url.endswith("thinkwork-release.json"):
220
+ return manifest_url[: -len("thinkwork-release.json")] + "thinkwork-release.sig.json"
221
+ return ""
222
+
223
+
224
+ def trusted_release_keys():
225
+ raw = os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON", "[]")
226
+ try:
227
+ keys = json.loads(raw)
228
+ except json.JSONDecodeError as exc:
229
+ raise RuntimeError("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON must be JSON") from exc
230
+ if not isinstance(keys, list):
231
+ raise RuntimeError("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON must be a JSON array")
232
+ return keys
233
+
234
+
235
+ def require_string(value, path):
236
+ if not isinstance(value, str) or not value:
237
+ raise RuntimeError(f"{path} is required")
238
+ return value
239
+
240
+
241
+ def assert_time_window(now, not_before, expires_at, label):
242
+ start = datetime.fromisoformat(
243
+ require_string(not_before, f"{label}.notBefore").replace("Z", "+00:00")
244
+ )
245
+ end = datetime.fromisoformat(
246
+ require_string(expires_at, f"{label}.expiresAt").replace("Z", "+00:00")
247
+ )
248
+ if now < start:
249
+ raise RuntimeError(f"{label} is not valid before {not_before}")
250
+ if now > end:
251
+ raise RuntimeError(f"{label} expired at {expires_at}")
252
+
253
+
254
+ def verify_signature_bytes(public_key_pem, signed_bytes, signature_bytes):
255
+ with TemporaryDirectory() as temp_dir:
256
+ temp = Path(temp_dir)
257
+ key_path = temp / "trusted-release-key.pem"
258
+ payload_path = temp / "manifest.canonical.json"
259
+ signature_path = temp / "thinkwork-release.sig"
260
+ key_path.write_text(public_key_pem, encoding="utf-8")
261
+ payload_path.write_bytes(signed_bytes)
262
+ signature_path.write_bytes(signature_bytes)
263
+ result = subprocess.run(
264
+ [
265
+ "openssl",
266
+ "pkeyutl",
267
+ "-verify",
268
+ "-rawin",
269
+ "-pubin",
270
+ "-inkey",
271
+ str(key_path),
272
+ "-sigfile",
273
+ str(signature_path),
274
+ "-in",
275
+ str(payload_path),
276
+ ],
277
+ capture_output=True,
278
+ text=True,
279
+ )
280
+ if result.returncode != 0:
281
+ raise RuntimeError(f"Release manifest signature is invalid: {result.stderr.strip()}")
282
+
283
+
284
+ def verify_release_manifest_signature(manifest, manifest_sha256, signature_url):
285
+ signature_path = RELEASE / "thinkwork-release.sig.json"
286
+ download(signature_url, signature_path)
287
+ signature = json.loads(signature_path.read_text(encoding="utf-8"))
288
+ if signature.get("schemaVersion") != 1:
289
+ raise RuntimeError("Release manifest signature schemaVersion must be 1")
290
+ if signature.get("algorithm") != "ed25519":
291
+ raise RuntimeError("Release manifest signature algorithm must be ed25519")
292
+ key_id = require_string(signature.get("keyId"), "signature.keyId")
293
+ if signature.get("manifestSha256") != manifest_sha256:
294
+ raise RuntimeError(
295
+ "Release manifest signature digest mismatch: "
296
+ f"expected {signature.get('manifestSha256')}, got {manifest_sha256}"
297
+ )
298
+ signing = manifest.get("signing") or {}
299
+ accepted_key_ids = signing.get("acceptedKeyIds") or []
300
+ revoked_key_ids = set(signing.get("revokedKeyIds") or [])
301
+ if key_id in revoked_key_ids:
302
+ raise RuntimeError(f"Release manifest signing key is revoked: {key_id}")
303
+ if key_id not in accepted_key_ids:
304
+ raise RuntimeError(f"Release manifest does not accept signing key: {key_id}")
305
+ now = datetime.now(UTC)
306
+ assert_time_window(now, signature.get("notBefore"), signature.get("expiresAt"), "signature")
307
+ trusted_key = next((key for key in trusted_release_keys() if key.get("keyId") == key_id), None)
308
+ if not trusted_key:
309
+ raise RuntimeError(f"Release manifest signing key is not trusted: {key_id}")
310
+ if trusted_key.get("notBefore") or trusted_key.get("expiresAt"):
311
+ assert_time_window(
312
+ now,
313
+ trusted_key.get("notBefore", "1970-01-01T00:00:00.000Z"),
314
+ trusted_key.get("expiresAt", "9999-12-31T23:59:59.999Z"),
315
+ f"trusted key {key_id}",
316
+ )
317
+ verify_signature_bytes(
318
+ require_string(trusted_key.get("publicKeyPem"), f"trusted key {key_id}.publicKeyPem"),
319
+ stable_json_bytes(manifest),
320
+ base64.b64decode(require_string(signature.get("signature"), "signature.signature")),
321
+ )
322
+ return {
323
+ "signatureVerified": True,
324
+ "keyId": key_id,
325
+ "signatureUrl": signature_url,
326
+ }
327
+
328
+
329
+ def enforce_release_manifest_trust(manifest, manifest_digest, manifest_url):
330
+ policy = release_manifest_trust_policy()
331
+ configured_signature_url = os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL", "")
332
+ signature_url = configured_signature_url or default_signature_url(manifest_url)
333
+ evidence = {
334
+ "policy": policy,
335
+ "signatureRequired": policy == "require_signature",
336
+ "signatureVerified": False,
337
+ "unsignedCanaryAllowed": False,
338
+ }
339
+ if policy == "require_signature":
340
+ if not signature_url:
341
+ raise RuntimeError("Release manifest signature URL is required by trust policy")
342
+ evidence.update(verify_release_manifest_signature(manifest, manifest_digest, signature_url))
343
+ return evidence
344
+
345
+ if configured_signature_url:
346
+ evidence.update(
347
+ verify_release_manifest_signature(manifest, manifest_digest, configured_signature_url)
348
+ )
349
+ return evidence
350
+
351
+ if not is_canary_release(manifest):
352
+ raise RuntimeError(
353
+ "Unsigned release manifest is only allowed for canary releases; "
354
+ "set THINKWORK_RELEASE_MANIFEST_TRUST_POLICY=require_signature for customer-safe runs"
355
+ )
356
+ evidence["unsignedCanaryAllowed"] = True
357
+ return evidence
358
+
359
+
360
+ def safe_join(base, relative_path):
361
+ relative = Path(relative_path)
362
+ if relative.is_absolute():
363
+ raise RuntimeError(f"Archive member path must be relative: {relative_path}")
364
+ resolved_base = base.resolve()
365
+ resolved = (base / relative).resolve()
366
+ if resolved != resolved_base and resolved_base not in resolved.parents:
367
+ raise RuntimeError(f"Archive member escapes destination: {relative_path}")
368
+ return resolved
369
+
370
+
371
+ def safe_extract_tar_file(archive_path, destination):
372
+ destination.mkdir(parents=True, exist_ok=True)
373
+ with tarfile.open(archive_path, "r:*") as tar:
374
+ members = tar.getmembers()
375
+ for member in members:
376
+ if member.issym() or member.islnk():
377
+ raise RuntimeError(f"Archive member links are not allowed: {member.name}")
378
+ if not (member.isfile() or member.isdir()):
379
+ raise RuntimeError(f"Archive member type is not allowed: {member.name}")
380
+ safe_join(destination, member.name)
381
+ tar.extractall(destination, members=members)
382
+
383
+
384
+ def release_artifacts_by_name(manifest):
385
+ return {
386
+ artifact.get("name"): artifact
387
+ for artifact in manifest.get("artifacts", [])
388
+ if isinstance(artifact.get("name"), str)
389
+ }
390
+
391
+
392
+ def artifact_bundle_url(bundle):
393
+ url = bundle.get("url")
394
+ if not url:
395
+ raise RuntimeError(f"Release artifact bundle {bundle.get('name')} is missing url")
396
+ return url
397
+
398
+
399
+ def bundle_extract_dir(bundle):
400
+ name = str(bundle.get("name") or "platform")
401
+ safe_name = "".join(ch if ch.isalnum() or ch in "._=-" else "_" for ch in name)
402
+ return RELEASE / "bundles" / safe_name
403
+
404
+
405
+ def download_and_extract_artifact_bundles(manifest):
406
+ artifacts = release_artifacts_by_name(manifest)
407
+ bundled_paths = {}
408
+ bundle_evidence = []
409
+
410
+ for bundle in manifest.get("artifactBundles", []) or []:
411
+ bundle_name = bundle.get("name")
412
+ bundle_path = safe_join(
413
+ RELEASE,
414
+ str(bundle.get("relativePath") or bundle.get("fileName")),
415
+ )
416
+ download(artifact_bundle_url(bundle), bundle_path)
417
+ digest = sha256_file(bundle_path)
418
+ if digest != bundle.get("sha256"):
419
+ raise RuntimeError(f"Artifact bundle digest mismatch for {bundle_name}")
420
+
421
+ extract_dir = bundle_extract_dir(bundle)
422
+ safe_extract_tar_file(bundle_path, extract_dir)
423
+
424
+ contained = []
425
+ for artifact_name in bundle.get("contains", []):
426
+ artifact = artifacts.get(artifact_name)
427
+ if not artifact:
428
+ raise RuntimeError(
429
+ f"Release artifact bundle {bundle_name} references unknown artifact {artifact_name}"
430
+ )
431
+ artifact_path = safe_join(extract_dir, artifact["relativePath"])
432
+ if not artifact_path.is_file():
433
+ raise RuntimeError(
434
+ f"Release artifact {artifact_name} is missing from bundle {bundle_name}"
435
+ )
436
+ bundled_paths[artifact_name] = artifact_path
437
+ contained.append(artifact_name)
438
+
439
+ bundle_evidence.append(
440
+ {
441
+ "name": bundle_name,
442
+ "fileName": bundle.get("fileName"),
443
+ "sha256": digest,
444
+ "contains": contained,
445
+ }
446
+ )
447
+
448
+ return bundled_paths, bundle_evidence
449
+
450
+
451
+ def materialize_release_artifact(artifact, bundled_paths):
452
+ destination = safe_join(RELEASE, artifact["relativePath"])
453
+ url = artifact.get("url")
454
+ if url:
455
+ download(url, destination)
456
+ source = "url"
457
+ else:
458
+ bundled_path = bundled_paths.get(artifact.get("name"))
459
+ if not bundled_path:
460
+ raise RuntimeError(
461
+ f"Release artifact {artifact.get('name')} is missing url and is not available in an artifact bundle"
462
+ )
463
+ destination.parent.mkdir(parents=True, exist_ok=True)
464
+ if destination.resolve() != bundled_path.resolve():
465
+ destination.write_bytes(bundled_path.read_bytes())
466
+ source = "bundle"
467
+
468
+ digest = sha256_file(destination)
469
+ if digest != artifact.get("sha256"):
470
+ raise RuntimeError(f"Artifact digest mismatch for {artifact.get('name')}")
471
+ return destination, digest, source
472
+
473
+
474
+ def evidence_s3_uri(name):
475
+ prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
476
+ bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
477
+ if not prefix or not bucket:
478
+ return ""
479
+ return f"s3://{bucket}/{prefix}/{name}"
480
+
481
+
482
+ def upload_evidence_artifact(path, name=None):
483
+ artifact_name = name or Path(path).name
484
+ uri = evidence_s3_uri(artifact_name)
485
+ if uri:
486
+ run(["aws", "s3", "cp", str(path), uri])
487
+ return uri
488
+
489
+
490
+ def write_json_evidence_artifact(name, payload):
491
+ path = Path(name)
492
+ path.write_text(
493
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
494
+ encoding="utf-8",
495
+ )
496
+ return {
497
+ "fileName": name,
498
+ "sha256": sha256_file(path),
499
+ "s3Uri": upload_evidence_artifact(path, name),
500
+ }
501
+
502
+
503
+ def redacted_tfvars(vars_json):
504
+ redacted = dict(vars_json)
505
+ for key in [
506
+ "api_auth_secret",
507
+ "db_password",
508
+ "google_oauth_client_secret",
509
+ ]:
510
+ if key in redacted:
511
+ redacted[key] = "[redacted]"
512
+ return redacted
513
+
514
+
515
+ def controller_input_summary(payload):
516
+ release = payload.get("release")
517
+ if not isinstance(release, dict):
518
+ release = {
519
+ "version": payload.get("releaseVersion") or os.environ.get("THINKWORK_RELEASE_VERSION"),
520
+ "manifestUrl": payload.get("releaseManifestUrl")
521
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
522
+ "manifestSha256": payload.get("releaseManifestSha256")
523
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
524
+ }
525
+ return {
526
+ "schemaVersion": payload.get("schemaVersion"),
527
+ "contract": payload.get("contract"),
528
+ "phase": payload.get("phase"),
529
+ "action": payload.get("action"),
530
+ "sessionId": payload.get("sessionId"),
531
+ "customer": {
532
+ "name": payload.get("customerName"),
533
+ "environmentName": payload.get("environmentName"),
534
+ "awsAccountId": payload.get("awsAccountId"),
535
+ "awsRegion": payload.get("awsRegion"),
536
+ "availabilityZones": payload.get("availabilityZones"),
537
+ },
538
+ "evidence": payload.get("evidence")
539
+ or {
540
+ "bucket": payload.get("evidenceBucket") or os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
541
+ "prefix": os.environ.get("THINKWORK_EVIDENCE_PREFIX"),
542
+ },
543
+ "features": payload.get("features")
544
+ or {
545
+ "baseInstall": {
546
+ "cognee": False,
547
+ "slack": False,
548
+ "stripe": False,
549
+ "twenty": False,
550
+ },
551
+ "optionalApps": [],
552
+ },
553
+ "operation": payload.get("operation"),
554
+ "release": release,
555
+ "terraform": payload.get("terraform"),
556
+ }
557
+
558
+
559
+ def controller_identity(payload):
560
+ return {
561
+ "stateMachineArn": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN")
562
+ or payload.get("stateMachineArn"),
563
+ "stateMachineName": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_NAME"),
564
+ "codebuildProjectName": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
565
+ "codebuildProjectArn": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_ARN"),
566
+ "evidenceBucketName": os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
567
+ "ssmPrefix": os.environ.get("THINKWORK_SSM_PREFIX"),
568
+ }
569
+
570
+
571
+ def release_selection(payload):
572
+ release = payload.get("release")
573
+ if isinstance(release, dict):
574
+ return {
575
+ "version": release.get("version")
576
+ or payload.get("releaseVersion")
577
+ or os.environ.get("THINKWORK_RELEASE_VERSION"),
578
+ "manifestUrl": release.get("manifestUrl")
579
+ or payload.get("releaseManifestUrl")
580
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
581
+ "manifestSha256": release.get("manifestSha256")
582
+ or payload.get("releaseManifestSha256")
583
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
584
+ "manifestSignatureUrl": release.get("manifestSignatureUrl")
585
+ or payload.get("releaseManifestSignatureUrl")
586
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL"),
587
+ "manifestTrustPolicy": release.get("manifestTrustPolicy")
588
+ or payload.get("releaseManifestTrustPolicy")
589
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY"),
590
+ }
591
+ return {
592
+ "version": payload.get("releaseVersion") or os.environ.get("THINKWORK_RELEASE_VERSION"),
593
+ "manifestUrl": payload.get("releaseManifestUrl")
594
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
595
+ "manifestSha256": payload.get("releaseManifestSha256")
596
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
597
+ "manifestSignatureUrl": payload.get("releaseManifestSignatureUrl")
598
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL"),
599
+ "manifestTrustPolicy": payload.get("releaseManifestTrustPolicy")
600
+ or os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY"),
601
+ }
602
+
603
+
604
+ def apply_release_selection(payload):
605
+ selected = release_selection(payload)
606
+ env_names = {
607
+ "version": "THINKWORK_RELEASE_VERSION",
608
+ "manifestUrl": "THINKWORK_RELEASE_MANIFEST_URL",
609
+ "manifestSha256": "THINKWORK_RELEASE_MANIFEST_SHA256",
610
+ "manifestSignatureUrl": "THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL",
611
+ "manifestTrustPolicy": "THINKWORK_RELEASE_MANIFEST_TRUST_POLICY",
612
+ }
613
+ for key, env_name in env_names.items():
614
+ value = selected.get(key)
615
+ if isinstance(value, str) and value:
616
+ os.environ[env_name] = value
617
+ return selected
618
+
619
+
620
+ def write_controller_status_evidence(payload):
621
+ proof = {
622
+ "schemaVersion": 1,
623
+ "contract": "thinkwork.deployment.controller.status.v1",
624
+ "status": "ready",
625
+ "action": "status",
626
+ "sessionId": payload.get("sessionId") or os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
627
+ "checkedAt": datetime.now(UTC).isoformat(),
628
+ "controller": controller_identity(payload),
629
+ "release": release_selection(payload),
630
+ }
631
+ return {
632
+ "proof": proof,
633
+ "artifact": write_json_evidence_artifact("controller-status.json", proof),
634
+ }
635
+
636
+
637
+ def terraform_plan_summary(plan_json):
638
+ resource_changes = plan_json.get("resource_changes", [])
639
+ by_action = {}
640
+ for change in resource_changes:
641
+ actions = change.get("change", {}).get("actions", [])
642
+ action_key = ",".join(actions) if actions else "unknown"
643
+ by_action[action_key] = by_action.get(action_key, 0) + 1
644
+ return {
645
+ "formatVersion": plan_json.get("format_version"),
646
+ "terraformVersion": plan_json.get("terraform_version"),
647
+ "resourceChangeCount": len(resource_changes),
648
+ "resourceChangesByAction": by_action,
649
+ }
650
+
651
+
652
+ def managed_app_terraform_target_args(payload):
653
+ app_key = payload.get("appKey")
654
+ if app_key == "n8n":
655
+ return [
656
+ "-target=module.thinkwork.terraform_data.n8n_configuration_guardrails",
657
+ "-target=module.thinkwork.module.n8n",
658
+ "-target=aws_acm_certificate.n8n",
659
+ "-target=cloudflare_record.n8n_acm_validation",
660
+ "-target=aws_acm_certificate_validation.n8n",
661
+ "-target=cloudflare_record.n8n",
662
+ ]
663
+ return []
664
+
665
+
666
+ def truthy(value):
667
+ if isinstance(value, bool):
668
+ return value
669
+ if isinstance(value, str):
670
+ return value.strip().lower() in {"1", "true", "yes", "on"}
671
+ return False
672
+
673
+
674
+ def managed_app_state_isolation_enabled(payload):
675
+ features = payload.get("features")
676
+ if isinstance(features, dict) and truthy(features.get("managedAppStateIsolation")):
677
+ return True
678
+ contract = payload.get("operationContract")
679
+ if isinstance(contract, dict) and truthy(contract.get("managedAppStateIsolation")):
680
+ return True
681
+ return truthy(os.environ.get("THINKWORK_MANAGED_APP_STATE_ISOLATION"))
682
+
683
+
684
+ def managed_app_backend_app_key(payload):
685
+ app_key = str(payload.get("appKey") or "").strip().lower()
686
+ if not app_key:
687
+ raise RuntimeError("Managed app state isolation requires appKey.")
688
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", app_key):
689
+ raise RuntimeError(f"Invalid managed app key for Terraform state: {app_key!r}")
690
+ return app_key
691
+
692
+
693
+ def terraform_backend_key(stage, payload):
694
+ if is_managed_app_operation(payload) and managed_app_state_isolation_enabled(payload):
695
+ app_key = managed_app_backend_app_key(payload)
696
+ return f"thinkwork/{stage}/managed-apps/{app_key}/terraform.tfstate"
697
+ return f"thinkwork/{stage}/terraform.tfstate"
698
+
699
+
700
+ def terraform_workspace_name(stage, payload):
701
+ if is_managed_app_operation(payload) and managed_app_state_isolation_enabled(payload):
702
+ # Per-app backend keys already include the stage. Staying on the
703
+ # default workspace keeps the S3 object key exact and gives each app a
704
+ # separate lock row instead of env:/<stage>/... workspace indirection.
705
+ return "default"
706
+ return stage
707
+
708
+
709
+ def refresh_outputs_after_targeted_apply(payload):
710
+ if not is_managed_app_operation(payload):
711
+ return {"status": "skipped", "reason": "not-managed-app"}
712
+ print("[runner] refreshing Terraform outputs after targeted managed-app apply")
713
+ command = [
714
+ "terraform",
715
+ "apply",
716
+ "-refresh-only",
717
+ "-auto-approve",
718
+ "-no-color",
719
+ ]
720
+ try:
721
+ run(command, cwd=TF)
722
+ except subprocess.CalledProcessError as error:
723
+ detail = {
724
+ "status": "failed",
725
+ "command": [str(part) for part in command],
726
+ "exitCode": error.returncode,
727
+ "nonFatal": True,
728
+ }
729
+ TERRAFORM_EVIDENCE["outputRefresh"] = detail
730
+ print(
731
+ f"[runner] managed-app output refresh failed after targeted apply (non-fatal): {error}"
732
+ )
733
+ return detail
734
+ detail = {"status": "succeeded", "command": [str(part) for part in command]}
735
+ TERRAFORM_EVIDENCE["outputRefresh"] = detail
736
+ return detail
737
+
738
+
739
+ def write_outputs_after_apply(payload, vars_json, outputs_path):
740
+ refresh_outputs_after_targeted_apply(payload)
741
+ try:
742
+ outputs_path.write_text(output(["terraform", "output", "-json"], cwd=TF), encoding="utf-8")
743
+ source = "terraform-output"
744
+ except Exception as error:
745
+ if not is_managed_app_operation(payload):
746
+ raise
747
+ outputs = current_terraform_outputs(str(vars_json.get("stage") or ""))
748
+ if not outputs:
749
+ TERRAFORM_EVIDENCE["outputs"] = {
750
+ "status": "unavailable",
751
+ "source": "state",
752
+ "error": str(error),
753
+ }
754
+ print(
755
+ "[runner] managed-app Terraform outputs unavailable after targeted apply "
756
+ f"(non-fatal): {error}"
757
+ )
758
+ return
759
+ outputs_path.write_text(
760
+ json.dumps(outputs, indent=2, sort_keys=True) + "\n",
761
+ encoding="utf-8",
762
+ )
763
+ TERRAFORM_EVIDENCE["outputReadFallback"] = {
764
+ "status": "succeeded",
765
+ "source": "state",
766
+ "reason": str(error),
767
+ }
768
+ source = "state"
769
+ TERRAFORM_EVIDENCE["outputs"] = {
770
+ "fileName": "terraform-outputs.json",
771
+ "sha256": sha256_file(outputs_path),
772
+ "s3Uri": upload_evidence_artifact(outputs_path, "terraform-outputs.json"),
773
+ "source": source,
774
+ }
775
+
776
+
777
+ def is_managed_app_operation(payload):
778
+ contract = payload.get("operationContract")
779
+ if isinstance(contract, dict) and contract.get("kind") == "managed_app":
780
+ return True
781
+ return bool(payload.get("appKey"))
782
+
783
+
784
+ def is_web_only_operation(payload, action=None):
785
+ if action == "web":
786
+ return True
787
+ operation = payload.get("operation")
788
+ if isinstance(operation, dict):
789
+ kind = str(operation.get("kind") or "").strip().lower()
790
+ if kind in {"web", "web-only", "web_only", "static-web"}:
791
+ return True
792
+ component = str(payload.get("component") or "").strip().lower()
793
+ return component == "web" or truthy(payload.get("webOnly"))
794
+
795
+
796
+ def configure_managed_app_evidence_prefix(payload):
797
+ if not is_managed_app_operation(payload):
798
+ return
799
+ os.environ["THINKWORK_MANAGED_APP_OPERATION"] = "true"
800
+ evidence = payload.get("evidence")
801
+ if isinstance(evidence, dict):
802
+ prefix = evidence.get("prefix")
803
+ if isinstance(prefix, str) and prefix:
804
+ os.environ["THINKWORK_EVIDENCE_PREFIX"] = prefix
805
+
806
+
807
+ def validate_managed_app_plan_scope(payload, plan_json):
808
+ app_key = payload.get("appKey")
809
+ if app_key != "n8n":
810
+ return
811
+ allowed_prefixes = [
812
+ "module.thinkwork.module.n8n",
813
+ "module.thinkwork.terraform_data.n8n_configuration_guardrails",
814
+ "module.thinkwork.terraform_data.n8n_runtime_state_guardrails",
815
+ "aws_acm_certificate.n8n",
816
+ "cloudflare_record.n8n_acm_validation",
817
+ "aws_acm_certificate_validation.n8n",
818
+ "cloudflare_record.n8n",
819
+ ]
820
+ unsafe_changes = []
821
+ for change in plan_json.get("resource_changes", []):
822
+ actions = change.get("change", {}).get("actions", [])
823
+ if not actions or actions == ["no-op"] or actions == ["read"]:
824
+ continue
825
+ address = change.get("address", "")
826
+ if any(address.startswith(prefix) for prefix in allowed_prefixes):
827
+ continue
828
+ unsafe_changes.append(
829
+ {
830
+ "address": address,
831
+ "actions": actions,
832
+ }
833
+ )
834
+ if unsafe_changes:
835
+ preview = ", ".join(
836
+ f"{item['address']}:{'/'.join(item['actions'])}" for item in unsafe_changes[:10]
837
+ )
838
+ raise RuntimeError(
839
+ f"Managed app plan for {app_key} contains non-{app_key} changes; "
840
+ "refusing to continue. "
841
+ f"Examples: {preview}"
842
+ )
843
+
844
+
845
+ def destructive_plan_actions(actions):
846
+ return "delete" in (actions or [])
847
+
848
+
849
+ def allow_customer_domain_removal(payload):
850
+ return safe_get_bool({}, payload or {}, "allowCustomerDomainRemoval", default=False)
851
+
852
+
853
+ def validate_environment_plan_scope(payload, plan_json):
854
+ if is_managed_app_operation(payload):
855
+ return
856
+ if allow_customer_domain_removal(payload):
857
+ return
858
+
859
+ unsafe_changes = []
860
+ for change in plan_json.get("resource_changes", []):
861
+ actions = change.get("change", {}).get("actions", [])
862
+ if not actions or actions == ["no-op"] or actions == ["read"]:
863
+ continue
864
+ address = change.get("address", "")
865
+ before = change.get("change", {}).get("before") or {}
866
+ after = change.get("change", {}).get("after") or {}
867
+
868
+ if address.startswith(
869
+ "module.thinkwork.module.customer_domain."
870
+ ) and destructive_plan_actions(actions):
871
+ unsafe_changes.append({"address": address, "actions": actions})
872
+ continue
873
+
874
+ if (
875
+ address.startswith("module.thinkwork.module.computer_site.aws_cloudfront_distribution.")
876
+ and "update" in actions
877
+ and customer_domain_cloudfront_alias_removed(before, after)
878
+ ):
879
+ unsafe_changes.append({"address": address, "actions": actions})
880
+
881
+ if unsafe_changes:
882
+ preview = ", ".join(
883
+ f"{item['address']}:{'/'.join(item['actions'])}" for item in unsafe_changes[:10]
884
+ )
885
+ raise RuntimeError(
886
+ "Terraform plan would remove customer-domain web resources; refusing to continue. "
887
+ "Preserve customerDomain/customerDomainDelegated in the controller input or runner "
888
+ "secret. Set allowCustomerDomainRemoval=true only for an intentional, reviewed "
889
+ f"domain-retirement operation. Examples: {preview}"
890
+ )
891
+
892
+
893
+ def customer_domain_cloudfront_alias_removed(before, after):
894
+ before_aliases = set(cloudfront_alias_items(before.get("aliases")))
895
+ after_aliases = set(cloudfront_alias_items(after.get("aliases")))
896
+ if before_aliases and not after_aliases:
897
+ return True
898
+ return bool(before_aliases - after_aliases)
899
+
900
+
901
+ def cloudfront_alias_items(value):
902
+ if isinstance(value, list):
903
+ return [str(item) for item in value if item]
904
+ if isinstance(value, dict):
905
+ items = value.get("items") or value.get("Items")
906
+ if isinstance(items, list):
907
+ return [str(item) for item in items if item]
908
+ return []
909
+
910
+
911
+ def write_terraform_plan_evidence(payload=None):
912
+ plan_path = Path("terraform-plan.json")
913
+ with plan_path.open("w", encoding="utf-8") as handle:
914
+ run(["terraform", "show", "-json", "tfplan"], cwd=TF, stdout=handle)
915
+ plan_json = json.loads(plan_path.read_text(encoding="utf-8"))
916
+ validate_managed_app_plan_scope(payload or {}, plan_json)
917
+ validate_environment_plan_scope(payload or {}, plan_json)
918
+ artifact = {
919
+ "fileName": plan_path.name,
920
+ "sha256": sha256_file(plan_path),
921
+ "s3Uri": upload_evidence_artifact(plan_path),
922
+ }
923
+ return {
924
+ "artifact": artifact,
925
+ "summary": terraform_plan_summary(plan_json),
926
+ }
927
+
928
+
929
+ def secret_payload(payload):
930
+ arn = payload.get("runnerSecretArn") or payload.get("deploymentSecretsSecretArn")
931
+ if not arn:
932
+ return {}
933
+ body = output(
934
+ [
935
+ "aws",
936
+ "secretsmanager",
937
+ "get-secret-value",
938
+ "--secret-id",
939
+ arn,
940
+ "--query",
941
+ "SecretString",
942
+ "--output",
943
+ "text",
944
+ ]
945
+ )
946
+ return json.loads(body or "{}")
947
+
948
+
949
+ def safe_get(mapping, *names, default=""):
950
+ for name in names:
951
+ value = mapping.get(name)
952
+ if isinstance(value, str) and value:
953
+ return value
954
+ return default
955
+
956
+
957
+ def safe_get_bool(runner_secrets, payload, name, default=False):
958
+ """Boolean analogue of the safe_get(runner_secrets, default=safe_get(payload))
959
+ precedence. Controller payloads carry real JSON booleans, but Secrets
960
+ Manager JSON values frequently arrive as strings ("true"). Generated-root
961
+ variables typed `bool` reject strings, so boolean wiring points must
962
+ round-trip through this helper and always emit real booleans.
963
+ """
964
+ for source in (runner_secrets, payload):
965
+ value = source.get(name)
966
+ if isinstance(value, bool):
967
+ return value
968
+ if isinstance(value, str) and value:
969
+ return value.strip().lower() in {"1", "true", "yes", "on"}
970
+ return default
971
+
972
+
973
+ def current_terraform_state(stage):
974
+ bucket = os.environ.get("THINKWORK_TERRAFORM_STATE_BUCKET")
975
+ if not bucket:
976
+ return {}
977
+ keys = [
978
+ f"env:/{stage}/thinkwork/{stage}/terraform.tfstate",
979
+ f"thinkwork/{stage}/terraform.tfstate",
980
+ ]
981
+ for key in keys:
982
+ try:
983
+ body = output(["aws", "s3", "cp", f"s3://{bucket}/{key}", "-"])
984
+ state = json.loads(body)
985
+ if isinstance(state, dict):
986
+ return state
987
+ except Exception:
988
+ continue
989
+ return {}
990
+
991
+
992
+ def current_terraform_outputs(stage):
993
+ outputs = current_terraform_state(stage).get("outputs")
994
+ return outputs if isinstance(outputs, dict) else {}
995
+
996
+
997
+ def cloudflare_api_token(stage):
998
+ existing = os.environ.get("CLOUDFLARE_API_TOKEN")
999
+ if existing:
1000
+ return existing
1001
+ parameter_name = f"/thinkwork/{stage}/cloudflare-namespace-token"
1002
+ try:
1003
+ token = output(
1004
+ [
1005
+ "aws",
1006
+ "ssm",
1007
+ "get-parameter",
1008
+ "--name",
1009
+ parameter_name,
1010
+ "--with-decryption",
1011
+ "--query",
1012
+ "Parameter.Value",
1013
+ "--output",
1014
+ "text",
1015
+ ]
1016
+ )
1017
+ except Exception:
1018
+ return ""
1019
+ if token:
1020
+ os.environ["CLOUDFLARE_API_TOKEN"] = token
1021
+ return token
1022
+
1023
+
1024
+ def configure_cloudflare_provider_auth(stage):
1025
+ cloudflare_api_token(stage)
1026
+
1027
+
1028
+ def cloudflare_zone_id_for_hostname(stage, hostname):
1029
+ configured = os.environ.get("THINKWORK_CLOUDFLARE_ZONE_ID", "")
1030
+ if configured:
1031
+ return configured
1032
+ hostname = (hostname or "").strip().lower().rstrip(".")
1033
+ if not hostname:
1034
+ return ""
1035
+ token = cloudflare_api_token(stage)
1036
+ if not token:
1037
+ return ""
1038
+ request = urllib.request.Request(
1039
+ "https://api.cloudflare.com/client/v4/zones?per_page=50",
1040
+ headers={"Authorization": f"Bearer {token}"},
1041
+ )
1042
+ try:
1043
+ with urllib.request.urlopen(request, timeout=30) as response:
1044
+ body = json.loads(response.read().decode("utf-8"))
1045
+ except Exception:
1046
+ return ""
1047
+ zones = body.get("result")
1048
+ if not isinstance(zones, list):
1049
+ return ""
1050
+ matches = []
1051
+ for zone in zones:
1052
+ if not isinstance(zone, dict):
1053
+ continue
1054
+ name = str(zone.get("name") or "").strip().lower().rstrip(".")
1055
+ zone_id = str(zone.get("id") or "").strip()
1056
+ if not name or not zone_id:
1057
+ continue
1058
+ if hostname == name or hostname.endswith(f".{name}"):
1059
+ matches.append((len(name), zone_id))
1060
+ if not matches:
1061
+ return ""
1062
+ return sorted(matches, reverse=True)[0][1]
1063
+
1064
+
1065
+ def state_output(outputs, name, default=None):
1066
+ value = outputs.get(name)
1067
+ if isinstance(value, dict) and "value" in value:
1068
+ return value["value"]
1069
+ return default
1070
+
1071
+
1072
+ def string_state_output(outputs, name):
1073
+ value = state_output(outputs, name, "")
1074
+ return value.strip() if isinstance(value, str) else ""
1075
+
1076
+
1077
+ def bool_state_output(outputs, name):
1078
+ return bool(state_output(outputs, name, False))
1079
+
1080
+
1081
+ def has_state_output(outputs, name):
1082
+ value = outputs.get(name)
1083
+ return isinstance(value, dict) and "value" in value
1084
+
1085
+
1086
+ def enforce_customer_domain_preservation(current_outputs, vars_json, payload, runner_secrets):
1087
+ previous_domain = string_state_output(current_outputs, "customer_domain")
1088
+ if not previous_domain:
1089
+ return
1090
+
1091
+ allow_removal = safe_get_bool(
1092
+ runner_secrets,
1093
+ payload,
1094
+ "allowCustomerDomainRemoval",
1095
+ default=False,
1096
+ )
1097
+ next_domain = str(vars_json.get("customer_domain") or "").strip()
1098
+ if not next_domain:
1099
+ if allow_removal:
1100
+ return
1101
+ vars_json["customer_domain"] = previous_domain
1102
+ vars_json["customer_domain_delegated"] = (
1103
+ bool_state_output(current_outputs, "customer_domain_delegated")
1104
+ if has_state_output(current_outputs, "customer_domain_delegated")
1105
+ else True
1106
+ )
1107
+ vars_json["customer_domain_legacy_retired"] = bool_state_output(
1108
+ current_outputs, "customer_domain_legacy_retired"
1109
+ )
1110
+ return
1111
+
1112
+ if next_domain != previous_domain and not allow_removal:
1113
+ raise RuntimeError(
1114
+ "Refusing to change customer_domain during a controller update. "
1115
+ f"Current Terraform state has {previous_domain!r}, but this run "
1116
+ f"generated {next_domain!r}. Domain changes require an explicit, "
1117
+ "reviewed domain migration with allowCustomerDomainRemoval=true."
1118
+ )
1119
+
1120
+ previous_delegated = (
1121
+ bool_state_output(current_outputs, "customer_domain_delegated")
1122
+ if has_state_output(current_outputs, "customer_domain_delegated")
1123
+ else True
1124
+ )
1125
+ next_delegated = bool(vars_json.get("customer_domain_delegated", False))
1126
+ if previous_delegated and not next_delegated and not allow_removal:
1127
+ raise RuntimeError(
1128
+ "Refusing to turn off customer_domain_delegated during a controller "
1129
+ f"update for {previous_domain!r}. Disabling delegation would remove "
1130
+ "customer-domain CloudFront aliases and Cognito callbacks. Use an "
1131
+ "explicit reviewed domain-retirement operation if this is intended."
1132
+ )
1133
+
1134
+
1135
+ def state_terraform_data_input(state, name):
1136
+ for resource in state.get("resources", []) or []:
1137
+ if resource.get("type") != "terraform_data" or resource.get("name") != name:
1138
+ continue
1139
+ for instance in resource.get("instances", []) or []:
1140
+ attributes = instance.get("attributes") or {}
1141
+ terraform_input = attributes.get("input")
1142
+ if isinstance(terraform_input, dict):
1143
+ value = terraform_input.get("value")
1144
+ if isinstance(value, dict):
1145
+ return value
1146
+ return terraform_input
1147
+ return {}
1148
+
1149
+
1150
+ def state_cloudflare_zone_id(state):
1151
+ for resource in state.get("resources", []) or []:
1152
+ if resource.get("type") != "cloudflare_record":
1153
+ continue
1154
+ for instance in resource.get("instances", []) or []:
1155
+ attributes = instance.get("attributes") or {}
1156
+ zone_id = attributes.get("zone_id")
1157
+ if isinstance(zone_id, str) and zone_id:
1158
+ return zone_id
1159
+ return os.environ.get("THINKWORK_CLOUDFLARE_ZONE_ID", "")
1160
+
1161
+
1162
+ def url_hostname(url):
1163
+ try:
1164
+ return urllib.parse.urlparse(url).hostname or ""
1165
+ except Exception:
1166
+ return ""
1167
+
1168
+
1169
+ def config_value(desired_config, manifest_images, key, env_name, image_names=None, default=""):
1170
+ value = desired_config.get(key)
1171
+ if isinstance(value, str) and value:
1172
+ return value
1173
+ for image_name in image_names or []:
1174
+ image = manifest_images.get(image_name)
1175
+ if isinstance(image, str) and image:
1176
+ return image
1177
+ return os.environ.get(env_name, default)
1178
+
1179
+
1180
+ def n8n_binary_data_mode(value):
1181
+ if isinstance(value, str) and value.strip() == "database":
1182
+ return "default"
1183
+ return value
1184
+
1185
+
1186
+ def config_bool(desired_config, key, env_name, default=False):
1187
+ value = desired_config.get(key)
1188
+ if isinstance(value, bool):
1189
+ return value
1190
+ if isinstance(value, str) and value:
1191
+ return truthy(value)
1192
+ env_value = os.environ.get(env_name)
1193
+ if env_value:
1194
+ return truthy(env_value)
1195
+ return default
1196
+
1197
+
1198
+ def config_int(desired_config, key, env_name, default):
1199
+ value = desired_config.get(key)
1200
+ if isinstance(value, int):
1201
+ return value
1202
+ if isinstance(value, float) and value.is_integer():
1203
+ return int(value)
1204
+ if isinstance(value, str) and value:
1205
+ return int(value)
1206
+ env_value = os.environ.get(env_name)
1207
+ if env_value:
1208
+ return int(env_value)
1209
+ return default
1210
+
1211
+
1212
+ def config_string_list(desired_config, key, env_name, default=None):
1213
+ value = desired_config.get(key)
1214
+ if isinstance(value, list):
1215
+ return [str(item).strip() for item in value if str(item).strip()]
1216
+ if isinstance(value, str) and value.strip():
1217
+ return [item.strip() for item in value.split(",") if item.strip()]
1218
+ env_value = os.environ.get(env_name)
1219
+ if env_value:
1220
+ return [item.strip() for item in env_value.split(",") if item.strip()]
1221
+ return list(default or [])
1222
+
1223
+
1224
+ def required_config_value(
1225
+ desired_config,
1226
+ manifest_images,
1227
+ key,
1228
+ env_name,
1229
+ label,
1230
+ image_names=None,
1231
+ default="",
1232
+ app_label="n8n",
1233
+ ):
1234
+ value = config_value(desired_config, manifest_images, key, env_name, image_names, default)
1235
+ if isinstance(value, str) and value:
1236
+ return value
1237
+ raise RuntimeError(
1238
+ f"{app_label} managed app operation is missing required desired-state field: {label}."
1239
+ )
1240
+
1241
+
1242
+ def twenty_public_url_from_domain(domain):
1243
+ if not isinstance(domain, str) or not domain.strip():
1244
+ return ""
1245
+ value = domain.strip()
1246
+ if value.startswith("http://") or value.startswith("https://"):
1247
+ return value.rstrip("/")
1248
+ hostname = value if value.startswith("crm.") else f"crm.{value}"
1249
+ return f"https://{hostname}".rstrip("/")
1250
+
1251
+
1252
+ def n8n_public_url_from_domain(domain):
1253
+ if not isinstance(domain, str) or not domain.strip():
1254
+ return ""
1255
+ value = domain.strip()
1256
+ if value.startswith("http://") or value.startswith("https://"):
1257
+ return value.rstrip("/")
1258
+ hostname = value if value.startswith("n8n.") else f"n8n.{value}"
1259
+ return f"https://{hostname}".rstrip("/")
1260
+
1261
+
1262
+ def sibling_app_base_domain(public_url):
1263
+ hostname = url_hostname(public_url)
1264
+ if not hostname:
1265
+ return ""
1266
+ labels = hostname.split(".")
1267
+ if len(labels) <= 2:
1268
+ return hostname
1269
+ return ".".join(labels[1:])
1270
+
1271
+
1272
+ def first_non_empty_string(*values):
1273
+ for value in values:
1274
+ if isinstance(value, str) and value.strip():
1275
+ return value.strip()
1276
+ return ""
1277
+
1278
+
1279
+ def canonical_json(value):
1280
+ if value is None or not isinstance(value, (dict, list)):
1281
+ return json.dumps(value, separators=(",", ":"))
1282
+ if isinstance(value, list):
1283
+ return "[" + ",".join(canonical_json(item) for item in value) + "]"
1284
+ return (
1285
+ "{"
1286
+ + ",".join(
1287
+ json.dumps(key, separators=(",", ":")) + ":" + canonical_json(value[key])
1288
+ for key in sorted(value.keys())
1289
+ )
1290
+ + "}"
1291
+ )
1292
+
1293
+
1294
+ def normalize_n8n_package_config(desired_config):
1295
+ specs = config_string_list(
1296
+ desired_config,
1297
+ "customPackageSpecs",
1298
+ "THINKWORK_N8N_CUSTOM_PACKAGE_SPECS",
1299
+ )
1300
+ by_name = {}
1301
+ for raw_spec in specs:
1302
+ if re.search(r"\s", raw_spec) or "://" in raw_spec:
1303
+ raise RuntimeError(
1304
+ f'n8n custom package "{raw_spec}" must be an exact public npm package spec.'
1305
+ )
1306
+ if raw_spec.startswith(("./", "../", "/")) or raw_spec.endswith(".tgz"):
1307
+ raise RuntimeError(
1308
+ f'n8n custom package "{raw_spec}" must be resolved from the public npm registry.'
1309
+ )
1310
+ version_separator_index = raw_spec.rfind("@")
1311
+ if version_separator_index <= 0:
1312
+ raise RuntimeError(
1313
+ f'n8n custom package "{raw_spec}" must include an exact public npm version.'
1314
+ )
1315
+ name = raw_spec[:version_separator_index]
1316
+ version = raw_spec[version_separator_index + 1 :]
1317
+ if not re.fullmatch(
1318
+ r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
1319
+ r"(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?"
1320
+ r"(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?",
1321
+ version,
1322
+ ):
1323
+ raise RuntimeError(f'n8n custom package "{raw_spec}" must pin an exact semver version.')
1324
+ existing = by_name.get(name)
1325
+ if existing and existing["version"] != version:
1326
+ raise RuntimeError(
1327
+ "n8n custom package "
1328
+ f"{name} declares multiple versions: {existing['version']} and {version}"
1329
+ )
1330
+ by_name[name] = {"name": name, "version": version, "spec": f"{name}@{version}"}
1331
+
1332
+ packages = sorted(by_name.values(), key=lambda entry: entry["name"])
1333
+ digest_payload = {
1334
+ "schemaVersion": 1,
1335
+ "packages": [{"name": entry["name"], "version": entry["version"]} for entry in packages],
1336
+ }
1337
+ digest = hashlib.sha256(canonical_json(digest_payload).encode()).hexdigest()
1338
+ return {
1339
+ "packageSpecs": [entry["spec"] for entry in packages],
1340
+ "digest": digest,
1341
+ }
1342
+
1343
+
1344
+ def assert_optional_digest(expected, actual, label):
1345
+ if expected is None:
1346
+ return
1347
+ if not isinstance(expected, str) or not expected.strip():
1348
+ raise RuntimeError(f"{label} must be a sha256 hex digest.")
1349
+ if not re.fullmatch(r"[a-fA-F0-9]{64}", expected):
1350
+ raise RuntimeError(f"{label} must be a sha256 hex digest.")
1351
+ if expected.lower() != actual.lower():
1352
+ raise RuntimeError(f"{label} must match normalized n8n package config digest {actual}.")
1353
+
1354
+
1355
+ def n8n_expected_package_digest(desired_config, key, env_name):
1356
+ value = desired_config.get(key)
1357
+ if isinstance(value, str) and value.strip():
1358
+ return value.strip()
1359
+ env_value = os.environ.get(env_name)
1360
+ if env_value and env_value.strip():
1361
+ return env_value.strip()
1362
+ return None
1363
+
1364
+
1365
+ def n8n_terraform_overrides(
1366
+ stage,
1367
+ account_id,
1368
+ operation,
1369
+ desired_config,
1370
+ manifest_images,
1371
+ current_outputs=None,
1372
+ n8n_guardrails=None,
1373
+ twenty_guardrails=None,
1374
+ ):
1375
+ current_outputs = current_outputs or {}
1376
+ n8n_guardrails = n8n_guardrails or {}
1377
+ twenty_guardrails = twenty_guardrails or {}
1378
+ provisioned = operation != "DESTROY"
1379
+ runtime_enabled = provisioned and operation != "PARK"
1380
+ if not provisioned:
1381
+ return {
1382
+ "n8n_provisioned": False,
1383
+ "n8n_runtime_enabled": False,
1384
+ }
1385
+
1386
+ package_config = normalize_n8n_package_config(desired_config)
1387
+ package_specs = package_config["packageSpecs"]
1388
+ package_config_digest = n8n_expected_package_digest(
1389
+ desired_config,
1390
+ "packageConfigDigest",
1391
+ "THINKWORK_N8N_PACKAGE_CONFIG_DIGEST",
1392
+ )
1393
+ package_image_config_digest = n8n_expected_package_digest(
1394
+ desired_config,
1395
+ "packageImageConfigDigest",
1396
+ "THINKWORK_N8N_PACKAGE_IMAGE_CONFIG_DIGEST",
1397
+ )
1398
+ assert_optional_digest(
1399
+ package_config_digest,
1400
+ package_config["digest"],
1401
+ "n8n packageConfigDigest",
1402
+ )
1403
+ assert_optional_digest(
1404
+ package_image_config_digest,
1405
+ package_config["digest"],
1406
+ "n8n packageImageConfigDigest",
1407
+ )
1408
+
1409
+ release_image_uri = first_non_empty_string(
1410
+ release_runtime_image("n8n-runtime"),
1411
+ release_runtime_image("n8n"),
1412
+ release_runtime_image("managed-app-n8n"),
1413
+ n8n_guardrails.get("n8n_image_uri", ""),
1414
+ )
1415
+ base_image_uri = required_config_value(
1416
+ desired_config,
1417
+ manifest_images,
1418
+ "imageUri",
1419
+ "THINKWORK_N8N_IMAGE_URI",
1420
+ "imageUri",
1421
+ ["n8n", "n8n-runtime", "managed-app-n8n"],
1422
+ default=release_image_uri,
1423
+ )
1424
+ package_image_uri = config_value(
1425
+ desired_config,
1426
+ manifest_images,
1427
+ "packageImageUri",
1428
+ "THINKWORK_N8N_PACKAGE_IMAGE_URI",
1429
+ )
1430
+ if package_image_uri and not package_specs:
1431
+ raise RuntimeError("n8n packageImageUri requires at least one custom package spec.")
1432
+ if package_specs and not package_image_uri:
1433
+ raise RuntimeError("n8n custom package specs require n8n packageImageUri.")
1434
+ resolved_image_uri = package_image_uri if package_specs else base_image_uri
1435
+
1436
+ inferred_base_domain = first_non_empty_string(
1437
+ os.environ.get("THINKWORK_DOMAIN", ""),
1438
+ sibling_app_base_domain(twenty_guardrails.get("twenty_public_url", "")),
1439
+ sibling_app_base_domain(state_output(current_outputs, "twenty_url", "")),
1440
+ sibling_app_base_domain(state_output(current_outputs, "app_url", "")),
1441
+ )
1442
+ domain = config_value(
1443
+ desired_config,
1444
+ manifest_images,
1445
+ "domain",
1446
+ "THINKWORK_N8N_DOMAIN",
1447
+ default=inferred_base_domain,
1448
+ )
1449
+ public_url = config_value(
1450
+ desired_config,
1451
+ manifest_images,
1452
+ "publicUrl",
1453
+ "THINKWORK_N8N_PUBLIC_URL",
1454
+ default=n8n_guardrails.get("n8n_public_url", ""),
1455
+ ) or n8n_public_url_from_domain(domain)
1456
+ n8n_domain = url_hostname(public_url) or domain
1457
+ database_admin_secret_arn = config_value(
1458
+ desired_config,
1459
+ manifest_images,
1460
+ "databaseAdminSecretArn",
1461
+ "THINKWORK_N8N_DATABASE_ADMIN_SECRET_ARN",
1462
+ default=first_non_empty_string(
1463
+ n8n_guardrails.get("n8n_database_admin_secret_arn", ""),
1464
+ state_output(current_outputs, "db_secret_arn", ""),
1465
+ ),
1466
+ )
1467
+ if not database_admin_secret_arn:
1468
+ raise RuntimeError(
1469
+ "n8n managed app operation is missing required desired-state field: "
1470
+ "databaseAdminSecretArn."
1471
+ )
1472
+ generated_secret_guardrail_default = "" if operation == "ENABLE" else None
1473
+ database_url_secret_arn = config_value(
1474
+ desired_config,
1475
+ manifest_images,
1476
+ "databaseUrlSecretArn",
1477
+ "THINKWORK_N8N_DATABASE_URL_SECRET_ARN",
1478
+ default=(
1479
+ generated_secret_guardrail_default
1480
+ if generated_secret_guardrail_default is not None
1481
+ else n8n_guardrails.get("n8n_database_url_secret_arn", "")
1482
+ ),
1483
+ )
1484
+ encryption_key_secret_arn = config_value(
1485
+ desired_config,
1486
+ manifest_images,
1487
+ "encryptionKeySecretArn",
1488
+ "THINKWORK_N8N_ENCRYPTION_KEY_SECRET_ARN",
1489
+ default=(
1490
+ generated_secret_guardrail_default
1491
+ if generated_secret_guardrail_default is not None
1492
+ else n8n_guardrails.get("n8n_encryption_key_secret_arn", "")
1493
+ ),
1494
+ )
1495
+ operator_secret_arn = config_value(
1496
+ desired_config,
1497
+ manifest_images,
1498
+ "operatorSecretArn",
1499
+ "THINKWORK_N8N_OPERATOR_SECRET_ARN",
1500
+ default=(
1501
+ generated_secret_guardrail_default
1502
+ if generated_secret_guardrail_default is not None
1503
+ else n8n_guardrails.get("n8n_operator_secret_arn", "")
1504
+ ),
1505
+ )
1506
+ service_credential_secret_arn = config_value(
1507
+ desired_config,
1508
+ manifest_images,
1509
+ "serviceCredentialSecretArn",
1510
+ "THINKWORK_N8N_SERVICE_CREDENTIAL_SECRET_ARN",
1511
+ default=(
1512
+ generated_secret_guardrail_default
1513
+ if generated_secret_guardrail_default is not None
1514
+ else n8n_guardrails.get("n8n_service_credential_secret_arn", "")
1515
+ ),
1516
+ )
1517
+ certificate_arn = config_value(
1518
+ desired_config,
1519
+ manifest_images,
1520
+ "certificateArn",
1521
+ "THINKWORK_N8N_CERTIFICATE_ARN",
1522
+ default="",
1523
+ )
1524
+ create_secret_placeholders = any(
1525
+ not value
1526
+ for value in [
1527
+ database_url_secret_arn,
1528
+ encryption_key_secret_arn,
1529
+ operator_secret_arn,
1530
+ service_credential_secret_arn,
1531
+ ]
1532
+ )
1533
+
1534
+ return {
1535
+ "n8n_provisioned": True,
1536
+ "n8n_runtime_enabled": runtime_enabled,
1537
+ "n8n_image_uri": resolved_image_uri,
1538
+ "n8n_database_admin_secret_arn": database_admin_secret_arn,
1539
+ "n8n_database_url_secret_arn": database_url_secret_arn,
1540
+ "n8n_database_name": config_value(
1541
+ desired_config,
1542
+ manifest_images,
1543
+ "databaseName",
1544
+ "THINKWORK_N8N_DATABASE_NAME",
1545
+ default=n8n_guardrails.get("n8n_database_name", "thinkwork_n8n"),
1546
+ ),
1547
+ "n8n_database_username": config_value(
1548
+ desired_config,
1549
+ manifest_images,
1550
+ "databaseUsername",
1551
+ "THINKWORK_N8N_DATABASE_USERNAME",
1552
+ default=n8n_guardrails.get("n8n_database_username", "thinkwork_n8n"),
1553
+ ),
1554
+ "n8n_encryption_key_secret_arn": encryption_key_secret_arn,
1555
+ "n8n_operator_secret_arn": operator_secret_arn,
1556
+ "n8n_service_credential_secret_arn": service_credential_secret_arn,
1557
+ "n8n_storage_bucket_name": config_value(
1558
+ desired_config,
1559
+ manifest_images,
1560
+ "storageBucketName",
1561
+ "THINKWORK_N8N_STORAGE_BUCKET_NAME",
1562
+ default=n8n_guardrails.get(
1563
+ "n8n_storage_bucket_name",
1564
+ f"thinkwork-{stage}-{account_id}-n8n",
1565
+ ),
1566
+ ),
1567
+ "n8n_create_storage_bucket": config_bool(
1568
+ desired_config,
1569
+ "createStorageBucket",
1570
+ "THINKWORK_N8N_CREATE_STORAGE_BUCKET",
1571
+ bool(n8n_guardrails.get("n8n_create_storage_bucket", True)),
1572
+ ),
1573
+ "n8n_storage_prefix": config_value(
1574
+ desired_config,
1575
+ manifest_images,
1576
+ "storagePrefix",
1577
+ "THINKWORK_N8N_STORAGE_PREFIX",
1578
+ default=n8n_guardrails.get("n8n_storage_prefix", "managed-apps/n8n"),
1579
+ ),
1580
+ "n8n_domain": n8n_domain,
1581
+ "n8n_public_url": public_url,
1582
+ "n8n_certificate_arn": certificate_arn,
1583
+ "n8n_main_desired_count": config_int(
1584
+ desired_config,
1585
+ "mainDesiredCount",
1586
+ "THINKWORK_N8N_MAIN_DESIRED_COUNT",
1587
+ n8n_guardrails.get("n8n_main_desired_count", 1),
1588
+ ),
1589
+ "n8n_worker_desired_count": config_int(
1590
+ desired_config,
1591
+ "workerDesiredCount",
1592
+ "THINKWORK_N8N_WORKER_DESIRED_COUNT",
1593
+ n8n_guardrails.get("n8n_worker_desired_count", 1),
1594
+ ),
1595
+ "n8n_worker_concurrency": config_int(
1596
+ desired_config,
1597
+ "workerConcurrency",
1598
+ "THINKWORK_N8N_WORKER_CONCURRENCY",
1599
+ n8n_guardrails.get("n8n_worker_concurrency", 10),
1600
+ ),
1601
+ "n8n_container_port": config_int(
1602
+ desired_config,
1603
+ "containerPort",
1604
+ "THINKWORK_N8N_CONTAINER_PORT",
1605
+ n8n_guardrails.get("n8n_container_port", 5678),
1606
+ ),
1607
+ "n8n_queue_mode": True,
1608
+ "n8n_task_runners_enabled": config_bool(
1609
+ desired_config,
1610
+ "taskRunnersEnabled",
1611
+ "THINKWORK_N8N_TASK_RUNNERS_ENABLED",
1612
+ bool(n8n_guardrails.get("n8n_task_runners_enabled", True)),
1613
+ ),
1614
+ "n8n_package_config_digest": package_config["digest"]
1615
+ if package_specs or package_config_digest is not None
1616
+ else n8n_guardrails.get("n8n_package_config_digest", ""),
1617
+ "n8n_custom_package_specs": package_specs
1618
+ or n8n_guardrails.get("n8n_custom_package_specs", []),
1619
+ "n8n_execution_data_storage_mode": config_value(
1620
+ desired_config,
1621
+ manifest_images,
1622
+ "executionDataStorageMode",
1623
+ "THINKWORK_N8N_EXECUTION_DATA_STORAGE_MODE",
1624
+ default=n8n_guardrails.get("n8n_execution_data_storage_mode", "database"),
1625
+ ),
1626
+ "n8n_binary_data_mode": n8n_binary_data_mode(
1627
+ config_value(
1628
+ desired_config,
1629
+ manifest_images,
1630
+ "binaryDataMode",
1631
+ "THINKWORK_N8N_BINARY_DATA_MODE",
1632
+ default=n8n_guardrails.get("n8n_binary_data_mode", "default"),
1633
+ )
1634
+ ),
1635
+ "n8n_cache_engine": config_value(
1636
+ desired_config,
1637
+ manifest_images,
1638
+ "cacheEngine",
1639
+ "THINKWORK_N8N_CACHE_ENGINE",
1640
+ default=n8n_guardrails.get("n8n_cache_engine", "valkey"),
1641
+ ),
1642
+ "n8n_cache_engine_version": config_value(
1643
+ desired_config,
1644
+ manifest_images,
1645
+ "cacheEngineVersion",
1646
+ "THINKWORK_N8N_CACHE_ENGINE_VERSION",
1647
+ default=n8n_guardrails.get("n8n_cache_engine_version", "8.0"),
1648
+ ),
1649
+ "n8n_cache_parameter_group_family": config_value(
1650
+ desired_config,
1651
+ manifest_images,
1652
+ "cacheParameterGroupFamily",
1653
+ "THINKWORK_N8N_CACHE_PARAMETER_GROUP_FAMILY",
1654
+ default=n8n_guardrails.get(
1655
+ "n8n_cache_parameter_group_family",
1656
+ "valkey8",
1657
+ ),
1658
+ ),
1659
+ "n8n_cache_node_type": config_value(
1660
+ desired_config,
1661
+ manifest_images,
1662
+ "cacheNodeType",
1663
+ "THINKWORK_N8N_CACHE_NODE_TYPE",
1664
+ default=n8n_guardrails.get("n8n_cache_node_type", "cache.t4g.micro"),
1665
+ ),
1666
+ "n8n_cache_num_cache_clusters": config_int(
1667
+ desired_config,
1668
+ "cacheNumCacheClusters",
1669
+ "THINKWORK_N8N_CACHE_NUM_CACHE_CLUSTERS",
1670
+ n8n_guardrails.get("n8n_cache_num_cache_clusters", 1),
1671
+ ),
1672
+ "n8n_allowed_public_cidr_blocks": config_string_list(
1673
+ desired_config,
1674
+ "allowedPublicCidrBlocks",
1675
+ "THINKWORK_N8N_ALLOWED_PUBLIC_CIDR_BLOCKS",
1676
+ n8n_guardrails.get("n8n_allowed_public_cidr_blocks", ["0.0.0.0/0"]),
1677
+ ),
1678
+ "n8n_kms_key_arns": config_string_list(
1679
+ desired_config,
1680
+ "kmsKeyArns",
1681
+ "THINKWORK_N8N_KMS_KEY_ARNS",
1682
+ n8n_guardrails.get("n8n_kms_key_arns", []),
1683
+ ),
1684
+ "deployment_control_plane_create_secret_placeholders": create_secret_placeholders,
1685
+ }
1686
+
1687
+
1688
+ def twenty_terraform_overrides(
1689
+ operation,
1690
+ desired_config,
1691
+ manifest_images,
1692
+ current_outputs=None,
1693
+ twenty_guardrails=None,
1694
+ payload=None,
1695
+ ):
1696
+ current_outputs = current_outputs or {}
1697
+ twenty_guardrails = twenty_guardrails or {}
1698
+ payload = payload or {}
1699
+ provisioned = operation != "DESTROY"
1700
+ runtime_enabled = provisioned and operation != "PARK"
1701
+ if not provisioned:
1702
+ return {
1703
+ "twenty_provisioned": False,
1704
+ "twenty_runtime_enabled": False,
1705
+ }
1706
+
1707
+ release_image_uri = first_non_empty_string(
1708
+ release_runtime_image("twenty"),
1709
+ release_runtime_image("twenty-crm"),
1710
+ release_runtime_image("managed-app-twenty"),
1711
+ twenty_guardrails.get("twenty_image_uri", ""),
1712
+ )
1713
+ image_uri = required_config_value(
1714
+ desired_config,
1715
+ manifest_images,
1716
+ "imageUri",
1717
+ "THINKWORK_TWENTY_IMAGE_URI",
1718
+ "imageUri",
1719
+ ["twenty", "twenty-crm", "managed-app-twenty"],
1720
+ default=release_image_uri,
1721
+ app_label="twenty",
1722
+ )
1723
+ inferred_domain = first_non_empty_string(
1724
+ desired_config.get("domain", ""),
1725
+ os.environ.get("THINKWORK_TWENTY_DOMAIN", ""),
1726
+ os.environ.get("THINKWORK_DOMAIN", ""),
1727
+ payload.get("customerDomain", ""),
1728
+ sibling_app_base_domain(state_output(current_outputs, "app_url", "")),
1729
+ )
1730
+ public_url = config_value(
1731
+ desired_config,
1732
+ manifest_images,
1733
+ "publicUrl",
1734
+ "THINKWORK_TWENTY_PUBLIC_URL",
1735
+ default=first_non_empty_string(
1736
+ twenty_guardrails.get("twenty_public_url", ""),
1737
+ state_output(current_outputs, "twenty_url", ""),
1738
+ twenty_public_url_from_domain(inferred_domain),
1739
+ ),
1740
+ )
1741
+ certificate_arn = required_config_value(
1742
+ desired_config,
1743
+ manifest_images,
1744
+ "certificateArn",
1745
+ "THINKWORK_TWENTY_CERTIFICATE_ARN",
1746
+ "certificateArn",
1747
+ default=first_non_empty_string(
1748
+ twenty_guardrails.get("twenty_certificate_arn", ""),
1749
+ payload.get("appCertificateArn", ""),
1750
+ ),
1751
+ app_label="twenty",
1752
+ )
1753
+ db_url_secret_arn = config_value(
1754
+ desired_config,
1755
+ manifest_images,
1756
+ "dbUrlSecretArn",
1757
+ "THINKWORK_TWENTY_DB_URL_SECRET_ARN",
1758
+ default=twenty_guardrails.get("twenty_db_url_secret_arn", ""),
1759
+ )
1760
+ encryption_key_secret_arn = config_value(
1761
+ desired_config,
1762
+ manifest_images,
1763
+ "encryptionKeySecretArn",
1764
+ "THINKWORK_TWENTY_ENCRYPTION_KEY_SECRET_ARN",
1765
+ default=twenty_guardrails.get("twenty_encryption_key_secret_arn", ""),
1766
+ )
1767
+
1768
+ return {
1769
+ "twenty_provisioned": True,
1770
+ "twenty_runtime_enabled": runtime_enabled,
1771
+ "twenty_image_uri": image_uri,
1772
+ "twenty_db_username": config_value(
1773
+ desired_config,
1774
+ manifest_images,
1775
+ "dbUsername",
1776
+ "THINKWORK_TWENTY_DB_USERNAME",
1777
+ default=twenty_guardrails.get("twenty_db_username", "thinkwork_twenty"),
1778
+ ),
1779
+ "twenty_db_name": config_value(
1780
+ desired_config,
1781
+ manifest_images,
1782
+ "dbName",
1783
+ "THINKWORK_TWENTY_DB_NAME",
1784
+ default=twenty_guardrails.get("twenty_db_name", "thinkwork_twenty"),
1785
+ ),
1786
+ "twenty_db_url_secret_arn": db_url_secret_arn,
1787
+ "twenty_encryption_key_secret_arn": encryption_key_secret_arn,
1788
+ "twenty_email_from_address": config_value(
1789
+ desired_config,
1790
+ manifest_images,
1791
+ "emailFromAddress",
1792
+ "THINKWORK_TWENTY_EMAIL_FROM_ADDRESS",
1793
+ default=twenty_guardrails.get("twenty_email_from_address", ""),
1794
+ ),
1795
+ "twenty_email_from_name": config_value(
1796
+ desired_config,
1797
+ manifest_images,
1798
+ "emailFromName",
1799
+ "THINKWORK_TWENTY_EMAIL_FROM_NAME",
1800
+ default=twenty_guardrails.get("twenty_email_from_name", "ThinkWork CRM"),
1801
+ ),
1802
+ "twenty_public_url": public_url,
1803
+ "twenty_certificate_arn": certificate_arn,
1804
+ "deployment_control_plane_create_secret_placeholders": (
1805
+ not db_url_secret_arn or not encryption_key_secret_arn
1806
+ ),
1807
+ }
1808
+
1809
+
1810
+ def validate_managed_app_desired_state(payload, desired_config, manifest_images):
1811
+ app_key = payload.get("appKey")
1812
+ if not app_key:
1813
+ return
1814
+ operation = str(payload.get("operation") or "").upper()
1815
+ if operation not in {"ENABLE", "PARK", "DESTROY", "UPGRADE"}:
1816
+ raise RuntimeError(
1817
+ "Managed app operation requires operation to be one of "
1818
+ "ENABLE, PARK, DESTROY, or UPGRADE."
1819
+ )
1820
+
1821
+
1822
+ def managed_app_terraform_overrides(payload, stage, account_id, current_outputs, current_state):
1823
+ app_key = payload.get("appKey")
1824
+ operation = str(payload.get("operation") or "").upper()
1825
+ desired_config = payload.get("desiredConfig")
1826
+ if not isinstance(desired_config, dict):
1827
+ desired_config = {}
1828
+ manifest_images = payload.get("manifestImages")
1829
+ if not isinstance(manifest_images, dict):
1830
+ manifest_images = {}
1831
+ validate_managed_app_desired_state(payload, desired_config, manifest_images)
1832
+ cognee_guardrails = state_terraform_data_input(
1833
+ current_state,
1834
+ "cognee_configuration_guardrails",
1835
+ )
1836
+ twenty_guardrails = state_terraform_data_input(
1837
+ current_state,
1838
+ "twenty_configuration_guardrails",
1839
+ )
1840
+ n8n_guardrails = state_terraform_data_input(
1841
+ current_state,
1842
+ "n8n_configuration_guardrails",
1843
+ )
1844
+
1845
+ overrides = {
1846
+ "enable_cognee": bool(state_output(current_outputs, "cognee_enabled", False)),
1847
+ "cognee_image_uri": cognee_guardrails.get("cognee_image_uri", ""),
1848
+ "cognee_db_username": cognee_guardrails.get("cognee_db_username", "thinkwork_cognee"),
1849
+ "cognee_db_name": cognee_guardrails.get("cognee_db_name", "thinkwork_cognee"),
1850
+ "cognee_db_password_secret_arn": cognee_guardrails.get(
1851
+ "cognee_db_password_secret_arn",
1852
+ "",
1853
+ ),
1854
+ "cognee_backend_mode": cognee_guardrails.get("cognee_backend_mode", "dogfood"),
1855
+ "cognee_desired_count": cognee_guardrails.get("cognee_desired_count", 1),
1856
+ "cognee_brain_tenant_id": state_output(current_outputs, "cognee_brain_tenant_id", ""),
1857
+ "cognee_brain_instance_key": state_output(
1858
+ current_outputs,
1859
+ "cognee_brain_instance_key",
1860
+ "",
1861
+ ),
1862
+ "cognee_brain_storage_tier": cognee_guardrails.get(
1863
+ "cognee_brain_storage_tier",
1864
+ state_output(current_outputs, "cognee_brain_storage_tier", "default"),
1865
+ ),
1866
+ "cognee_brain_s3_artifact_root": state_output(
1867
+ current_outputs,
1868
+ "cognee_s3_artifact_root",
1869
+ "",
1870
+ ),
1871
+ "cognee_brain_s3_manifest_root": state_output(
1872
+ current_outputs,
1873
+ "cognee_s3_manifest_root",
1874
+ "",
1875
+ ),
1876
+ "cognee_brain_s3_vault_projection_root": state_output(
1877
+ current_outputs,
1878
+ "cognee_s3_vault_projection_root",
1879
+ "",
1880
+ ),
1881
+ "cognee_private_substrate_mode": bool(
1882
+ state_output(current_outputs, "cognee_private_substrate_mode", True)
1883
+ ),
1884
+ "cognee_llm_provider": cognee_guardrails.get("cognee_llm_provider", "bedrock"),
1885
+ "cognee_llm_model": "bedrock/moonshotai.kimi-k2.5",
1886
+ "cognee_embedding_provider": cognee_guardrails.get(
1887
+ "cognee_embedding_provider",
1888
+ "bedrock",
1889
+ ),
1890
+ "cognee_embedding_model": state_output(
1891
+ current_outputs,
1892
+ "cognee_embedding_model",
1893
+ "amazon.titan-embed-text-v2:0",
1894
+ ),
1895
+ "cognee_embedding_dimensions": state_output(
1896
+ current_outputs,
1897
+ "cognee_embedding_dimensions",
1898
+ 1024,
1899
+ ),
1900
+ "cognee_vector_db_provider": state_output(
1901
+ current_outputs,
1902
+ "cognee_vector_db_provider",
1903
+ "lancedb",
1904
+ ),
1905
+ "cognee_graph_database_provider": state_output(
1906
+ current_outputs,
1907
+ "cognee_graph_database_provider",
1908
+ "kuzu",
1909
+ ),
1910
+ "cognee_neptune_graph_id": state_output(current_outputs, "cognee_neptune_graph_id", ""),
1911
+ "cognee_neptune_endpoint": state_output(current_outputs, "cognee_neptune_endpoint", ""),
1912
+ "cognee_production_posture": state_output(
1913
+ current_outputs,
1914
+ "cognee_production_posture",
1915
+ "",
1916
+ ),
1917
+ "cognee_bedrock_model_resource_arns": cognee_guardrails.get(
1918
+ "cognee_bedrock_model_resources",
1919
+ [],
1920
+ ),
1921
+ "twenty_provisioned": bool(state_output(current_outputs, "twenty_provisioned", False)),
1922
+ "twenty_runtime_enabled": bool(
1923
+ state_output(current_outputs, "twenty_runtime_enabled", False)
1924
+ ),
1925
+ "twenty_image_uri": twenty_guardrails.get("twenty_image_uri", ""),
1926
+ "twenty_db_username": twenty_guardrails.get("twenty_db_username", "thinkwork_twenty"),
1927
+ "twenty_db_name": twenty_guardrails.get("twenty_db_name", "thinkwork_twenty"),
1928
+ "twenty_db_url_secret_arn": twenty_guardrails.get("twenty_db_url_secret_arn", ""),
1929
+ "twenty_encryption_key_secret_arn": twenty_guardrails.get(
1930
+ "twenty_encryption_key_secret_arn",
1931
+ "",
1932
+ ),
1933
+ "twenty_email_from_address": "",
1934
+ "twenty_email_from_name": "ThinkWork CRM",
1935
+ "twenty_public_url": twenty_guardrails.get(
1936
+ "twenty_public_url",
1937
+ state_output(current_outputs, "twenty_url", ""),
1938
+ ),
1939
+ "twenty_certificate_arn": twenty_guardrails.get("twenty_certificate_arn", ""),
1940
+ "enable_deployment_control_plane": False,
1941
+ "deployment_control_plane_create_secret_placeholders": False,
1942
+ "cloudflare_zone_id": state_cloudflare_zone_id(current_state),
1943
+ "n8n_provisioned": bool(state_output(current_outputs, "n8n_provisioned", False)),
1944
+ "n8n_runtime_enabled": bool(state_output(current_outputs, "n8n_runtime_enabled", False)),
1945
+ "n8n_image_uri": n8n_guardrails.get("n8n_image_uri", ""),
1946
+ "n8n_database_admin_secret_arn": n8n_guardrails.get(
1947
+ "n8n_database_admin_secret_arn",
1948
+ "",
1949
+ ),
1950
+ "n8n_database_url_secret_arn": n8n_guardrails.get(
1951
+ "n8n_database_url_secret_arn",
1952
+ "",
1953
+ ),
1954
+ "n8n_database_name": n8n_guardrails.get("n8n_database_name", "thinkwork_n8n"),
1955
+ "n8n_database_username": n8n_guardrails.get(
1956
+ "n8n_database_username",
1957
+ "thinkwork_n8n",
1958
+ ),
1959
+ "n8n_encryption_key_secret_arn": n8n_guardrails.get(
1960
+ "n8n_encryption_key_secret_arn",
1961
+ "",
1962
+ ),
1963
+ "n8n_operator_secret_arn": n8n_guardrails.get("n8n_operator_secret_arn", ""),
1964
+ "n8n_service_credential_secret_arn": n8n_guardrails.get(
1965
+ "n8n_service_credential_secret_arn",
1966
+ "",
1967
+ ),
1968
+ "n8n_storage_bucket_name": n8n_guardrails.get("n8n_storage_bucket_name", ""),
1969
+ "n8n_create_storage_bucket": n8n_guardrails.get("n8n_create_storage_bucket", True),
1970
+ "n8n_storage_prefix": n8n_guardrails.get("n8n_storage_prefix", "managed-apps/n8n"),
1971
+ "n8n_domain": "",
1972
+ "n8n_public_url": n8n_guardrails.get(
1973
+ "n8n_public_url",
1974
+ state_output(current_outputs, "n8n_url", ""),
1975
+ ),
1976
+ "n8n_certificate_arn": n8n_guardrails.get("n8n_certificate_arn", ""),
1977
+ "n8n_main_desired_count": n8n_guardrails.get("n8n_main_desired_count", 1),
1978
+ "n8n_worker_desired_count": n8n_guardrails.get("n8n_worker_desired_count", 1),
1979
+ "n8n_worker_concurrency": n8n_guardrails.get("n8n_worker_concurrency", 10),
1980
+ "n8n_container_port": n8n_guardrails.get("n8n_container_port", 5678),
1981
+ "n8n_queue_mode": n8n_guardrails.get("n8n_queue_mode", True),
1982
+ "n8n_task_runners_enabled": n8n_guardrails.get("n8n_task_runners_enabled", True),
1983
+ "n8n_package_config_digest": n8n_guardrails.get("n8n_package_config_digest", ""),
1984
+ "n8n_custom_package_specs": n8n_guardrails.get("n8n_custom_package_specs", []),
1985
+ "n8n_execution_data_storage_mode": n8n_guardrails.get(
1986
+ "n8n_execution_data_storage_mode",
1987
+ "database",
1988
+ ),
1989
+ "n8n_binary_data_mode": n8n_binary_data_mode(
1990
+ n8n_guardrails.get("n8n_binary_data_mode", "default")
1991
+ ),
1992
+ "n8n_cache_engine": n8n_guardrails.get("n8n_cache_engine", "valkey"),
1993
+ "n8n_cache_engine_version": n8n_guardrails.get("n8n_cache_engine_version", "8.0"),
1994
+ "n8n_cache_parameter_group_family": n8n_guardrails.get(
1995
+ "n8n_cache_parameter_group_family",
1996
+ "valkey8",
1997
+ ),
1998
+ "n8n_cache_node_type": n8n_guardrails.get(
1999
+ "n8n_cache_node_type",
2000
+ "cache.t4g.micro",
2001
+ ),
2002
+ "n8n_cache_num_cache_clusters": n8n_guardrails.get(
2003
+ "n8n_cache_num_cache_clusters",
2004
+ 1,
2005
+ ),
2006
+ "n8n_allowed_public_cidr_blocks": n8n_guardrails.get(
2007
+ "n8n_allowed_public_cidr_blocks",
2008
+ ["0.0.0.0/0"],
2009
+ ),
2010
+ "n8n_kms_key_arns": n8n_guardrails.get("n8n_kms_key_arns", []),
2011
+ "n8n_dns_enabled": False,
2012
+ "n8n_dns_name": "",
2013
+ }
2014
+
2015
+ if app_key == "n8n":
2016
+ provisioned = operation != "DESTROY"
2017
+ overrides.update(
2018
+ n8n_terraform_overrides(
2019
+ stage,
2020
+ account_id,
2021
+ operation,
2022
+ desired_config,
2023
+ manifest_images,
2024
+ current_outputs,
2025
+ n8n_guardrails,
2026
+ twenty_guardrails,
2027
+ )
2028
+ )
2029
+ n8n_dns_name = overrides["n8n_domain"] or url_hostname(overrides["n8n_public_url"])
2030
+ overrides["cloudflare_zone_id"] = overrides[
2031
+ "cloudflare_zone_id"
2032
+ ] or cloudflare_zone_id_for_hostname(stage, n8n_dns_name)
2033
+ overrides["n8n_dns_name"] = n8n_dns_name
2034
+ overrides["n8n_dns_enabled"] = (
2035
+ provisioned and bool(overrides["cloudflare_zone_id"]) and bool(n8n_dns_name)
2036
+ )
2037
+ return overrides
2038
+
2039
+ if app_key == "twenty":
2040
+ overrides.update(
2041
+ twenty_terraform_overrides(
2042
+ operation,
2043
+ desired_config,
2044
+ manifest_images,
2045
+ current_outputs,
2046
+ twenty_guardrails,
2047
+ payload,
2048
+ )
2049
+ )
2050
+ return overrides
2051
+
2052
+ return overrides
2053
+
2054
+
2055
+ def existing_stage_secret_string(stage, suffix):
2056
+ """Read a plain-string platform secret (e.g. thinkwork/<stage>/api-auth).
2057
+
2058
+ Returns "" when the secret does not exist yet (first install) or cannot
2059
+ be read — callers mint a fresh value in that case.
2060
+ """
2061
+ try:
2062
+ body = output(
2063
+ [
2064
+ "aws",
2065
+ "secretsmanager",
2066
+ "get-secret-value",
2067
+ "--secret-id",
2068
+ f"thinkwork/{stage}/{suffix}",
2069
+ "--query",
2070
+ "SecretString",
2071
+ "--output",
2072
+ "text",
2073
+ ]
2074
+ )
2075
+ return body.strip() if isinstance(body, str) else ""
2076
+ except Exception:
2077
+ return ""
2078
+
2079
+
2080
+ def existing_stage_secret_field(stage, field):
2081
+ try:
2082
+ body = output(
2083
+ [
2084
+ "aws",
2085
+ "secretsmanager",
2086
+ "get-secret-value",
2087
+ "--secret-id",
2088
+ f"thinkwork-{stage}-db-credentials",
2089
+ "--query",
2090
+ "SecretString",
2091
+ "--output",
2092
+ "text",
2093
+ ]
2094
+ )
2095
+ secret = json.loads(body or "{}")
2096
+ value = secret.get(field)
2097
+ return value if isinstance(value, str) else ""
2098
+ except Exception:
2099
+ return ""
2100
+
2101
+
2102
+ def hcl_string(value):
2103
+ return json.dumps(value)
2104
+
2105
+
2106
+ def release_runtime_image(name):
2107
+ if not MANIFEST.exists():
2108
+ return ""
2109
+ manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
2110
+ for image in manifest.get("runtimeImages", []):
2111
+ if image.get("name") == name:
2112
+ return image.get("uri") or ""
2113
+ return ""
2114
+
2115
+
2116
+ def release_git_sha():
2117
+ if not MANIFEST.exists():
2118
+ return ""
2119
+ manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
2120
+ release = manifest.get("release")
2121
+ if isinstance(release, dict):
2122
+ git_sha = release.get("gitSha")
2123
+ return git_sha if isinstance(git_sha, str) else ""
2124
+ return ""
2125
+
2126
+
2127
+ def ensure_release_manifest_available(manifest_url, manifest_sha256):
2128
+ if not manifest_url and not MANIFEST.exists():
2129
+ return
2130
+ if not MANIFEST.exists():
2131
+ download(manifest_url, MANIFEST)
2132
+ verify_release_manifest_digest(MANIFEST, manifest_sha256)
2133
+
2134
+
2135
+ def source_repo_and_ref(module_source, release_version):
2136
+ source = module_source.removeprefix("git::")
2137
+ source_path, _, query = source.partition("?")
2138
+ params = urllib.parse.parse_qs(query)
2139
+ ref = params.get("ref", [release_version])[0]
2140
+ if source_path == "thinkwork-ai/thinkwork/aws":
2141
+ repo = "https://github.com/thinkwork-ai/thinkwork.git"
2142
+ ref = release_git_sha() or release_version
2143
+ elif source_path.startswith("github.com/"):
2144
+ github_source = source_path
2145
+ if "//terraform/" in github_source:
2146
+ github_source = github_source.split("//terraform/", 1)[0]
2147
+ repo = f"https://{github_source.removesuffix('.git')}.git"
2148
+ elif ".git//" in source_path:
2149
+ repo = source_path.split(".git//", 1)[0] + ".git"
2150
+ elif "//terraform/" in source_path:
2151
+ repo = source_path.split("//terraform/", 1)[0]
2152
+ else:
2153
+ repo = source_path
2154
+ return repo, ref
2155
+
2156
+
2157
+ def terraform_module_source_and_version(module_source, module_version, release_version):
2158
+ source = module_source.removeprefix("git::")
2159
+ source_path, _, _query = source.partition("?")
2160
+ if source_path == "thinkwork-ai/thinkwork/aws":
2161
+ ref = release_git_sha() or release_version
2162
+ quoted_ref = urllib.parse.quote(ref, safe="")
2163
+ return (
2164
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
2165
+ f"//terraform/modules/thinkwork?ref={quoted_ref}",
2166
+ "",
2167
+ )
2168
+ if module_source.startswith("git::") or ".git//" in source_path:
2169
+ return module_source, ""
2170
+ return module_source, module_version
2171
+
2172
+
2173
+ def is_pinned_thinkwork_module_source(module_source):
2174
+ source = module_source.removeprefix("git::")
2175
+ source_path, _, _query = source.partition("?")
2176
+ return source_path in {
2177
+ "https://github.com/thinkwork-ai/thinkwork.git//terraform/modules/thinkwork",
2178
+ "https://github.com/thinkwork-ai/thinkwork//terraform/modules/thinkwork",
2179
+ "github.com/thinkwork-ai/thinkwork.git//terraform/modules/thinkwork",
2180
+ "github.com/thinkwork-ai/thinkwork//terraform/modules/thinkwork",
2181
+ }
2182
+
2183
+
2184
+ def selected_module_source_inputs(payload, release_version):
2185
+ payload_module_source = safe_get(payload, "terraformModuleSource", default="")
2186
+ if payload_module_source:
2187
+ return (
2188
+ payload_module_source,
2189
+ safe_get(payload, "terraformModuleVersion", default=""),
2190
+ )
2191
+
2192
+ module_source = os.environ["THINKWORK_TERRAFORM_MODULE_SOURCE"]
2193
+ module_version = os.environ.get("THINKWORK_TERRAFORM_MODULE_VERSION", "")
2194
+ if is_pinned_thinkwork_module_source(module_source):
2195
+ # Existing controllers may have persisted a concrete git ref for the
2196
+ # module source. Managed-app payloads still generate the wrapper from
2197
+ # the selected release, so the child module must follow that release
2198
+ # too or new app arguments fail Terraform config loading immediately.
2199
+ return "thinkwork-ai/thinkwork/aws", release_version.removeprefix("v")
2200
+ return module_source, module_version
2201
+
2202
+
2203
+ def checkout_source(module_source, release_version):
2204
+ if SOURCE.exists():
2205
+ return
2206
+ repo, ref = source_repo_and_ref(module_source, release_version)
2207
+ if not repo.startswith(("https://", "git@")):
2208
+ raise RuntimeError(f"Cannot initialize database schema from module source: {module_source}")
2209
+ run(["git", "clone", "--no-checkout", "--filter=blob:none", repo, str(SOURCE)])
2210
+ run(["git", "-C", str(SOURCE), "fetch", "--depth", "1", "origin", ref])
2211
+ run(["git", "-C", str(SOURCE), "checkout", "--detach", "FETCH_HEAD"])
2212
+
2213
+
2214
+ def database_url_from_outputs(outputs):
2215
+ endpoint = outputs.get("db_cluster_endpoint", {}).get("value")
2216
+ secret_arn = outputs.get("db_secret_arn", {}).get("value")
2217
+ database_name = outputs.get("database_name", {}).get("value") or "thinkwork"
2218
+ if not endpoint or not secret_arn:
2219
+ raise RuntimeError("Terraform outputs missing database endpoint or secret ARN")
2220
+ body = output(
2221
+ [
2222
+ "aws",
2223
+ "secretsmanager",
2224
+ "get-secret-value",
2225
+ "--secret-id",
2226
+ str(secret_arn),
2227
+ "--query",
2228
+ "SecretString",
2229
+ "--output",
2230
+ "text",
2231
+ ]
2232
+ )
2233
+ secret = json.loads(body or "{}")
2234
+ username = secret.get("username") or "thinkwork_admin"
2235
+ password = secret.get("password") or ""
2236
+ if not password:
2237
+ raise RuntimeError("Database secret is missing password")
2238
+ return (
2239
+ "postgresql://"
2240
+ f"{urllib.parse.quote(str(username), safe='')}:"
2241
+ f"{urllib.parse.quote(str(password), safe='')}@"
2242
+ f"{endpoint}:5432/{database_name}?sslmode=require"
2243
+ )
2244
+
2245
+
2246
+ def psql_env(database_url):
2247
+ env = os.environ.copy()
2248
+ env["DATABASE_URL"] = database_url
2249
+ return env
2250
+
2251
+
2252
+ def psql(database_url, sql=None, file=None, variables=None):
2253
+ args = ["psql", database_url, "-v", "ON_ERROR_STOP=1"]
2254
+ for key, value in (variables or {}).items():
2255
+ args.extend(["-v", f"{key}={value}"])
2256
+ if file:
2257
+ args.extend(["-f", str(file)])
2258
+ return run(args)
2259
+ return subprocess.run(args, input=sql, check=True, text=True)
2260
+
2261
+
2262
+ def psql_output(database_url, sql):
2263
+ return output(["psql", database_url, "-tAc", sql])
2264
+
2265
+
2266
+ def put_secret_value(secret_id, payload):
2267
+ run(
2268
+ [
2269
+ "aws",
2270
+ "secretsmanager",
2271
+ "put-secret-value",
2272
+ "--secret-id",
2273
+ secret_id,
2274
+ "--secret-string",
2275
+ json.dumps(payload),
2276
+ ]
2277
+ )
2278
+
2279
+
2280
+ def ensure_compliance_roles(database_url, outputs, vars_json):
2281
+ stage = vars_json["stage"]
2282
+ endpoint = outputs.get("db_cluster_endpoint", {}).get("value")
2283
+ database_name = outputs.get("database_name", {}).get("value") or "thinkwork"
2284
+ secrets_by_role = {
2285
+ "writer_pass": ("compliance_writer", f"thinkwork/{stage}/compliance/writer-credentials"),
2286
+ "drainer_pass": ("compliance_drainer", f"thinkwork/{stage}/compliance/drainer-credentials"),
2287
+ "reader_pass": ("compliance_reader", f"thinkwork/{stage}/compliance/reader-credentials"),
2288
+ }
2289
+ variables = {}
2290
+ for variable, (username, secret_id) in secrets_by_role.items():
2291
+ password = secrets.token_urlsafe(36)
2292
+ variables[variable] = password
2293
+ put_secret_value(
2294
+ secret_id,
2295
+ {
2296
+ "username": username,
2297
+ "password": password,
2298
+ "host": endpoint,
2299
+ "port": 5432,
2300
+ "dbname": database_name,
2301
+ },
2302
+ )
2303
+ psql(
2304
+ database_url,
2305
+ file=SOURCE / "packages/database-pg/drizzle/0070_compliance_aurora_roles.sql",
2306
+ variables=variables,
2307
+ )
2308
+
2309
+
2310
+ def migration_files():
2311
+ migrations = SOURCE / "packages/database-pg/drizzle"
2312
+ return sorted(path for path in migrations.glob("*.sql") if "rollback" not in path.name)
2313
+
2314
+
2315
+ def apply_migration_file(database_url, outputs, vars_json, path):
2316
+ if path.name == "0031_thread_cleanup_drops.sql":
2317
+ psql(
2318
+ database_url,
2319
+ sql="""
2320
+ DROP INDEX IF EXISTS public.idx_threads_tenant_status;
2321
+ DROP INDEX IF EXISTS public.idx_threads_parent_id;
2322
+ DROP TABLE IF EXISTS public.thread_comments CASCADE;
2323
+ """,
2324
+ )
2325
+ return
2326
+ if path.name == "0070_compliance_aurora_roles.sql":
2327
+ ensure_compliance_roles(database_url, outputs, vars_json)
2328
+ return
2329
+ psql(database_url, file=path, variables={"stage": vars_json["stage"]})
2330
+
2331
+
2332
+ def initialize_greenfield_database(database_url, outputs, vars_json):
2333
+ psql(
2334
+ database_url,
2335
+ sql="""
2336
+ DROP SCHEMA IF EXISTS public CASCADE;
2337
+ DROP SCHEMA IF EXISTS ontology CASCADE;
2338
+ DROP SCHEMA IF EXISTS compliance CASCADE;
2339
+ DROP SCHEMA IF EXISTS brain CASCADE;
2340
+ DROP SCHEMA IF EXISTS wiki CASCADE;
2341
+ CREATE SCHEMA public;
2342
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
2343
+ CREATE EXTENSION IF NOT EXISTS vector;
2344
+ """,
2345
+ )
2346
+ for path in migration_files():
2347
+ apply_migration_file(database_url, outputs, vars_json, path)
2348
+
2349
+
2350
+ def sql_literal(value):
2351
+ return "'" + str(value).replace("'", "''") + "'"
2352
+
2353
+
2354
+ def ensure_migration_ledger(database_url):
2355
+ psql(
2356
+ database_url,
2357
+ sql="""
2358
+ CREATE TABLE IF NOT EXISTS public.platform_schema_migrations (
2359
+ name text PRIMARY KEY,
2360
+ source text NOT NULL DEFAULT 'runner',
2361
+ applied_at timestamptz NOT NULL DEFAULT now()
2362
+ );
2363
+ """,
2364
+ )
2365
+
2366
+
2367
+ def recorded_platform_migrations(database_url):
2368
+ rows = psql_output(database_url, "SELECT name FROM public.platform_schema_migrations")
2369
+ return {line.strip() for line in rows.splitlines() if line.strip()}
2370
+
2371
+
2372
+ def record_platform_migrations(database_url, names, source):
2373
+ names = list(names)
2374
+ if not names:
2375
+ return
2376
+ values = ", ".join(f"({sql_literal(name)}, {sql_literal(source)})" for name in names)
2377
+ psql(
2378
+ database_url,
2379
+ sql=(
2380
+ "INSERT INTO public.platform_schema_migrations (name, source) "
2381
+ f"VALUES {values} ON CONFLICT (name) DO NOTHING;"
2382
+ ),
2383
+ )
2384
+
2385
+
2386
+ def declared_migration_objects(path):
2387
+ """Parse `-- creates*:` markers from a migration file's leading comment block."""
2388
+ objects = []
2389
+ for line in path.read_text(encoding="utf-8").splitlines():
2390
+ stripped = line.strip()
2391
+ if not stripped:
2392
+ continue
2393
+ if not stripped.startswith("--"):
2394
+ break
2395
+ for prefix, kind in MIGRATION_MARKER_KINDS:
2396
+ if stripped.startswith(prefix):
2397
+ name = stripped[len(prefix) :].strip()
2398
+ if name:
2399
+ objects.append((kind, name))
2400
+ break
2401
+ return objects
2402
+
2403
+
2404
+ def platform_migration_object_present(database_url, kind, name):
2405
+ """True/False when verifiable against the database; None when the marker
2406
+ names something we cannot check (the transition then assumes applied)."""
2407
+ parts = [part for part in name.split(".") if part]
2408
+ if kind == "column" and len(parts) == 3:
2409
+ schema, table, column = parts
2410
+ return bool(
2411
+ psql_output(
2412
+ database_url,
2413
+ "SELECT 1 FROM information_schema.columns WHERE "
2414
+ f"table_schema = {sql_literal(schema)} AND table_name = {sql_literal(table)} "
2415
+ f"AND column_name = {sql_literal(column)}",
2416
+ ).strip()
2417
+ )
2418
+ if kind == "constraint" and len(parts) == 3:
2419
+ schema, table, constraint = parts
2420
+ return bool(
2421
+ psql_output(
2422
+ database_url,
2423
+ "SELECT 1 FROM pg_constraint c "
2424
+ "JOIN pg_class r ON r.oid = c.conrelid "
2425
+ "JOIN pg_namespace n ON n.oid = r.relnamespace "
2426
+ f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
2427
+ f"AND c.conname = {sql_literal(constraint)}",
2428
+ ).strip()
2429
+ )
2430
+ if kind == "object" and len(parts) == 2:
2431
+ schema, obj = parts
2432
+ return bool(
2433
+ psql_output(
2434
+ database_url,
2435
+ f"SELECT 1 WHERE to_regclass({sql_literal(name)}) IS NOT NULL "
2436
+ "UNION ALL SELECT 1 FROM pg_proc p "
2437
+ "JOIN pg_namespace n ON n.oid = p.pronamespace "
2438
+ f"WHERE n.nspname = {sql_literal(schema)} AND p.proname = {sql_literal(obj)} "
2439
+ "LIMIT 1",
2440
+ ).strip()
2441
+ )
2442
+ if kind == "object" and len(parts) == 3:
2443
+ schema, table, child = parts
2444
+ return bool(
2445
+ psql_output(
2446
+ database_url,
2447
+ "SELECT 1 FROM information_schema.columns WHERE "
2448
+ f"table_schema = {sql_literal(schema)} AND table_name = {sql_literal(table)} "
2449
+ f"AND column_name = {sql_literal(child)} "
2450
+ "UNION ALL SELECT 1 FROM pg_constraint c "
2451
+ "JOIN pg_class r ON r.oid = c.conrelid "
2452
+ "JOIN pg_namespace n ON n.oid = r.relnamespace "
2453
+ f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
2454
+ f"AND c.conname = {sql_literal(child)} "
2455
+ "UNION ALL SELECT 1 FROM pg_trigger t "
2456
+ "JOIN pg_class r ON r.oid = t.tgrelid "
2457
+ "JOIN pg_namespace n ON n.oid = r.relnamespace "
2458
+ f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
2459
+ f"AND t.tgname = {sql_literal(child)} AND NOT t.tgisinternal "
2460
+ "LIMIT 1",
2461
+ ).strip()
2462
+ )
2463
+ return None
2464
+
2465
+
2466
+ def backfill_platform_migration_ledger(database_url):
2467
+ """One-time ledger bootstrap for environments installed before the ledger
2468
+ existed. Every file shipping with this release is recorded as assumed
2469
+ applied — auto-re-running old files is unsafe (markers can name objects
2470
+ that later migrations intentionally dropped). Marker-verified drift is
2471
+ reported so an operator can true it up manually; releases after the
2472
+ transition apply through the exact pending path."""
2473
+ verified = []
2474
+ assumed = []
2475
+ for path in migration_files():
2476
+ verdicts = [
2477
+ platform_migration_object_present(database_url, kind, name)
2478
+ for kind, name in declared_migration_objects(path)
2479
+ ]
2480
+ if any(verdict is False for verdict in verdicts):
2481
+ print(
2482
+ f"[migrations] transition WARNING: {path.name} declares objects missing from "
2483
+ "this database; apply it manually if the feature it backs is expected here"
2484
+ )
2485
+ assumed.append(path.name)
2486
+ elif any(verdict is True for verdict in verdicts):
2487
+ verified.append(path.name)
2488
+ else:
2489
+ assumed.append(path.name)
2490
+ record_platform_migrations(database_url, verified, "transition-verified")
2491
+ record_platform_migrations(database_url, assumed, "transition-assumed")
2492
+
2493
+
2494
+ def apply_pending_platform_migrations(database_url, outputs, vars_json):
2495
+ recorded = recorded_platform_migrations(database_url)
2496
+ for path in migration_files():
2497
+ if path.name in recorded:
2498
+ continue
2499
+ print(f"[migrations] applying {path.name}")
2500
+ apply_migration_file(database_url, outputs, vars_json, path)
2501
+ record_platform_migrations(database_url, [path.name], "runner")
2502
+
2503
+
2504
+ def seed_platform_bootstrap_defaults(database_url):
2505
+ psql(
2506
+ database_url,
2507
+ sql="""
2508
+ BEGIN;
2509
+
2510
+ INSERT INTO public.model_catalog (
2511
+ model_id,
2512
+ provider,
2513
+ display_name,
2514
+ input_cost_per_million,
2515
+ output_cost_per_million,
2516
+ context_window,
2517
+ max_output_tokens,
2518
+ supports_vision,
2519
+ supports_tools,
2520
+ is_available
2521
+ ) VALUES
2522
+ (
2523
+ 'us.anthropic.claude-sonnet-4-6',
2524
+ 'anthropic',
2525
+ 'Claude Sonnet 4.6',
2526
+ 3.00,
2527
+ 15.00,
2528
+ 200000,
2529
+ 64000,
2530
+ true,
2531
+ true,
2532
+ true
2533
+ ),
2534
+ (
2535
+ 'us.anthropic.claude-opus-4-6-v1',
2536
+ 'anthropic',
2537
+ 'Claude Opus 4.6',
2538
+ 15.00,
2539
+ 75.00,
2540
+ 200000,
2541
+ 32000,
2542
+ true,
2543
+ true,
2544
+ true
2545
+ ),
2546
+ (
2547
+ 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
2548
+ 'anthropic',
2549
+ 'Claude Haiku 4.5',
2550
+ 0.80,
2551
+ 4.00,
2552
+ 200000,
2553
+ 64000,
2554
+ true,
2555
+ true,
2556
+ true
2557
+ )
2558
+ ON CONFLICT (model_id) DO UPDATE SET
2559
+ provider = EXCLUDED.provider,
2560
+ display_name = EXCLUDED.display_name,
2561
+ input_cost_per_million = EXCLUDED.input_cost_per_million,
2562
+ output_cost_per_million = EXCLUDED.output_cost_per_million,
2563
+ context_window = EXCLUDED.context_window,
2564
+ max_output_tokens = EXCLUDED.max_output_tokens,
2565
+ supports_vision = EXCLUDED.supports_vision,
2566
+ supports_tools = EXCLUDED.supports_tools,
2567
+ is_available = EXCLUDED.is_available,
2568
+ updated_at = now();
2569
+
2570
+ INSERT INTO public.tenant_settings (tenant_id, default_model)
2571
+ SELECT id, 'us.anthropic.claude-sonnet-4-6'
2572
+ FROM public.tenants
2573
+ ON CONFLICT (tenant_id) DO UPDATE SET
2574
+ default_model = COALESCE(public.tenant_settings.default_model, EXCLUDED.default_model),
2575
+ updated_at = now();
2576
+
2577
+ INSERT INTO public.agents (
2578
+ tenant_id,
2579
+ name,
2580
+ slug,
2581
+ workspace_folder_name,
2582
+ source,
2583
+ runtime,
2584
+ status,
2585
+ system_prompt,
2586
+ model,
2587
+ is_platform_default
2588
+ )
2589
+ SELECT
2590
+ t.id,
2591
+ 'ThinkWork Agent',
2592
+ 'thinkwork-agent-' || left(md5(t.id::text), 12),
2593
+ 'thinkwork-agent',
2594
+ 'system',
2595
+ 'pi',
2596
+ 'idle',
2597
+ 'You are ThinkWork Agent, the default assistant for this workspace.',
2598
+ 'us.anthropic.claude-sonnet-4-6',
2599
+ true
2600
+ FROM public.tenants t
2601
+ WHERE NOT EXISTS (
2602
+ SELECT 1
2603
+ FROM public.agents a
2604
+ WHERE a.tenant_id = t.id
2605
+ AND a.is_platform_default IS TRUE
2606
+ );
2607
+
2608
+ UPDATE public.agents
2609
+ SET model = 'us.anthropic.claude-sonnet-4-6',
2610
+ updated_at = now()
2611
+ WHERE is_platform_default IS TRUE
2612
+ AND model IS NULL;
2613
+
2614
+ WITH default_models AS (
2615
+ SELECT tenant_id, default_model AS model_id
2616
+ FROM public.tenant_settings
2617
+ WHERE default_model IS NOT NULL
2618
+ UNION
2619
+ SELECT tenant_id, model AS model_id
2620
+ FROM public.agents
2621
+ WHERE model IS NOT NULL
2622
+ UNION
2623
+ SELECT tenant_id, model AS model_id
2624
+ FROM public.agent_templates
2625
+ WHERE model IS NOT NULL
2626
+ ),
2627
+ available_defaults AS (
2628
+ SELECT DISTINCT
2629
+ u.tenant_id,
2630
+ u.id AS user_id,
2631
+ d.model_id
2632
+ FROM public.users u
2633
+ JOIN default_models d
2634
+ ON d.tenant_id = u.tenant_id
2635
+ JOIN public.model_catalog mc
2636
+ ON mc.model_id = d.model_id
2637
+ AND mc.is_available IS TRUE
2638
+ WHERE u.tenant_id IS NOT NULL
2639
+ )
2640
+ INSERT INTO public.user_model_approvals (tenant_id, user_id, model_id)
2641
+ SELECT tenant_id, user_id, model_id
2642
+ FROM available_defaults
2643
+ ON CONFLICT (tenant_id, user_id, model_id) DO NOTHING;
2644
+
2645
+ COMMIT;
2646
+ """,
2647
+ )
2648
+
2649
+
2650
+ def is_valid_tenant_slug(value):
2651
+ if not value or len(value) > 63:
2652
+ return False
2653
+ if value[0] == "-" or value[-1] == "-":
2654
+ return False
2655
+ return all(ch.isascii() and (ch.islower() or ch.isdigit() or ch == "-") for ch in value)
2656
+
2657
+
2658
+ def is_plausible_email(value):
2659
+ if not value or any(ch.isspace() or ch in "'\"\\" for ch in value):
2660
+ return False
2661
+ local, sep, domain = value.partition("@")
2662
+ return bool(sep and local and "." in domain and domain[0] != "." and domain[-1] != ".")
2663
+
2664
+
2665
+ def first_admin_email(vars_json):
2666
+ raw = vars_json.get("platform_operator_emails") or ""
2667
+ first = raw.split(",")[0].strip()
2668
+ return first if is_plausible_email(first) else ""
2669
+
2670
+
2671
+ def first_admin_tenant_slug(payload, runner_secrets, vars_json):
2672
+ """Slug for the first-run tenant. MUST equal the customer-domain label when a
2673
+ customer domain is configured (KTD8: email-inbound resolves the tenant from
2674
+ the recipient subdomain), so the domain label outranks everything except an
2675
+ explicit override."""
2676
+ explicit = (
2677
+ safe_get(
2678
+ runner_secrets,
2679
+ "tenantSlug",
2680
+ default=safe_get(payload, "tenantSlug", default=""),
2681
+ )
2682
+ .strip()
2683
+ .lower()
2684
+ )
2685
+ domain = (vars_json.get("customer_domain") or "").strip().lower()
2686
+ domain_label = domain.split(".")[0] if domain else ""
2687
+ candidate = explicit or domain_label or vars_json["stage"]
2688
+ if not is_valid_tenant_slug(candidate):
2689
+ raise RuntimeError(f"first-admin tenant slug {candidate!r} fails the slug pattern")
2690
+ if domain_label and candidate != domain_label:
2691
+ raise RuntimeError(
2692
+ f"first-admin tenant slug {candidate!r} must equal the customer domain "
2693
+ f"label {domain_label!r} (KTD8) — inbound email resolves tenants by subdomain"
2694
+ )
2695
+ return candidate
2696
+
2697
+
2698
+ def cognito_idp(args, region, check=True):
2699
+ result = subprocess.run(
2700
+ ["aws", "cognito-idp", *args, "--region", region],
2701
+ capture_output=True,
2702
+ text=True,
2703
+ )
2704
+ if check and result.returncode != 0:
2705
+ raise RuntimeError(f"cognito-idp {args[0]} failed: {result.stderr.strip()}")
2706
+ return result
2707
+
2708
+
2709
+ def ensure_first_admin_cognito_user(user_pool_id, email, region):
2710
+ """Idempotently ensure the first admin exists in the user pool. Returns
2711
+ (sub, created). New users get Cognito's invite email with a temporary
2712
+ password — sent by Cognito's default sender, so it works while the
2713
+ account's SES identity is still sandboxed."""
2714
+ probe = cognito_idp(
2715
+ ["admin-get-user", "--user-pool-id", user_pool_id, "--username", email],
2716
+ region,
2717
+ check=False,
2718
+ )
2719
+ created = False
2720
+ if probe.returncode != 0:
2721
+ if "UserNotFoundException" not in (probe.stderr or ""):
2722
+ raise RuntimeError(f"admin-get-user failed: {probe.stderr.strip()}")
2723
+ cognito_idp(
2724
+ [
2725
+ "admin-create-user",
2726
+ "--user-pool-id",
2727
+ user_pool_id,
2728
+ "--username",
2729
+ email,
2730
+ "--user-attributes",
2731
+ f"Name=email,Value={email}",
2732
+ "Name=email_verified,Value=true",
2733
+ "--desired-delivery-mediums",
2734
+ "EMAIL",
2735
+ ],
2736
+ region,
2737
+ )
2738
+ created = True
2739
+ probe = cognito_idp(
2740
+ ["admin-get-user", "--user-pool-id", user_pool_id, "--username", email],
2741
+ region,
2742
+ )
2743
+ attributes = json.loads(probe.stdout or "{}").get("UserAttributes", [])
2744
+ sub = next((a["Value"] for a in attributes if a.get("Name") == "sub"), "")
2745
+ if not sub:
2746
+ raise RuntimeError(f"could not resolve Cognito sub for {email}")
2747
+ return sub, created
2748
+
2749
+
2750
+ FIRST_ADMIN_PROVISION_SQL = """
2751
+ BEGIN;
2752
+
2753
+ -- Tenant: created only when the environment has no tenants at all. An
2754
+ -- established environment is never given an extra tenant by this step.
2755
+ INSERT INTO public.tenants (name, slug, plan, issue_prefix, issue_counter)
2756
+ SELECT :'tenant_name', :'tenant_slug', 'free', 'TW', 0
2757
+ WHERE NOT EXISTS (SELECT 1 FROM public.tenants);
2758
+
2759
+ INSERT INTO public.tenant_settings (tenant_id, default_model)
2760
+ SELECT id, 'us.anthropic.claude-sonnet-4-6'
2761
+ FROM public.tenants WHERE slug = :'tenant_slug'
2762
+ ON CONFLICT (tenant_id) DO NOTHING;
2763
+
2764
+ INSERT INTO public.users (tenant_id, email, name, workspace_folder_name, cognito_sub)
2765
+ SELECT t.id, :'admin_email', :'admin_name', :'admin_folder', :'cognito_sub'
2766
+ FROM public.tenants t
2767
+ WHERE t.slug = :'tenant_slug'
2768
+ AND NOT EXISTS (
2769
+ SELECT 1 FROM public.users WHERE lower(email) = lower(:'admin_email')
2770
+ );
2771
+
2772
+ -- Heal a stranded user row (signed in before provisioning existed).
2773
+ UPDATE public.users u
2774
+ SET tenant_id = t.id,
2775
+ cognito_sub = COALESCE(u.cognito_sub, :'cognito_sub'),
2776
+ updated_at = now()
2777
+ FROM public.tenants t
2778
+ WHERE t.slug = :'tenant_slug'
2779
+ AND lower(u.email) = lower(:'admin_email')
2780
+ AND u.tenant_id IS NULL;
2781
+
2782
+ INSERT INTO public.tenant_members (tenant_id, principal_type, principal_id, role, status)
2783
+ SELECT u.tenant_id, 'user', u.id, 'owner', 'active'
2784
+ FROM public.users u
2785
+ JOIN public.tenants t ON t.id = u.tenant_id
2786
+ WHERE t.slug = :'tenant_slug'
2787
+ AND lower(u.email) = lower(:'admin_email')
2788
+ AND NOT EXISTS (
2789
+ SELECT 1 FROM public.tenant_members m
2790
+ WHERE m.tenant_id = u.tenant_id
2791
+ AND m.principal_type = 'user'
2792
+ AND m.principal_id = u.id
2793
+ );
2794
+
2795
+ UPDATE public.tenants t
2796
+ SET pending_owner_email = NULL,
2797
+ first_admin_claim_required = false,
2798
+ first_admin_claimed_at = COALESCE(t.first_admin_claimed_at, now()),
2799
+ first_admin_claimed_user_id = COALESCE(t.first_admin_claimed_user_id, u.id),
2800
+ updated_at = now()
2801
+ FROM public.users u
2802
+ WHERE t.slug = :'tenant_slug'
2803
+ AND u.tenant_id = t.id
2804
+ AND lower(u.email) = lower(:'admin_email');
2805
+
2806
+ -- Default Space: created only when the tenant has no Spaces, so a fresh
2807
+ -- environment's composer has a target without operator setup.
2808
+ INSERT INTO public.spaces (
2809
+ tenant_id, slug, workspace_folder_name, name, description,
2810
+ status, kind, access_mode, template_key, config
2811
+ )
2812
+ SELECT t.id, 'general', 'general', 'General',
2813
+ 'Default workspace created at install time.',
2814
+ 'active', 'custom', 'public', 'general',
2815
+ '{"workflow":"custom","version":1,"source":"first_admin_bootstrap"}'::jsonb
2816
+ FROM public.tenants t
2817
+ WHERE t.slug = :'tenant_slug'
2818
+ AND NOT EXISTS (SELECT 1 FROM public.spaces s WHERE s.tenant_id = t.id);
2819
+
2820
+ INSERT INTO public.space_members (tenant_id, space_id, user_id, role, notification_preference)
2821
+ SELECT s.tenant_id, s.id, u.id, 'owner', 'subscribed'
2822
+ FROM public.spaces s
2823
+ JOIN public.tenants t ON t.id = s.tenant_id
2824
+ JOIN public.users u ON u.tenant_id = t.id AND lower(u.email) = lower(:'admin_email')
2825
+ WHERE t.slug = :'tenant_slug'
2826
+ AND s.slug = 'general'
2827
+ AND NOT EXISTS (
2828
+ SELECT 1 FROM public.space_members m WHERE m.space_id = s.id AND m.user_id = u.id
2829
+ );
2830
+
2831
+ COMMIT;
2832
+ """
2833
+
2834
+
2835
+ def ensure_first_admin(outputs_path, vars_json, payload, runner_secrets):
2836
+ """First-run admin provisioning: when the deployment carries an adminEmail,
2837
+ make a fresh environment sign-in-ready — tenant (slug = customer-domain
2838
+ label, KTD8), Cognito admin user with an invite email, owner membership,
2839
+ custom:tenant_id, and a default Space. Idempotent; never mutates an
2840
+ environment that already has tenants beyond attaching the admin when the
2841
+ expected tenant slug exists. Non-fatal: failures are logged and echoed
2842
+ into deployment evidence (firstAdminBootstrap) instead of failing an
2843
+ otherwise healthy deploy."""
2844
+ global FIRST_ADMIN_EVIDENCE
2845
+ email = first_admin_email(vars_json)
2846
+ if not email:
2847
+ FIRST_ADMIN_EVIDENCE = {"status": "skipped", "reason": "no adminEmail configured"}
2848
+ return
2849
+ try:
2850
+ slug = first_admin_tenant_slug(payload, runner_secrets, vars_json)
2851
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
2852
+ user_pool_id = (outputs.get("user_pool_id") or {}).get("value", "")
2853
+ if not user_pool_id:
2854
+ raise RuntimeError("terraform outputs are missing user_pool_id")
2855
+ database_url = database_url_from_outputs(outputs)
2856
+
2857
+ tenant_count = int(psql_output(database_url, "SELECT count(*) FROM public.tenants") or 0)
2858
+ slug_present = bool(
2859
+ psql_output(
2860
+ database_url,
2861
+ f"SELECT 1 FROM public.tenants WHERE slug = {pg_literal(slug)}",
2862
+ ).strip()
2863
+ )
2864
+ if tenant_count and not slug_present:
2865
+ FIRST_ADMIN_EVIDENCE = {
2866
+ "status": "skipped",
2867
+ "reason": f"environment already has {tenant_count} tenant(s) and none is {slug!r}",
2868
+ }
2869
+ print(f"[runner] first-admin bootstrap skipped: {FIRST_ADMIN_EVIDENCE['reason']}")
2870
+ return
2871
+
2872
+ region = vars_json.get("region") or os.environ.get("AWS_REGION") or "us-east-1"
2873
+ sub, created = ensure_first_admin_cognito_user(user_pool_id, email, region)
2874
+ local_part = email.split("@")[0]
2875
+ folder = (
2876
+ "".join(
2877
+ ch if (ch.isascii() and (ch.islower() or ch.isdigit())) else "-"
2878
+ for ch in local_part.lower()
2879
+ ).strip("-")
2880
+ or "user"
2881
+ )
2882
+ psql(
2883
+ database_url,
2884
+ sql=FIRST_ADMIN_PROVISION_SQL,
2885
+ variables={
2886
+ "tenant_name": slug.capitalize(),
2887
+ "tenant_slug": slug,
2888
+ "admin_email": email,
2889
+ "admin_name": local_part,
2890
+ "admin_folder": folder,
2891
+ "cognito_sub": sub,
2892
+ },
2893
+ )
2894
+ tenant_id = psql_output(
2895
+ database_url,
2896
+ f"SELECT id FROM public.tenants WHERE slug = {pg_literal(slug)}",
2897
+ ).strip()
2898
+ if tenant_id:
2899
+ cognito_idp(
2900
+ [
2901
+ "admin-update-user-attributes",
2902
+ "--user-pool-id",
2903
+ user_pool_id,
2904
+ "--username",
2905
+ email,
2906
+ "--user-attributes",
2907
+ f"Name=custom:tenant_id,Value={tenant_id}",
2908
+ ],
2909
+ region,
2910
+ )
2911
+ # Re-run the platform seed so the just-created tenant gets the default
2912
+ # agent / settings / model approvals (the earlier seed saw no tenants).
2913
+ seed_platform_bootstrap_defaults(database_url)
2914
+ FIRST_ADMIN_EVIDENCE = {
2915
+ "status": "succeeded",
2916
+ "adminEmail": email,
2917
+ "tenantSlug": slug,
2918
+ "tenantId": tenant_id,
2919
+ "cognitoUserCreated": created,
2920
+ "inviteEmailSent": created,
2921
+ }
2922
+ print(
2923
+ f"[runner] first-admin bootstrap succeeded: tenant={slug} admin={email} "
2924
+ f"cognitoUserCreated={created}"
2925
+ )
2926
+ except Exception as exc:
2927
+ FIRST_ADMIN_EVIDENCE = {"status": "failed", "adminEmail": email, "error": str(exc)}
2928
+ print(f"[runner] first-admin bootstrap FAILED (non-fatal): {exc}")
2929
+
2930
+
2931
+ def pg_literal(value):
2932
+ return "'" + value.replace("'", "''") + "'"
2933
+
2934
+
2935
+ def push_database_schema(outputs_path, vars_json):
2936
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
2937
+ checkout_source(
2938
+ vars_json.get("deployment_terraform_module_source")
2939
+ or os.environ["THINKWORK_TERRAFORM_MODULE_SOURCE"],
2940
+ os.environ.get("THINKWORK_RELEASE_VERSION", "main"),
2941
+ )
2942
+ database_url = database_url_from_outputs(outputs)
2943
+ fresh = not psql_output(database_url, "SELECT to_regclass('public.tenants')").strip()
2944
+ ledger_present = bool(
2945
+ psql_output(database_url, "SELECT to_regclass('public.platform_schema_migrations')").strip()
2946
+ )
2947
+ if fresh:
2948
+ initialize_greenfield_database(database_url, outputs, vars_json)
2949
+ ensure_migration_ledger(database_url)
2950
+ if fresh:
2951
+ record_platform_migrations(
2952
+ database_url, [path.name for path in migration_files()], "greenfield"
2953
+ )
2954
+ elif not ledger_present:
2955
+ backfill_platform_migration_ledger(database_url)
2956
+ apply_pending_platform_migrations(database_url, outputs, vars_json)
2957
+ seed_platform_bootstrap_defaults(database_url)
2958
+ migrations = SOURCE / "packages/database-pg/drizzle"
2959
+ for name in POST_SEED_MIGRATIONS:
2960
+ path = migrations / name
2961
+ if not path.is_file():
2962
+ raise RuntimeError(f"Required platform migration is missing: {path}")
2963
+ apply_migration_file(database_url, outputs, vars_json, path)
2964
+
2965
+
2966
+ def write_runner_files(payload, runner_secrets):
2967
+ preserved_config = payload.get("preservedConfig")
2968
+ if not isinstance(preserved_config, dict):
2969
+ preserved_config = {}
2970
+ reviewed_payload = dict(payload)
2971
+ reviewed_payload.update(preserved_config)
2972
+ stage = safe_get(
2973
+ payload,
2974
+ "stage",
2975
+ "environmentName",
2976
+ default=os.environ["THINKWORK_STAGE"],
2977
+ )
2978
+ region = safe_get(
2979
+ payload,
2980
+ "awsRegion",
2981
+ "region",
2982
+ default=os.environ.get("AWS_REGION") or "us-east-1",
2983
+ )
2984
+ account_id = safe_get(payload, "awsAccountId", "accountId", default="")
2985
+ if not account_id:
2986
+ account_id = output(
2987
+ ["aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text"]
2988
+ )
2989
+ current_state = current_terraform_state(stage)
2990
+ current_outputs = current_state.get("outputs")
2991
+ if not isinstance(current_outputs, dict):
2992
+ current_outputs = {}
2993
+
2994
+ selected_release = release_selection(payload)
2995
+ release_version = selected_release.get("version") or "unresolved"
2996
+ release_manifest_url = selected_release.get("manifestUrl") or ""
2997
+ release_manifest_sha256 = selected_release.get("manifestSha256") or ""
2998
+ release_manifest_signature_url = selected_release.get("manifestSignatureUrl") or ""
2999
+ release_manifest_trust_policy_value = (
3000
+ selected_release.get("manifestTrustPolicy") or "allow_unsigned_canary"
3001
+ )
3002
+ if release_manifest_trust_policy_value not in RELEASE_MANIFEST_TRUST_POLICIES:
3003
+ raise RuntimeError(
3004
+ "Unsupported release manifest trust policy "
3005
+ f"{release_manifest_trust_policy_value!r}; expected one of "
3006
+ f"{sorted(RELEASE_MANIFEST_TRUST_POLICIES)}"
3007
+ )
3008
+ ensure_release_manifest_available(release_manifest_url, release_manifest_sha256)
3009
+ release_manifest_trusted_keys_json = json.dumps(
3010
+ trusted_release_keys(),
3011
+ separators=(",", ":"),
3012
+ sort_keys=True,
3013
+ )
3014
+ module_source, module_version = selected_module_source_inputs(
3015
+ payload,
3016
+ release_version,
3017
+ )
3018
+ terraform_module_source, terraform_module_version = terraform_module_source_and_version(
3019
+ module_source,
3020
+ module_version,
3021
+ release_version,
3022
+ )
3023
+ module_version_line = (
3024
+ f" version = {hcl_string(terraform_module_version)}\n" if terraform_module_version else ""
3025
+ )
3026
+ db_password = safe_get(
3027
+ runner_secrets,
3028
+ "dbPassword",
3029
+ "databasePassword",
3030
+ default=safe_get(payload, "dbPassword", "databasePassword", default=""),
3031
+ )
3032
+ if not db_password:
3033
+ db_password = existing_stage_secret_field(stage, "password")
3034
+ api_auth_secret = safe_get(
3035
+ runner_secrets,
3036
+ "apiAuthSecret",
3037
+ default=safe_get(payload, "apiAuthSecret", default=""),
3038
+ )
3039
+ if not db_password:
3040
+ db_password = secrets.token_urlsafe(36)
3041
+ if not api_auth_secret:
3042
+ # Reuse the stage's existing service-auth secret (written to Secrets
3043
+ # Manager by terraform on the previous apply). Minting a fresh value
3044
+ # on every run rotated API_AUTH_SECRET per release, which silently
3045
+ # drifted the env copy away from the pinned Secrets Manager copy —
3046
+ # harmless while readers are env-first, fatal once env drops (R8).
3047
+ api_auth_secret = existing_stage_secret_string(stage, "api-auth")
3048
+ if not api_auth_secret:
3049
+ api_auth_secret = secrets.token_urlsafe(48)
3050
+
3051
+ vars_json = {
3052
+ "stage": stage,
3053
+ "region": region,
3054
+ "account_id": account_id,
3055
+ "db_password": db_password,
3056
+ "api_auth_secret": api_auth_secret,
3057
+ "database_engine": safe_get(
3058
+ reviewed_payload,
3059
+ "databaseEngine",
3060
+ default="aurora-serverless",
3061
+ ),
3062
+ "enable_hindsight": safe_get_bool(
3063
+ {},
3064
+ reviewed_payload,
3065
+ "enableHindsight",
3066
+ default=False,
3067
+ ),
3068
+ "platform_operator_emails": safe_get(
3069
+ runner_secrets,
3070
+ "adminEmail",
3071
+ "platformOperatorEmails",
3072
+ default=safe_get(
3073
+ reviewed_payload,
3074
+ "adminEmail",
3075
+ "platformOperatorEmails",
3076
+ default="",
3077
+ ),
3078
+ ),
3079
+ "google_oauth_client_id": safe_get(
3080
+ runner_secrets,
3081
+ "googleOauthClientId",
3082
+ default=safe_get(
3083
+ reviewed_payload,
3084
+ "googleOauthClientId",
3085
+ default="",
3086
+ ),
3087
+ ),
3088
+ "google_oauth_client_secret": safe_get(
3089
+ runner_secrets,
3090
+ "googleOauthClientSecret",
3091
+ default=safe_get(
3092
+ reviewed_payload,
3093
+ "googleOauthClientSecret",
3094
+ default="",
3095
+ ),
3096
+ ),
3097
+ "cognito_email_source_arn": safe_get(
3098
+ runner_secrets,
3099
+ "cognitoEmailSourceArn",
3100
+ default=safe_get(
3101
+ reviewed_payload,
3102
+ "cognitoEmailSourceArn",
3103
+ default="",
3104
+ ),
3105
+ ),
3106
+ "cognito_from_email_address": safe_get(
3107
+ runner_secrets,
3108
+ "cognitoFromEmailAddress",
3109
+ default=safe_get(
3110
+ reviewed_payload,
3111
+ "cognitoFromEmailAddress",
3112
+ default="",
3113
+ ),
3114
+ ),
3115
+ "cognito_reply_to_email_address": safe_get(
3116
+ runner_secrets,
3117
+ "cognitoReplyToEmailAddress",
3118
+ default=safe_get(
3119
+ reviewed_payload,
3120
+ "cognitoReplyToEmailAddress",
3121
+ default="",
3122
+ ),
3123
+ ),
3124
+ "app_domain": safe_get(
3125
+ runner_secrets,
3126
+ "appDomain",
3127
+ default=safe_get(reviewed_payload, "appDomain", default=""),
3128
+ ),
3129
+ "app_certificate_arn": safe_get(
3130
+ runner_secrets,
3131
+ "appCertificateArn",
3132
+ default=safe_get(reviewed_payload, "appCertificateArn", default=""),
3133
+ ),
3134
+ "customer_domain": safe_get(
3135
+ runner_secrets,
3136
+ "customerDomain",
3137
+ default=safe_get(reviewed_payload, "customerDomain", default=""),
3138
+ ),
3139
+ "customer_domain_delegated": safe_get_bool(
3140
+ runner_secrets,
3141
+ reviewed_payload,
3142
+ "customerDomainDelegated",
3143
+ default=False,
3144
+ ),
3145
+ "customer_domain_legacy_retired": safe_get_bool(
3146
+ runner_secrets,
3147
+ reviewed_payload,
3148
+ "customerDomainLegacyRetired",
3149
+ default=False,
3150
+ ),
3151
+ "lambda_artifact_bucket": os.environ["THINKWORK_RELEASE_ARTIFACT_BUCKET"],
3152
+ "lambda_artifact_prefix": f"releases/{release_version}/lambdas",
3153
+ "deployment_release_version": release_version,
3154
+ "deployment_release_manifest_url": release_manifest_url,
3155
+ "deployment_release_manifest_sha256": release_manifest_sha256,
3156
+ "deployment_release_manifest_signature_url": release_manifest_signature_url,
3157
+ "deployment_release_manifest_trust_policy": release_manifest_trust_policy_value,
3158
+ "deployment_release_manifest_trusted_keys_json": release_manifest_trusted_keys_json,
3159
+ "deployment_state_machine_arn": os.environ.get(
3160
+ "THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN",
3161
+ "",
3162
+ ),
3163
+ "deployment_evidence_bucket": os.environ.get("THINKWORK_EVIDENCE_BUCKET", ""),
3164
+ "deployment_terraform_module_source": terraform_module_source,
3165
+ "deployment_terraform_module_version": terraform_module_version,
3166
+ "agentcore_pi_source_image_uri": safe_get(
3167
+ payload,
3168
+ "agentcorePiSourceImageUri",
3169
+ default=release_runtime_image("agentcore-pi-amd64"),
3170
+ ),
3171
+ }
3172
+ enforce_customer_domain_preservation(
3173
+ current_outputs,
3174
+ vars_json,
3175
+ payload,
3176
+ runner_secrets,
3177
+ )
3178
+ vars_json.update(
3179
+ managed_app_terraform_overrides(payload, stage, account_id, current_outputs, current_state)
3180
+ )
3181
+
3182
+ TF.mkdir(parents=True, exist_ok=True)
3183
+ (TF / "backend.hcl").write_text(
3184
+ "\n".join(
3185
+ [
3186
+ f"bucket = {hcl_string(os.environ['THINKWORK_TERRAFORM_STATE_BUCKET'])}",
3187
+ f"key = {hcl_string(terraform_backend_key(stage, payload))}",
3188
+ f"region = {hcl_string(region)}",
3189
+ f"dynamodb_table = {hcl_string(os.environ['THINKWORK_TERRAFORM_LOCK_TABLE'])}",
3190
+ "encrypt = true",
3191
+ "",
3192
+ ]
3193
+ ),
3194
+ encoding="utf-8",
3195
+ )
3196
+ (TF / "terraform.auto.tfvars.json").write_text(
3197
+ json.dumps(vars_json, indent=2, sort_keys=True),
3198
+ encoding="utf-8",
3199
+ )
3200
+ (TF / "main.tf").write_text(
3201
+ f"""
3202
+ terraform {{
3203
+ required_version = ">= 1.5"
3204
+
3205
+ backend "s3" {{}}
3206
+
3207
+ required_providers {{
3208
+ aws = {{
3209
+ source = "hashicorp/aws"
3210
+ version = "~> 5.0"
3211
+ }}
3212
+ cloudflare = {{
3213
+ source = "cloudflare/cloudflare"
3214
+ version = "~> 4.0"
3215
+ }}
3216
+ }}
3217
+ }}
3218
+
3219
+ provider "aws" {{
3220
+ region = var.region
3221
+ }}
3222
+
3223
+ # The thinkwork module declares configuration_aliases = [aws.us_east_1]
3224
+ # (customer-domain ACM certificates must live in us-east-1 for CloudFront),
3225
+ # so every generated root must define the alias and pass it through the
3226
+ # module's providers mapping — even when no customer domain is configured.
3227
+ provider "aws" {{
3228
+ alias = "us_east_1"
3229
+ region = "us-east-1"
3230
+ }}
3231
+
3232
+ # Existing greenfield stacks keep Cloudflare DNS resources in the same state
3233
+ # file. Managed-app targeted plans still need the provider schema available
3234
+ # even though they do not change those records.
3235
+ provider "cloudflare" {{}}
3236
+
3237
+ variable "stage" {{
3238
+ type = string
3239
+ }}
3240
+
3241
+ variable "region" {{
3242
+ type = string
3243
+ }}
3244
+
3245
+ variable "account_id" {{
3246
+ type = string
3247
+ }}
3248
+
3249
+ variable "db_password" {{
3250
+ type = string
3251
+ sensitive = true
3252
+ }}
3253
+
3254
+ variable "api_auth_secret" {{
3255
+ type = string
3256
+ sensitive = true
3257
+ }}
3258
+
3259
+ variable "database_engine" {{
3260
+ type = string
3261
+ }}
3262
+
3263
+ variable "enable_hindsight" {{
3264
+ type = bool
3265
+ }}
3266
+
3267
+ variable "platform_operator_emails" {{
3268
+ type = string
3269
+ }}
3270
+
3271
+ variable "cognito_email_source_arn" {{
3272
+ type = string
3273
+ }}
3274
+
3275
+ variable "cognito_from_email_address" {{
3276
+ type = string
3277
+ }}
3278
+
3279
+ variable "cognito_reply_to_email_address" {{
3280
+ type = string
3281
+ }}
3282
+
3283
+ variable "app_domain" {{
3284
+ type = string
3285
+ }}
3286
+
3287
+ variable "app_certificate_arn" {{
3288
+ type = string
3289
+ }}
3290
+
3291
+ variable "customer_domain" {{
3292
+ type = string
3293
+ }}
3294
+
3295
+ variable "customer_domain_delegated" {{
3296
+ type = bool
3297
+ }}
3298
+
3299
+ variable "customer_domain_legacy_retired" {{
3300
+ type = bool
3301
+ }}
3302
+
3303
+ variable "google_oauth_client_id" {{
3304
+ type = string
3305
+ }}
3306
+
3307
+ variable "google_oauth_client_secret" {{
3308
+ type = string
3309
+ sensitive = true
3310
+ }}
3311
+
3312
+ variable "lambda_artifact_bucket" {{
3313
+ type = string
3314
+ }}
3315
+
3316
+ variable "lambda_artifact_prefix" {{
3317
+ type = string
3318
+ }}
3319
+
3320
+ variable "agentcore_pi_source_image_uri" {{
3321
+ type = string
3322
+ }}
3323
+
3324
+ variable "deployment_release_version" {{
3325
+ type = string
3326
+ }}
3327
+
3328
+ variable "deployment_release_manifest_url" {{
3329
+ type = string
3330
+ }}
3331
+
3332
+ variable "deployment_release_manifest_sha256" {{
3333
+ type = string
3334
+ }}
3335
+
3336
+ variable "deployment_release_manifest_signature_url" {{
3337
+ type = string
3338
+ }}
3339
+
3340
+ variable "deployment_release_manifest_trust_policy" {{
3341
+ type = string
3342
+ }}
3343
+
3344
+ variable "deployment_release_manifest_trusted_keys_json" {{
3345
+ type = string
3346
+ }}
3347
+
3348
+ variable "deployment_state_machine_arn" {{
3349
+ type = string
3350
+ }}
3351
+
3352
+ variable "deployment_evidence_bucket" {{
3353
+ type = string
3354
+ }}
3355
+
3356
+ variable "deployment_terraform_module_source" {{
3357
+ type = string
3358
+ }}
3359
+
3360
+ variable "deployment_terraform_module_version" {{
3361
+ type = string
3362
+ }}
3363
+
3364
+ variable "deployment_control_plane_create_secret_placeholders" {{
3365
+ type = bool
3366
+ }}
3367
+
3368
+ variable "enable_cognee" {{
3369
+ type = bool
3370
+ }}
3371
+
3372
+ variable "cognee_image_uri" {{
3373
+ type = string
3374
+ }}
3375
+
3376
+ variable "cognee_db_username" {{
3377
+ type = string
3378
+ }}
3379
+
3380
+ variable "cognee_db_name" {{
3381
+ type = string
3382
+ }}
3383
+
3384
+ variable "cognee_db_password_secret_arn" {{
3385
+ type = string
3386
+ }}
3387
+
3388
+ variable "cognee_backend_mode" {{
3389
+ type = string
3390
+ }}
3391
+
3392
+ variable "cognee_desired_count" {{
3393
+ type = number
3394
+ }}
3395
+
3396
+ variable "cognee_brain_tenant_id" {{
3397
+ type = string
3398
+ }}
3399
+
3400
+ variable "cognee_brain_instance_key" {{
3401
+ type = string
3402
+ }}
3403
+
3404
+ variable "cognee_brain_storage_tier" {{
3405
+ type = string
3406
+ }}
3407
+
3408
+ variable "cognee_brain_s3_artifact_root" {{
3409
+ type = string
3410
+ }}
3411
+
3412
+ variable "cognee_brain_s3_manifest_root" {{
3413
+ type = string
3414
+ }}
3415
+
3416
+ variable "cognee_brain_s3_vault_projection_root" {{
3417
+ type = string
3418
+ }}
3419
+
3420
+ variable "cognee_private_substrate_mode" {{
3421
+ type = bool
3422
+ }}
3423
+
3424
+ variable "cognee_llm_provider" {{
3425
+ type = string
3426
+ }}
3427
+
3428
+ variable "cognee_llm_model" {{
3429
+ type = string
3430
+ }}
3431
+
3432
+ variable "cognee_embedding_provider" {{
3433
+ type = string
3434
+ }}
3435
+
3436
+ variable "cognee_embedding_model" {{
3437
+ type = string
3438
+ }}
3439
+
3440
+ variable "cognee_embedding_dimensions" {{
3441
+ type = number
3442
+ }}
3443
+
3444
+ variable "cognee_vector_db_provider" {{
3445
+ type = string
3446
+ }}
3447
+
3448
+ variable "cognee_graph_database_provider" {{
3449
+ type = string
3450
+ }}
3451
+
3452
+ variable "cognee_neptune_graph_id" {{
3453
+ type = string
3454
+ }}
3455
+
3456
+ variable "cognee_neptune_endpoint" {{
3457
+ type = string
3458
+ }}
3459
+
3460
+ variable "cognee_production_posture" {{
3461
+ type = string
3462
+ }}
3463
+
3464
+ variable "cognee_bedrock_model_resource_arns" {{
3465
+ type = list(string)
3466
+ }}
3467
+
3468
+ variable "enable_deployment_control_plane" {{
3469
+ type = bool
3470
+ }}
3471
+
3472
+ variable "twenty_provisioned" {{
3473
+ type = bool
3474
+ }}
3475
+
3476
+ variable "twenty_runtime_enabled" {{
3477
+ type = bool
3478
+ }}
3479
+
3480
+ variable "twenty_image_uri" {{
3481
+ type = string
3482
+ }}
3483
+
3484
+ variable "twenty_db_username" {{
3485
+ type = string
3486
+ }}
3487
+
3488
+ variable "twenty_db_name" {{
3489
+ type = string
3490
+ }}
3491
+
3492
+ variable "twenty_db_url_secret_arn" {{
3493
+ type = string
3494
+ }}
3495
+
3496
+ variable "twenty_encryption_key_secret_arn" {{
3497
+ type = string
3498
+ }}
3499
+
3500
+ variable "twenty_email_from_address" {{
3501
+ type = string
3502
+ }}
3503
+
3504
+ variable "twenty_email_from_name" {{
3505
+ type = string
3506
+ }}
3507
+
3508
+ variable "twenty_public_url" {{
3509
+ type = string
3510
+ }}
3511
+
3512
+ variable "twenty_certificate_arn" {{
3513
+ type = string
3514
+ }}
3515
+
3516
+ variable "n8n_provisioned" {{
3517
+ type = bool
3518
+ }}
3519
+
3520
+ variable "n8n_runtime_enabled" {{
3521
+ type = bool
3522
+ }}
3523
+
3524
+ variable "n8n_image_uri" {{
3525
+ type = string
3526
+ }}
3527
+
3528
+ variable "n8n_database_admin_secret_arn" {{
3529
+ type = string
3530
+ }}
3531
+
3532
+ variable "n8n_database_url_secret_arn" {{
3533
+ type = string
3534
+ }}
3535
+
3536
+ variable "n8n_database_username" {{
3537
+ type = string
3538
+ }}
3539
+
3540
+ variable "n8n_database_name" {{
3541
+ type = string
3542
+ }}
3543
+
3544
+ variable "n8n_encryption_key_secret_arn" {{
3545
+ type = string
3546
+ }}
3547
+
3548
+ variable "n8n_operator_secret_arn" {{
3549
+ type = string
3550
+ }}
3551
+
3552
+ variable "n8n_service_credential_secret_arn" {{
3553
+ type = string
3554
+ }}
3555
+
3556
+ variable "n8n_storage_bucket_name" {{
3557
+ type = string
3558
+ }}
3559
+
3560
+ variable "n8n_create_storage_bucket" {{
3561
+ type = bool
3562
+ }}
3563
+
3564
+ variable "n8n_storage_prefix" {{
3565
+ type = string
3566
+ }}
3567
+
3568
+ variable "n8n_domain" {{
3569
+ type = string
3570
+ }}
3571
+
3572
+ variable "n8n_public_url" {{
3573
+ type = string
3574
+ }}
3575
+
3576
+ variable "n8n_certificate_arn" {{
3577
+ type = string
3578
+ }}
3579
+
3580
+ variable "n8n_main_desired_count" {{
3581
+ type = number
3582
+ }}
3583
+
3584
+ variable "n8n_worker_desired_count" {{
3585
+ type = number
3586
+ }}
3587
+
3588
+ variable "n8n_worker_concurrency" {{
3589
+ type = number
3590
+ }}
3591
+
3592
+ variable "n8n_container_port" {{
3593
+ type = number
3594
+ }}
3595
+
3596
+ variable "n8n_queue_mode" {{
3597
+ type = bool
3598
+ }}
3599
+
3600
+ variable "n8n_task_runners_enabled" {{
3601
+ type = bool
3602
+ }}
3603
+
3604
+ variable "n8n_package_config_digest" {{
3605
+ type = string
3606
+ }}
3607
+
3608
+ variable "n8n_custom_package_specs" {{
3609
+ type = list(string)
3610
+ }}
3611
+
3612
+ variable "n8n_execution_data_storage_mode" {{
3613
+ type = string
3614
+ }}
3615
+
3616
+ variable "n8n_binary_data_mode" {{
3617
+ type = string
3618
+ }}
3619
+
3620
+ variable "n8n_cache_engine" {{
3621
+ type = string
3622
+ }}
3623
+
3624
+ variable "n8n_cache_engine_version" {{
3625
+ type = string
3626
+ }}
3627
+
3628
+ variable "n8n_cache_parameter_group_family" {{
3629
+ type = string
3630
+ }}
3631
+
3632
+ variable "n8n_cache_node_type" {{
3633
+ type = string
3634
+ }}
3635
+
3636
+ variable "n8n_cache_num_cache_clusters" {{
3637
+ type = number
3638
+ }}
3639
+
3640
+ variable "n8n_allowed_public_cidr_blocks" {{
3641
+ type = list(string)
3642
+ }}
3643
+
3644
+ variable "n8n_kms_key_arns" {{
3645
+ type = list(string)
3646
+ }}
3647
+
3648
+ variable "cloudflare_zone_id" {{
3649
+ type = string
3650
+ }}
3651
+
3652
+ variable "n8n_dns_enabled" {{
3653
+ type = bool
3654
+ }}
3655
+
3656
+ variable "n8n_dns_name" {{
3657
+ type = string
3658
+ }}
3659
+
3660
+ locals {{
3661
+ n8n_managed_certificate_enabled = (
3662
+ var.n8n_provisioned &&
3663
+ var.n8n_certificate_arn == "" &&
3664
+ var.n8n_domain != "" &&
3665
+ var.cloudflare_zone_id != ""
3666
+ )
3667
+ n8n_effective_certificate_arn = (
3668
+ var.n8n_certificate_arn != ""
3669
+ ? var.n8n_certificate_arn
3670
+ : (
3671
+ local.n8n_managed_certificate_enabled
3672
+ ? aws_acm_certificate_validation.n8n[0].certificate_arn
3673
+ : ""
3674
+ )
3675
+ )
3676
+ }}
3677
+
3678
+ resource "aws_acm_certificate" "n8n" {{
3679
+ count = local.n8n_managed_certificate_enabled ? 1 : 0
3680
+
3681
+ domain_name = var.n8n_domain
3682
+ validation_method = "DNS"
3683
+
3684
+ lifecycle {{
3685
+ create_before_destroy = true
3686
+ }}
3687
+
3688
+ tags = {{
3689
+ Name = "thinkwork-${{var.stage}}-n8n"
3690
+ }}
3691
+ }}
3692
+
3693
+ resource "cloudflare_record" "n8n_acm_validation" {{
3694
+ for_each = local.n8n_managed_certificate_enabled ? {{
3695
+ for cert in aws_acm_certificate.n8n :
3696
+ cert.domain_name => tolist(cert.domain_validation_options)[0]
3697
+ }} : {{}}
3698
+
3699
+ zone_id = var.cloudflare_zone_id
3700
+ name = trimsuffix(each.value.resource_record_name, ".")
3701
+ content = trimsuffix(each.value.resource_record_value, ".")
3702
+ type = each.value.resource_record_type
3703
+ ttl = 60
3704
+ proxied = false
3705
+ comment = "ACM DNS validation for ${{each.key}}"
3706
+
3707
+ allow_overwrite = true
3708
+ }}
3709
+
3710
+ resource "aws_acm_certificate_validation" "n8n" {{
3711
+ count = local.n8n_managed_certificate_enabled ? 1 : 0
3712
+
3713
+ certificate_arn = aws_acm_certificate.n8n[0].arn
3714
+ validation_record_fqdns = [
3715
+ for record in cloudflare_record.n8n_acm_validation : record.hostname
3716
+ ]
3717
+ }}
3718
+
3719
+ module "thinkwork" {{
3720
+ source = {hcl_string(terraform_module_source)}
3721
+ {module_version_line}
3722
+ providers = {{
3723
+ aws.us_east_1 = aws.us_east_1
3724
+ }}
3725
+
3726
+ stage = var.stage
3727
+ region = var.region
3728
+ account_id = var.account_id
3729
+
3730
+ db_password = var.db_password
3731
+ api_auth_secret = var.api_auth_secret
3732
+ database_engine = var.database_engine
3733
+
3734
+ google_oauth_client_id = var.google_oauth_client_id
3735
+ google_oauth_client_secret = var.google_oauth_client_secret
3736
+ platform_operator_emails = var.platform_operator_emails
3737
+
3738
+ cognito_email_source_arn = var.cognito_email_source_arn
3739
+ cognito_from_email_address = var.cognito_from_email_address
3740
+ cognito_reply_to_email_address = var.cognito_reply_to_email_address
3741
+
3742
+ app_domain = var.app_domain
3743
+ app_certificate_arn = var.app_certificate_arn
3744
+
3745
+ customer_domain = var.customer_domain
3746
+ customer_domain_delegated = var.customer_domain_delegated
3747
+ customer_domain_legacy_retired = var.customer_domain_legacy_retired
3748
+
3749
+ lambda_artifact_bucket = var.lambda_artifact_bucket
3750
+ lambda_artifact_prefix = var.lambda_artifact_prefix
3751
+ require_lambda_artifacts = true
3752
+ agentcore_pi_source_image_uri = var.agentcore_pi_source_image_uri
3753
+
3754
+ enable_hindsight = var.enable_hindsight
3755
+ enable_workspace_orchestration = true
3756
+
3757
+ enable_cognee = var.enable_cognee
3758
+ cognee_image_uri = var.cognee_image_uri
3759
+ cognee_db_username = var.cognee_db_username
3760
+ cognee_db_name = var.cognee_db_name
3761
+ cognee_db_password_secret_arn = var.cognee_db_password_secret_arn
3762
+ cognee_backend_mode = var.cognee_backend_mode
3763
+ cognee_desired_count = var.cognee_desired_count
3764
+ cognee_brain_tenant_id = var.cognee_brain_tenant_id
3765
+ cognee_brain_instance_key = var.cognee_brain_instance_key
3766
+ cognee_brain_storage_tier = var.cognee_brain_storage_tier
3767
+ cognee_brain_s3_artifact_root = var.cognee_brain_s3_artifact_root
3768
+ cognee_brain_s3_manifest_root = var.cognee_brain_s3_manifest_root
3769
+ cognee_brain_s3_vault_projection_root = var.cognee_brain_s3_vault_projection_root
3770
+ cognee_private_substrate_mode = var.cognee_private_substrate_mode
3771
+ cognee_llm_provider = var.cognee_llm_provider
3772
+ cognee_llm_model = var.cognee_llm_model
3773
+ cognee_embedding_provider = var.cognee_embedding_provider
3774
+ cognee_embedding_model = var.cognee_embedding_model
3775
+ cognee_embedding_dimensions = var.cognee_embedding_dimensions
3776
+ cognee_vector_db_provider = var.cognee_vector_db_provider
3777
+ cognee_graph_database_provider = var.cognee_graph_database_provider
3778
+ cognee_neptune_graph_id = var.cognee_neptune_graph_id
3779
+ cognee_neptune_endpoint = var.cognee_neptune_endpoint
3780
+ cognee_production_posture = var.cognee_production_posture
3781
+ cognee_bedrock_model_resource_arns = var.cognee_bedrock_model_resource_arns
3782
+ twenty_provisioned = var.twenty_provisioned
3783
+ twenty_runtime_enabled = var.twenty_runtime_enabled
3784
+ twenty_image_uri = var.twenty_image_uri
3785
+ twenty_db_username = var.twenty_db_username
3786
+ twenty_db_name = var.twenty_db_name
3787
+ twenty_db_url_secret_arn = var.twenty_db_url_secret_arn
3788
+ twenty_encryption_key_secret_arn = var.twenty_encryption_key_secret_arn
3789
+ twenty_email_from_address = var.twenty_email_from_address
3790
+ twenty_email_from_name = var.twenty_email_from_name
3791
+ twenty_public_url = var.twenty_public_url
3792
+ twenty_certificate_arn = var.twenty_certificate_arn
3793
+ n8n_provisioned = var.n8n_provisioned
3794
+ n8n_runtime_enabled = var.n8n_runtime_enabled
3795
+ n8n_image_uri = var.n8n_image_uri
3796
+ n8n_database_admin_secret_arn = var.n8n_database_admin_secret_arn
3797
+ n8n_database_url_secret_arn = var.n8n_database_url_secret_arn
3798
+ n8n_database_username = var.n8n_database_username
3799
+ n8n_database_name = var.n8n_database_name
3800
+ n8n_encryption_key_secret_arn = var.n8n_encryption_key_secret_arn
3801
+ n8n_operator_secret_arn = var.n8n_operator_secret_arn
3802
+ n8n_service_credential_secret_arn = var.n8n_service_credential_secret_arn
3803
+ n8n_storage_bucket_name = var.n8n_storage_bucket_name
3804
+ n8n_create_storage_bucket = var.n8n_create_storage_bucket
3805
+ n8n_storage_prefix = var.n8n_storage_prefix
3806
+ n8n_domain = var.n8n_domain
3807
+ n8n_public_url = var.n8n_public_url
3808
+ n8n_certificate_arn = local.n8n_effective_certificate_arn
3809
+ n8n_main_desired_count = var.n8n_main_desired_count
3810
+ n8n_worker_desired_count = var.n8n_worker_desired_count
3811
+ n8n_worker_concurrency = var.n8n_worker_concurrency
3812
+ n8n_container_port = var.n8n_container_port
3813
+ n8n_queue_mode = var.n8n_queue_mode
3814
+ n8n_task_runners_enabled = var.n8n_task_runners_enabled
3815
+ n8n_package_config_digest = var.n8n_package_config_digest
3816
+ n8n_custom_package_specs = var.n8n_custom_package_specs
3817
+ n8n_execution_data_storage_mode = var.n8n_execution_data_storage_mode
3818
+ n8n_binary_data_mode = var.n8n_binary_data_mode
3819
+ n8n_cache_engine = var.n8n_cache_engine
3820
+ n8n_cache_engine_version = var.n8n_cache_engine_version
3821
+ n8n_cache_parameter_group_family = var.n8n_cache_parameter_group_family
3822
+ n8n_cache_node_type = var.n8n_cache_node_type
3823
+ n8n_cache_num_cache_clusters = var.n8n_cache_num_cache_clusters
3824
+ n8n_allowed_public_cidr_blocks = var.n8n_allowed_public_cidr_blocks
3825
+ n8n_kms_key_arns = var.n8n_kms_key_arns
3826
+ enable_stripe_billing = false
3827
+ enable_slack_workspace_app = false
3828
+
3829
+ enable_deployment_control_plane = var.enable_deployment_control_plane
3830
+ deployment_control_plane_create_secret_placeholders = var.deployment_control_plane_create_secret_placeholders
3831
+ deployment_state_machine_arn = var.deployment_state_machine_arn
3832
+ deployment_evidence_bucket = var.deployment_evidence_bucket
3833
+ deployment_release_version = var.deployment_release_version
3834
+ deployment_release_manifest_url = var.deployment_release_manifest_url
3835
+ deployment_release_manifest_sha256 = var.deployment_release_manifest_sha256
3836
+ deployment_release_manifest_signature_url = var.deployment_release_manifest_signature_url
3837
+ deployment_release_manifest_trust_policy = var.deployment_release_manifest_trust_policy
3838
+ deployment_release_manifest_trusted_keys_json = var.deployment_release_manifest_trusted_keys_json
3839
+ deployment_terraform_module_source = var.deployment_terraform_module_source
3840
+ deployment_terraform_module_version = var.deployment_terraform_module_version
3841
+ }}
3842
+
3843
+ resource "cloudflare_record" "n8n" {{
3844
+ count = var.n8n_dns_enabled ? 1 : 0
3845
+
3846
+ zone_id = var.cloudflare_zone_id
3847
+ name = var.n8n_dns_name
3848
+ content = module.thinkwork.n8n_alb_dns_name
3849
+ type = "CNAME"
3850
+ ttl = 300
3851
+ proxied = false
3852
+ comment = "thinkwork-${{var.stage}} n8n -> n8n public ALB"
3853
+ }}
3854
+
3855
+ output "app_url" {{ value = module.thinkwork.app_url }}
3856
+ output "app_bucket_name" {{ value = module.thinkwork.app_bucket_name }}
3857
+ output "app_distribution_id" {{ value = module.thinkwork.app_distribution_id }}
3858
+ output "api_endpoint" {{ value = module.thinkwork.api_endpoint }}
3859
+ output "appsync_api_url" {{ value = module.thinkwork.appsync_api_url }}
3860
+ output "appsync_realtime_url" {{ value = module.thinkwork.appsync_realtime_url }}
3861
+ output "appsync_api_key" {{
3862
+ value = module.thinkwork.appsync_api_key
3863
+ sensitive = true
3864
+ }}
3865
+ output "auth_domain" {{ value = module.thinkwork.auth_domain }}
3866
+ output "customer_domain" {{ value = module.thinkwork.customer_domain }}
3867
+ output "customer_domain_name_servers" {{ value = module.thinkwork.customer_domain_name_servers }}
3868
+ output "db_cluster_endpoint" {{ value = module.thinkwork.db_cluster_endpoint }}
3869
+ output "db_secret_arn" {{ value = module.thinkwork.db_secret_arn }}
3870
+ output "database_name" {{ value = module.thinkwork.database_name }}
3871
+ output "user_pool_id" {{ value = module.thinkwork.user_pool_id }}
3872
+ output "admin_client_id" {{ value = module.thinkwork.admin_client_id }}
3873
+ output "docs_bucket_name" {{ value = module.thinkwork.docs_bucket_name }}
3874
+ output "docs_distribution_id" {{ value = module.thinkwork.docs_distribution_id }}
3875
+ output "docs_distribution_domain" {{ value = module.thinkwork.docs_distribution_domain }}
3876
+ output "cognee_enabled" {{ value = module.thinkwork.cognee_enabled }}
3877
+ output "twenty_provisioned" {{ value = module.thinkwork.twenty_provisioned }}
3878
+ output "twenty_runtime_enabled" {{ value = module.thinkwork.twenty_runtime_enabled }}
3879
+ output "twenty_url" {{ value = module.thinkwork.twenty_url }}
3880
+ output "n8n_provisioned" {{ value = module.thinkwork.n8n_provisioned }}
3881
+ output "n8n_runtime_enabled" {{ value = module.thinkwork.n8n_runtime_enabled }}
3882
+ output "n8n_url" {{ value = module.thinkwork.n8n_url }}
3883
+ output "n8n_alb_dns_name" {{ value = module.thinkwork.n8n_alb_dns_name }}
3884
+ output "n8n_alb_arn" {{ value = module.thinkwork.n8n_alb_arn }}
3885
+ output "n8n_target_group_arn" {{ value = module.thinkwork.n8n_target_group_arn }}
3886
+ output "n8n_cluster_arn" {{ value = module.thinkwork.n8n_cluster_arn }}
3887
+ output "n8n_main_service_name" {{ value = module.thinkwork.n8n_main_service_name }}
3888
+ output "n8n_worker_service_name" {{ value = module.thinkwork.n8n_worker_service_name }}
3889
+ output "n8n_main_log_group_name" {{ value = module.thinkwork.n8n_main_log_group_name }}
3890
+ output "n8n_worker_log_group_name" {{ value = module.thinkwork.n8n_worker_log_group_name }}
3891
+ output "n8n_database_name" {{ value = module.thinkwork.n8n_database_name }}
3892
+ output "n8n_database_secret_arn" {{ value = module.thinkwork.n8n_database_secret_arn }}
3893
+ output "n8n_valkey_endpoint" {{ value = module.thinkwork.n8n_valkey_endpoint }}
3894
+ output "n8n_storage_bucket_name" {{ value = module.thinkwork.n8n_storage_bucket_name }}
3895
+ output "n8n_storage_prefix" {{ value = module.thinkwork.n8n_storage_prefix }}
3896
+ output "n8n_image_digest" {{ value = module.thinkwork.n8n_image_digest }}
3897
+ output "n8n_package_config_digest" {{ value = module.thinkwork.n8n_package_config_digest }}
3898
+ output "n8n_service_credential_secret_arn" {{ value = module.thinkwork.n8n_service_credential_secret_arn }}
3899
+ output "deployment_control_plane_enabled" {{ value = module.thinkwork.deployment_control_plane_enabled }}
3900
+ output "deployment_state_machine_arn" {{ value = module.thinkwork.deployment_state_machine_arn }}
3901
+ output "deployment_state_machine_name" {{ value = module.thinkwork.deployment_state_machine_name }}
3902
+ output "deployment_runner_project_name" {{ value = module.thinkwork.deployment_runner_project_name }}
3903
+ output "deployment_runner_project_arn" {{ value = module.thinkwork.deployment_runner_project_arn }}
3904
+ output "deployment_evidence_bucket_name" {{ value = module.thinkwork.deployment_evidence_bucket_name }}
3905
+ output "deployment_ssm_prefix" {{ value = module.thinkwork.deployment_ssm_prefix }}
3906
+ output "deployment_appconfig_application_id" {{ value = module.thinkwork.deployment_appconfig_application_id }}
3907
+ output "deployment_appconfig_environment_id" {{ value = module.thinkwork.deployment_appconfig_environment_id }}
3908
+ output "deployment_appconfig_configuration_profile_id" {{ value = module.thinkwork.deployment_appconfig_configuration_profile_id }}
3909
+ """,
3910
+ encoding="utf-8",
3911
+ )
3912
+ return vars_json
3913
+
3914
+
3915
+ def sync_release_artifacts(artifact_types=None, artifact_names=None):
3916
+ global RELEASE_EVIDENCE
3917
+ artifact_types = set(artifact_types or {"lambda", "static-site"})
3918
+ artifact_names = set(artifact_names or [])
3919
+ manifest_url = os.environ.get("THINKWORK_RELEASE_MANIFEST_URL")
3920
+ expected = os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256", "").lower()
3921
+ if not manifest_url:
3922
+ raise RuntimeError("THINKWORK_RELEASE_MANIFEST_URL is required")
3923
+ download(manifest_url, MANIFEST)
3924
+ manifest_byte_digest = sha256_file(MANIFEST)
3925
+ manifest = verify_release_manifest_digest(MANIFEST, expected)
3926
+ canonical_digest = release_manifest_sha256(manifest)
3927
+ trust_evidence = enforce_release_manifest_trust(manifest, canonical_digest, manifest_url)
3928
+ bundled_paths, bundle_evidence = download_and_extract_artifact_bundles(manifest)
3929
+ lambda_prefix = f"releases/{os.environ['THINKWORK_RELEASE_VERSION']}/lambdas"
3930
+ static_files = {}
3931
+ artifact_evidence = []
3932
+ for artifact in manifest.get("artifacts", []):
3933
+ if artifact.get("type") not in artifact_types:
3934
+ continue
3935
+ if artifact_names and artifact.get("name") not in artifact_names:
3936
+ continue
3937
+ destination, digest, source = materialize_release_artifact(artifact, bundled_paths)
3938
+ artifact_evidence.append(
3939
+ {
3940
+ "name": artifact.get("name"),
3941
+ "type": artifact.get("type"),
3942
+ "fileName": artifact.get("fileName"),
3943
+ "sha256": digest,
3944
+ "source": source,
3945
+ }
3946
+ )
3947
+ if artifact.get("type") == "lambda":
3948
+ run(
3949
+ [
3950
+ "aws",
3951
+ "s3",
3952
+ "cp",
3953
+ str(destination),
3954
+ f"s3://{os.environ['THINKWORK_RELEASE_ARTIFACT_BUCKET']}/{lambda_prefix}/{artifact['fileName']}",
3955
+ ]
3956
+ )
3957
+ else:
3958
+ static_files[artifact.get("name")] = destination
3959
+ RELEASE_EVIDENCE = {
3960
+ "manifestSha256": manifest_byte_digest,
3961
+ "manifestCanonicalSha256": canonical_digest,
3962
+ "trust": trust_evidence,
3963
+ "bundles": bundle_evidence,
3964
+ "artifacts": artifact_evidence,
3965
+ }
3966
+ return static_files
3967
+
3968
+
3969
+ def managed_app_operation(payload):
3970
+ return str(payload.get("operation") or "").strip().upper()
3971
+
3972
+
3973
+ def is_twenty_thinkwork_app_sync_operation(payload):
3974
+ app_key = str(payload.get("appKey") or "").strip().lower()
3975
+ return app_key == "twenty" and managed_app_operation(payload) in {"ENABLE", "UPGRADE"}
3976
+
3977
+
3978
+ def stage_managed_app_release_artifacts(action, payload):
3979
+ if action not in {"deploy", "update"}:
3980
+ return {}
3981
+ if not is_twenty_thinkwork_app_sync_operation(payload):
3982
+ return {}
3983
+ return sync_release_artifacts(
3984
+ artifact_types={"seed"},
3985
+ artifact_names={"twenty-thinkwork-app"},
3986
+ )
3987
+
3988
+
3989
+ def twenty_app_sync_api_key(runner_secrets, payload):
3990
+ return first_non_empty_string(
3991
+ runner_secrets.get("twentyAppSyncApiKey", ""),
3992
+ runner_secrets.get("twentyDeployApiKey", ""),
3993
+ payload.get("twentyAppSyncApiKey", ""),
3994
+ payload.get("twentyDeployApiKey", ""),
3995
+ os.environ.get("TWENTY_APP_SYNC_API_KEY", ""),
3996
+ os.environ.get("TWENTY_DEPLOY_API_KEY", ""),
3997
+ )
3998
+
3999
+
4000
+ def sync_twenty_thinkwork_app(outputs_path, vars_json, payload, runner_secrets, artifacts):
4001
+ global MANAGED_APP_EVIDENCE
4002
+ if not is_twenty_thinkwork_app_sync_operation(payload):
4003
+ return
4004
+
4005
+ outputs = {}
4006
+ if outputs_path.is_file():
4007
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
4008
+
4009
+ runtime_enabled = bool_state_output(outputs, "twenty_runtime_enabled") or bool(
4010
+ vars_json.get("twenty_runtime_enabled", False)
4011
+ )
4012
+ if not runtime_enabled:
4013
+ MANAGED_APP_EVIDENCE["twentyThinkWorkApp"] = {
4014
+ "status": "skipped",
4015
+ "reason": "twenty-runtime-disabled",
4016
+ }
4017
+ return
4018
+
4019
+ public_url = first_non_empty_string(
4020
+ string_state_output(outputs, "twenty_url"),
4021
+ vars_json.get("twenty_public_url", ""),
4022
+ payload.get("desiredConfig", {}).get("publicUrl", "")
4023
+ if isinstance(payload.get("desiredConfig"), dict)
4024
+ else "",
4025
+ )
4026
+ if not public_url:
4027
+ raise RuntimeError(
4028
+ "Twenty ThinkWork native app sync requires twenty_url from Terraform outputs "
4029
+ "or twenty_public_url from runner variables."
4030
+ )
4031
+
4032
+ api_key = twenty_app_sync_api_key(runner_secrets, payload)
4033
+ if not api_key:
4034
+ raise RuntimeError(
4035
+ "Twenty managed app ENABLE/UPGRADE requires twentyAppSyncApiKey or "
4036
+ "twentyDeployApiKey in the deployment runner secret."
4037
+ )
4038
+
4039
+ archive = artifacts.get("twenty-thinkwork-app")
4040
+ if not archive or not archive.is_file():
4041
+ raise RuntimeError(
4042
+ "Twenty managed app ENABLE/UPGRADE requires release artifact twenty-thinkwork-app."
4043
+ )
4044
+
4045
+ target = RELEASE / "extract-twenty-thinkwork-app"
4046
+ safe_extract_tar_file(archive, target)
4047
+ script = target / "plugins/twenty/scripts/sync-thinkwork-app.mjs"
4048
+ app_dir = target / "plugins/twenty/twenty-app"
4049
+ if not script.is_file() or not app_dir.is_dir():
4050
+ raise RuntimeError(
4051
+ "Twenty ThinkWork app artifact must contain "
4052
+ "plugins/twenty/scripts/sync-thinkwork-app.mjs and "
4053
+ "plugins/twenty/twenty-app."
4054
+ )
4055
+
4056
+ env = dict(os.environ)
4057
+ env.update(
4058
+ {
4059
+ "TWENTY_PUBLIC_URL": public_url,
4060
+ "TWENTY_APP_SYNC_API_KEY": api_key,
4061
+ "TWENTY_THINKWORK_APP_SYNC_DRY_RUN": "0",
4062
+ "TWENTY_THINKWORK_APP_DIR": str(app_dir),
4063
+ }
4064
+ )
4065
+ run(["node", str(script), "--apply"], cwd=target, env=env)
4066
+ MANAGED_APP_EVIDENCE["twentyThinkWorkApp"] = {
4067
+ "status": "installed",
4068
+ "artifact": "twenty-thinkwork-app",
4069
+ "publicUrl": public_url,
4070
+ "operation": managed_app_operation(payload),
4071
+ }
4072
+
4073
+
4074
+ def write_current_outputs_from_state(stage, outputs_path):
4075
+ outputs = current_terraform_outputs(stage)
4076
+ required = ["app_bucket_name", "app_distribution_id", "app_url"]
4077
+ missing = [name for name in required if name not in outputs]
4078
+ if missing:
4079
+ raise RuntimeError(
4080
+ "Web-only update requires existing Terraform outputs in state; missing "
4081
+ + ", ".join(missing)
4082
+ )
4083
+ outputs_path.parent.mkdir(parents=True, exist_ok=True)
4084
+ outputs_path.write_text(json.dumps(outputs, indent=2, sort_keys=True) + "\n", encoding="utf-8")
4085
+ return outputs
4086
+
4087
+
4088
+ def write_outputs_to_ssm(outputs_path, vars_json):
4089
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
4090
+ profile, web_env = runtime_profile(outputs, vars_json)
4091
+ mapping = {
4092
+ "profile/api-endpoint": "api_endpoint",
4093
+ "profile/app-url": "app_url",
4094
+ "profile/graphql-http-url": None,
4095
+ "profile/appsync-url": "appsync_api_url",
4096
+ "profile/appsync-realtime-url": "appsync_realtime_url",
4097
+ "profile/appsync-api-key": "appsync_api_key",
4098
+ "profile/cognito-domain": None,
4099
+ "profile/cognito-user-pool-id": "user_pool_id",
4100
+ "profile/cognito-client-id": "admin_client_id",
4101
+ }
4102
+ for suffix, output_name in mapping.items():
4103
+ if output_name:
4104
+ value = outputs.get(output_name, {}).get("value")
4105
+ elif suffix == "profile/graphql-http-url":
4106
+ value = profile.get("graphqlHttpUrl")
4107
+ elif suffix == "profile/cognito-domain":
4108
+ value = profile.get("cognitoDomain")
4109
+ else:
4110
+ value = None
4111
+ if value:
4112
+ run(
4113
+ [
4114
+ "aws",
4115
+ "ssm",
4116
+ "put-parameter",
4117
+ "--overwrite",
4118
+ "--type",
4119
+ "String",
4120
+ "--name",
4121
+ f"{os.environ['THINKWORK_SSM_PREFIX']}/{suffix}",
4122
+ "--value",
4123
+ str(value),
4124
+ ]
4125
+ )
4126
+
4127
+ run(
4128
+ [
4129
+ "aws",
4130
+ "ssm",
4131
+ "put-parameter",
4132
+ "--overwrite",
4133
+ "--type",
4134
+ "String",
4135
+ "--name",
4136
+ f"{os.environ['THINKWORK_SSM_PREFIX']}/profile/json",
4137
+ "--value",
4138
+ json.dumps(profile, sort_keys=True),
4139
+ ]
4140
+ )
4141
+ run(
4142
+ [
4143
+ "aws",
4144
+ "ssm",
4145
+ "put-parameter",
4146
+ "--overwrite",
4147
+ "--type",
4148
+ "String",
4149
+ "--name",
4150
+ f"{os.environ['THINKWORK_SSM_PREFIX']}/profile/web-env",
4151
+ "--value",
4152
+ web_env,
4153
+ ]
4154
+ )
4155
+
4156
+
4157
+ def controller_terraform_module_version(vars_json):
4158
+ configured = vars_json.get("deployment_terraform_module_version")
4159
+ if configured:
4160
+ return configured
4161
+ source = vars_json.get("deployment_terraform_module_source") or ""
4162
+ if source.startswith("git::") or source.startswith("github.com/"):
4163
+ return ""
4164
+ return str(vars_json.get("deployment_release_version") or "").removeprefix("v")
4165
+
4166
+
4167
+ def put_controller_parameter(name, value):
4168
+ if not value:
4169
+ return
4170
+ run(
4171
+ [
4172
+ "aws",
4173
+ "ssm",
4174
+ "put-parameter",
4175
+ "--overwrite",
4176
+ "--type",
4177
+ "String",
4178
+ "--name",
4179
+ f"{os.environ['THINKWORK_SSM_PREFIX']}/{name}",
4180
+ "--value",
4181
+ str(value),
4182
+ ]
4183
+ )
4184
+
4185
+
4186
+ def write_controller_release_selection_to_ssm(vars_json):
4187
+ if not os.environ.get("THINKWORK_SSM_PREFIX"):
4188
+ return {}
4189
+ selected = {
4190
+ "selected-release-version": vars_json.get("deployment_release_version"),
4191
+ "selected-release-manifest-url": vars_json.get("deployment_release_manifest_url"),
4192
+ "selected-release-manifest-sha256": vars_json.get("deployment_release_manifest_sha256"),
4193
+ "selected-release-signature-url": vars_json.get(
4194
+ "deployment_release_manifest_signature_url"
4195
+ ),
4196
+ "selected-release-trust-policy": vars_json.get("deployment_release_manifest_trust_policy"),
4197
+ "selected-release-trusted-keys-json": vars_json.get(
4198
+ "deployment_release_manifest_trusted_keys_json"
4199
+ ),
4200
+ "terraform-module-source": vars_json.get("deployment_terraform_module_source"),
4201
+ "terraform-module-version": controller_terraform_module_version(vars_json),
4202
+ }
4203
+ for name, value in selected.items():
4204
+ put_controller_parameter(name, value)
4205
+ return {name: value for name, value in selected.items() if value}
4206
+
4207
+
4208
+ def runtime_profile(outputs, vars_json):
4209
+ def output_value(name):
4210
+ return outputs.get(name, {}).get("value")
4211
+
4212
+ api_endpoint = output_value("api_endpoint") or ""
4213
+ app_url = output_value("app_url") or ""
4214
+ region = vars_json["region"]
4215
+ auth_domain = output_value("auth_domain") or ""
4216
+ cognito_domain = (
4217
+ auth_domain
4218
+ if auth_domain.startswith("https://")
4219
+ else f"https://{auth_domain}.auth.{region}.amazoncognito.com"
4220
+ if auth_domain
4221
+ else ""
4222
+ )
4223
+ graphql_http_url = f"{api_endpoint.rstrip('/')}/graphql" if api_endpoint else ""
4224
+ profile = {
4225
+ "stage": vars_json["stage"],
4226
+ "region": region,
4227
+ "accountId": vars_json["account_id"],
4228
+ "releaseVersion": os.environ.get("THINKWORK_RELEASE_VERSION"),
4229
+ "releaseManifestUrl": os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
4230
+ "releaseManifestSha256": os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
4231
+ "deploymentId": f"thinkwork-{vars_json['stage']}",
4232
+ "displayName": "ThinkWork",
4233
+ "appUrl": app_url,
4234
+ "apiEndpoint": api_endpoint,
4235
+ "graphqlHttpUrl": graphql_http_url,
4236
+ "appsyncUrl": output_value("appsync_api_url"),
4237
+ "appsyncRealtimeUrl": output_value("appsync_realtime_url"),
4238
+ "appsyncApiKey": output_value("appsync_api_key"),
4239
+ "cognitoDomain": cognito_domain,
4240
+ "cognitoUserPoolId": output_value("user_pool_id"),
4241
+ "cognitoClientId": output_value("admin_client_id"),
4242
+ "controller": {
4243
+ "stateMachineArn": output_value("deployment_state_machine_arn")
4244
+ or os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN"),
4245
+ "stateMachineName": output_value("deployment_state_machine_name")
4246
+ or os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_NAME"),
4247
+ "codebuildProjectName": output_value("deployment_runner_project_name")
4248
+ or os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
4249
+ "codebuildProjectArn": output_value("deployment_runner_project_arn")
4250
+ or os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_ARN"),
4251
+ "evidenceBucketName": output_value("deployment_evidence_bucket_name")
4252
+ or os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
4253
+ "ssmPrefix": output_value("deployment_ssm_prefix")
4254
+ or os.environ.get("THINKWORK_SSM_PREFIX"),
4255
+ "appconfigApplicationId": output_value("deployment_appconfig_application_id"),
4256
+ "appconfigEnvironmentId": output_value("deployment_appconfig_environment_id"),
4257
+ "appconfigConfigurationProfileId": output_value(
4258
+ "deployment_appconfig_configuration_profile_id"
4259
+ ),
4260
+ "verifiedAt": datetime.now(UTC).isoformat(),
4261
+ },
4262
+ "issuedAt": datetime.now(UTC).isoformat(),
4263
+ }
4264
+ vite_env = {
4265
+ "VITE_API_URL": profile["apiEndpoint"],
4266
+ "VITE_GRAPHQL_HTTP_URL": profile["graphqlHttpUrl"],
4267
+ "VITE_GRAPHQL_URL": profile["appsyncUrl"],
4268
+ "VITE_GRAPHQL_WS_URL": profile["appsyncRealtimeUrl"],
4269
+ "VITE_GRAPHQL_API_KEY": profile["appsyncApiKey"],
4270
+ "VITE_COGNITO_DOMAIN": profile["cognitoDomain"],
4271
+ "VITE_COGNITO_USER_POOL_ID": profile["cognitoUserPoolId"],
4272
+ "VITE_COGNITO_CLIENT_ID": profile["cognitoClientId"],
4273
+ "VITE_DEPLOYMENT_ID": profile["deploymentId"],
4274
+ "VITE_DEPLOYMENT_DISPLAY_NAME": profile["displayName"],
4275
+ "VITE_DEPLOYMENT_PROFILE_ISSUED_AT": profile["issuedAt"],
4276
+ "VITE_SPACES_URL": profile["appUrl"],
4277
+ "VITE_STAGE": profile["stage"],
4278
+ "VITE_AWS_REGION": profile["region"],
4279
+ "VITE_AWS_ACCOUNT_ID": profile["accountId"],
4280
+ "VITE_RELEASE_VERSION": profile["releaseVersion"],
4281
+ "VITE_RELEASE_MANIFEST_URL": profile["releaseManifestUrl"],
4282
+ "VITE_RELEASE_MANIFEST_SHA256": profile["releaseManifestSha256"],
4283
+ "VITE_DEPLOYMENT_CONTROLLER_ARN": profile["controller"]["stateMachineArn"],
4284
+ "VITE_DEPLOYMENT_CONTROLLER_NAME": profile["controller"]["stateMachineName"],
4285
+ "VITE_DEPLOYMENT_RUNNER_PROJECT_NAME": profile["controller"]["codebuildProjectName"],
4286
+ "VITE_DEPLOYMENT_RUNNER_PROJECT_ARN": profile["controller"]["codebuildProjectArn"],
4287
+ "VITE_DEPLOYMENT_EVIDENCE_BUCKET": profile["controller"]["evidenceBucketName"],
4288
+ "VITE_DEPLOYMENT_SSM_PREFIX": profile["controller"]["ssmPrefix"],
4289
+ }
4290
+ profile["viteEnv"] = vite_env
4291
+ web_env = "\n".join(f"{key}={value or ''}" for key, value in sorted(vite_env.items()))
4292
+ return profile, web_env + "\n"
4293
+
4294
+
4295
+ def sync_static(outputs_path, static_files, vars_json, artifact_names=None):
4296
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
4297
+ artifact_names = set(artifact_names or [])
4298
+ syncs = [
4299
+ ("web", "app_bucket_name", "app_distribution_id"),
4300
+ ("docs", "docs_bucket_name", "docs_distribution_id"),
4301
+ ]
4302
+ for artifact_name, bucket_output, distribution_output in syncs:
4303
+ if artifact_names and artifact_name not in artifact_names:
4304
+ continue
4305
+ archive = static_files.get(artifact_name)
4306
+ bucket = outputs.get(bucket_output, {}).get("value")
4307
+ if not archive or not bucket:
4308
+ continue
4309
+ target = RELEASE / f"extract-{artifact_name}"
4310
+ target.mkdir(parents=True, exist_ok=True)
4311
+ safe_extract_tar_file(archive, target)
4312
+ run(["aws", "s3", "sync", "--delete", str(target), f"s3://{bucket}/"])
4313
+ if artifact_name == "web":
4314
+ index_path = target / "index.html"
4315
+ if index_path.exists():
4316
+ run(
4317
+ [
4318
+ "aws",
4319
+ "s3",
4320
+ "cp",
4321
+ str(index_path),
4322
+ f"s3://{bucket}/index.html",
4323
+ "--content-type",
4324
+ "text/html",
4325
+ "--cache-control",
4326
+ "no-store",
4327
+ ]
4328
+ )
4329
+ profile, _ = runtime_profile(outputs, vars_json)
4330
+ runtime_config_path = RELEASE / "thinkwork-runtime-config.json"
4331
+ runtime_config_path.write_text(
4332
+ json.dumps(profile, indent=2, sort_keys=True) + "\n",
4333
+ encoding="utf-8",
4334
+ )
4335
+ run(
4336
+ [
4337
+ "aws",
4338
+ "s3",
4339
+ "cp",
4340
+ str(runtime_config_path),
4341
+ f"s3://{bucket}/thinkwork-runtime-config.json",
4342
+ "--content-type",
4343
+ "application/json",
4344
+ "--cache-control",
4345
+ "no-store",
4346
+ ]
4347
+ )
4348
+ distribution_id = outputs.get(distribution_output, {}).get("value")
4349
+ if distribution_id:
4350
+ run(
4351
+ [
4352
+ "aws",
4353
+ "cloudfront",
4354
+ "create-invalidation",
4355
+ "--distribution-id",
4356
+ str(distribution_id),
4357
+ "--paths",
4358
+ "/*",
4359
+ ]
4360
+ )
4361
+
4362
+
4363
+ def read_current_status_pointer(bucket):
4364
+ try:
4365
+ return json.loads(
4366
+ output(["aws", "s3", "cp", f"s3://{bucket}/deployment/status/current.json", "-"])
4367
+ )
4368
+ except Exception:
4369
+ return {}
4370
+
4371
+
4372
+ def build_deployment_status_pointer(
4373
+ status,
4374
+ *,
4375
+ action,
4376
+ release,
4377
+ previous,
4378
+ controller,
4379
+ environment_url,
4380
+ stage,
4381
+ region,
4382
+ account_id,
4383
+ started_at,
4384
+ recorded_at,
4385
+ terraform_exit_code=None,
4386
+ error=None,
4387
+ evidence_bucket=None,
4388
+ evidence_key=None,
4389
+ ):
4390
+ pointer = {
4391
+ "schemaVersion": 1,
4392
+ "contract": "thinkwork.deployment.status.v1",
4393
+ "stage": stage,
4394
+ "region": region,
4395
+ "accountId": account_id,
4396
+ "environmentUrl": environment_url or previous.get("environmentUrl"),
4397
+ "status": status,
4398
+ "action": action,
4399
+ "source": "deployment-controller",
4400
+ "recordedAt": recorded_at,
4401
+ "controller": {key: value for key, value in controller.items() if value},
4402
+ }
4403
+ if status == "succeeded":
4404
+ pointer["activeRelease"] = release
4405
+ pointer["lastSuccessfulDeployment"] = {
4406
+ "sessionId": controller.get("sessionId"),
4407
+ "startedAt": started_at,
4408
+ "finishedAt": recorded_at,
4409
+ "terraformExitCode": terraform_exit_code,
4410
+ "evidenceBucket": evidence_bucket,
4411
+ "evidenceKey": evidence_key,
4412
+ }
4413
+ else:
4414
+ pointer["targetRelease"] = release
4415
+ for carried in ("activeRelease", "lastSuccessfulDeployment", "historyKey"):
4416
+ if previous.get(carried):
4417
+ pointer[carried] = previous[carried]
4418
+ if error:
4419
+ pointer["error"] = str(error)
4420
+ return pointer
4421
+
4422
+
4423
+ def write_deployment_status_pointer(status, vars_json=None, terraform_exit_code=None, error=None):
4424
+ """Publish environment-owned deployed-release state. Best-effort: a status
4425
+ write must never change the deploy result."""
4426
+ bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
4427
+ action = os.environ.get("THINKWORK_DEPLOYMENT_ACTION")
4428
+ if os.environ.get("THINKWORK_MANAGED_APP_OPERATION") == "true":
4429
+ return
4430
+ if not bucket or action not in {"deploy", "update", "web"}:
4431
+ return
4432
+ vars_json = vars_json or {}
4433
+ previous = read_current_status_pointer(bucket)
4434
+ environment_url = None
4435
+ outputs_path = TF / "outputs.json"
4436
+ if outputs_path.is_file():
4437
+ try:
4438
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
4439
+ environment_url = outputs.get("app_url", {}).get("value")
4440
+ except Exception:
4441
+ environment_url = None
4442
+ recorded_at = datetime.now(UTC).isoformat()
4443
+ prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
4444
+ pointer = build_deployment_status_pointer(
4445
+ status,
4446
+ action=action,
4447
+ release={
4448
+ "version": os.environ.get("THINKWORK_RELEASE_VERSION"),
4449
+ "manifestUrl": os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
4450
+ "manifestSha256": os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
4451
+ },
4452
+ previous=previous,
4453
+ controller={
4454
+ "stateMachineArn": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN"),
4455
+ "codebuildProjectName": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
4456
+ "codebuildBuildId": os.environ.get("CODEBUILD_BUILD_ID"),
4457
+ "sessionId": os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
4458
+ },
4459
+ environment_url=environment_url,
4460
+ stage=os.environ.get("THINKWORK_STAGE"),
4461
+ region=vars_json.get("region") or os.environ.get("AWS_REGION"),
4462
+ account_id=vars_json.get("account_id") or previous.get("accountId"),
4463
+ started_at=STARTED_AT,
4464
+ recorded_at=recorded_at,
4465
+ terraform_exit_code=terraform_exit_code,
4466
+ error=error,
4467
+ evidence_bucket=bucket,
4468
+ evidence_key=f"{prefix}/deployment-evidence.json" if prefix else None,
4469
+ )
4470
+ if status in {"succeeded", "failed"}:
4471
+ timestamp = recorded_at.replace("-", "").replace(":", "").split(".")[0] + "Z"
4472
+ version = (pointer.get("activeRelease") or pointer.get("targetRelease") or {}).get(
4473
+ "version"
4474
+ ) or "unknown"
4475
+ pointer["historyKey"] = f"deployment/status/history/{timestamp}-{version}.json"
4476
+ body = Path("deployment-status-pointer.json")
4477
+ body.write_text(json.dumps(pointer, indent=2, sort_keys=True) + "\n", encoding="utf-8")
4478
+ if status in {"succeeded", "failed"}:
4479
+ run(["aws", "s3", "cp", str(body), f"s3://{bucket}/{pointer['historyKey']}"])
4480
+ run(["aws", "s3", "cp", str(body), f"s3://{bucket}/deployment/status/current.json"])
4481
+
4482
+
4483
+ def self_update_runner_script():
4484
+ """Refresh the controller's runner script from the release source just
4485
+ deployed. The customer-update terraform root provisions with
4486
+ enable_deployment_control_plane = false (the controller cannot manage
4487
+ itself mid-run), so nothing else ever updates the script the next build
4488
+ downloads — without this step it stays frozen at provision time."""
4489
+ script_uri = os.environ.get("THINKWORK_RUNNER_SCRIPT_S3_URI")
4490
+ if not script_uri:
4491
+ return
4492
+ source_script = SOURCE / "terraform/modules/app/deployment-control-plane/runner.py"
4493
+ if not source_script.is_file():
4494
+ print("[runner] release source has no runner.py; skipping self-update")
4495
+ return
4496
+ run(["aws", "s3", "cp", str(source_script), script_uri])
4497
+ print(f"[runner] self-updated runner script at {script_uri}")
4498
+
4499
+
4500
+ def write_evidence(status, vars_json=None, terraform_exit_code=None, error=None):
4501
+ vars_json = vars_json or {}
4502
+ evidence = {
4503
+ "status": status,
4504
+ "stage": os.environ.get("THINKWORK_STAGE"),
4505
+ "release": os.environ.get("THINKWORK_RELEASE_VERSION"),
4506
+ "action": os.environ.get("THINKWORK_DEPLOYMENT_ACTION"),
4507
+ "sessionId": os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
4508
+ "environmentName": vars_json.get("stage"),
4509
+ "awsAccountId": vars_json.get("account_id"),
4510
+ "awsRegion": vars_json.get("region"),
4511
+ "managedApps": {"cognee": False, "twenty": False},
4512
+ "codebuildBuildId": os.environ.get("CODEBUILD_BUILD_ID"),
4513
+ "terraformExitCode": terraform_exit_code,
4514
+ "startedAt": STARTED_AT,
4515
+ "recordedAt": datetime.now(UTC).isoformat(),
4516
+ }
4517
+ if "customer_domain" in vars_json:
4518
+ # Echoed-fields guard (KTD5): record the domain fields this runner
4519
+ # version actually consumed so the controller can detect an outdated
4520
+ # runner that silently dropped them. Booleans must stay booleans.
4521
+ evidence["consumedDomainFields"] = {
4522
+ "customerDomain": vars_json.get("customer_domain", ""),
4523
+ "customerDomainDelegated": bool(vars_json.get("customer_domain_delegated", False)),
4524
+ "customerDomainLegacyRetired": bool(
4525
+ vars_json.get("customer_domain_legacy_retired", False)
4526
+ ),
4527
+ }
4528
+ if error:
4529
+ evidence["error"] = str(error)
4530
+ if FIRST_ADMIN_EVIDENCE:
4531
+ evidence["firstAdminBootstrap"] = FIRST_ADMIN_EVIDENCE
4532
+ if RELEASE_EVIDENCE:
4533
+ evidence["releaseArtifacts"] = RELEASE_EVIDENCE
4534
+ if CONTROLLER_EVIDENCE:
4535
+ evidence["controller"] = CONTROLLER_EVIDENCE
4536
+ if TERRAFORM_EVIDENCE:
4537
+ evidence["terraform"] = TERRAFORM_EVIDENCE
4538
+ if MANAGED_APP_EVIDENCE:
4539
+ evidence["managedAppPostInstall"] = MANAGED_APP_EVIDENCE
4540
+ Path("deployment-evidence.json").write_text(
4541
+ json.dumps(evidence, indent=2, sort_keys=True) + "\n",
4542
+ encoding="utf-8",
4543
+ )
4544
+ prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
4545
+ bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
4546
+ if prefix and bucket:
4547
+ run(
4548
+ [
4549
+ "aws",
4550
+ "s3",
4551
+ "cp",
4552
+ "deployment-evidence.json",
4553
+ f"s3://{bucket}/{prefix}/deployment-evidence.json",
4554
+ ]
4555
+ )
4556
+ try:
4557
+ write_deployment_status_pointer(status, vars_json, terraform_exit_code, error)
4558
+ except Exception as status_error:
4559
+ print(f"[status] failed to write deployment status pointer: {status_error}")
4560
+
4561
+
4562
+ def main():
4563
+ global CONTROLLER_EVIDENCE, TERRAFORM_EVIDENCE
4564
+ WORK.mkdir(parents=True, exist_ok=True)
4565
+ payload = read_json_env("THINKWORK_DEPLOYMENT_INPUT", {})
4566
+ apply_release_selection(payload)
4567
+ action = os.environ.get("THINKWORK_DEPLOYMENT_ACTION") or payload.get("action") or "deploy"
4568
+ if action == "teardown":
4569
+ action = "destroy"
4570
+ if action not in {"deploy", "update", "destroy", "plan", "status", "web"}:
4571
+ raise RuntimeError(f"Unsupported deployment action: {action}")
4572
+ os.environ["THINKWORK_DEPLOYMENT_ACTION"] = action
4573
+ configure_managed_app_evidence_prefix(payload)
4574
+
4575
+ if action == "status":
4576
+ CONTROLLER_EVIDENCE = {
4577
+ "status": write_controller_status_evidence(payload),
4578
+ }
4579
+ write_evidence(
4580
+ "succeeded",
4581
+ {
4582
+ "stage": safe_get(payload, "stage", "environmentName", default=""),
4583
+ "account_id": safe_get(payload, "awsAccountId", "accountId", default=""),
4584
+ "region": safe_get(payload, "awsRegion", "region", default=""),
4585
+ },
4586
+ 0,
4587
+ )
4588
+ return 0
4589
+
4590
+ runner_secrets = secret_payload(payload)
4591
+ web_only = is_web_only_operation(payload, action)
4592
+ static_files = {}
4593
+ if action in {"deploy", "update", "web"} and not is_managed_app_operation(payload):
4594
+ static_files = sync_release_artifacts(
4595
+ artifact_types={"static-site"} if web_only else None,
4596
+ artifact_names={"web"} if web_only else None,
4597
+ )
4598
+ managed_app_artifacts = stage_managed_app_release_artifacts(action, payload)
4599
+ vars_json = write_runner_files(payload, runner_secrets)
4600
+ controller_summary = controller_input_summary(payload)
4601
+ CONTROLLER_EVIDENCE = {
4602
+ "inputSummary": controller_summary,
4603
+ "artifact": write_json_evidence_artifact(
4604
+ "controller-input-summary.json",
4605
+ controller_summary,
4606
+ ),
4607
+ }
4608
+ TERRAFORM_EVIDENCE = {
4609
+ "redactedVariables": write_json_evidence_artifact(
4610
+ "redacted-terraform-vars.json",
4611
+ redacted_tfvars(vars_json),
4612
+ )
4613
+ }
4614
+ write_evidence("running", vars_json)
4615
+
4616
+ if web_only:
4617
+ outputs_path = TF / "outputs.json"
4618
+ write_current_outputs_from_state(vars_json["stage"], outputs_path)
4619
+ TERRAFORM_EVIDENCE["outputs"] = {
4620
+ "fileName": "terraform-outputs.json",
4621
+ "sha256": sha256_file(outputs_path),
4622
+ "s3Uri": upload_evidence_artifact(outputs_path, "terraform-outputs.json"),
4623
+ "source": "state",
4624
+ }
4625
+ sync_static(outputs_path, static_files, vars_json, artifact_names={"web"})
4626
+ selected_controller_release = write_controller_release_selection_to_ssm(vars_json)
4627
+ if selected_controller_release:
4628
+ CONTROLLER_EVIDENCE["releaseSelection"] = write_json_evidence_artifact(
4629
+ "controller-release-selection.json",
4630
+ selected_controller_release,
4631
+ )
4632
+ write_evidence("succeeded", vars_json, 0)
4633
+ return 0
4634
+
4635
+ configure_cloudflare_provider_auth(vars_json["stage"])
4636
+ configure_terraform_provider_mirror()
4637
+ run(["terraform", "init", "-backend-config=backend.hcl", "-no-color"], cwd=TF)
4638
+ patch_downloaded_customer_domain_module()
4639
+ workspace = terraform_workspace_name(vars_json["stage"], payload)
4640
+ if workspace != "default":
4641
+ selected = subprocess.run(
4642
+ ["terraform", "workspace", "select", workspace, "-no-color"],
4643
+ cwd=TF,
4644
+ text=True,
4645
+ )
4646
+ if selected.returncode != 0:
4647
+ run(["terraform", "workspace", "new", workspace, "-no-color"], cwd=TF)
4648
+ target_args = managed_app_terraform_target_args(payload)
4649
+ if action == "destroy":
4650
+ plan = subprocess.run(
4651
+ ["terraform", "plan", "-destroy", *target_args, "-out=tfplan", "-no-color"],
4652
+ cwd=TF,
4653
+ text=True,
4654
+ )
4655
+ if plan.returncode == 0:
4656
+ TERRAFORM_EVIDENCE["plan"] = write_terraform_plan_evidence(payload)
4657
+ result = subprocess.run(
4658
+ ["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
4659
+ cwd=TF,
4660
+ text=True,
4661
+ )
4662
+ else:
4663
+ result = plan
4664
+ else:
4665
+ plan = subprocess.run(
4666
+ ["terraform", "plan", *target_args, "-out=tfplan", "-no-color"],
4667
+ cwd=TF,
4668
+ text=True,
4669
+ )
4670
+ if plan.returncode == 0:
4671
+ TERRAFORM_EVIDENCE["plan"] = write_terraform_plan_evidence(payload)
4672
+ if action == "plan" or plan.returncode != 0:
4673
+ result = plan
4674
+ else:
4675
+ result = subprocess.run(
4676
+ ["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
4677
+ cwd=TF,
4678
+ text=True,
4679
+ )
4680
+
4681
+ outputs_path = TF / "outputs.json"
4682
+ if result.returncode == 0 and action in {"deploy", "update"}:
4683
+ write_outputs_after_apply(payload, vars_json, outputs_path)
4684
+ if is_managed_app_operation(payload):
4685
+ sync_twenty_thinkwork_app(
4686
+ outputs_path,
4687
+ vars_json,
4688
+ payload,
4689
+ runner_secrets,
4690
+ managed_app_artifacts,
4691
+ )
4692
+ else:
4693
+ push_database_schema(outputs_path, vars_json)
4694
+ ensure_first_admin(outputs_path, vars_json, payload, runner_secrets)
4695
+ write_outputs_to_ssm(outputs_path, vars_json)
4696
+ selected_controller_release = write_controller_release_selection_to_ssm(vars_json)
4697
+ if selected_controller_release:
4698
+ CONTROLLER_EVIDENCE["releaseSelection"] = write_json_evidence_artifact(
4699
+ "controller-release-selection.json",
4700
+ selected_controller_release,
4701
+ )
4702
+ sync_static(outputs_path, static_files, vars_json)
4703
+ try:
4704
+ self_update_runner_script()
4705
+ except Exception as self_update_error:
4706
+ print(f"[runner] self-update failed (non-fatal): {self_update_error}")
4707
+ write_evidence(
4708
+ "succeeded" if result.returncode == 0 else "failed",
4709
+ vars_json,
4710
+ result.returncode,
4711
+ )
4712
+ return result.returncode
4713
+
4714
+
4715
+ if __name__ == "__main__":
4716
+ try:
4717
+ raise SystemExit(main())
4718
+ except Exception as exc:
4719
+ write_evidence("failed", error=exc)
4720
+ raise