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,3026 @@
1
+ import base64
2
+ import importlib.util
3
+ import io
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tarfile
9
+ from hashlib import sha256
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+
15
+ def load_runner():
16
+ runner_path = Path(__file__).with_name("runner.py")
17
+ spec = importlib.util.spec_from_file_location("deployment_control_runner", runner_path)
18
+ assert spec and spec.loader
19
+ module = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ def write_tar(path: Path, files: dict[str, bytes]) -> None:
25
+ with tarfile.open(path, "w:gz") as tar:
26
+ for name, data in files.items():
27
+ info = tarfile.TarInfo(name)
28
+ info.size = len(data)
29
+ tar.addfile(info, io.BytesIO(data))
30
+
31
+
32
+ def file_url(path: Path) -> str:
33
+ return path.resolve().as_uri()
34
+
35
+
36
+ def digest(data: bytes) -> str:
37
+ return sha256(data).hexdigest()
38
+
39
+
40
+ def canonical_manifest_digest(manifest: dict) -> str:
41
+ encoded = json.dumps(
42
+ manifest,
43
+ sort_keys=True,
44
+ separators=(",", ":"),
45
+ ensure_ascii=False,
46
+ ).encode("utf-8")
47
+ return digest(encoded)
48
+
49
+
50
+ def write_manifest(path: Path, manifest: dict) -> str:
51
+ encoded = json.dumps(manifest, indent=2).encode()
52
+ path.write_bytes(encoded)
53
+ return digest(encoded)
54
+
55
+
56
+ def release_manifest(
57
+ bundle_path: Path,
58
+ bundle_sha: str,
59
+ artifacts: list[dict],
60
+ version: str = "0.1.0-canary.134",
61
+ ) -> dict:
62
+ return {
63
+ "schemaVersion": 1,
64
+ "release": {
65
+ "version": version,
66
+ "gitSha": "abc123",
67
+ "createdAt": "2026-06-09T00:00:00.000Z",
68
+ },
69
+ "artifactBundles": [
70
+ {
71
+ "name": "platform",
72
+ "fileName": "platform-artifacts.tar.gz",
73
+ "relativePath": "platform-artifacts.tar.gz",
74
+ "url": file_url(bundle_path),
75
+ "sha256": bundle_sha,
76
+ "sizeBytes": bundle_path.stat().st_size,
77
+ "contains": [artifact["name"] for artifact in artifacts],
78
+ }
79
+ ],
80
+ "artifacts": artifacts,
81
+ "runtimeImages": [],
82
+ "managedApps": [],
83
+ "signing": {
84
+ "acceptedKeyIds": [],
85
+ "revokedKeyIds": [],
86
+ },
87
+ }
88
+
89
+
90
+ def write_drizzle_files(source_dir: Path, names: list[str]) -> None:
91
+ migrations = source_dir / "packages/database-pg/drizzle"
92
+ migrations.mkdir(parents=True)
93
+ for name in names:
94
+ (migrations / name).write_text(f"-- {name}\n", encoding="utf-8")
95
+
96
+
97
+ def test_sync_release_artifacts_stages_artifacts_from_platform_bundle(
98
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
99
+ ) -> None:
100
+ runner = load_runner()
101
+ release_dir = tmp_path / "release-work"
102
+ manifest_path = tmp_path / "thinkwork-release.json"
103
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
104
+ lambda_bytes = b"lambda-zip"
105
+ web_bytes = b"web-tarball"
106
+ write_tar(
107
+ bundle_path,
108
+ {
109
+ "lambdas/graphql-http.zip": lambda_bytes,
110
+ "static/web.tar.gz": web_bytes,
111
+ },
112
+ )
113
+ artifacts = [
114
+ {
115
+ "name": "graphql-http",
116
+ "type": "lambda",
117
+ "fileName": "graphql-http.zip",
118
+ "relativePath": "lambdas/graphql-http.zip",
119
+ "url": None,
120
+ "sha256": digest(lambda_bytes),
121
+ "sizeBytes": len(lambda_bytes),
122
+ },
123
+ {
124
+ "name": "web",
125
+ "type": "static-site",
126
+ "fileName": "web.tar.gz",
127
+ "relativePath": "static/web.tar.gz",
128
+ "url": None,
129
+ "sha256": digest(web_bytes),
130
+ "sizeBytes": len(web_bytes),
131
+ },
132
+ ]
133
+ manifest_sha = write_manifest(
134
+ manifest_path,
135
+ release_manifest(bundle_path, runner.sha256_file(bundle_path), artifacts),
136
+ )
137
+ calls: list[list[str]] = []
138
+
139
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
140
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
141
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
142
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
143
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
144
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "0.1.0-canary.134")
145
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
146
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
147
+
148
+ static_files = runner.sync_release_artifacts()
149
+
150
+ assert static_files["web"].read_bytes() == web_bytes
151
+ assert calls == [
152
+ [
153
+ "aws",
154
+ "s3",
155
+ "cp",
156
+ str(release_dir / "lambdas/graphql-http.zip"),
157
+ "s3://thinkwork-artifacts/releases/0.1.0-canary.134/lambdas/graphql-http.zip",
158
+ ]
159
+ ]
160
+ assert runner.RELEASE_EVIDENCE["manifestSha256"] == manifest_sha
161
+ assert runner.RELEASE_EVIDENCE["trust"] == {
162
+ "policy": "allow_unsigned_canary",
163
+ "signatureRequired": False,
164
+ "signatureVerified": False,
165
+ "unsignedCanaryAllowed": True,
166
+ }
167
+ assert runner.RELEASE_EVIDENCE["bundles"][0]["contains"] == ["graphql-http", "web"]
168
+ assert {artifact["source"] for artifact in runner.RELEASE_EVIDENCE["artifacts"]} == {"bundle"}
169
+
170
+
171
+ def test_ensure_release_manifest_available_accepts_manifest_file_digest(
172
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
173
+ ) -> None:
174
+ runner = load_runner()
175
+ manifest_path = tmp_path / "thinkwork-release.json"
176
+ downloaded_path = tmp_path / "downloaded-release.json"
177
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
178
+ write_tar(bundle_path, {})
179
+ manifest = release_manifest(bundle_path, runner.sha256_file(bundle_path), [])
180
+ manifest_sha = write_manifest(manifest_path, manifest)
181
+ canonical_sha = runner.release_manifest_sha256(manifest)
182
+
183
+ assert digest(manifest_path.read_bytes()) == manifest_sha
184
+ assert canonical_sha != manifest_sha
185
+
186
+ monkeypatch.setattr(runner, "MANIFEST", downloaded_path)
187
+
188
+ runner.ensure_release_manifest_available(file_url(manifest_path), manifest_sha)
189
+
190
+ assert downloaded_path.exists()
191
+ assert digest(downloaded_path.read_bytes()) == manifest_sha
192
+
193
+ downloaded_path.unlink()
194
+
195
+ with pytest.raises(RuntimeError, match="Release manifest digest mismatch"):
196
+ runner.ensure_release_manifest_available(file_url(manifest_path), canonical_sha)
197
+
198
+
199
+ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
200
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
201
+ ) -> None:
202
+ runner = load_runner()
203
+ release_dir = tmp_path / "release-work"
204
+ manifest_path = tmp_path / "thinkwork-release.json"
205
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
206
+ lambda_bytes = b"lambda-zip"
207
+ web_bytes = b"web-tarball"
208
+ docs_bytes = b"docs-tarball"
209
+ write_tar(
210
+ bundle_path,
211
+ {
212
+ "lambdas/graphql-http.zip": lambda_bytes,
213
+ "static/web.tar.gz": web_bytes,
214
+ "static/docs.tar.gz": docs_bytes,
215
+ },
216
+ )
217
+ artifacts = [
218
+ {
219
+ "name": "graphql-http",
220
+ "type": "lambda",
221
+ "fileName": "graphql-http.zip",
222
+ "relativePath": "lambdas/graphql-http.zip",
223
+ "url": None,
224
+ "sha256": digest(lambda_bytes),
225
+ "sizeBytes": len(lambda_bytes),
226
+ },
227
+ {
228
+ "name": "web",
229
+ "type": "static-site",
230
+ "fileName": "web.tar.gz",
231
+ "relativePath": "static/web.tar.gz",
232
+ "url": None,
233
+ "sha256": digest(web_bytes),
234
+ "sizeBytes": len(web_bytes),
235
+ },
236
+ {
237
+ "name": "docs",
238
+ "type": "static-site",
239
+ "fileName": "docs.tar.gz",
240
+ "relativePath": "static/docs.tar.gz",
241
+ "url": None,
242
+ "sha256": digest(docs_bytes),
243
+ "sizeBytes": len(docs_bytes),
244
+ },
245
+ ]
246
+ manifest_sha = write_manifest(
247
+ manifest_path,
248
+ release_manifest(bundle_path, runner.sha256_file(bundle_path), artifacts),
249
+ )
250
+ calls: list[list[str]] = []
251
+
252
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
253
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
254
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
255
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
256
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
257
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "0.1.0-canary.134")
258
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
259
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
260
+
261
+ static_files = runner.sync_release_artifacts(
262
+ artifact_types={"static-site"},
263
+ artifact_names={"web"},
264
+ )
265
+
266
+ assert static_files == {"web": release_dir / "static/web.tar.gz"}
267
+ assert static_files["web"].read_bytes() == web_bytes
268
+ assert calls == []
269
+ assert [artifact["name"] for artifact in runner.RELEASE_EVIDENCE["artifacts"]] == ["web"]
270
+
271
+
272
+ def test_sync_release_artifacts_requires_signature_for_non_canary_release(
273
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
274
+ ) -> None:
275
+ runner = load_runner()
276
+ release_dir = tmp_path / "release-work"
277
+ manifest_path = tmp_path / "thinkwork-release.json"
278
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
279
+ lambda_bytes = b"lambda-zip"
280
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": lambda_bytes})
281
+ artifacts = [
282
+ {
283
+ "name": "graphql-http",
284
+ "type": "lambda",
285
+ "fileName": "graphql-http.zip",
286
+ "relativePath": "lambdas/graphql-http.zip",
287
+ "url": None,
288
+ "sha256": digest(lambda_bytes),
289
+ "sizeBytes": len(lambda_bytes),
290
+ }
291
+ ]
292
+ manifest_sha = write_manifest(
293
+ manifest_path,
294
+ release_manifest(
295
+ bundle_path,
296
+ runner.sha256_file(bundle_path),
297
+ artifacts,
298
+ version="1.0.0",
299
+ ),
300
+ )
301
+ calls: list[list[str]] = []
302
+
303
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
304
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
305
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
306
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
307
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
308
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "1.0.0")
309
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
310
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
311
+
312
+ with pytest.raises(RuntimeError, match="Unsigned release manifest is only allowed"):
313
+ runner.sync_release_artifacts()
314
+
315
+ assert calls == []
316
+ assert runner.RELEASE_EVIDENCE == {}
317
+
318
+
319
+ def test_sync_release_artifacts_require_signature_policy_fails_without_signature_url(
320
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
321
+ ) -> None:
322
+ runner = load_runner()
323
+ release_dir = tmp_path / "release-work"
324
+ manifest_path = tmp_path / "custom-manifest.json"
325
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
326
+ lambda_bytes = b"lambda-zip"
327
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": lambda_bytes})
328
+ artifacts = [
329
+ {
330
+ "name": "graphql-http",
331
+ "type": "lambda",
332
+ "fileName": "graphql-http.zip",
333
+ "relativePath": "lambdas/graphql-http.zip",
334
+ "url": None,
335
+ "sha256": digest(lambda_bytes),
336
+ "sizeBytes": len(lambda_bytes),
337
+ }
338
+ ]
339
+ manifest_sha = write_manifest(
340
+ manifest_path,
341
+ release_manifest(bundle_path, runner.sha256_file(bundle_path), artifacts),
342
+ )
343
+ calls: list[list[str]] = []
344
+
345
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
346
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
347
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
348
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
349
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
350
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY", "require_signature")
351
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "0.1.0-canary.134")
352
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
353
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
354
+
355
+ with pytest.raises(RuntimeError, match="signature URL is required"):
356
+ runner.sync_release_artifacts()
357
+
358
+ assert calls == []
359
+ assert runner.RELEASE_EVIDENCE == {}
360
+
361
+
362
+ def test_sync_release_artifacts_verifies_detached_manifest_signature(
363
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
364
+ ) -> None:
365
+ runner = load_runner()
366
+ release_dir = tmp_path / "release-work"
367
+ manifest_path = tmp_path / "thinkwork-release.json"
368
+ signature_path = tmp_path / "thinkwork-release.sig.json"
369
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
370
+ private_key_path = tmp_path / "private.pem"
371
+ public_key_path = tmp_path / "public.pem"
372
+ canonical_path = tmp_path / "manifest.canonical.json"
373
+ signature_bytes_path = tmp_path / "thinkwork-release.sig"
374
+ lambda_bytes = b"lambda-zip"
375
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": lambda_bytes})
376
+ artifacts = [
377
+ {
378
+ "name": "graphql-http",
379
+ "type": "lambda",
380
+ "fileName": "graphql-http.zip",
381
+ "relativePath": "lambdas/graphql-http.zip",
382
+ "url": None,
383
+ "sha256": digest(lambda_bytes),
384
+ "sizeBytes": len(lambda_bytes),
385
+ }
386
+ ]
387
+ manifest = release_manifest(
388
+ bundle_path,
389
+ runner.sha256_file(bundle_path),
390
+ artifacts,
391
+ version="1.0.0",
392
+ )
393
+ manifest["signing"]["acceptedKeyIds"] = ["test-key"]
394
+ manifest_sha = write_manifest(manifest_path, manifest)
395
+ canonical_path.write_bytes(runner.stable_json_bytes(manifest))
396
+
397
+ subprocess.run(
398
+ ["openssl", "genpkey", "-algorithm", "Ed25519", "-out", str(private_key_path)],
399
+ check=True,
400
+ )
401
+ subprocess.run(
402
+ ["openssl", "pkey", "-in", str(private_key_path), "-pubout", "-out", str(public_key_path)],
403
+ check=True,
404
+ )
405
+ subprocess.run(
406
+ [
407
+ "openssl",
408
+ "pkeyutl",
409
+ "-sign",
410
+ "-rawin",
411
+ "-inkey",
412
+ str(private_key_path),
413
+ "-in",
414
+ str(canonical_path),
415
+ "-out",
416
+ str(signature_bytes_path),
417
+ ],
418
+ check=True,
419
+ )
420
+ signature_path.write_text(
421
+ json.dumps(
422
+ {
423
+ "schemaVersion": 1,
424
+ "algorithm": "ed25519",
425
+ "keyId": "test-key",
426
+ "manifestSha256": runner.release_manifest_sha256(manifest),
427
+ "signedAt": "2026-06-09T00:00:00.000Z",
428
+ "notBefore": "2026-06-09T00:00:00.000Z",
429
+ "expiresAt": "9999-12-31T23:59:59.999Z",
430
+ "signature": base64.b64encode(signature_bytes_path.read_bytes()).decode("ascii"),
431
+ }
432
+ ),
433
+ encoding="utf-8",
434
+ )
435
+ calls: list[list[str]] = []
436
+
437
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
438
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
439
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
440
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
441
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
442
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL", file_url(signature_path))
443
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY", "require_signature")
444
+ monkeypatch.setenv(
445
+ "THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON",
446
+ json.dumps(
447
+ [
448
+ {
449
+ "keyId": "test-key",
450
+ "publicKeyPem": public_key_path.read_text(encoding="utf-8"),
451
+ }
452
+ ]
453
+ ),
454
+ )
455
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "1.0.0")
456
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
457
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
458
+
459
+ runner.sync_release_artifacts()
460
+
461
+ assert calls == [
462
+ [
463
+ "aws",
464
+ "s3",
465
+ "cp",
466
+ str(release_dir / "lambdas/graphql-http.zip"),
467
+ "s3://thinkwork-artifacts/releases/1.0.0/lambdas/graphql-http.zip",
468
+ ]
469
+ ]
470
+ assert runner.RELEASE_EVIDENCE["trust"] == {
471
+ "policy": "require_signature",
472
+ "signatureRequired": True,
473
+ "signatureVerified": True,
474
+ "unsignedCanaryAllowed": False,
475
+ "keyId": "test-key",
476
+ "signatureUrl": file_url(signature_path),
477
+ }
478
+
479
+
480
+ class FakeLedgerDb:
481
+ """In-memory stand-in for the psql/psql_output pair: tracks the migration
482
+ ledger and records which migration files were applied."""
483
+
484
+ def __init__(
485
+ self,
486
+ tenants_exist: bool,
487
+ ledger: set[str] | None = None,
488
+ present_objects: set[str] | None = None,
489
+ ) -> None:
490
+ self.tenants_exist = tenants_exist
491
+ self.ledger: dict[str, str] = {name: "preexisting" for name in (ledger or set())}
492
+ self.ledger_table_exists = ledger is not None
493
+ self.present_objects = present_objects or set()
494
+ self.applied_files: list[str] = []
495
+ self.events: list[tuple[str, str]] = []
496
+
497
+ def psql(self, _database_url, sql=None, file=None, variables=None):
498
+ if file is not None:
499
+ self.applied_files.append(Path(file).name)
500
+ self.events.append(("apply", Path(file).name))
501
+ return
502
+ assert sql is not None
503
+ if "CREATE TABLE IF NOT EXISTS public.platform_schema_migrations" in sql:
504
+ self.ledger_table_exists = True
505
+ self.events.append(("ensure-ledger", ""))
506
+ return
507
+ if "INSERT INTO public.platform_schema_migrations" in sql:
508
+ for name, source in re.findall(r"\('([^']+)', '([^']+)'\)", sql):
509
+ self.ledger.setdefault(name, source)
510
+ self.events.append(("record", f"{name}:{source}"))
511
+ return
512
+ self.events.append(("sql", sql.strip().splitlines()[0] if sql.strip() else ""))
513
+
514
+ def psql_output(self, _database_url, sql):
515
+ if "to_regclass('public.tenants')" in sql:
516
+ return "public.tenants" if self.tenants_exist else ""
517
+ if "to_regclass('public.platform_schema_migrations')" in sql:
518
+ return "public.platform_schema_migrations" if self.ledger_table_exists else ""
519
+ if "SELECT name FROM public.platform_schema_migrations" in sql:
520
+ return "\n".join(sorted(self.ledger))
521
+ for obj in self.present_objects:
522
+ if f"'{obj}'" in sql or f"to_regclass('{obj}')" in sql:
523
+ return "1"
524
+ return ""
525
+
526
+
527
+ def run_push_database_schema(
528
+ runner,
529
+ tmp_path: Path,
530
+ monkeypatch: pytest.MonkeyPatch,
531
+ db: FakeLedgerDb,
532
+ migration_names: list[str],
533
+ migration_bodies: dict[str, str] | None = None,
534
+ vars_json: dict | None = None,
535
+ ) -> list[tuple[str, str]]:
536
+ source_dir = tmp_path / "source"
537
+ outputs_path = tmp_path / "outputs.json"
538
+ outputs_path.write_text("{}", encoding="utf-8")
539
+ write_drizzle_files(source_dir, migration_names)
540
+ for name, body in (migration_bodies or {}).items():
541
+ (source_dir / "packages/database-pg/drizzle" / name).write_text(body, encoding="utf-8")
542
+
543
+ monkeypatch.setattr(runner, "SOURCE", source_dir)
544
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_SOURCE", "thinkwork-ai/thinkwork/aws")
545
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.170")
546
+ monkeypatch.setattr(runner, "checkout_source", lambda *_args: None)
547
+ monkeypatch.setattr(runner, "database_url_from_outputs", lambda _outputs: "postgres://db")
548
+ monkeypatch.setattr(runner, "psql", db.psql)
549
+ monkeypatch.setattr(runner, "psql_output", db.psql_output)
550
+ monkeypatch.setattr(
551
+ runner,
552
+ "initialize_greenfield_database",
553
+ lambda *_args: db.events.append(("initialize", "")),
554
+ )
555
+ monkeypatch.setattr(
556
+ runner,
557
+ "seed_platform_bootstrap_defaults",
558
+ lambda _database_url: db.events.append(("seed", "")),
559
+ )
560
+
561
+ runner.push_database_schema(outputs_path, vars_json or {"stage": "tei-e2e"})
562
+ return db.events
563
+
564
+
565
+ def test_push_database_schema_checks_out_release_selected_module_source(
566
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
567
+ ) -> None:
568
+ runner = load_runner()
569
+ db = FakeLedgerDb(tenants_exist=True, ledger={"0155_tenant_model_catalog.sql"})
570
+ checkout_args: list[tuple[str, str]] = []
571
+
572
+ source_dir = tmp_path / "source"
573
+ outputs_path = tmp_path / "outputs.json"
574
+ outputs_path.write_text("{}", encoding="utf-8")
575
+ write_drizzle_files(source_dir, ["0155_tenant_model_catalog.sql"])
576
+
577
+ monkeypatch.setattr(runner, "SOURCE", source_dir)
578
+ monkeypatch.setenv(
579
+ "THINKWORK_TERRAFORM_MODULE_SOURCE",
580
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
581
+ "//terraform/modules/thinkwork?ref=v0.1.0-canary.249",
582
+ )
583
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.270")
584
+ monkeypatch.setattr(runner, "checkout_source", lambda *args: checkout_args.append(args))
585
+ monkeypatch.setattr(runner, "database_url_from_outputs", lambda _outputs: "postgres://db")
586
+ monkeypatch.setattr(runner, "psql", db.psql)
587
+ monkeypatch.setattr(runner, "psql_output", db.psql_output)
588
+ monkeypatch.setattr(
589
+ runner,
590
+ "seed_platform_bootstrap_defaults",
591
+ lambda _database_url: db.events.append(("seed", "")),
592
+ )
593
+
594
+ runner.push_database_schema(
595
+ outputs_path,
596
+ {
597
+ "stage": "tei-e2e",
598
+ "deployment_terraform_module_source": (
599
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
600
+ "//terraform/modules/thinkwork?ref=09d199cc68afdbfc5f34e9cd1fb38448f20205d0"
601
+ ),
602
+ },
603
+ )
604
+
605
+ assert checkout_args == [
606
+ (
607
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
608
+ "//terraform/modules/thinkwork?ref=09d199cc68afdbfc5f34e9cd1fb38448f20205d0",
609
+ "v0.1.0-canary.270",
610
+ )
611
+ ]
612
+
613
+
614
+ def test_push_database_schema_applies_only_unrecorded_migrations(
615
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
616
+ ) -> None:
617
+ runner = load_runner()
618
+ db = FakeLedgerDb(
619
+ tenants_exist=True,
620
+ ledger={
621
+ "0149_user_model_approvals.sql",
622
+ "0152_agent_profiles.sql",
623
+ "0155_tenant_model_catalog.sql",
624
+ },
625
+ )
626
+ run_push_database_schema(
627
+ runner,
628
+ tmp_path,
629
+ monkeypatch,
630
+ db,
631
+ [
632
+ "0149_user_model_approvals.sql",
633
+ "0152_agent_profiles.sql",
634
+ "0155_tenant_model_catalog.sql",
635
+ "0158_pending_user_questions.sql",
636
+ ],
637
+ )
638
+
639
+ assert db.applied_files == [
640
+ "0158_pending_user_questions.sql",
641
+ "0155_tenant_model_catalog.sql", # post-seed backfill re-run
642
+ ]
643
+ assert db.ledger["0158_pending_user_questions.sql"] == "runner"
644
+ assert ("seed", "") in db.events
645
+ # seed runs after the pending migration and before the post-seed re-run
646
+ assert db.events.index(("seed", "")) > db.events.index(
647
+ ("apply", "0158_pending_user_questions.sql")
648
+ )
649
+
650
+
651
+ def test_push_database_schema_transition_backfills_ledger_via_markers(
652
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
653
+ ) -> None:
654
+ runner = load_runner()
655
+ db = FakeLedgerDb(
656
+ tenants_exist=True,
657
+ ledger=None, # no ledger table yet: pre-ledger environment
658
+ present_objects={"public.user_model_approvals"},
659
+ )
660
+ run_push_database_schema(
661
+ runner,
662
+ tmp_path,
663
+ monkeypatch,
664
+ db,
665
+ ["0001_ancient.sql", "0155_tenant_model_catalog.sql"],
666
+ migration_bodies={
667
+ "0149_user_model_approvals.sql": (
668
+ "-- creates: public.user_model_approvals\nSELECT 1;\n"
669
+ ),
670
+ "0158_pending_user_questions.sql": (
671
+ "-- creates: public.pending_user_questions\nCREATE TABLE IF NOT EXISTS "
672
+ "public.pending_user_questions ();\n"
673
+ ),
674
+ },
675
+ )
676
+
677
+ # transition never re-runs existing files — markers can name objects that
678
+ # later migrations intentionally dropped, so auto-apply is unsafe
679
+ assert db.applied_files == ["0155_tenant_model_catalog.sql"] # post-seed re-run only
680
+ # marker objects present -> verified
681
+ assert db.ledger["0149_user_model_approvals.sql"] == "transition-verified"
682
+ # marker objects missing -> recorded as assumed, surfaced as a warning
683
+ assert db.ledger["0158_pending_user_questions.sql"] == "transition-assumed"
684
+ # no markers -> assumed applied
685
+ assert db.ledger["0001_ancient.sql"] == "transition-assumed"
686
+
687
+
688
+ def test_push_database_schema_greenfield_records_full_ledger(
689
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
690
+ ) -> None:
691
+ runner = load_runner()
692
+ db = FakeLedgerDb(tenants_exist=False)
693
+ run_push_database_schema(
694
+ runner,
695
+ tmp_path,
696
+ monkeypatch,
697
+ db,
698
+ ["0001_init.sql", "0155_tenant_model_catalog.sql"],
699
+ )
700
+
701
+ assert ("initialize", "") in db.events
702
+ assert db.ledger == {
703
+ "0001_init.sql": "greenfield",
704
+ "0155_tenant_model_catalog.sql": "greenfield",
705
+ }
706
+ # nothing re-applies through the pending path; only the post-seed re-run
707
+ assert db.applied_files == ["0155_tenant_model_catalog.sql"]
708
+
709
+
710
+ def test_declared_migration_objects_parses_header_markers(tmp_path: Path) -> None:
711
+ runner = load_runner()
712
+ path = tmp_path / "0158_pending_user_questions.sql"
713
+ path.write_text(
714
+ "-- Purpose: example\n"
715
+ "-- creates: public.pending_user_questions\n"
716
+ "-- creates: public.idx_pending_user_questions_tenant\n"
717
+ "-- creates-column: public.computers.scope\n"
718
+ "-- creates-constraint: public.pending_user_questions.pending_status_allowed\n"
719
+ "\n"
720
+ "CREATE TABLE IF NOT EXISTS public.pending_user_questions ();\n"
721
+ "-- creates: public.after_body_ignored\n",
722
+ encoding="utf-8",
723
+ )
724
+
725
+ assert runner.declared_migration_objects(path) == [
726
+ ("object", "public.pending_user_questions"),
727
+ ("object", "public.idx_pending_user_questions_tenant"),
728
+ ("column", "public.computers.scope"),
729
+ ("constraint", "public.pending_user_questions.pending_status_allowed"),
730
+ ]
731
+
732
+
733
+ def test_build_deployment_status_pointer_success_sets_active_release() -> None:
734
+ runner = load_runner()
735
+ pointer = runner.build_deployment_status_pointer(
736
+ "succeeded",
737
+ action="update",
738
+ release={"version": "v0.1.0-canary.170", "manifestUrl": "u", "manifestSha256": "s"},
739
+ previous={"activeRelease": {"version": "v0.1.0-canary.165"}},
740
+ controller={"codebuildBuildId": "b:1", "sessionId": "sess", "stateMachineArn": None},
741
+ environment_url="https://example.cloudfront.net",
742
+ stage="tei-e2e",
743
+ region="us-east-1",
744
+ account_id="123",
745
+ started_at="t0",
746
+ recorded_at="t1",
747
+ terraform_exit_code=0,
748
+ evidence_bucket="bucket",
749
+ evidence_key="sessions/sess/update/deployment-evidence.json",
750
+ )
751
+
752
+ assert pointer["contract"] == "thinkwork.deployment.status.v1"
753
+ assert pointer["activeRelease"]["version"] == "v0.1.0-canary.170"
754
+ assert pointer["lastSuccessfulDeployment"]["sessionId"] == "sess"
755
+ assert pointer["lastSuccessfulDeployment"]["terraformExitCode"] == 0
756
+ assert "targetRelease" not in pointer
757
+ assert "stateMachineArn" not in pointer["controller"]
758
+
759
+
760
+ def test_build_deployment_status_pointer_failure_preserves_active_release() -> None:
761
+ runner = load_runner()
762
+ previous = {
763
+ "activeRelease": {"version": "v0.1.0-canary.165"},
764
+ "lastSuccessfulDeployment": {"sessionId": "old"},
765
+ "environmentUrl": "https://example.cloudfront.net",
766
+ }
767
+ pointer = runner.build_deployment_status_pointer(
768
+ "failed",
769
+ action="update",
770
+ release={"version": "v0.1.0-canary.170", "manifestUrl": "u", "manifestSha256": "s"},
771
+ previous=previous,
772
+ controller={"codebuildBuildId": "b:2"},
773
+ environment_url=None,
774
+ stage="tei-e2e",
775
+ region="us-east-1",
776
+ account_id="123",
777
+ started_at="t0",
778
+ recorded_at="t1",
779
+ error=RuntimeError("terraform exploded"),
780
+ )
781
+
782
+ assert pointer["status"] == "failed"
783
+ assert pointer["activeRelease"]["version"] == "v0.1.0-canary.165"
784
+ assert pointer["targetRelease"]["version"] == "v0.1.0-canary.170"
785
+ assert pointer["lastSuccessfulDeployment"]["sessionId"] == "old"
786
+ assert pointer["environmentUrl"] == "https://example.cloudfront.net"
787
+ assert "terraform exploded" in pointer["error"]
788
+
789
+
790
+ def test_write_deployment_status_pointer_uploads_current_and_history(
791
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
792
+ ) -> None:
793
+ runner = load_runner()
794
+ monkeypatch.chdir(tmp_path)
795
+ monkeypatch.setattr(runner, "TF", tmp_path / "tf-none")
796
+ monkeypatch.setenv("THINKWORK_EVIDENCE_BUCKET", "evidence-bucket")
797
+ monkeypatch.setenv("THINKWORK_DEPLOYMENT_ACTION", "update")
798
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.170")
799
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", "https://example.com/m.json")
800
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", "abc")
801
+ monkeypatch.setenv("THINKWORK_DEPLOYMENT_SESSION_ID", "sess-1")
802
+ monkeypatch.setenv("THINKWORK_EVIDENCE_PREFIX", "sessions/sess-1/update")
803
+
804
+ uploads: list[str] = []
805
+ monkeypatch.setattr(runner, "run", lambda args, **_kw: uploads.append(args[-1]))
806
+ monkeypatch.setattr(
807
+ runner, "output", lambda *_args, **_kw: (_ for _ in ()).throw(RuntimeError("404"))
808
+ )
809
+
810
+ runner.write_deployment_status_pointer("succeeded", {"region": "us-east-1"}, 0)
811
+
812
+ body = json.loads((tmp_path / "deployment-status-pointer.json").read_text(encoding="utf-8"))
813
+ assert body["activeRelease"]["version"] == "v0.1.0-canary.170"
814
+ assert body["lastSuccessfulDeployment"]["sessionId"] == "sess-1"
815
+ assert uploads[-1] == "s3://evidence-bucket/deployment/status/current.json"
816
+ assert any("deployment/status/history/" in target for target in uploads)
817
+
818
+
819
+ def test_write_deployment_status_pointer_skips_non_deploy_actions(
820
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
821
+ ) -> None:
822
+ runner = load_runner()
823
+ monkeypatch.chdir(tmp_path)
824
+ monkeypatch.setenv("THINKWORK_EVIDENCE_BUCKET", "evidence-bucket")
825
+ monkeypatch.setenv("THINKWORK_DEPLOYMENT_ACTION", "plan")
826
+ uploads: list[str] = []
827
+ monkeypatch.setattr(runner, "run", lambda args, **_kw: uploads.append(args[-1]))
828
+
829
+ runner.write_deployment_status_pointer("succeeded", {}, 0)
830
+
831
+ assert uploads == []
832
+
833
+
834
+ def test_payload_release_selection_overrides_stale_runner_environment(
835
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
836
+ ) -> None:
837
+ runner = load_runner()
838
+ release_dir = tmp_path / "release-work"
839
+ manifest_path = tmp_path / "thinkwork-release.json"
840
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
841
+ lambda_bytes = b"lambda-zip"
842
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": lambda_bytes})
843
+ artifacts = [
844
+ {
845
+ "name": "graphql-http",
846
+ "type": "lambda",
847
+ "fileName": "graphql-http.zip",
848
+ "relativePath": "lambdas/graphql-http.zip",
849
+ "url": None,
850
+ "sha256": digest(lambda_bytes),
851
+ "sizeBytes": len(lambda_bytes),
852
+ }
853
+ ]
854
+ manifest_sha = write_manifest(
855
+ manifest_path,
856
+ release_manifest(bundle_path, runner.sha256_file(bundle_path), artifacts),
857
+ )
858
+ calls: list[list[str]] = []
859
+
860
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
861
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
862
+ monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
863
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", "https://example.test/old.json")
864
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", "0" * 64)
865
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.130")
866
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
867
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
868
+
869
+ runner.apply_release_selection(
870
+ {
871
+ "release": {
872
+ "version": "v0.1.0-canary.137",
873
+ "manifestUrl": file_url(manifest_path),
874
+ "manifestSha256": manifest_sha,
875
+ }
876
+ }
877
+ )
878
+ runner.sync_release_artifacts()
879
+
880
+ assert calls == [
881
+ [
882
+ "aws",
883
+ "s3",
884
+ "cp",
885
+ str(release_dir / "lambdas/graphql-http.zip"),
886
+ "s3://thinkwork-artifacts/releases/v0.1.0-canary.137/lambdas/graphql-http.zip",
887
+ ]
888
+ ]
889
+ assert runner.os.environ["THINKWORK_RELEASE_VERSION"] == "v0.1.0-canary.137"
890
+ assert runner.os.environ["THINKWORK_RELEASE_MANIFEST_URL"] == file_url(manifest_path)
891
+ assert runner.os.environ["THINKWORK_RELEASE_MANIFEST_SHA256"] == manifest_sha
892
+
893
+
894
+ def test_write_runner_files_persists_selected_release_to_controller_module(
895
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
896
+ ) -> None:
897
+ runner = load_runner()
898
+ tf_dir = tmp_path / "terraform"
899
+ old_manifest_url = "https://github.com/thinkwork-ai/thinkwork/releases/download/v0.1.0-canary.145/thinkwork-release.json"
900
+ new_manifest_path = tmp_path / "new-thinkwork-release.json"
901
+ new_manifest_git_sha = "c706fd93b917ee71a01add97ee7dc7c977cc2bb8"
902
+ new_manifest_sha = write_manifest(
903
+ new_manifest_path,
904
+ {
905
+ "schemaVersion": 1,
906
+ "release": {
907
+ "version": "v0.1.0-canary.146",
908
+ "gitSha": new_manifest_git_sha,
909
+ "createdAt": "2026-06-09T00:00:00.000Z",
910
+ },
911
+ },
912
+ )
913
+ new_manifest_url = file_url(new_manifest_path)
914
+
915
+ monkeypatch.setattr(runner, "TF", tf_dir)
916
+ monkeypatch.setattr(runner, "MANIFEST", tmp_path / "missing-manifest.json")
917
+ monkeypatch.setenv("THINKWORK_STAGE", "tei-e2e")
918
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
919
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_SOURCE", "thinkwork-ai/thinkwork/aws")
920
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_VERSION", "0.1.0-canary.145")
921
+ monkeypatch.setenv("THINKWORK_TERRAFORM_STATE_BUCKET", "thinkwork-state")
922
+ monkeypatch.setenv("THINKWORK_TERRAFORM_LOCK_TABLE", "thinkwork-locks")
923
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
924
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.145")
925
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", old_manifest_url)
926
+ monkeypatch.setenv(
927
+ "THINKWORK_RELEASE_MANIFEST_SHA256",
928
+ "f0a149db34d59e290fc4a43bc098a57539dcae508445e0fb626b8ce45f9eaf1c",
929
+ )
930
+ monkeypatch.setenv(
931
+ "THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON",
932
+ '[{"keyId":"test-key","publicKeyPem":"-----BEGIN PUBLIC KEY-----\\nabc\\n-----END PUBLIC KEY-----"}]',
933
+ )
934
+
935
+ vars_json = runner.write_runner_files(
936
+ {
937
+ "stage": "tei-e2e",
938
+ "awsRegion": "us-east-1",
939
+ "awsAccountId": "637423202447",
940
+ "dbPassword": "db-secret",
941
+ "apiAuthSecret": "api-secret",
942
+ "release": {
943
+ "version": "v0.1.0-canary.146",
944
+ "manifestUrl": new_manifest_url,
945
+ "manifestSha256": new_manifest_sha,
946
+ "manifestTrustPolicy": "allow_unsigned_canary",
947
+ },
948
+ },
949
+ {},
950
+ )
951
+
952
+ tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
953
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
954
+
955
+ assert vars_json["deployment_release_version"] == "v0.1.0-canary.146"
956
+ assert vars_json["deployment_release_manifest_url"] == new_manifest_url
957
+ assert vars_json["deployment_release_manifest_sha256"] == new_manifest_sha
958
+ assert vars_json["deployment_release_manifest_trust_policy"] == "allow_unsigned_canary"
959
+ assert vars_json["deployment_release_manifest_trusted_keys_json"] == (
960
+ '[{"keyId":"test-key","publicKeyPem":"-----BEGIN PUBLIC KEY-----\\nabc\\n-----END PUBLIC KEY-----"}]'
961
+ )
962
+ assert tfvars["deployment_release_version"] == "v0.1.0-canary.146"
963
+ assert tfvars["deployment_release_manifest_url"] == new_manifest_url
964
+ assert old_manifest_url not in main_tf
965
+ assert f"ref={new_manifest_git_sha}" in main_tf
966
+ assert "deployment_release_manifest_signature_url" in main_tf
967
+ assert "deployment_release_manifest_trusted_keys_json" in main_tf
968
+ assert "deployment_terraform_module_source" in main_tf
969
+
970
+
971
+ def _cognito_email_runner_env(runner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
972
+ tf_dir = tmp_path / "terraform"
973
+ manifest_path = tmp_path / "thinkwork-release.json"
974
+ manifest_sha = write_manifest(
975
+ manifest_path,
976
+ {
977
+ "schemaVersion": 1,
978
+ "release": {
979
+ "version": "v0.1.0-canary.150",
980
+ "gitSha": "abc123",
981
+ "createdAt": "2026-06-09T00:00:00.000Z",
982
+ },
983
+ "artifactBundles": [],
984
+ "artifacts": [],
985
+ "runtimeImages": [],
986
+ },
987
+ )
988
+ monkeypatch.setattr(runner, "TF", tf_dir)
989
+ monkeypatch.setattr(runner, "MANIFEST", tmp_path / "missing-manifest.json")
990
+ monkeypatch.setenv("THINKWORK_STAGE", "tei-e2e")
991
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
992
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_SOURCE", "thinkwork-ai/thinkwork/aws")
993
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_VERSION", "0.1.0-canary.150")
994
+ monkeypatch.setenv("THINKWORK_TERRAFORM_STATE_BUCKET", "thinkwork-state")
995
+ monkeypatch.setenv("THINKWORK_TERRAFORM_LOCK_TABLE", "thinkwork-locks")
996
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
997
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.150")
998
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
999
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
1000
+ return tf_dir
1001
+
1002
+
1003
+ def test_write_runner_files_threads_cognito_email_vars_from_payload(
1004
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1005
+ ) -> None:
1006
+ runner = load_runner()
1007
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1008
+ identity_arn = "arn:aws:ses:us-east-1:637423202447:identity/lastmile-tei.com"
1009
+
1010
+ vars_json = runner.write_runner_files(
1011
+ {
1012
+ "stage": "tei-e2e",
1013
+ "awsRegion": "us-east-1",
1014
+ "awsAccountId": "637423202447",
1015
+ "dbPassword": "db-secret",
1016
+ "apiAuthSecret": "api-secret",
1017
+ "cognitoEmailSourceArn": identity_arn,
1018
+ "cognitoFromEmailAddress": "ThinkWork <noreply@lastmile-tei.com>",
1019
+ "cognitoReplyToEmailAddress": "support@lastmile-tei.com",
1020
+ "appDomain": "tw.lastmile-tei.com",
1021
+ "appCertificateArn": (
1022
+ "arn:aws:acm:us-east-1:637423202447:certificate/4c53e8c5-3f62-41db-baf8-7bd030d80499"
1023
+ ),
1024
+ },
1025
+ {},
1026
+ )
1027
+
1028
+ tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
1029
+ assert vars_json["cognito_email_source_arn"] == identity_arn
1030
+ assert vars_json["cognito_from_email_address"] == "ThinkWork <noreply@lastmile-tei.com>"
1031
+ assert vars_json["cognito_reply_to_email_address"] == "support@lastmile-tei.com"
1032
+ assert vars_json["app_domain"] == "tw.lastmile-tei.com"
1033
+ assert vars_json["app_certificate_arn"].endswith("4c53e8c5-3f62-41db-baf8-7bd030d80499")
1034
+ assert tfvars["cognito_email_source_arn"] == identity_arn
1035
+ assert tfvars["cognito_from_email_address"] == "ThinkWork <noreply@lastmile-tei.com>"
1036
+ assert tfvars["cognito_reply_to_email_address"] == "support@lastmile-tei.com"
1037
+ assert tfvars["app_domain"] == "tw.lastmile-tei.com"
1038
+
1039
+ # A value in terraform.auto.tfvars.json that the generated root module
1040
+ # never declares is dropped by Terraform with only a warning — every
1041
+ # controller-configurable var needs all three wiring points: vars_json,
1042
+ # a root-module variable declaration, and a module argument.
1043
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
1044
+ for name in (
1045
+ "cognito_email_source_arn",
1046
+ "cognito_from_email_address",
1047
+ "cognito_reply_to_email_address",
1048
+ "app_domain",
1049
+ "app_certificate_arn",
1050
+ ):
1051
+ assert f'variable "{name}"' in main_tf
1052
+ assert f"= var.{name}" in main_tf
1053
+
1054
+
1055
+ def test_write_runner_files_repins_stale_thinkwork_git_module_source_to_release(
1056
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1057
+ ) -> None:
1058
+ runner = load_runner()
1059
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1060
+ monkeypatch.setenv(
1061
+ "THINKWORK_TERRAFORM_MODULE_SOURCE",
1062
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
1063
+ "//terraform/modules/thinkwork?ref=c66a3aa7f3a5606c66b920b40b39af57a7cc06d0",
1064
+ )
1065
+ monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_VERSION", "0.1.0-canary.150")
1066
+
1067
+ vars_json = runner.write_runner_files(
1068
+ {
1069
+ "stage": "tei-e2e",
1070
+ "awsRegion": "us-east-1",
1071
+ "awsAccountId": "637423202447",
1072
+ "dbPassword": "db-secret",
1073
+ "apiAuthSecret": "api-secret",
1074
+ },
1075
+ {},
1076
+ )
1077
+
1078
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
1079
+
1080
+ assert "ref=abc123" in main_tf
1081
+ assert "ref=c66a3aa7f3a5606c66b920b40b39af57a7cc06d0" not in main_tf
1082
+ assert vars_json["deployment_terraform_module_source"].endswith(
1083
+ "//terraform/modules/thinkwork?ref=abc123"
1084
+ )
1085
+ assert vars_json["deployment_terraform_module_version"] == ""
1086
+
1087
+
1088
+ def test_write_runner_files_cognito_email_vars_prefer_runner_secrets_and_default_empty(
1089
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1090
+ ) -> None:
1091
+ runner = load_runner()
1092
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1093
+ secret_arn = "arn:aws:ses:us-east-1:637423202447:identity/secret.example.com"
1094
+
1095
+ vars_json = runner.write_runner_files(
1096
+ {
1097
+ "stage": "tei-e2e",
1098
+ "awsRegion": "us-east-1",
1099
+ "awsAccountId": "637423202447",
1100
+ "dbPassword": "db-secret",
1101
+ "apiAuthSecret": "api-secret",
1102
+ "cognitoEmailSourceArn": "arn:aws:ses:us-east-1:637423202447:identity/payload.example.com",
1103
+ },
1104
+ {"cognitoEmailSourceArn": secret_arn},
1105
+ )
1106
+ assert vars_json["cognito_email_source_arn"] == secret_arn
1107
+
1108
+ vars_json_default = runner.write_runner_files(
1109
+ {
1110
+ "stage": "tei-e2e",
1111
+ "awsRegion": "us-east-1",
1112
+ "awsAccountId": "637423202447",
1113
+ "dbPassword": "db-secret",
1114
+ "apiAuthSecret": "api-secret",
1115
+ },
1116
+ {},
1117
+ )
1118
+ assert vars_json_default["cognito_email_source_arn"] == ""
1119
+ assert vars_json_default["cognito_from_email_address"] == ""
1120
+ assert vars_json_default["cognito_reply_to_email_address"] == ""
1121
+ assert vars_json_default["app_domain"] == ""
1122
+ assert vars_json_default["app_certificate_arn"] == ""
1123
+
1124
+
1125
+ def test_write_runner_files_threads_customer_domain_vars_from_payload(
1126
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1127
+ ) -> None:
1128
+ runner = load_runner()
1129
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1130
+
1131
+ vars_json = runner.write_runner_files(
1132
+ {
1133
+ "stage": "tei-e2e",
1134
+ "awsRegion": "us-east-1",
1135
+ "awsAccountId": "637423202447",
1136
+ "dbPassword": "db-secret",
1137
+ "apiAuthSecret": "api-secret",
1138
+ "customerDomain": "tei.thinkwork.ai",
1139
+ "customerDomainDelegated": True,
1140
+ "customerDomainLegacyRetired": False,
1141
+ },
1142
+ {},
1143
+ )
1144
+
1145
+ assert vars_json["customer_domain"] == "tei.thinkwork.ai"
1146
+ assert vars_json["customer_domain_delegated"] is True
1147
+ assert vars_json["customer_domain_legacy_retired"] is False
1148
+
1149
+ # Booleans must survive the tfvars round-trip as real JSON booleans —
1150
+ # the generated root declares them `type = bool` and rejects strings.
1151
+ tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
1152
+ assert tfvars["customer_domain"] == "tei.thinkwork.ai"
1153
+ assert tfvars["customer_domain_delegated"] is True
1154
+ assert tfvars["customer_domain_legacy_retired"] is False
1155
+
1156
+ # All four wiring points: vars_json (above), root variable declarations,
1157
+ # module arguments, and the aliased us-east-1 provider + providers mapping
1158
+ # the thinkwork module's configuration_aliases requires.
1159
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
1160
+ for name in (
1161
+ "customer_domain",
1162
+ "customer_domain_delegated",
1163
+ "customer_domain_legacy_retired",
1164
+ ):
1165
+ assert f'variable "{name}"' in main_tf
1166
+ assert f"= var.{name}" in main_tf
1167
+ assert 'variable "customer_domain_delegated" {\n type = bool\n}' in main_tf
1168
+ assert 'variable "customer_domain_legacy_retired" {\n type = bool\n}' in main_tf
1169
+ assert 'alias = "us_east_1"' in main_tf
1170
+ assert 'region = "us-east-1"' in main_tf
1171
+ assert "aws.us_east_1 = aws.us_east_1" in main_tf
1172
+
1173
+
1174
+ def test_write_runner_files_without_domain_keeps_defaults_and_provider_alias(
1175
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1176
+ ) -> None:
1177
+ runner = load_runner()
1178
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1179
+
1180
+ vars_json = runner.write_runner_files(
1181
+ {
1182
+ "stage": "tei-e2e",
1183
+ "awsRegion": "us-east-1",
1184
+ "awsAccountId": "637423202447",
1185
+ "dbPassword": "db-secret",
1186
+ "apiAuthSecret": "api-secret",
1187
+ },
1188
+ {},
1189
+ )
1190
+
1191
+ assert vars_json["customer_domain"] == ""
1192
+ assert vars_json["customer_domain_delegated"] is False
1193
+ assert vars_json["customer_domain_legacy_retired"] is False
1194
+
1195
+ tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
1196
+ assert tfvars["customer_domain"] == ""
1197
+ assert tfvars["customer_domain_delegated"] is False
1198
+ assert tfvars["customer_domain_legacy_retired"] is False
1199
+
1200
+ # The thinkwork module requires the us-east-1 alias unconditionally, so
1201
+ # the provider block and providers mapping must exist even without a
1202
+ # customer domain.
1203
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
1204
+ assert 'alias = "us_east_1"' in main_tf
1205
+ assert "aws.us_east_1 = aws.us_east_1" in main_tf
1206
+
1207
+ # Existing greenfield state can contain Cloudflare DNS records. Managed-app
1208
+ # targeted plans do not change those records, but Terraform still needs the
1209
+ # provider schema to refresh state.
1210
+ assert 'source = "cloudflare/cloudflare"' in main_tf
1211
+ assert 'provider "cloudflare" {}' in main_tf
1212
+
1213
+
1214
+ def test_terraform_backend_key_keeps_platform_state_on_root_key(
1215
+ monkeypatch: pytest.MonkeyPatch,
1216
+ ) -> None:
1217
+ runner = load_runner()
1218
+ monkeypatch.delenv("THINKWORK_MANAGED_APP_STATE_ISOLATION", raising=False)
1219
+
1220
+ assert runner.terraform_backend_key("dev", {}) == "thinkwork/dev/terraform.tfstate"
1221
+ assert runner.terraform_workspace_name("dev", {}) == "dev"
1222
+
1223
+
1224
+ def test_terraform_backend_key_isolates_managed_app_when_enabled(
1225
+ monkeypatch: pytest.MonkeyPatch,
1226
+ ) -> None:
1227
+ runner = load_runner()
1228
+ monkeypatch.setenv("THINKWORK_MANAGED_APP_STATE_ISOLATION", "true")
1229
+
1230
+ payload = {"appKey": "n8n", "operation": "UPGRADE"}
1231
+
1232
+ assert (
1233
+ runner.terraform_backend_key("dev", payload)
1234
+ == "thinkwork/dev/managed-apps/n8n/terraform.tfstate"
1235
+ )
1236
+ assert runner.terraform_workspace_name("dev", payload) == "default"
1237
+
1238
+
1239
+ def test_write_runner_files_keeps_managed_apps_on_root_backend_until_migration_enabled(
1240
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1241
+ ) -> None:
1242
+ runner = load_runner()
1243
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1244
+ monkeypatch.delenv("THINKWORK_MANAGED_APP_STATE_ISOLATION", raising=False)
1245
+
1246
+ runner.write_runner_files(
1247
+ {
1248
+ "stage": "tei-e2e",
1249
+ "awsRegion": "us-east-1",
1250
+ "awsAccountId": "637423202447",
1251
+ "dbPassword": "db-secret",
1252
+ "apiAuthSecret": "api-secret",
1253
+ "appKey": "n8n",
1254
+ "operation": "DESTROY",
1255
+ },
1256
+ {},
1257
+ )
1258
+
1259
+ backend = (tf_dir / "backend.hcl").read_text(encoding="utf-8")
1260
+ assert 'key = "thinkwork/tei-e2e/terraform.tfstate"' in backend
1261
+ assert "managed-apps/n8n" not in backend
1262
+
1263
+
1264
+ def test_write_runner_files_can_target_per_app_backend_after_state_migration(
1265
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1266
+ ) -> None:
1267
+ runner = load_runner()
1268
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1269
+
1270
+ runner.write_runner_files(
1271
+ {
1272
+ "stage": "tei-e2e",
1273
+ "awsRegion": "us-east-1",
1274
+ "awsAccountId": "637423202447",
1275
+ "dbPassword": "db-secret",
1276
+ "apiAuthSecret": "api-secret",
1277
+ "appKey": "n8n",
1278
+ "operation": "DESTROY",
1279
+ "features": {"managedAppStateIsolation": True},
1280
+ },
1281
+ {},
1282
+ )
1283
+
1284
+ backend = (tf_dir / "backend.hcl").read_text(encoding="utf-8")
1285
+ assert 'key = "thinkwork/tei-e2e/managed-apps/n8n/terraform.tfstate"' in backend
1286
+
1287
+
1288
+ def test_n8n_managed_app_runner_writes_dns_record_and_target(
1289
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1290
+ ) -> None:
1291
+ runner = load_runner()
1292
+ tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
1293
+ monkeypatch.setattr(
1294
+ runner,
1295
+ "current_terraform_state",
1296
+ lambda _stage: {
1297
+ "outputs": {
1298
+ "db_secret_arn": {
1299
+ "value": (
1300
+ "arn:aws:secretsmanager:us-east-1:"
1301
+ "637423202447:secret:thinkwork-dev-db-credentials"
1302
+ )
1303
+ },
1304
+ "deployment_control_plane_enabled": {"value": True},
1305
+ "n8n_provisioned": {"value": False},
1306
+ "n8n_runtime_enabled": {"value": False},
1307
+ },
1308
+ "resources": [
1309
+ {
1310
+ "type": "cloudflare_record",
1311
+ "name": "app",
1312
+ "instances": [{"attributes": {"zone_id": "zone_123"}}],
1313
+ }
1314
+ ],
1315
+ },
1316
+ )
1317
+
1318
+ vars_json = runner.write_runner_files(
1319
+ {
1320
+ "stage": "tei-e2e",
1321
+ "awsRegion": "us-east-1",
1322
+ "awsAccountId": "637423202447",
1323
+ "dbPassword": "db-secret",
1324
+ "apiAuthSecret": "api-secret",
1325
+ "appKey": "n8n",
1326
+ "operation": "UPGRADE",
1327
+ "desiredConfig": {
1328
+ "databaseName": "thinkwork_n8n",
1329
+ "storagePrefix": "managed-apps/n8n",
1330
+ "publicUrl": "https://n8n.thinkwork.ai",
1331
+ "certificateArn": "arn:aws:acm:us-east-1:637423202447:certificate/test",
1332
+ },
1333
+ "manifestImages": {
1334
+ "n8n-runtime": (
1335
+ "637423202447.dkr.ecr.us-east-1.amazonaws.com/thinkwork/n8n"
1336
+ "@sha256:"
1337
+ "2222222222222222222222222222222222222222222222222222222222222222"
1338
+ ),
1339
+ },
1340
+ },
1341
+ {},
1342
+ )
1343
+
1344
+ tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
1345
+ main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
1346
+
1347
+ assert vars_json["cloudflare_zone_id"] == "zone_123"
1348
+ assert vars_json["n8n_dns_name"] == "n8n.thinkwork.ai"
1349
+ assert vars_json["n8n_dns_enabled"] is True
1350
+ assert tfvars["cloudflare_zone_id"] == "zone_123"
1351
+ assert tfvars["n8n_dns_enabled"] is True
1352
+ assert 'resource "aws_acm_certificate" "n8n"' in main_tf
1353
+ assert 'resource "cloudflare_record" "n8n_acm_validation"' in main_tf
1354
+ assert 'resource "aws_acm_certificate_validation" "n8n"' in main_tf
1355
+ assert "n8n_certificate_arn = local.n8n_effective_certificate_arn" in main_tf
1356
+ assert 'resource "cloudflare_record" "n8n"' in main_tf
1357
+ assert "content = module.thinkwork.n8n_alb_dns_name" in main_tf
1358
+ target_args = runner.managed_app_terraform_target_args({"appKey": "n8n"})
1359
+ assert "-target=aws_acm_certificate.n8n" in target_args
1360
+ assert "-target=cloudflare_record.n8n_acm_validation" in target_args
1361
+ assert "-target=aws_acm_certificate_validation.n8n" in target_args
1362
+ assert "-target=cloudflare_record.n8n" in target_args
1363
+
1364
+
1365
+ def test_n8n_managed_app_overrides_complete_sparse_live_install_payload(
1366
+ monkeypatch: pytest.MonkeyPatch,
1367
+ ) -> None:
1368
+ runner = load_runner()
1369
+ n8n_image_uri = (
1370
+ "487219502366.dkr.ecr.us-east-1.amazonaws.com/thinkwork/n8n"
1371
+ "@sha256:"
1372
+ "3333333333333333333333333333333333333333333333333333333333333333"
1373
+ )
1374
+ monkeypatch.setattr(
1375
+ runner,
1376
+ "release_runtime_image",
1377
+ lambda name: n8n_image_uri if name == "n8n-runtime" else "",
1378
+ )
1379
+ state = {
1380
+ "resources": [
1381
+ {
1382
+ "type": "terraform_data",
1383
+ "name": "n8n_configuration_guardrails",
1384
+ "instances": [
1385
+ {
1386
+ "attributes": {
1387
+ "input": {
1388
+ "value": {
1389
+ "n8n_database_url_secret_arn": (
1390
+ "arn:aws:secretsmanager:us-east-1:"
1391
+ "487219502366:secret:deleted-n8n-db-url"
1392
+ ),
1393
+ "n8n_encryption_key_secret_arn": (
1394
+ "arn:aws:secretsmanager:us-east-1:"
1395
+ "487219502366:secret:deleted-n8n-key"
1396
+ ),
1397
+ "n8n_operator_secret_arn": (
1398
+ "arn:aws:secretsmanager:us-east-1:"
1399
+ "487219502366:secret:deleted-n8n-operator"
1400
+ ),
1401
+ "n8n_service_credential_secret_arn": (
1402
+ "arn:aws:secretsmanager:us-east-1:"
1403
+ "487219502366:secret:deleted-n8n-service"
1404
+ ),
1405
+ }
1406
+ }
1407
+ }
1408
+ }
1409
+ ],
1410
+ },
1411
+ {
1412
+ "type": "terraform_data",
1413
+ "name": "twenty_configuration_guardrails",
1414
+ "instances": [
1415
+ {
1416
+ "attributes": {
1417
+ "input": {
1418
+ "value": {
1419
+ "twenty_public_url": "https://crm.thinkwork.ai",
1420
+ "twenty_certificate_arn": (
1421
+ "arn:aws:acm:us-east-1:487219502366:certificate/www"
1422
+ ),
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+ ],
1428
+ },
1429
+ ]
1430
+ }
1431
+
1432
+ overrides = runner.managed_app_terraform_overrides(
1433
+ {
1434
+ "appKey": "n8n",
1435
+ "operation": "ENABLE",
1436
+ "desiredConfig": {
1437
+ "databaseName": "thinkwork_n8n",
1438
+ "storagePrefix": "managed-apps/n8n",
1439
+ "mainDesiredCount": 1,
1440
+ "workerDesiredCount": 1,
1441
+ },
1442
+ },
1443
+ "dev",
1444
+ "487219502366",
1445
+ {
1446
+ "db_secret_arn": {
1447
+ "value": (
1448
+ "arn:aws:secretsmanager:us-east-1:"
1449
+ "487219502366:secret:thinkwork-dev-db-credentials"
1450
+ )
1451
+ },
1452
+ "n8n_provisioned": {"value": False},
1453
+ "n8n_runtime_enabled": {"value": False},
1454
+ },
1455
+ state,
1456
+ )
1457
+
1458
+ assert overrides["n8n_provisioned"] is True
1459
+ assert overrides["n8n_runtime_enabled"] is True
1460
+ assert overrides["n8n_image_uri"] == n8n_image_uri
1461
+ assert overrides["n8n_database_admin_secret_arn"].endswith(
1462
+ ":secret:thinkwork-dev-db-credentials"
1463
+ )
1464
+ assert overrides["n8n_database_url_secret_arn"] == ""
1465
+ assert overrides["n8n_encryption_key_secret_arn"] == ""
1466
+ assert overrides["n8n_operator_secret_arn"] == ""
1467
+ assert overrides["n8n_service_credential_secret_arn"] == ""
1468
+ assert overrides["deployment_control_plane_create_secret_placeholders"] is True
1469
+ assert overrides["n8n_public_url"] == "https://n8n.thinkwork.ai"
1470
+ assert overrides["n8n_domain"] == "n8n.thinkwork.ai"
1471
+ assert overrides["n8n_certificate_arn"] == ""
1472
+ assert overrides["n8n_binary_data_mode"] == "default"
1473
+ assert overrides["n8n_storage_bucket_name"] == "thinkwork-dev-487219502366-n8n"
1474
+ assert overrides["n8n_storage_prefix"] == "managed-apps/n8n"
1475
+ assert overrides["n8n_custom_package_specs"] == []
1476
+
1477
+
1478
+ def test_unrelated_managed_app_overrides_preserve_existing_n8n_guardrails() -> None:
1479
+ runner = load_runner()
1480
+ state = {
1481
+ "resources": [
1482
+ {
1483
+ "type": "terraform_data",
1484
+ "name": "n8n_configuration_guardrails",
1485
+ "instances": [
1486
+ {
1487
+ "attributes": {
1488
+ "input": {
1489
+ "value": {
1490
+ "n8n_runtime_enabled": True,
1491
+ "n8n_image_uri": (
1492
+ "487219502366.dkr.ecr.us-east-1.amazonaws.com/"
1493
+ "thinkwork/n8n@sha256:"
1494
+ "444444444444444444444444444444444444444444"
1495
+ "4444444444444444444444"
1496
+ ),
1497
+ "n8n_database_name": "thinkwork_n8n",
1498
+ "n8n_database_username": "thinkwork_n8n",
1499
+ "n8n_database_admin_secret_arn": (
1500
+ "arn:aws:secretsmanager:us-east-1:"
1501
+ "487219502366:secret:n8n-db-admin"
1502
+ ),
1503
+ "n8n_database_url_secret_arn": (
1504
+ "arn:aws:secretsmanager:us-east-1:"
1505
+ "487219502366:secret:n8n-db-url"
1506
+ ),
1507
+ "n8n_encryption_key_secret_arn": (
1508
+ "arn:aws:secretsmanager:us-east-1:"
1509
+ "487219502366:secret:n8n-key"
1510
+ ),
1511
+ "n8n_operator_secret_arn": (
1512
+ "arn:aws:secretsmanager:us-east-1:"
1513
+ "487219502366:secret:n8n-operator"
1514
+ ),
1515
+ "n8n_service_credential_secret_arn": (
1516
+ "arn:aws:secretsmanager:us-east-1:"
1517
+ "487219502366:secret:n8n-service"
1518
+ ),
1519
+ "n8n_storage_bucket_name": "thinkwork-dev-n8n",
1520
+ "n8n_storage_prefix": "managed-apps/n8n",
1521
+ "n8n_public_url": "https://n8n.thinkwork.ai",
1522
+ "n8n_certificate_arn": (
1523
+ "arn:aws:acm:us-east-1:487219502366:certificate/n8n"
1524
+ ),
1525
+ "n8n_main_desired_count": 2,
1526
+ "n8n_worker_desired_count": 3,
1527
+ "n8n_package_config_digest": "abc123",
1528
+ "n8n_custom_package_specs": ["luxon@3.7.2"],
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+ ],
1534
+ }
1535
+ ]
1536
+ }
1537
+
1538
+ overrides = runner.managed_app_terraform_overrides(
1539
+ {
1540
+ "appKey": "twenty",
1541
+ "operation": "UPGRADE",
1542
+ "desiredConfig": {
1543
+ "certificateArn": ("arn:aws:acm:us-east-1:487219502366:certificate/twenty"),
1544
+ "publicUrl": "https://twenty.thinkwork.ai",
1545
+ },
1546
+ "manifestImages": {
1547
+ "twenty": "487219502366.dkr.ecr.us-east-1.amazonaws.com/thinkwork/twenty"
1548
+ "@sha256:"
1549
+ "5555555555555555555555555555555555555555555555555555555555555555"
1550
+ },
1551
+ },
1552
+ "dev",
1553
+ "487219502366",
1554
+ {
1555
+ "n8n_provisioned": {"value": True},
1556
+ "n8n_runtime_enabled": {"value": True},
1557
+ },
1558
+ state,
1559
+ )
1560
+
1561
+ assert overrides["n8n_provisioned"] is True
1562
+ assert overrides["n8n_runtime_enabled"] is True
1563
+ assert overrides["n8n_image_uri"].endswith("@" + "sha256:" + "4" * 64)
1564
+ assert overrides["n8n_database_name"] == "thinkwork_n8n"
1565
+ assert overrides["n8n_public_url"] == "https://n8n.thinkwork.ai"
1566
+ assert overrides["n8n_main_desired_count"] == 2
1567
+ assert overrides["n8n_worker_desired_count"] == 3
1568
+ assert overrides["n8n_package_config_digest"] == "abc123"
1569
+ assert overrides["n8n_custom_package_specs"] == ["luxon@3.7.2"]
1570
+
1571
+
1572
+ def test_n8n_destroy_normalizes_legacy_binary_database_guardrail() -> None:
1573
+ runner = load_runner()
1574
+ state = {
1575
+ "resources": [
1576
+ {
1577
+ "type": "terraform_data",
1578
+ "name": "n8n_configuration_guardrails",
1579
+ "instances": [
1580
+ {
1581
+ "attributes": {
1582
+ "input": {
1583
+ "value": {
1584
+ "n8n_binary_data_mode": "database",
1585
+ "n8n_execution_data_storage_mode": "database",
1586
+ }
1587
+ }
1588
+ }
1589
+ }
1590
+ ],
1591
+ }
1592
+ ]
1593
+ }
1594
+
1595
+ overrides = runner.managed_app_terraform_overrides(
1596
+ {
1597
+ "appKey": "n8n",
1598
+ "operation": "DESTROY",
1599
+ "desiredConfig": {},
1600
+ "manifestImages": {},
1601
+ },
1602
+ "dev",
1603
+ "487219502366",
1604
+ {
1605
+ "n8n_provisioned": {"value": True},
1606
+ "n8n_runtime_enabled": {"value": True},
1607
+ },
1608
+ state,
1609
+ )
1610
+
1611
+ assert overrides["n8n_provisioned"] is False
1612
+ assert overrides["n8n_runtime_enabled"] is False
1613
+ assert overrides["n8n_binary_data_mode"] == "default"
1614
+ assert overrides["n8n_execution_data_storage_mode"] == "database"
1615
+
1616
+
1617
+ def test_twenty_managed_app_overrides_enable_sparse_payload_from_customer_domain(
1618
+ monkeypatch: pytest.MonkeyPatch,
1619
+ ) -> None:
1620
+ runner = load_runner()
1621
+ twenty_image_uri = (
1622
+ "487219502366.dkr.ecr.us-east-1.amazonaws.com/thinkwork/twenty"
1623
+ "@sha256:"
1624
+ "5555555555555555555555555555555555555555555555555555555555555555"
1625
+ )
1626
+ monkeypatch.setattr(
1627
+ runner,
1628
+ "release_runtime_image",
1629
+ lambda name: twenty_image_uri if name == "twenty-crm" else "",
1630
+ )
1631
+
1632
+ overrides = runner.managed_app_terraform_overrides(
1633
+ {
1634
+ "appKey": "twenty",
1635
+ "operation": "ENABLE",
1636
+ "desiredConfig": {},
1637
+ "customerDomain": "tei.thinkwork.ai",
1638
+ "appCertificateArn": ("arn:aws:acm:us-east-1:487219502366:certificate/crm"),
1639
+ },
1640
+ "dev",
1641
+ "487219502366",
1642
+ {},
1643
+ {"resources": []},
1644
+ )
1645
+
1646
+ assert overrides["twenty_provisioned"] is True
1647
+ assert overrides["twenty_runtime_enabled"] is True
1648
+ assert overrides["twenty_image_uri"] == twenty_image_uri
1649
+ assert overrides["twenty_public_url"] == "https://crm.tei.thinkwork.ai"
1650
+ assert (
1651
+ overrides["twenty_certificate_arn"] == "arn:aws:acm:us-east-1:487219502366:certificate/crm"
1652
+ )
1653
+ assert overrides["deployment_control_plane_create_secret_placeholders"] is True
1654
+
1655
+
1656
+ def test_twenty_managed_app_stages_native_app_seed_artifact(
1657
+ monkeypatch: pytest.MonkeyPatch,
1658
+ ) -> None:
1659
+ runner = load_runner()
1660
+ calls: list[dict] = []
1661
+
1662
+ def fake_sync_release_artifacts(**kwargs):
1663
+ calls.append(kwargs)
1664
+ return {"twenty-thinkwork-app": Path("/tmp/twenty-thinkwork-app.tar.gz")}
1665
+
1666
+ monkeypatch.setattr(runner, "sync_release_artifacts", fake_sync_release_artifacts)
1667
+
1668
+ artifacts = runner.stage_managed_app_release_artifacts(
1669
+ "update",
1670
+ {"appKey": "twenty", "operation": "ENABLE"},
1671
+ )
1672
+
1673
+ assert artifacts == {"twenty-thinkwork-app": Path("/tmp/twenty-thinkwork-app.tar.gz")}
1674
+ assert calls == [
1675
+ {
1676
+ "artifact_types": {"seed"},
1677
+ "artifact_names": {"twenty-thinkwork-app"},
1678
+ }
1679
+ ]
1680
+ assert (
1681
+ runner.stage_managed_app_release_artifacts(
1682
+ "update",
1683
+ {"appKey": "twenty", "operation": "PARK"},
1684
+ )
1685
+ == {}
1686
+ )
1687
+ assert (
1688
+ runner.stage_managed_app_release_artifacts(
1689
+ "plan",
1690
+ {"appKey": "twenty", "operation": "ENABLE"},
1691
+ )
1692
+ == {}
1693
+ )
1694
+
1695
+
1696
+ def test_twenty_post_install_sync_uses_release_artifact_and_secret_env(
1697
+ tmp_path: Path,
1698
+ monkeypatch: pytest.MonkeyPatch,
1699
+ ) -> None:
1700
+ runner = load_runner()
1701
+ release_dir = tmp_path / "release"
1702
+ archive_path = tmp_path / "twenty-thinkwork-app.tar.gz"
1703
+ write_tar(
1704
+ archive_path,
1705
+ {
1706
+ "plugins/twenty/scripts/sync-thinkwork-app.mjs": b"console.log('sync');\n",
1707
+ "plugins/twenty/twenty-app/package.json": b"{}",
1708
+ },
1709
+ )
1710
+ outputs_path = tmp_path / "outputs.json"
1711
+ outputs_path.write_text(
1712
+ json.dumps(
1713
+ {
1714
+ "twenty_runtime_enabled": {"value": True},
1715
+ "twenty_url": {"value": "https://crm.tei.thinkwork.ai"},
1716
+ }
1717
+ ),
1718
+ encoding="utf-8",
1719
+ )
1720
+ calls: list[dict] = []
1721
+
1722
+ def fake_run(args, **kwargs):
1723
+ calls.append({"args": args, **kwargs})
1724
+
1725
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
1726
+ monkeypatch.setattr(runner, "MANAGED_APP_EVIDENCE", {})
1727
+ monkeypatch.setattr(runner, "run", fake_run)
1728
+
1729
+ runner.sync_twenty_thinkwork_app(
1730
+ outputs_path,
1731
+ {"twenty_public_url": "https://crm-fallback.tei.thinkwork.ai"},
1732
+ {"appKey": "twenty", "operation": "ENABLE"},
1733
+ {"twentyAppSyncApiKey": "secret-api-key"},
1734
+ {"twenty-thinkwork-app": archive_path},
1735
+ )
1736
+
1737
+ assert len(calls) == 1
1738
+ assert calls[0]["args"][0] == "node"
1739
+ assert "secret-api-key" not in " ".join(str(part) for part in calls[0]["args"])
1740
+ assert calls[0]["env"]["TWENTY_APP_SYNC_API_KEY"] == "secret-api-key"
1741
+ assert calls[0]["env"]["TWENTY_PUBLIC_URL"] == "https://crm.tei.thinkwork.ai"
1742
+ assert calls[0]["env"]["TWENTY_THINKWORK_APP_SYNC_DRY_RUN"] == "0"
1743
+ assert runner.MANAGED_APP_EVIDENCE["twentyThinkWorkApp"] == {
1744
+ "status": "installed",
1745
+ "artifact": "twenty-thinkwork-app",
1746
+ "publicUrl": "https://crm.tei.thinkwork.ai",
1747
+ "operation": "ENABLE",
1748
+ }
1749
+
1750
+
1751
+ def test_managed_app_success_refreshes_root_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
1752
+ runner = load_runner()
1753
+ calls: list[list[str]] = []
1754
+ monkeypatch.setattr(runner, "TERRAFORM_EVIDENCE", {})
1755
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
1756
+
1757
+ runner.refresh_outputs_after_targeted_apply({"appKey": "n8n"})
1758
+ runner.refresh_outputs_after_targeted_apply({})
1759
+
1760
+ assert calls == [
1761
+ [
1762
+ "terraform",
1763
+ "apply",
1764
+ "-refresh-only",
1765
+ "-auto-approve",
1766
+ "-no-color",
1767
+ ]
1768
+ ]
1769
+ assert runner.TERRAFORM_EVIDENCE["outputRefresh"] == {
1770
+ "status": "succeeded",
1771
+ "command": [
1772
+ "terraform",
1773
+ "apply",
1774
+ "-refresh-only",
1775
+ "-auto-approve",
1776
+ "-no-color",
1777
+ ],
1778
+ }
1779
+
1780
+
1781
+ def test_managed_app_output_refresh_failure_is_non_fatal(
1782
+ monkeypatch: pytest.MonkeyPatch,
1783
+ ) -> None:
1784
+ runner = load_runner()
1785
+ monkeypatch.setattr(runner, "TERRAFORM_EVIDENCE", {})
1786
+
1787
+ def fail_refresh(args, **_kwargs):
1788
+ raise subprocess.CalledProcessError(1, args)
1789
+
1790
+ monkeypatch.setattr(runner, "run", fail_refresh)
1791
+
1792
+ result = runner.refresh_outputs_after_targeted_apply({"appKey": "n8n"})
1793
+
1794
+ assert result["status"] == "failed"
1795
+ assert result["nonFatal"] is True
1796
+ assert runner.TERRAFORM_EVIDENCE["outputRefresh"]["exitCode"] == 1
1797
+
1798
+
1799
+ def test_managed_app_outputs_fall_back_to_state_after_output_failure(
1800
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1801
+ ) -> None:
1802
+ runner = load_runner()
1803
+ outputs_path = tmp_path / "outputs.json"
1804
+ monkeypatch.setattr(runner, "TERRAFORM_EVIDENCE", {})
1805
+ monkeypatch.setattr(runner, "refresh_outputs_after_targeted_apply", lambda _payload: None)
1806
+ monkeypatch.setattr(
1807
+ runner,
1808
+ "output",
1809
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(
1810
+ subprocess.CalledProcessError(1, ["terraform", "output", "-json"])
1811
+ ),
1812
+ )
1813
+ monkeypatch.setattr(
1814
+ runner,
1815
+ "current_terraform_outputs",
1816
+ lambda stage: {
1817
+ "app_url": {"value": f"https://{stage}.thinkwork.ai", "type": "string"},
1818
+ },
1819
+ )
1820
+ monkeypatch.setattr(
1821
+ runner,
1822
+ "upload_evidence_artifact",
1823
+ lambda path, name=None: f"s3://evidence/{name or Path(path).name}",
1824
+ )
1825
+
1826
+ runner.write_outputs_after_apply({"appKey": "n8n"}, {"stage": "dev"}, outputs_path)
1827
+
1828
+ assert json.loads(outputs_path.read_text(encoding="utf-8")) == {
1829
+ "app_url": {"value": "https://dev.thinkwork.ai", "type": "string"}
1830
+ }
1831
+ assert runner.TERRAFORM_EVIDENCE["outputs"]["source"] == "state"
1832
+ assert runner.TERRAFORM_EVIDENCE["outputReadFallback"]["status"] == "succeeded"
1833
+
1834
+
1835
+ def test_managed_app_overrides_reject_missing_operation() -> None:
1836
+ runner = load_runner()
1837
+
1838
+ with pytest.raises(RuntimeError, match="operation to be one of"):
1839
+ runner.managed_app_terraform_overrides(
1840
+ {"appKey": "n8n"},
1841
+ "dev",
1842
+ "487219502366",
1843
+ {},
1844
+ {"resources": []},
1845
+ )
1846
+
1847
+
1848
+ def test_validate_managed_app_plan_scope_allows_n8n_dns_record() -> None:
1849
+ runner = load_runner()
1850
+
1851
+ runner.validate_managed_app_plan_scope(
1852
+ {"appKey": "n8n"},
1853
+ {
1854
+ "resource_changes": [
1855
+ {
1856
+ "address": "cloudflare_record.n8n[0]",
1857
+ "change": {"actions": ["create"]},
1858
+ }
1859
+ ]
1860
+ },
1861
+ )
1862
+
1863
+
1864
+ def test_validate_managed_app_plan_scope_rejects_other_dns_records() -> None:
1865
+ runner = load_runner()
1866
+
1867
+ with pytest.raises(RuntimeError, match="cloudflare_record.app"):
1868
+ runner.validate_managed_app_plan_scope(
1869
+ {"appKey": "n8n"},
1870
+ {
1871
+ "resource_changes": [
1872
+ {
1873
+ "address": "cloudflare_record.app[0]",
1874
+ "change": {"actions": ["create"]},
1875
+ }
1876
+ ]
1877
+ },
1878
+ )
1879
+
1880
+
1881
+ def test_validate_environment_plan_scope_rejects_customer_domain_deletes() -> None:
1882
+ runner = load_runner()
1883
+
1884
+ with pytest.raises(RuntimeError, match="customer-domain web resources"):
1885
+ runner.validate_environment_plan_scope(
1886
+ {},
1887
+ {
1888
+ "resource_changes": [
1889
+ {
1890
+ "address": (
1891
+ "module.thinkwork.module.customer_domain."
1892
+ "aws_route53_record.app_alias_a[0]"
1893
+ ),
1894
+ "change": {"actions": ["delete"]},
1895
+ }
1896
+ ]
1897
+ },
1898
+ )
1899
+
1900
+
1901
+ def test_validate_environment_plan_scope_allows_reviewed_domain_removal() -> None:
1902
+ runner = load_runner()
1903
+
1904
+ runner.validate_environment_plan_scope(
1905
+ {"allowCustomerDomainRemoval": True},
1906
+ {
1907
+ "resource_changes": [
1908
+ {
1909
+ "address": (
1910
+ "module.thinkwork.module.customer_domain.aws_route53_record.app_alias_a[0]"
1911
+ ),
1912
+ "change": {"actions": ["delete"]},
1913
+ }
1914
+ ]
1915
+ },
1916
+ )
1917
+
1918
+
1919
+ def test_validate_environment_plan_scope_rejects_cloudfront_alias_removal() -> None:
1920
+ runner = load_runner()
1921
+
1922
+ with pytest.raises(RuntimeError, match="customer-domain web resources"):
1923
+ runner.validate_environment_plan_scope(
1924
+ {},
1925
+ {
1926
+ "resource_changes": [
1927
+ {
1928
+ "address": (
1929
+ "module.thinkwork.module.computer_site.aws_cloudfront_distribution.site"
1930
+ ),
1931
+ "change": {
1932
+ "actions": ["update"],
1933
+ "before": {"aliases": ["tei.thinkwork.ai"]},
1934
+ "after": {"aliases": []},
1935
+ },
1936
+ }
1937
+ ]
1938
+ },
1939
+ )
1940
+
1941
+
1942
+ def test_configure_cloudflare_provider_auth_reads_stage_ssm_without_tfvars(
1943
+ monkeypatch: pytest.MonkeyPatch,
1944
+ ) -> None:
1945
+ runner = load_runner()
1946
+ calls: list[list[str]] = []
1947
+ monkeypatch.delenv("CLOUDFLARE_API_TOKEN", raising=False)
1948
+
1949
+ def fake_output(args: list[str], **_kwargs) -> str:
1950
+ calls.append(args)
1951
+ return "cf-token"
1952
+
1953
+ monkeypatch.setattr(runner, "output", fake_output)
1954
+
1955
+ runner.configure_cloudflare_provider_auth("dev")
1956
+
1957
+ assert os.environ["CLOUDFLARE_API_TOKEN"] == "cf-token"
1958
+ assert calls == [
1959
+ [
1960
+ "aws",
1961
+ "ssm",
1962
+ "get-parameter",
1963
+ "--name",
1964
+ "/thinkwork/dev/cloudflare-namespace-token",
1965
+ "--with-decryption",
1966
+ "--query",
1967
+ "Parameter.Value",
1968
+ "--output",
1969
+ "text",
1970
+ ]
1971
+ ]
1972
+
1973
+
1974
+ def test_cloudflare_zone_id_for_hostname_matches_longest_suffix(
1975
+ monkeypatch: pytest.MonkeyPatch,
1976
+ ) -> None:
1977
+ runner = load_runner()
1978
+ monkeypatch.setattr(runner, "cloudflare_api_token", lambda _stage: "cf-token")
1979
+
1980
+ class FakeResponse:
1981
+ def __enter__(self):
1982
+ return self
1983
+
1984
+ def __exit__(self, *_args):
1985
+ return None
1986
+
1987
+ def read(self) -> bytes:
1988
+ return json.dumps(
1989
+ {
1990
+ "result": [
1991
+ {"name": "thinkwork.ai", "id": "zone-thinkwork"},
1992
+ {"name": "agents.thinkwork.ai", "id": "zone-agents"},
1993
+ ]
1994
+ }
1995
+ ).encode()
1996
+
1997
+ def fake_urlopen(request, timeout: int):
1998
+ assert timeout == 30
1999
+ assert request.headers["Authorization"] == "Bearer cf-token"
2000
+ return FakeResponse()
2001
+
2002
+ monkeypatch.setattr(runner.urllib.request, "urlopen", fake_urlopen)
2003
+
2004
+ assert runner.cloudflare_zone_id_for_hostname("dev", "n8n.agents.thinkwork.ai") == "zone-agents"
2005
+
2006
+
2007
+ def test_configure_terraform_provider_mirror_seeds_cloudflare_for_codebuild(
2008
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2009
+ ) -> None:
2010
+ runner = load_runner()
2011
+ provider_zip = b"cloudflare-provider"
2012
+ provider_digest = digest(provider_zip)
2013
+
2014
+ monkeypatch.setattr(runner, "WORK", tmp_path)
2015
+ monkeypatch.setattr(runner.platform, "system", lambda: "Linux")
2016
+ monkeypatch.setattr(runner.platform, "machine", lambda: "x86_64")
2017
+ monkeypatch.setattr(runner, "CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256", provider_digest)
2018
+ monkeypatch.delenv("TF_CLI_CONFIG_FILE", raising=False)
2019
+
2020
+ def fake_download(url: str, destination: Path) -> None:
2021
+ assert "terraform-provider-cloudflare_4.52.7_linux_amd64.zip" in url
2022
+ destination.parent.mkdir(parents=True, exist_ok=True)
2023
+ destination.write_bytes(provider_zip)
2024
+
2025
+ monkeypatch.setattr(runner, "download", fake_download)
2026
+
2027
+ runner.configure_terraform_provider_mirror()
2028
+
2029
+ package = (
2030
+ tmp_path
2031
+ / "provider-mirror"
2032
+ / "registry.terraform.io"
2033
+ / "cloudflare"
2034
+ / "cloudflare"
2035
+ / "terraform-provider-cloudflare_4.52.7_linux_amd64.zip"
2036
+ )
2037
+ assert package.read_bytes() == provider_zip
2038
+
2039
+ terraformrc = tmp_path / "terraformrc"
2040
+ contents = terraformrc.read_text(encoding="utf-8")
2041
+ assert 'include = ["registry.terraform.io/cloudflare/cloudflare"]' in contents
2042
+ assert 'exclude = ["registry.terraform.io/cloudflare/cloudflare"]' in contents
2043
+ assert os.environ["TF_CLI_CONFIG_FILE"] == str(terraformrc)
2044
+
2045
+
2046
+ def test_write_runner_files_customer_domain_prefers_secrets_and_coerces_booleans(
2047
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2048
+ ) -> None:
2049
+ runner = load_runner()
2050
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2051
+
2052
+ vars_json = runner.write_runner_files(
2053
+ {
2054
+ "stage": "tei-e2e",
2055
+ "awsRegion": "us-east-1",
2056
+ "awsAccountId": "637423202447",
2057
+ "dbPassword": "db-secret",
2058
+ "apiAuthSecret": "api-secret",
2059
+ "customerDomain": "payload.thinkwork.ai",
2060
+ "customerDomainDelegated": False,
2061
+ "customerDomainLegacyRetired": True,
2062
+ },
2063
+ {
2064
+ "customerDomain": "secret.thinkwork.ai",
2065
+ # Secrets Manager JSON values arrive as strings; they must land
2066
+ # in vars_json as real booleans.
2067
+ "customerDomainDelegated": "true",
2068
+ "customerDomainLegacyRetired": "false",
2069
+ },
2070
+ )
2071
+
2072
+ assert vars_json["customer_domain"] == "secret.thinkwork.ai"
2073
+ assert vars_json["customer_domain_delegated"] is True
2074
+ assert vars_json["customer_domain_legacy_retired"] is False
2075
+
2076
+
2077
+ def test_write_runner_files_preserves_existing_customer_domain_from_state_outputs(
2078
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2079
+ ) -> None:
2080
+ runner = load_runner()
2081
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2082
+ monkeypatch.setattr(
2083
+ runner,
2084
+ "current_terraform_state",
2085
+ lambda _stage: {
2086
+ "outputs": {
2087
+ "customer_domain": {"value": "tei.thinkwork.ai"},
2088
+ "customer_domain_delegated": {"value": True},
2089
+ "customer_domain_legacy_retired": {"value": False},
2090
+ }
2091
+ },
2092
+ )
2093
+
2094
+ vars_json = runner.write_runner_files(
2095
+ {
2096
+ "stage": "tei-e2e",
2097
+ "awsRegion": "us-east-1",
2098
+ "awsAccountId": "637423202447",
2099
+ "dbPassword": "db-secret",
2100
+ "apiAuthSecret": "api-secret",
2101
+ },
2102
+ {},
2103
+ )
2104
+
2105
+ assert vars_json["customer_domain"] == "tei.thinkwork.ai"
2106
+ assert vars_json["customer_domain_delegated"] is True
2107
+ assert vars_json["customer_domain_legacy_retired"] is False
2108
+
2109
+
2110
+ def test_write_runner_files_defaults_existing_customer_domain_to_delegated_when_legacy_output_missing(
2111
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2112
+ ) -> None:
2113
+ runner = load_runner()
2114
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2115
+ monkeypatch.setattr(
2116
+ runner,
2117
+ "current_terraform_state",
2118
+ lambda _stage: {"outputs": {"customer_domain": {"value": "tei.thinkwork.ai"}}},
2119
+ )
2120
+
2121
+ vars_json = runner.write_runner_files(
2122
+ {
2123
+ "stage": "tei-e2e",
2124
+ "awsRegion": "us-east-1",
2125
+ "awsAccountId": "637423202447",
2126
+ "dbPassword": "db-secret",
2127
+ "apiAuthSecret": "api-secret",
2128
+ },
2129
+ {},
2130
+ )
2131
+
2132
+ assert vars_json["customer_domain"] == "tei.thinkwork.ai"
2133
+ assert vars_json["customer_domain_delegated"] is True
2134
+ assert vars_json["customer_domain_legacy_retired"] is False
2135
+
2136
+
2137
+ def test_write_runner_files_refuses_to_disable_existing_customer_domain_delegation(
2138
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2139
+ ) -> None:
2140
+ runner = load_runner()
2141
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2142
+ monkeypatch.setattr(
2143
+ runner,
2144
+ "current_terraform_state",
2145
+ lambda _stage: {
2146
+ "outputs": {
2147
+ "customer_domain": {"value": "tei.thinkwork.ai"},
2148
+ "customer_domain_delegated": {"value": True},
2149
+ }
2150
+ },
2151
+ )
2152
+
2153
+ with pytest.raises(RuntimeError, match="Refusing to turn off customer_domain_delegated"):
2154
+ runner.write_runner_files(
2155
+ {
2156
+ "stage": "tei-e2e",
2157
+ "awsRegion": "us-east-1",
2158
+ "awsAccountId": "637423202447",
2159
+ "dbPassword": "db-secret",
2160
+ "apiAuthSecret": "api-secret",
2161
+ "customerDomain": "tei.thinkwork.ai",
2162
+ "customerDomainDelegated": False,
2163
+ },
2164
+ {},
2165
+ )
2166
+
2167
+
2168
+ def test_write_runner_files_preserves_existing_customer_domain_from_secrets(
2169
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2170
+ ) -> None:
2171
+ runner = load_runner()
2172
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2173
+ monkeypatch.setattr(
2174
+ runner,
2175
+ "current_terraform_state",
2176
+ lambda _stage: {
2177
+ "outputs": {
2178
+ "customer_domain": {"value": "tei.thinkwork.ai"},
2179
+ "customer_domain_delegated": {"value": True},
2180
+ }
2181
+ },
2182
+ )
2183
+
2184
+ vars_json = runner.write_runner_files(
2185
+ {
2186
+ "stage": "tei-e2e",
2187
+ "awsRegion": "us-east-1",
2188
+ "awsAccountId": "637423202447",
2189
+ "dbPassword": "db-secret",
2190
+ "apiAuthSecret": "api-secret",
2191
+ },
2192
+ {
2193
+ "customerDomain": "tei.thinkwork.ai",
2194
+ "customerDomainDelegated": "true",
2195
+ "customerDomainLegacyRetired": "false",
2196
+ },
2197
+ )
2198
+
2199
+ assert vars_json["customer_domain"] == "tei.thinkwork.ai"
2200
+ assert vars_json["customer_domain_delegated"] is True
2201
+ assert vars_json["customer_domain_legacy_retired"] is False
2202
+
2203
+
2204
+ def test_write_runner_files_requires_explicit_override_to_change_customer_domain(
2205
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2206
+ ) -> None:
2207
+ runner = load_runner()
2208
+ _cognito_email_runner_env(runner, tmp_path, monkeypatch)
2209
+ monkeypatch.setattr(
2210
+ runner,
2211
+ "current_terraform_state",
2212
+ lambda _stage: {
2213
+ "outputs": {
2214
+ "customer_domain": {"value": "tei.thinkwork.ai"},
2215
+ "customer_domain_delegated": {"value": True},
2216
+ }
2217
+ },
2218
+ )
2219
+
2220
+ with pytest.raises(RuntimeError, match="Refusing to change customer_domain"):
2221
+ runner.write_runner_files(
2222
+ {
2223
+ "stage": "tei-e2e",
2224
+ "awsRegion": "us-east-1",
2225
+ "awsAccountId": "637423202447",
2226
+ "dbPassword": "db-secret",
2227
+ "apiAuthSecret": "api-secret",
2228
+ "customerDomain": "mcpherson.thinkwork.ai",
2229
+ "customerDomainDelegated": True,
2230
+ },
2231
+ {},
2232
+ )
2233
+
2234
+ vars_json = runner.write_runner_files(
2235
+ {
2236
+ "stage": "tei-e2e",
2237
+ "awsRegion": "us-east-1",
2238
+ "awsAccountId": "637423202447",
2239
+ "dbPassword": "db-secret",
2240
+ "apiAuthSecret": "api-secret",
2241
+ "customerDomain": "mcpherson.thinkwork.ai",
2242
+ "customerDomainDelegated": True,
2243
+ "allowCustomerDomainRemoval": True,
2244
+ },
2245
+ {},
2246
+ )
2247
+
2248
+ assert vars_json["customer_domain"] == "mcpherson.thinkwork.ai"
2249
+
2250
+
2251
+ def test_write_evidence_records_consumed_domain_fields(
2252
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2253
+ ) -> None:
2254
+ runner = load_runner()
2255
+ monkeypatch.chdir(tmp_path)
2256
+ monkeypatch.delenv("THINKWORK_EVIDENCE_BUCKET", raising=False)
2257
+ monkeypatch.delenv("THINKWORK_EVIDENCE_PREFIX", raising=False)
2258
+ monkeypatch.delenv("THINKWORK_DEPLOYMENT_ACTION", raising=False)
2259
+
2260
+ runner.write_evidence(
2261
+ "succeeded",
2262
+ {
2263
+ "stage": "tei-e2e",
2264
+ "account_id": "637423202447",
2265
+ "region": "us-east-1",
2266
+ "customer_domain": "tei.thinkwork.ai",
2267
+ "customer_domain_delegated": True,
2268
+ "customer_domain_legacy_retired": False,
2269
+ },
2270
+ 0,
2271
+ )
2272
+
2273
+ evidence = json.loads((tmp_path / "deployment-evidence.json").read_text(encoding="utf-8"))
2274
+ assert evidence["consumedDomainFields"] == {
2275
+ "customerDomain": "tei.thinkwork.ai",
2276
+ "customerDomainDelegated": True,
2277
+ "customerDomainLegacyRetired": False,
2278
+ }
2279
+ assert evidence["consumedDomainFields"]["customerDomainDelegated"] is True
2280
+ assert evidence["consumedDomainFields"]["customerDomainLegacyRetired"] is False
2281
+
2282
+
2283
+ def test_write_evidence_omits_consumed_domain_fields_without_domain_vars(
2284
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2285
+ ) -> None:
2286
+ runner = load_runner()
2287
+ monkeypatch.chdir(tmp_path)
2288
+ monkeypatch.delenv("THINKWORK_EVIDENCE_BUCKET", raising=False)
2289
+ monkeypatch.delenv("THINKWORK_EVIDENCE_PREFIX", raising=False)
2290
+ monkeypatch.delenv("THINKWORK_DEPLOYMENT_ACTION", raising=False)
2291
+
2292
+ # The status action builds a minimal vars_json without domain keys — an
2293
+ # old-runner-shaped evidence document must stay distinguishable from a
2294
+ # new runner that consumed an empty domain.
2295
+ runner.write_evidence(
2296
+ "succeeded",
2297
+ {
2298
+ "stage": "tei-e2e",
2299
+ "account_id": "637423202447",
2300
+ "region": "us-east-1",
2301
+ },
2302
+ 0,
2303
+ )
2304
+
2305
+ evidence = json.loads((tmp_path / "deployment-evidence.json").read_text(encoding="utf-8"))
2306
+ assert "consumedDomainFields" not in evidence
2307
+
2308
+
2309
+ def test_registry_module_source_checks_out_release_manifest_sha(
2310
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2311
+ ) -> None:
2312
+ runner = load_runner()
2313
+ manifest_path = tmp_path / "thinkwork-release.json"
2314
+ manifest = {
2315
+ "schemaVersion": 1,
2316
+ "release": {
2317
+ "version": "0.1.0-canary.137",
2318
+ "gitSha": "f9ebf20d2e4d592df44e66252d6c7894746689c9",
2319
+ "createdAt": "2026-06-09T19:33:51.126Z",
2320
+ },
2321
+ "artifacts": [],
2322
+ "runtimeImages": [],
2323
+ }
2324
+ write_manifest(manifest_path, manifest)
2325
+
2326
+ monkeypatch.setattr(runner, "MANIFEST", manifest_path)
2327
+
2328
+ assert runner.source_repo_and_ref(
2329
+ "thinkwork-ai/thinkwork/aws",
2330
+ "v0.1.0-canary.137",
2331
+ ) == (
2332
+ "https://github.com/thinkwork-ai/thinkwork.git",
2333
+ "f9ebf20d2e4d592df44e66252d6c7894746689c9",
2334
+ )
2335
+
2336
+
2337
+ def test_registry_module_source_writes_pinned_git_module_source(
2338
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2339
+ ) -> None:
2340
+ runner = load_runner()
2341
+ manifest_path = tmp_path / "thinkwork-release.json"
2342
+ manifest = {
2343
+ "schemaVersion": 1,
2344
+ "release": {
2345
+ "version": "0.1.0-canary.140",
2346
+ "gitSha": "c706fd93b917ee71a01add97ee7dc7c977cc2bb8",
2347
+ "createdAt": "2026-06-09T22:07:19.000Z",
2348
+ },
2349
+ "artifacts": [],
2350
+ "runtimeImages": [],
2351
+ }
2352
+ write_manifest(manifest_path, manifest)
2353
+
2354
+ monkeypatch.setattr(runner, "MANIFEST", manifest_path)
2355
+
2356
+ assert runner.terraform_module_source_and_version(
2357
+ "thinkwork-ai/thinkwork/aws",
2358
+ "0.1.0-canary.140",
2359
+ "v0.1.0-canary.140",
2360
+ ) == (
2361
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
2362
+ "//terraform/modules/thinkwork?ref=c706fd93b917ee71a01add97ee7dc7c977cc2bb8",
2363
+ "",
2364
+ )
2365
+
2366
+
2367
+ def test_github_module_source_checks_out_https_repo() -> None:
2368
+ runner = load_runner()
2369
+
2370
+ assert runner.source_repo_and_ref(
2371
+ "github.com/thinkwork-ai/thinkwork//terraform/modules/thinkwork?ref=abc123",
2372
+ "main",
2373
+ ) == ("https://github.com/thinkwork-ai/thinkwork.git", "abc123")
2374
+
2375
+
2376
+ def test_git_module_source_ignores_registry_version() -> None:
2377
+ runner = load_runner()
2378
+
2379
+ assert runner.terraform_module_source_and_version(
2380
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
2381
+ "//terraform/modules/thinkwork?ref=codex/release-deploy-fix",
2382
+ "0.1.0-canary.189",
2383
+ "v0.1.0-canary.189",
2384
+ ) == (
2385
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
2386
+ "//terraform/modules/thinkwork?ref=codex/release-deploy-fix",
2387
+ "",
2388
+ )
2389
+
2390
+
2391
+ def test_safe_extract_rejects_archive_path_traversal(tmp_path: Path) -> None:
2392
+ runner = load_runner()
2393
+ archive_path = tmp_path / "evil.tar.gz"
2394
+ with tarfile.open(archive_path, "w:gz") as tar:
2395
+ info = tarfile.TarInfo("../evil.txt")
2396
+ data = b"nope"
2397
+ info.size = len(data)
2398
+ tar.addfile(info, io.BytesIO(data))
2399
+
2400
+ with pytest.raises(RuntimeError, match="escapes destination"):
2401
+ runner.safe_extract_tar_file(archive_path, tmp_path / "extract")
2402
+
2403
+
2404
+ def test_safe_extract_rejects_archive_links(tmp_path: Path) -> None:
2405
+ runner = load_runner()
2406
+ archive_path = tmp_path / "link.tar.gz"
2407
+ with tarfile.open(archive_path, "w:gz") as tar:
2408
+ info = tarfile.TarInfo("static/web.tar.gz")
2409
+ info.type = tarfile.SYMTYPE
2410
+ info.linkname = "/etc/passwd"
2411
+ tar.addfile(info)
2412
+
2413
+ with pytest.raises(RuntimeError, match="links are not allowed"):
2414
+ runner.safe_extract_tar_file(archive_path, tmp_path / "extract")
2415
+
2416
+
2417
+ def test_sync_release_artifacts_blocks_missing_bundle_before_staging(
2418
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2419
+ ) -> None:
2420
+ runner = load_runner()
2421
+ release_dir = tmp_path / "release-work"
2422
+ manifest_path = tmp_path / "thinkwork-release.json"
2423
+ lambda_bytes = b"lambda-zip"
2424
+ manifest = {
2425
+ "schemaVersion": 1,
2426
+ "release": {
2427
+ "version": "0.1.0-canary.134",
2428
+ "gitSha": "abc123",
2429
+ "createdAt": "2026-06-09T00:00:00.000Z",
2430
+ },
2431
+ "artifacts": [
2432
+ {
2433
+ "name": "graphql-http",
2434
+ "type": "lambda",
2435
+ "fileName": "graphql-http.zip",
2436
+ "relativePath": "lambdas/graphql-http.zip",
2437
+ "url": None,
2438
+ "sha256": digest(lambda_bytes),
2439
+ "sizeBytes": len(lambda_bytes),
2440
+ }
2441
+ ],
2442
+ "runtimeImages": [],
2443
+ "managedApps": [],
2444
+ "signing": {
2445
+ "acceptedKeyIds": [],
2446
+ "revokedKeyIds": [],
2447
+ },
2448
+ }
2449
+ manifest_sha = write_manifest(manifest_path, manifest)
2450
+ calls: list[list[str]] = []
2451
+
2452
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
2453
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
2454
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
2455
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
2456
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "0.1.0-canary.134")
2457
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
2458
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
2459
+
2460
+ with pytest.raises(RuntimeError, match="not available in an artifact bundle"):
2461
+ runner.sync_release_artifacts()
2462
+
2463
+ assert calls == []
2464
+
2465
+
2466
+ def test_sync_release_artifacts_rejects_bundled_artifact_hash_mismatch(
2467
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2468
+ ) -> None:
2469
+ runner = load_runner()
2470
+ release_dir = tmp_path / "release-work"
2471
+ manifest_path = tmp_path / "thinkwork-release.json"
2472
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
2473
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": b"actual"})
2474
+ artifacts = [
2475
+ {
2476
+ "name": "graphql-http",
2477
+ "type": "lambda",
2478
+ "fileName": "graphql-http.zip",
2479
+ "relativePath": "lambdas/graphql-http.zip",
2480
+ "url": None,
2481
+ "sha256": digest(b"expected"),
2482
+ "sizeBytes": len(b"expected"),
2483
+ }
2484
+ ]
2485
+ manifest_sha = write_manifest(
2486
+ manifest_path,
2487
+ release_manifest(bundle_path, runner.sha256_file(bundle_path), artifacts),
2488
+ )
2489
+
2490
+ monkeypatch.setattr(runner, "RELEASE", release_dir)
2491
+ monkeypatch.setattr(runner, "MANIFEST", release_dir / "thinkwork-release.json")
2492
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
2493
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
2494
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "0.1.0-canary.134")
2495
+ monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
2496
+ monkeypatch.setattr(runner, "run", lambda *_args, **_kwargs: None)
2497
+
2498
+ with pytest.raises(RuntimeError, match="Artifact digest mismatch"):
2499
+ runner.sync_release_artifacts()
2500
+
2501
+
2502
+ def test_artifact_bundle_download_path_must_stay_under_release_dir(
2503
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2504
+ ) -> None:
2505
+ runner = load_runner()
2506
+ bundle_path = tmp_path / "platform-artifacts.tar.gz"
2507
+ write_tar(bundle_path, {"lambdas/graphql-http.zip": b"lambda"})
2508
+
2509
+ monkeypatch.setattr(runner, "RELEASE", tmp_path / "release-work")
2510
+
2511
+ with pytest.raises(RuntimeError, match="escapes destination"):
2512
+ runner.download_and_extract_artifact_bundles(
2513
+ {
2514
+ "artifactBundles": [
2515
+ {
2516
+ "name": "platform",
2517
+ "fileName": "platform-artifacts.tar.gz",
2518
+ "relativePath": "../platform-artifacts.tar.gz",
2519
+ "url": file_url(bundle_path),
2520
+ "sha256": runner.sha256_file(bundle_path),
2521
+ "contains": [],
2522
+ }
2523
+ ],
2524
+ "artifacts": [],
2525
+ }
2526
+ )
2527
+
2528
+
2529
+ def test_materialized_artifact_path_must_stay_under_release_dir(
2530
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2531
+ ) -> None:
2532
+ runner = load_runner()
2533
+ bundled_path = tmp_path / "bundle" / "graphql-http.zip"
2534
+ bundled_path.parent.mkdir(parents=True)
2535
+ bundled_path.write_bytes(b"lambda")
2536
+
2537
+ monkeypatch.setattr(runner, "RELEASE", tmp_path / "release-work")
2538
+
2539
+ with pytest.raises(RuntimeError, match="escapes destination"):
2540
+ runner.materialize_release_artifact(
2541
+ {
2542
+ "name": "graphql-http",
2543
+ "relativePath": "../graphql-http.zip",
2544
+ "sha256": digest(b"lambda"),
2545
+ },
2546
+ {"graphql-http": bundled_path},
2547
+ )
2548
+
2549
+
2550
+ def test_controller_input_summary_redacts_to_deployment_contract() -> None:
2551
+ runner = load_runner()
2552
+
2553
+ summary = runner.controller_input_summary(
2554
+ {
2555
+ "schemaVersion": 1,
2556
+ "contract": "thinkwork.deployment.controller.v1",
2557
+ "phase": "deploy",
2558
+ "action": "deploy",
2559
+ "sessionId": "session-1",
2560
+ "customerName": "TEI",
2561
+ "environmentName": "tei-e2e",
2562
+ "awsAccountId": "123456789012",
2563
+ "awsRegion": "us-east-1",
2564
+ "availabilityZones": ["us-east-1a", "us-east-1b"],
2565
+ "firstAdmin": {
2566
+ "name": "Eric Odom",
2567
+ "email": "eric@example.com",
2568
+ "password": "do-not-record",
2569
+ },
2570
+ "release": {
2571
+ "version": "v0.1.0-canary.134",
2572
+ "manifestUrl": "https://example.test/thinkwork-release.json",
2573
+ "manifestSha256": "a" * 64,
2574
+ },
2575
+ "features": {
2576
+ "baseInstall": {
2577
+ "cognee": False,
2578
+ "slack": False,
2579
+ "stripe": False,
2580
+ "twenty": False,
2581
+ },
2582
+ "optionalApps": [],
2583
+ },
2584
+ }
2585
+ )
2586
+
2587
+ assert summary["contract"] == "thinkwork.deployment.controller.v1"
2588
+ assert summary["customer"]["environmentName"] == "tei-e2e"
2589
+ assert summary["release"]["manifestSha256"] == "a" * 64
2590
+ assert summary["features"]["baseInstall"] == {
2591
+ "cognee": False,
2592
+ "slack": False,
2593
+ "stripe": False,
2594
+ "twenty": False,
2595
+ }
2596
+ assert "firstAdmin" not in summary
2597
+ assert "do-not-record" not in json.dumps(summary)
2598
+
2599
+
2600
+ def test_controller_status_action_writes_noop_proof(
2601
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2602
+ ) -> None:
2603
+ runner = load_runner()
2604
+ monkeypatch.chdir(tmp_path)
2605
+ monkeypatch.setattr(runner, "WORK", tmp_path / "work")
2606
+ monkeypatch.setenv("THINKWORK_DEPLOYMENT_ACTION", "status")
2607
+ monkeypatch.setenv(
2608
+ "THINKWORK_DEPLOYMENT_INPUT",
2609
+ json.dumps(
2610
+ {
2611
+ "schemaVersion": 1,
2612
+ "contract": "thinkwork.deployment.controller.v1",
2613
+ "action": "status",
2614
+ "sessionId": "session-1",
2615
+ "environmentName": "tei-e2e",
2616
+ "awsAccountId": "123456789012",
2617
+ "awsRegion": "us-east-1",
2618
+ "release": {
2619
+ "version": "v0.1.0-canary.134",
2620
+ "manifestUrl": "https://example.com/thinkwork-release.json",
2621
+ "manifestSha256": "a" * 64,
2622
+ },
2623
+ }
2624
+ ),
2625
+ )
2626
+ monkeypatch.setenv(
2627
+ "THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN",
2628
+ "arn:aws:states:us-east-1:123456789012:stateMachine:thinkwork-tei-e2e-deployment-orchestrator",
2629
+ )
2630
+ monkeypatch.setenv(
2631
+ "THINKWORK_DEPLOYMENT_STATE_MACHINE_NAME",
2632
+ "thinkwork-tei-e2e-deployment-orchestrator",
2633
+ )
2634
+ monkeypatch.setenv("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME", "runner")
2635
+ monkeypatch.setenv(
2636
+ "THINKWORK_DEPLOYMENT_RUNNER_PROJECT_ARN",
2637
+ "arn:aws:codebuild:us-east-1:123456789012:project/runner",
2638
+ )
2639
+ monkeypatch.setenv("THINKWORK_EVIDENCE_BUCKET", "evidence-bucket")
2640
+ monkeypatch.setenv("THINKWORK_SSM_PREFIX", "/thinkwork/tei-e2e/deployment")
2641
+ monkeypatch.setattr(runner, "run", lambda *_args, **_kwargs: None)
2642
+
2643
+ assert runner.main() == 0
2644
+
2645
+ proof = json.loads((tmp_path / "controller-status.json").read_text())
2646
+ evidence = json.loads((tmp_path / "deployment-evidence.json").read_text())
2647
+ assert proof["status"] == "ready"
2648
+ assert proof["controller"]["stateMachineName"] == ("thinkwork-tei-e2e-deployment-orchestrator")
2649
+ assert proof["release"]["version"] == "v0.1.0-canary.134"
2650
+ assert evidence["status"] == "succeeded"
2651
+ assert evidence["controller"]["status"]["proof"]["action"] == "status"
2652
+
2653
+
2654
+ def test_runtime_profile_contains_customer_authority_metadata(
2655
+ monkeypatch: pytest.MonkeyPatch,
2656
+ ) -> None:
2657
+ runner = load_runner()
2658
+ monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.134")
2659
+ monkeypatch.setenv(
2660
+ "THINKWORK_RELEASE_MANIFEST_URL",
2661
+ "https://example.com/thinkwork-release.json",
2662
+ )
2663
+ monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", "a" * 64)
2664
+ outputs = {
2665
+ "api_endpoint": {"value": "https://api.example.com"},
2666
+ "app_url": {"value": "https://app.example.com"},
2667
+ "appsync_api_url": {"value": "https://appsync.example.com/graphql"},
2668
+ "appsync_realtime_url": {"value": "wss://appsync.example.com/graphql"},
2669
+ "appsync_api_key": {"value": "api-key"},
2670
+ "auth_domain": {"value": "thinkwork-tei-e2e"},
2671
+ "user_pool_id": {"value": "us-east-1_abc"},
2672
+ "admin_client_id": {"value": "client-id"},
2673
+ "deployment_state_machine_arn": {
2674
+ "value": "arn:aws:states:us-east-1:123456789012:stateMachine:controller"
2675
+ },
2676
+ "deployment_state_machine_name": {"value": "controller"},
2677
+ "deployment_runner_project_name": {"value": "runner"},
2678
+ "deployment_runner_project_arn": {
2679
+ "value": "arn:aws:codebuild:us-east-1:123456789012:project/runner"
2680
+ },
2681
+ "deployment_evidence_bucket_name": {"value": "evidence-bucket"},
2682
+ "deployment_ssm_prefix": {"value": "/thinkwork/tei-e2e/deployment"},
2683
+ }
2684
+ profile, web_env = runner.runtime_profile(
2685
+ outputs,
2686
+ {
2687
+ "stage": "tei-e2e",
2688
+ "region": "us-east-1",
2689
+ "account_id": "123456789012",
2690
+ },
2691
+ )
2692
+
2693
+ assert profile["accountId"] == "123456789012"
2694
+ assert profile["releaseVersion"] == "v0.1.0-canary.134"
2695
+ assert profile["releaseManifestSha256"] == "a" * 64
2696
+ assert profile["controller"]["stateMachineArn"].endswith(":stateMachine:controller")
2697
+ assert profile["controller"]["codebuildProjectArn"].endswith(":project/runner")
2698
+ assert "VITE_DEPLOYMENT_CONTROLLER_ARN=" in web_env
2699
+ assert "VITE_RELEASE_MANIFEST_SHA256=" in web_env
2700
+
2701
+
2702
+ def test_redacted_tfvars_removes_secret_values() -> None:
2703
+ runner = load_runner()
2704
+
2705
+ redacted = runner.redacted_tfvars(
2706
+ {
2707
+ "stage": "tei-e2e",
2708
+ "db_password": "secret-db",
2709
+ "api_auth_secret": "secret-api",
2710
+ "google_oauth_client_secret": "secret-google",
2711
+ }
2712
+ )
2713
+
2714
+ assert redacted == {
2715
+ "stage": "tei-e2e",
2716
+ "db_password": "[redacted]",
2717
+ "api_auth_secret": "[redacted]",
2718
+ "google_oauth_client_secret": "[redacted]",
2719
+ }
2720
+
2721
+
2722
+ def test_write_controller_release_selection_to_ssm_persists_selected_release(
2723
+ monkeypatch: pytest.MonkeyPatch,
2724
+ ) -> None:
2725
+ runner = load_runner()
2726
+ calls: list[list[str]] = []
2727
+
2728
+ monkeypatch.setenv("THINKWORK_SSM_PREFIX", "/thinkwork/tei-e2e/deployment")
2729
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
2730
+
2731
+ selected = runner.write_controller_release_selection_to_ssm(
2732
+ {
2733
+ "deployment_release_version": "v0.1.0-canary.147",
2734
+ "deployment_release_manifest_url": "https://example.test/thinkwork-release.json",
2735
+ "deployment_release_manifest_sha256": "f" * 64,
2736
+ "deployment_release_manifest_signature_url": "",
2737
+ "deployment_release_manifest_trust_policy": "allow_unsigned_canary",
2738
+ "deployment_release_manifest_trusted_keys_json": "[]",
2739
+ "deployment_terraform_module_source": "thinkwork-ai/thinkwork/aws",
2740
+ "deployment_terraform_module_version": "",
2741
+ }
2742
+ )
2743
+
2744
+ written = {call[7]: call[9] for call in calls}
2745
+ assert selected["terraform-module-version"] == "0.1.0-canary.147"
2746
+ assert written == {
2747
+ "/thinkwork/tei-e2e/deployment/selected-release-version": "v0.1.0-canary.147",
2748
+ "/thinkwork/tei-e2e/deployment/selected-release-manifest-url": (
2749
+ "https://example.test/thinkwork-release.json"
2750
+ ),
2751
+ "/thinkwork/tei-e2e/deployment/selected-release-manifest-sha256": "f" * 64,
2752
+ "/thinkwork/tei-e2e/deployment/selected-release-trust-policy": "allow_unsigned_canary",
2753
+ "/thinkwork/tei-e2e/deployment/selected-release-trusted-keys-json": "[]",
2754
+ "/thinkwork/tei-e2e/deployment/terraform-module-source": "thinkwork-ai/thinkwork/aws",
2755
+ "/thinkwork/tei-e2e/deployment/terraform-module-version": "0.1.0-canary.147",
2756
+ }
2757
+
2758
+
2759
+ def test_write_controller_release_selection_to_ssm_skips_empty_git_module_version(
2760
+ monkeypatch: pytest.MonkeyPatch,
2761
+ ) -> None:
2762
+ runner = load_runner()
2763
+ calls: list[list[str]] = []
2764
+
2765
+ monkeypatch.setenv("THINKWORK_SSM_PREFIX", "/thinkwork/dev/deployment")
2766
+ monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
2767
+
2768
+ selected = runner.write_controller_release_selection_to_ssm(
2769
+ {
2770
+ "deployment_release_version": "v0.1.0-canary.147",
2771
+ "deployment_release_manifest_url": "https://example.test/thinkwork-release.json",
2772
+ "deployment_release_manifest_sha256": "f" * 64,
2773
+ "deployment_release_manifest_trust_policy": "allow_unsigned_canary",
2774
+ "deployment_release_manifest_trusted_keys_json": "[]",
2775
+ "deployment_terraform_module_source": (
2776
+ "git::https://github.com/thinkwork-ai/thinkwork.git"
2777
+ "//terraform/modules/thinkwork?ref=37d7246"
2778
+ ),
2779
+ "deployment_terraform_module_version": "",
2780
+ }
2781
+ )
2782
+
2783
+ written_names = [call[7] for call in calls]
2784
+ assert "terraform-module-version" not in selected
2785
+ assert "/thinkwork/dev/deployment/terraform-module-version" not in written_names
2786
+
2787
+
2788
+ def test_terraform_plan_summary_counts_resource_actions() -> None:
2789
+ runner = load_runner()
2790
+
2791
+ summary = runner.terraform_plan_summary(
2792
+ {
2793
+ "format_version": "1.2",
2794
+ "terraform_version": "1.8.0",
2795
+ "resource_changes": [
2796
+ {"change": {"actions": ["create"]}},
2797
+ {"change": {"actions": ["update"]}},
2798
+ {"change": {"actions": ["delete", "create"]}},
2799
+ {"change": {"actions": ["create"]}},
2800
+ ],
2801
+ }
2802
+ )
2803
+
2804
+ assert summary == {
2805
+ "formatVersion": "1.2",
2806
+ "terraformVersion": "1.8.0",
2807
+ "resourceChangeCount": 4,
2808
+ "resourceChangesByAction": {
2809
+ "create": 2,
2810
+ "update": 1,
2811
+ "delete,create": 1,
2812
+ },
2813
+ }
2814
+
2815
+
2816
+ def test_self_update_runner_script_copies_release_runner_to_s3(
2817
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2818
+ ) -> None:
2819
+ runner = load_runner()
2820
+ source_dir = tmp_path / "source"
2821
+ script = source_dir / "terraform/modules/app/deployment-control-plane/runner.py"
2822
+ script.parent.mkdir(parents=True)
2823
+ script.write_text("# release runner\n", encoding="utf-8")
2824
+
2825
+ monkeypatch.setattr(runner, "SOURCE", source_dir)
2826
+ monkeypatch.setenv(
2827
+ "THINKWORK_RUNNER_SCRIPT_S3_URI",
2828
+ "s3://evidence-bucket/runner/thinkwork-runner.py",
2829
+ )
2830
+ calls: list[list[str]] = []
2831
+ monkeypatch.setattr(runner, "run", lambda args, **_kw: calls.append(args))
2832
+
2833
+ runner.self_update_runner_script()
2834
+
2835
+ assert calls == [
2836
+ [
2837
+ "aws",
2838
+ "s3",
2839
+ "cp",
2840
+ str(script),
2841
+ "s3://evidence-bucket/runner/thinkwork-runner.py",
2842
+ ]
2843
+ ]
2844
+
2845
+
2846
+ def test_self_update_runner_script_skips_when_source_missing(
2847
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2848
+ ) -> None:
2849
+ runner = load_runner()
2850
+ monkeypatch.setattr(runner, "SOURCE", tmp_path / "missing-source")
2851
+ monkeypatch.setenv(
2852
+ "THINKWORK_RUNNER_SCRIPT_S3_URI",
2853
+ "s3://evidence-bucket/runner/thinkwork-runner.py",
2854
+ )
2855
+ calls: list[list[str]] = []
2856
+ monkeypatch.setattr(runner, "run", lambda args, **_kw: calls.append(args))
2857
+
2858
+ runner.self_update_runner_script()
2859
+
2860
+ assert calls == []
2861
+
2862
+
2863
+ # ---------------------------------------------------------------------------
2864
+ # First-admin bootstrap (first-run provisioning)
2865
+ # ---------------------------------------------------------------------------
2866
+
2867
+
2868
+ def test_first_admin_tenant_slug_prefers_customer_domain_label() -> None:
2869
+ runner = load_runner()
2870
+ slug = runner.first_admin_tenant_slug(
2871
+ {}, {}, {"stage": "acme-prod", "customer_domain": "acme.thinkwork.ai"}
2872
+ )
2873
+ assert slug == "acme"
2874
+
2875
+
2876
+ def test_first_admin_tenant_slug_falls_back_to_stage() -> None:
2877
+ runner = load_runner()
2878
+ slug = runner.first_admin_tenant_slug({}, {}, {"stage": "acme", "customer_domain": ""})
2879
+ assert slug == "acme"
2880
+
2881
+
2882
+ def test_first_admin_tenant_slug_rejects_domain_mismatch() -> None:
2883
+ runner = load_runner()
2884
+ with pytest.raises(RuntimeError, match="KTD8"):
2885
+ runner.first_admin_tenant_slug(
2886
+ {},
2887
+ {"tenantSlug": "other"},
2888
+ {"stage": "acme", "customer_domain": "acme.thinkwork.ai"},
2889
+ )
2890
+
2891
+
2892
+ def test_first_admin_tenant_slug_rejects_invalid_slug() -> None:
2893
+ runner = load_runner()
2894
+ with pytest.raises(RuntimeError, match="slug pattern"):
2895
+ runner.first_admin_tenant_slug({}, {}, {"stage": "Bad_Stage!", "customer_domain": ""})
2896
+
2897
+
2898
+ def test_first_admin_email_takes_first_valid_entry() -> None:
2899
+ runner = load_runner()
2900
+ assert (
2901
+ runner.first_admin_email({"platform_operator_emails": "ops@acme.com, two@acme.com"})
2902
+ == "ops@acme.com"
2903
+ )
2904
+ assert runner.first_admin_email({"platform_operator_emails": ""}) == ""
2905
+ assert runner.first_admin_email({"platform_operator_emails": "not-an-email"}) == ""
2906
+ assert runner.first_admin_email({"platform_operator_emails": "a'b@acme.com"}) == ""
2907
+
2908
+
2909
+ def test_ensure_first_admin_skips_without_admin_email(tmp_path: Path) -> None:
2910
+ runner = load_runner()
2911
+ outputs = tmp_path / "outputs.json"
2912
+ outputs.write_text("{}", encoding="utf-8")
2913
+ runner.ensure_first_admin(outputs, {"platform_operator_emails": "", "stage": "acme"}, {}, {})
2914
+ assert runner.FIRST_ADMIN_EVIDENCE["status"] == "skipped"
2915
+
2916
+
2917
+ def test_ensure_first_admin_skips_established_env_without_slug(
2918
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2919
+ ) -> None:
2920
+ runner = load_runner()
2921
+ outputs = tmp_path / "outputs.json"
2922
+ outputs.write_text(json.dumps({"user_pool_id": {"value": "us-east-1_POOL"}}), encoding="utf-8")
2923
+ monkeypatch.setattr(runner, "database_url_from_outputs", lambda _o: "postgres://x")
2924
+
2925
+ def fake_psql_output(_url, sql):
2926
+ if "count(*)" in sql:
2927
+ return "3"
2928
+ return "" # slug not present
2929
+
2930
+ monkeypatch.setattr(runner, "psql_output", fake_psql_output)
2931
+ runner.ensure_first_admin(
2932
+ outputs,
2933
+ {"platform_operator_emails": "ops@acme.com", "stage": "acme", "customer_domain": ""},
2934
+ {},
2935
+ {},
2936
+ )
2937
+ assert runner.FIRST_ADMIN_EVIDENCE["status"] == "skipped"
2938
+ assert "3 tenant(s)" in runner.FIRST_ADMIN_EVIDENCE["reason"]
2939
+
2940
+
2941
+ def test_ensure_first_admin_provisions_fresh_environment(
2942
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
2943
+ ) -> None:
2944
+ runner = load_runner()
2945
+ outputs = tmp_path / "outputs.json"
2946
+ outputs.write_text(json.dumps({"user_pool_id": {"value": "us-east-1_POOL"}}), encoding="utf-8")
2947
+ monkeypatch.setattr(runner, "database_url_from_outputs", lambda _o: "postgres://x")
2948
+
2949
+ state = {"sql_calls": [], "cognito_calls": [], "seeded": 0}
2950
+
2951
+ def fake_psql_output(_url, sql):
2952
+ if "count(*)" in sql:
2953
+ return "0"
2954
+ if "SELECT id FROM public.tenants" in sql:
2955
+ return "tenant-uuid-1"
2956
+ return ""
2957
+
2958
+ def fake_psql(_url, sql=None, file=None, variables=None):
2959
+ state["sql_calls"].append((sql, variables))
2960
+
2961
+ def fake_ensure_user(pool, email, region):
2962
+ state["cognito_calls"].append(("ensure", pool, email, region))
2963
+ return "sub-123", True
2964
+
2965
+ def fake_cognito_idp(args, region, check=True):
2966
+ state["cognito_calls"].append((args[0], tuple(args), region))
2967
+
2968
+ class R:
2969
+ returncode = 0
2970
+ stdout = "{}"
2971
+ stderr = ""
2972
+
2973
+ return R()
2974
+
2975
+ monkeypatch.setattr(runner, "psql_output", fake_psql_output)
2976
+ monkeypatch.setattr(runner, "psql", fake_psql)
2977
+ monkeypatch.setattr(runner, "ensure_first_admin_cognito_user", fake_ensure_user)
2978
+ monkeypatch.setattr(runner, "cognito_idp", fake_cognito_idp)
2979
+ monkeypatch.setattr(
2980
+ runner,
2981
+ "seed_platform_bootstrap_defaults",
2982
+ lambda _url: state.__setitem__("seeded", state["seeded"] + 1),
2983
+ )
2984
+
2985
+ runner.ensure_first_admin(
2986
+ outputs,
2987
+ {
2988
+ "platform_operator_emails": "ops@acme.com",
2989
+ "stage": "acme-prod",
2990
+ "region": "us-east-1",
2991
+ "customer_domain": "acme.thinkwork.ai",
2992
+ },
2993
+ {},
2994
+ {},
2995
+ )
2996
+
2997
+ assert runner.FIRST_ADMIN_EVIDENCE["status"] == "succeeded"
2998
+ assert runner.FIRST_ADMIN_EVIDENCE["tenantSlug"] == "acme"
2999
+ assert runner.FIRST_ADMIN_EVIDENCE["tenantId"] == "tenant-uuid-1"
3000
+ assert runner.FIRST_ADMIN_EVIDENCE["cognitoUserCreated"] is True
3001
+
3002
+ sql, variables = state["sql_calls"][0]
3003
+ assert "INSERT INTO public.spaces" in sql
3004
+ assert variables["tenant_slug"] == "acme"
3005
+ assert variables["admin_email"] == "ops@acme.com"
3006
+ assert variables["cognito_sub"] == "sub-123"
3007
+
3008
+ attr_calls = [c for c in state["cognito_calls"] if c[0] == "admin-update-user-attributes"]
3009
+ assert attr_calls and "Name=custom:tenant_id,Value=tenant-uuid-1" in attr_calls[0][1]
3010
+ assert state["seeded"] == 1
3011
+
3012
+
3013
+ def test_ensure_first_admin_failure_is_nonfatal(
3014
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3015
+ ) -> None:
3016
+ runner = load_runner()
3017
+ outputs = tmp_path / "outputs.json"
3018
+ outputs.write_text("{}", encoding="utf-8") # missing user_pool_id
3019
+ runner.ensure_first_admin(
3020
+ outputs,
3021
+ {"platform_operator_emails": "ops@acme.com", "stage": "acme", "customer_domain": ""},
3022
+ {},
3023
+ {},
3024
+ )
3025
+ assert runner.FIRST_ADMIN_EVIDENCE["status"] == "failed"
3026
+ assert "user_pool_id" in runner.FIRST_ADMIN_EVIDENCE["error"]