pz-cli 5.0.0__py3-none-any.whl

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 (399) hide show
  1. pz_cli-5.0.0.dist-info/METADATA +51 -0
  2. pz_cli-5.0.0.dist-info/RECORD +399 -0
  3. pz_cli-5.0.0.dist-info/WHEEL +4 -0
  4. pz_cli-5.0.0.dist-info/entry_points.txt +5 -0
  5. pz_core/__init__.py +64 -0
  6. pz_core/__main__.py +7 -0
  7. pz_core/app.py +45 -0
  8. pz_core/assembly/__init__.py +17 -0
  9. pz_core/assembly/app_discovery.py +1240 -0
  10. pz_core/assembly/apps/__init__.py +25 -0
  11. pz_core/assembly/apps/compiler.py +824 -0
  12. pz_core/assembly/apps/providers.py +188 -0
  13. pz_core/assembly/cli/__init__.py +9 -0
  14. pz_core/assembly/cli/compiler.py +166 -0
  15. pz_core/assembly/diagnostics/__init__.py +21 -0
  16. pz_core/assembly/diagnostics/checks.py +257 -0
  17. pz_core/assembly/diagnostics/dev_sources.py +569 -0
  18. pz_core/assembly/events/__init__.py +5 -0
  19. pz_core/assembly/events/compiler.py +219 -0
  20. pz_core/assembly/extensions/__init__.py +5 -0
  21. pz_core/assembly/extensions/compiler.py +696 -0
  22. pz_core/assembly/infra/__init__.py +6 -0
  23. pz_core/assembly/infra/compiler.py +272 -0
  24. pz_core/assembly/lifecycle/__init__.py +5 -0
  25. pz_core/assembly/lifecycle/compiler.py +95 -0
  26. pz_core/assembly/middlewares/__init__.py +19 -0
  27. pz_core/assembly/middlewares/compiler.py +508 -0
  28. pz_core/assembly/project/__init__.py +7 -0
  29. pz_core/assembly/project/compiler.py +9 -0
  30. pz_core/assembly/project/context.py +17 -0
  31. pz_core/assembly/project/discovery.py +227 -0
  32. pz_core/assembly/resources/__init__.py +19 -0
  33. pz_core/assembly/resources/compiler.py +310 -0
  34. pz_core/assembly/resources/settings.py +129 -0
  35. pz_core/assembly/routes/__init__.py +7 -0
  36. pz_core/assembly/routes/compiler.py +400 -0
  37. pz_core/assembly/runtime_manifests/__init__.py +13 -0
  38. pz_core/assembly/runtime_manifests/compiler.py +159 -0
  39. pz_core/assembly/settings/__init__.py +17 -0
  40. pz_core/assembly/settings/compiler.py +786 -0
  41. pz_core/assembly/settings/host_project_overrides.py +82 -0
  42. pz_core/assembly/settings/host_runtime_endpoint.py +207 -0
  43. pz_core/authoring/__init__.py +79 -0
  44. pz_core/authoring/app_init_service.py +946 -0
  45. pz_core/authoring/app_versions.py +814 -0
  46. pz_core/authoring/cli.py +192 -0
  47. pz_core/authoring/commands.py +1810 -0
  48. pz_core/authoring/conflicts.py +180 -0
  49. pz_core/authoring/dev_bindings.py +916 -0
  50. pz_core/authoring/dev_git.py +1087 -0
  51. pz_core/authoring/dev_import.py +1061 -0
  52. pz_core/authoring/dev_manifest.py +522 -0
  53. pz_core/authoring/dev_sources.py +1638 -0
  54. pz_core/authoring/env_cli.py +454 -0
  55. pz_core/authoring/files.py +109 -0
  56. pz_core/authoring/ide_projection.py +1129 -0
  57. pz_core/authoring/naming.py +58 -0
  58. pz_core/authoring/official_dev_sources.py +432 -0
  59. pz_core/authoring/official_sources.json +156 -0
  60. pz_core/authoring/orchestration_service.py +558 -0
  61. pz_core/authoring/project_default.jsonc +53 -0
  62. pz_core/authoring/project_defaults.py +88 -0
  63. pz_core/authoring/project_metadata_service.py +199 -0
  64. pz_core/authoring/project_settings_checks.py +51 -0
  65. pz_core/authoring/project_settings_cli.py +299 -0
  66. pz_core/authoring/project_settings_service.py +1700 -0
  67. pz_core/authoring/project_state.py +399 -0
  68. pz_core/authoring/publish_git.py +423 -0
  69. pz_core/authoring/publish_lock.py +188 -0
  70. pz_core/authoring/publish_source.py +195 -0
  71. pz_core/authoring/release.py +1382 -0
  72. pz_core/authoring/repo_keys.py +29 -0
  73. pz_core/authoring/root_cli.py +28 -0
  74. pz_core/authoring/scaffold_service.py +325 -0
  75. pz_core/authoring/sync_service.py +787 -0
  76. pz_core/authoring/templates/app/__init__.py +1 -0
  77. pz_core/authoring/templates/app/api.py +25 -0
  78. pz_core/authoring/templates/app/apps.py +31 -0
  79. pz_core/authoring/templates/app/auth_package_init.py +14 -0
  80. pz_core/authoring/templates/app/auth_readme.py +29 -0
  81. pz_core/authoring/templates/app/configs_package_init.py +14 -0
  82. pz_core/authoring/templates/app/default_env.py +18 -0
  83. pz_core/authoring/templates/app/default_jsonc.py +25 -0
  84. pz_core/authoring/templates/app/locales_readme.py +25 -0
  85. pz_core/authoring/templates/app/migrations_readme.py +19 -0
  86. pz_core/authoring/templates/app/models.py +14 -0
  87. pz_core/authoring/templates/app/package_init.py +8 -0
  88. pz_core/authoring/templates/app/pages_base.py +161 -0
  89. pz_core/authoring/templates/app/pages_readme.py +25 -0
  90. pz_core/authoring/templates/app/public.py +31 -0
  91. pz_core/authoring/templates/app/pyproject.py +77 -0
  92. pz_core/authoring/templates/app/readme.py +74 -0
  93. pz_core/authoring/templates/app/settings.py +46 -0
  94. pz_core/authoring/templates/app/static_app_js.py +24 -0
  95. pz_core/authoring/templates/app/static_readme.py +31 -0
  96. pz_core/authoring/templates/app/template_config_page.py +23 -0
  97. pz_core/authoring/templates/app/template_index_html.py +28 -0
  98. pz_core/authoring/templates/app/templates_readme.py +32 -0
  99. pz_core/authoring/templates/app/urls.py +36 -0
  100. pz_core/authoring/templates/app/views.py +36 -0
  101. pz_core/authoring/templates/project/__init__.py +1 -0
  102. pz_core/authoring/templates/project/agents.py +51 -0
  103. pz_core/authoring/templates/project/apps_container_readme.py +37 -0
  104. pz_core/authoring/templates/project/main_py.py +59 -0
  105. pz_core/authoring/templates/project/project_env.py +19 -0
  106. pz_core/authoring/templates/project/pyproject.py +26 -0
  107. pz_core/authoring/templates/project/pz_env_env.py +65 -0
  108. pz_core/authoring/templates/project/pz_env_jsonc.py +69 -0
  109. pz_core/authoring/templates/project/pz_jsonc.py +12 -0
  110. pz_core/authoring/templates/project/readme.py +94 -0
  111. pz_core/authoring/templates/project/root_gitkeep.py +8 -0
  112. pz_core/authoring/templates/shared/__init__.py +5 -0
  113. pz_core/authoring/templates/shared/text.py +10 -0
  114. pz_core/authoring/venv_service.py +87 -0
  115. pz_core/authoring/workspace.py +167 -0
  116. pz_core/cli.py +291 -0
  117. pz_core/cli_options.py +74 -0
  118. pz_core/core/__init__.py +3 -0
  119. pz_core/core/errors/__init__.py +5 -0
  120. pz_core/core/errors/models.py +117 -0
  121. pz_core/core/events/__init__.py +5 -0
  122. pz_core/core/events/registry.py +54 -0
  123. pz_core/core/extensions/__init__.py +51 -0
  124. pz_core/core/extensions/registry.py +790 -0
  125. pz_core/core/health/__init__.py +6 -0
  126. pz_core/core/health/contracts.py +10 -0
  127. pz_core/core/middlewares/__init__.py +6 -0
  128. pz_core/core/middlewares/models.py +24 -0
  129. pz_core/core/routing/__init__.py +5 -0
  130. pz_core/core/routing/models.py +21 -0
  131. pz_core/core/settings_contracts.py +25 -0
  132. pz_core/docs/README.md +20 -0
  133. pz_core/docs/distribution-README.md +438 -0
  134. pz_core/docs/pz/345/221/275/344/273/244/346/200/273/350/247/210.md +1011 -0
  135. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/00-pz/346/236/266/346/236/204/346/200/273/347/272/262.md +240 -0
  136. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/01-/347/233/256/345/275/225/344/270/216/345/210/206/345/261/202/350/247/204/350/214/203.md +216 -0
  137. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/02-/345/214/205/344/270/216/345/210/206/345/217/221/350/247/204/350/214/203.md +403 -0
  138. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/03-App/346/234/200/345/260/217/345/255/220/345/214/205/350/247/204/350/214/203.md +571 -0
  139. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/04-Settings/344/270/216/351/205/215/347/275/256/350/247/204/350/214/203.md +1177 -0
  140. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/05-/350/265/204/346/272/220/344/270/216/350/277/220/350/241/214/347/233/256/345/275/225/350/247/204/350/214/203.md +575 -0
  141. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/06-/346/225/260/346/215/256/345/272/223/344/270/216/345/217/257/351/200/211/345/237/272/347/241/200/350/256/276/346/226/275/350/247/204/350/214/203.md +499 -0
  142. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/07-App/346/233/277/346/215/242/350/247/204/350/214/203.md +206 -0
  143. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/08-/345/271/263/345/217/260/347/272/247App/344/270/216/350/277/220/350/241/214/346/227/266/345/215/225/344/276/213/350/247/204/350/214/203.md +445 -0
  144. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/09-CLI/344/270/216Worker/345/205/245/345/217/243/350/247/204/350/214/203.md +950 -0
  145. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/10-/346/265/213/350/257/225/344/270/216/345/245/221/347/272/246/350/247/204/350/214/203.md +1001 -0
  146. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/11-/344/273/223/345/272/223/347/234/237/347/233/270/346/272/220/345/234/260/345/233/276.md +102 -0
  147. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/12-/346/236/204/345/273/272/344/270/216/345/217/221/345/270/203/346/243/200/346/237/245/346/270/205/345/215/225.md +171 -0
  148. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/13-/346/255/243/345/274/217Facade/346/200/273/350/241/250.md +402 -0
  149. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/14-/345/210/206/345/261/202/344/270/216/346/211/251/345/261/225/350/247/204/350/214/203.md +550 -0
  150. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/15-/345/255/220/345/214/205/345/274/200/345/217/221/344/275/277/347/224/250/346/211/213/345/206/214.md +1004 -0
  151. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/16-/346/211/251/345/261/225/347/202/271/346/255/243/345/274/217/345/215/217/350/256/256/346/270/205/345/215/225.md +352 -0
  152. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/17-pz.protocols.v1/346/255/243/345/274/217/345/215/217/350/256/256.md +266 -0
  153. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/18-/346/211/251/345/261/225/347/202/271/345/206/262/347/252/201/350/243/201/345/206/263/346/200/273/350/241/250.md +131 -0
  154. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/19-/351/224/231/350/257/257/347/240/201/344/270/216/350/257/212/346/226/255/345/215/217/350/256/256.md +176 -0
  155. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/20-/347/247/201/346/234/211/345/214/205/344/273/223/344/270/216/347/273/237/344/270/200/350/256/244/350/257/201/351/203/250/347/275/262/350/247/204/350/214/203.md +243 -0
  156. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/21-contrib/347/272/246/345/256/232/344/270/216Provider/346/216/245/345/205/245/350/247/204/350/214/203.md +365 -0
  157. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/22-/345/244/232/345/233/275/350/257/255/350/250/200/350/265/204/346/272/220/346/262/273/347/220/206/344/270/216/346/214/211/351/234/200/345/212/240/350/275/275/350/247/204/350/214/203.md +912 -0
  158. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/23-/351/246/226/345/261/217/345/261/225/347/244/272/344/270/216/351/246/226/345/270/247/347/250/263/345/256/232/350/247/204/350/214/203.md +269 -0
  159. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/24-pz/345/220/257/345/212/250/344/270/216/350/277/220/350/241/214/346/227/266/347/274/226/350/257/221/346/265/201/347/250/213.md +245 -0
  160. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/25-/351/235/242/345/220/221/345/257/271/350/261/241/345/274/200/345/217/221/350/247/204/350/214/203.md +300 -0
  161. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/README.md +98 -0
  162. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/00-legacy_core/346/240/207/345/207/206pip/345/214/205/345/214/226/351/207/215/346/236/204/346/200/273/347/272/262.md +1073 -0
  163. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/01-/347/233/256/345/275/225/344/270/216/345/210/206/345/261/202/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +224 -0
  164. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/02-/345/214/205/344/270/216/345/210/206/345/217/221/350/247/204/350/214/203-/345/220/253/345/216/206/345/217/262/345/217/243/345/276/204.md +671 -0
  165. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/13-/344/270/200/346/254/241/346/200/247/345/256/236/346/226/275/346/270/205/345/215/225.md +387 -0
  166. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/14-/345/255/220/345/214/205/351/200/202/351/205/215/346/224/271/351/200/240/346/270/205/345/215/225.md +592 -0
  167. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/15-infra/350/243/201/345/211/252/344/270/216/350/220/275/344/275/215/346/270/205/345/215/225.md +81 -0
  168. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/19-core/345/215/225/344/270/200/346/255/243/345/274/217/346/226/271/346/241/210/346/224/266/346/225/233/346/211/247/350/241/214/346/270/205/345/215/225.md +161 -0
  169. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/25-/345/215/217/350/256/256/345/210/260/346/272/220/347/240/201/345/267/256/350/267/235/346/270/205/345/215/225.md +377 -0
  170. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/26-cutover/345/256/214/345/267/245/346/243/200/346/237/245/346/270/205/345/215/225.md +117 -0
  171. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/29-/345/244/232/345/233/275/350/257/255/350/250/200/350/265/204/346/272/220/346/262/273/347/220/206/344/270/216/346/214/211/351/234/200/345/212/240/350/275/275/350/247/204/350/214/203-/345/220/253/350/277/201/347/247/273/347/255/226/347/225/245.md +956 -0
  172. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/99-/350/265/204/346/272/220/346/224/266/345/217/243/345/220/216/347/273/255TODO.md +84 -0
  173. pz_core/docs//345/217/221/347/211/210/345/211/215/346/270/205/345/215/225-4.0.11.md +84 -0
  174. pz_core/docs//346/226/260/346/211/213/346/214/207/345/215/227.md +422 -0
  175. pz_core/infra/__init__.py +17 -0
  176. pz_core/infra/db/__init__.py +23 -0
  177. pz_core/infra/db/contracts.py +73 -0
  178. pz_core/infra/health/__init__.py +5 -0
  179. pz_core/infra/health/contracts.py +8 -0
  180. pz_core/infra/kv/__init__.py +5 -0
  181. pz_core/infra/kv/contracts.py +17 -0
  182. pz_core/infra/observability/__init__.py +23 -0
  183. pz_core/infra/observability/contracts.py +142 -0
  184. pz_core/infra/resilience/__init__.py +5 -0
  185. pz_core/infra/resilience/contracts.py +17 -0
  186. pz_core/infra/resources/__init__.py +5 -0
  187. pz_core/infra/resources/contracts.py +16 -0
  188. pz_core/infra/settings/__init__.py +17 -0
  189. pz_core/infra/settings/models.py +81 -0
  190. pz_core/infra/storage/__init__.py +5 -0
  191. pz_core/infra/storage/ports.py +27 -0
  192. pz_core/project_root_commands.py +282 -0
  193. pz_core/project_runtime_cli.py +344 -0
  194. pz_core/project_sources.py +253 -0
  195. pz_core/pz_launcher.py +209 -0
  196. pz_core/root_cli.py +70 -0
  197. pz_core/runtime/__init__.py +229 -0
  198. pz_core/runtime/access.py +86 -0
  199. pz_core/runtime/adapters/__init__.py +5 -0
  200. pz_core/runtime/adapters/base.py +18 -0
  201. pz_core/runtime/asyncio_bridge.py +18 -0
  202. pz_core/runtime/bindings/__init__.py +5 -0
  203. pz_core/runtime/bindings/container.py +50 -0
  204. pz_core/runtime/cli/__init__.py +5 -0
  205. pz_core/runtime/cli/_args.py +67 -0
  206. pz_core/runtime/cli/app_commands.py +417 -0
  207. pz_core/runtime/cli/check.py +214 -0
  208. pz_core/runtime/cli/db.py +297 -0
  209. pz_core/runtime/cli/main.py +89 -0
  210. pz_core/runtime/cli/resources.py +280 -0
  211. pz_core/runtime/discovery.py +23 -0
  212. pz_core/runtime/environment_integrity.py +157 -0
  213. pz_core/runtime/errors.py +5 -0
  214. pz_core/runtime/events.py +87 -0
  215. pz_core/runtime/facades/__init__.py +38 -0
  216. pz_core/runtime/facades/_capabilities.py +46 -0
  217. pz_core/runtime/facades/cache.py +103 -0
  218. pz_core/runtime/facades/database.py +71 -0
  219. pz_core/runtime/facades/events.py +380 -0
  220. pz_core/runtime/facades/health.py +101 -0
  221. pz_core/runtime/facades/mail.py +121 -0
  222. pz_core/runtime/facades/observability.py +102 -0
  223. pz_core/runtime/facades/providers.py +75 -0
  224. pz_core/runtime/facades/resources.py +52 -0
  225. pz_core/runtime/facades/storage.py +296 -0
  226. pz_core/runtime/health/__init__.py +5 -0
  227. pz_core/runtime/health/service.py +102 -0
  228. pz_core/runtime/host.py +188 -0
  229. pz_core/runtime/host_runner.py +282 -0
  230. pz_core/runtime/http/__init__.py +20 -0
  231. pz_core/runtime/http/app.py +576 -0
  232. pz_core/runtime/http_timing.py +173 -0
  233. pz_core/runtime/invocation.py +29 -0
  234. pz_core/runtime/json_safe.py +47 -0
  235. pz_core/runtime/lifecycle/__init__.py +25 -0
  236. pz_core/runtime/lifecycle/manager.py +533 -0
  237. pz_core/runtime/plan.py +214 -0
  238. pz_core/runtime/plan_context.py +33 -0
  239. pz_core/runtime/plan_snapshot.py +177 -0
  240. pz_core/runtime/registries/__init__.py +27 -0
  241. pz_core/runtime/registries/capabilities.py +101 -0
  242. pz_core/runtime/registries/providers.py +396 -0
  243. pz_core/runtime/resource_assets.py +344 -0
  244. pz_core/runtime/run_cli.py +211 -0
  245. pz_core/runtime/settings.py +888 -0
  246. pz_core/runtime/worker/__init__.py +21 -0
  247. pz_core/runtime/worker/engine.py +921 -0
  248. pz_core/runtime/worker/runtime.py +25 -0
  249. pz_core/testing/__init__.py +15 -0
  250. pz_core/testing/doc_route_contracts.py +305 -0
  251. pz_core/utils/__init__.py +44 -0
  252. pz_core/utils/env.py +65 -0
  253. pz_core/utils/project_jsonc/__init__.py +29 -0
  254. pz_core/utils/project_jsonc/document.py +420 -0
  255. pz_core/utils/project_settings.py +99 -0
  256. pz_foundation/__init__.py +50 -0
  257. pz_foundation/_contracts.py +81 -0
  258. pz_foundation/_errors.py +34 -0
  259. pz_foundation/api.py +14 -0
  260. pz_foundation/apps.py +127 -0
  261. pz_foundation/auth/__init__.py +1 -0
  262. pz_foundation/cache_control.py +233 -0
  263. pz_foundation/cli/__init__.py +2 -0
  264. pz_foundation/cli/workers.py +565 -0
  265. pz_foundation/configs/__init__.py +0 -0
  266. pz_foundation/configs/default.env +15 -0
  267. pz_foundation/configs/default.jsonc +452 -0
  268. pz_foundation/db/__init__.py +94 -0
  269. pz_foundation/db/aerich_executor.py +664 -0
  270. pz_foundation/db/config.py +358 -0
  271. pz_foundation/db/connection_manager.py +689 -0
  272. pz_foundation/db/connection_probe.py +204 -0
  273. pz_foundation/db/database.py +1291 -0
  274. pz_foundation/db/deps.py +47 -0
  275. pz_foundation/db/lifecycle_status.py +85 -0
  276. pz_foundation/db/migrations.py +477 -0
  277. pz_foundation/db/mysql.py +57 -0
  278. pz_foundation/db/postgresql.py +409 -0
  279. pz_foundation/db/production_pool_fallback.py +143 -0
  280. pz_foundation/db/public.py +45 -0
  281. pz_foundation/db/session.py +369 -0
  282. pz_foundation/db/sqlite.py +15 -0
  283. pz_foundation/db/tortoise_manager.py +724 -0
  284. pz_foundation/db/tortoise_psycopg.py +148 -0
  285. pz_foundation/docs/README.md +18 -0
  286. pz_foundation/docs/distribution-README.md +319 -0
  287. pz_foundation/docs/infra/350/243/201/345/211/252/344/270/216/350/220/275/344/275/215/350/257/264/346/230/216.md +39 -0
  288. pz_foundation/docs//345/205/254/345/274/200/345/205/215/350/264/271/346/216/245/345/217/243/351/230/262/346/273/245/347/224/250/344/270/216/344/272/272/346/234/272/346/240/241/351/252/214.md +680 -0
  289. pz_foundation/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +288 -0
  290. pz_foundation/docs//350/267/257/347/224/261/344/270/216URL/345/244/204/347/220/206/350/247/204/350/214/203.md +119 -0
  291. pz_foundation/health.py +105 -0
  292. pz_foundation/host/__init__.py +11 -0
  293. pz_foundation/host/builder.py +34 -0
  294. pz_foundation/host/cache_runtime.py +44 -0
  295. pz_foundation/host/db/__init__.py +17 -0
  296. pz_foundation/host/db/aerich_config.py +36 -0
  297. pz_foundation/host/db/config.py +106 -0
  298. pz_foundation/host/db/connection_manager.py +48 -0
  299. pz_foundation/host/db/deps.py +5 -0
  300. pz_foundation/host/db/orm_retry_patch.py +566 -0
  301. pz_foundation/host/db/request_adapter.py +325 -0
  302. pz_foundation/host/db/router.py +28 -0
  303. pz_foundation/host/db/session.py +284 -0
  304. pz_foundation/host/db/tortoise_manager.py +10 -0
  305. pz_foundation/host/idempotency.py +132 -0
  306. pz_foundation/host/lifespan.py +247 -0
  307. pz_foundation/host/redis_runtime.py +100 -0
  308. pz_foundation/host/web.py +790 -0
  309. pz_foundation/infra_provider.py +236 -0
  310. pz_foundation/kv/__init__.py +25 -0
  311. pz_foundation/kv/cache.py +1166 -0
  312. pz_foundation/kv/deps.py +84 -0
  313. pz_foundation/kv/public.py +25 -0
  314. pz_foundation/kv/redis_manager.py +1531 -0
  315. pz_foundation/lazy_exports.py +43 -0
  316. pz_foundation/lazy_imports.py +124 -0
  317. pz_foundation/lifecycle.py +392 -0
  318. pz_foundation/managed_workers.py +1117 -0
  319. pz_foundation/middlewares/__init__.py +26 -0
  320. pz_foundation/middlewares/_settings.py +250 -0
  321. pz_foundation/middlewares/analytics.py +124 -0
  322. pz_foundation/middlewares/cache_control.py +156 -0
  323. pz_foundation/middlewares/compression.py +202 -0
  324. pz_foundation/middlewares/cors.py +62 -0
  325. pz_foundation/middlewares/geo_country.py +44 -0
  326. pz_foundation/middlewares/inner_router.py +50 -0
  327. pz_foundation/middlewares/rate_limited/__init__.py +3 -0
  328. pz_foundation/middlewares/rate_limited/key_limiter.py +115 -0
  329. pz_foundation/middlewares/request_logging.py +104 -0
  330. pz_foundation/middlewares/route_owner.py +309 -0
  331. pz_foundation/middlewares/security_csrf.py +44 -0
  332. pz_foundation/middlewares/security_headers/__init__.py +4 -0
  333. pz_foundation/middlewares/security_headers/conflicts.py +44 -0
  334. pz_foundation/middlewares/security_headers/csp_nonce.py +200 -0
  335. pz_foundation/middlewares/security_headers/security_headers.py +44 -0
  336. pz_foundation/migrations/__init__.py +1 -0
  337. pz_foundation/migrations/pz_foundation/__init__.py +1 -0
  338. pz_foundation/models.py +22 -0
  339. pz_foundation/notifications/__init__.py +23 -0
  340. pz_foundation/notifications/email.py +542 -0
  341. pz_foundation/notifications/public.py +31 -0
  342. pz_foundation/observability/__init__.py +71 -0
  343. pz_foundation/observability/http.py +34 -0
  344. pz_foundation/observability/logger/__init__.py +49 -0
  345. pz_foundation/observability/logger/_bot_settings.py +126 -0
  346. pz_foundation/observability/logger/bot_message.py +592 -0
  347. pz_foundation/observability/logger/core.py +385 -0
  348. pz_foundation/observability/logger/delivery_audit.py +84 -0
  349. pz_foundation/observability/logger/feishu_bot.py +199 -0
  350. pz_foundation/observability/logger/telegram_bot.py +194 -0
  351. pz_foundation/observability/metrics.py +365 -0
  352. pz_foundation/observability/runtime.py +68 -0
  353. pz_foundation/public.py +405 -0
  354. pz_foundation/resources/__init__.py +12 -0
  355. pz_foundation/resources/builder.py +900 -0
  356. pz_foundation/resources/images.py +363 -0
  357. pz_foundation/root_cli.py +20 -0
  358. pz_foundation/routing/__init__.py +203 -0
  359. pz_foundation/routing/canonical.py +486 -0
  360. pz_foundation/routing/convertors.py +82 -0
  361. pz_foundation/routing/declarations.py +843 -0
  362. pz_foundation/routing/exports.py +148 -0
  363. pz_foundation/routing/loaders.py +138 -0
  364. pz_foundation/routing/manifest.py +215 -0
  365. pz_foundation/routing/manifest_provider.py +47 -0
  366. pz_foundation/routing/mounts.py +101 -0
  367. pz_foundation/routing/ordering.py +172 -0
  368. pz_foundation/routing/registry.py +133 -0
  369. pz_foundation/routing/utils.py +28 -0
  370. pz_foundation/runtime.py +234 -0
  371. pz_foundation/settings.py +253 -0
  372. pz_foundation/settings_normalization.py +130 -0
  373. pz_foundation/storage/__init__.py +25 -0
  374. pz_foundation/storage/local.py +209 -0
  375. pz_foundation/storage/public.py +106 -0
  376. pz_foundation/storage/s3.py +116 -0
  377. pz_foundation/storage/service.py +234 -0
  378. pz_foundation/urls.py +19 -0
  379. pz_foundation/utils/__init__.py +6 -0
  380. pz_foundation/utils/merge.py +160 -0
  381. pz_foundation/worker_replicas.py +55 -0
  382. pz_settings_center/__init__.py +5 -0
  383. pz_settings_center/api/__init__.py +5 -0
  384. pz_settings_center/api/runtime_overrides.py +360 -0
  385. pz_settings_center/apps.py +28 -0
  386. pz_settings_center/configs/__init__.py +0 -0
  387. pz_settings_center/configs/default.env +2 -0
  388. pz_settings_center/configs/default.jsonc +1 -0
  389. pz_settings_center/docs/01-settings_center/350/203/275/345/212/233/350/276/271/347/225/214.md +124 -0
  390. pz_settings_center/docs/README.md +12 -0
  391. pz_settings_center/docs/distribution-README.md +150 -0
  392. pz_settings_center/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +48 -0
  393. pz_settings_center/integration_payload.py +18 -0
  394. pz_settings_center/project_helpers.py +405 -0
  395. pz_settings_center/public.py +139 -0
  396. pz_settings_center/routing_helpers.py +73 -0
  397. pz_settings_center/runtime_override_provider.py +61 -0
  398. pz_settings_center/typed_settings.py +324 -0
  399. pz_settings_center/urls.py +9 -0
@@ -0,0 +1,1117 @@
1
+ from __future__ import annotations
2
+
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pz_core.core.errors import PzError
15
+ from pz_core.core.extensions import WorkerRoleSpec
16
+ from pz_core.project_runtime_cli import build_project_pz_child_env, resolve_project_pz_executable as _resolve_project_pz_executable
17
+ from pz_core.runtime.plan import RuntimePlan
18
+
19
+ from .observability import get_logger
20
+ from .worker_replicas import WorkerReplicaCountResolver
21
+
22
+
23
+ _MANAGED_WORKER_WORKSPACE_ENV = "PZ_WORKER_WORKSPACE_DIR"
24
+ _FOUNDATION_APP_ID = "pz_foundation"
25
+ _MANAGED_WORKER_LOGGER_NAME = "pz_foundation.managed_workers"
26
+
27
+
28
+ def _utc_now() -> datetime:
29
+ return datetime.now(UTC)
30
+
31
+
32
+ def _project_pz_env(plan: RuntimePlan) -> str:
33
+ try:
34
+ payload = getattr(getattr(plan, "settings", None), "data", {}) or {}
35
+ project = payload.get("project") if isinstance(payload, dict) else {}
36
+ value = project.get("pz_env") if isinstance(project, dict) else None
37
+ return str(value or "develop").strip() or "develop"
38
+ except Exception:
39
+ return "develop"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ManagedWorkerSpec:
44
+ extension_id: str
45
+ owner_app: str
46
+ name: str
47
+ app_id: str
48
+ tool_name: str
49
+ auto_start: bool = False
50
+ auto_start_setting: str = ""
51
+ restart: str = "always"
52
+ worker_kind: str = "infrastructure_worker"
53
+ replica_count_setting: str = ""
54
+ description: str = ""
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ManagedWorkerPaths:
59
+ worker_root: Path
60
+ state_path: Path
61
+ heartbeat_path: Path
62
+ pid_path: Path
63
+ lock_path: Path
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class ManagedWorkerStatus:
68
+ name: str
69
+ app_id: str
70
+ tool_name: str
71
+ extension_id: str
72
+ owner_app: str
73
+ auto_start: bool
74
+ restart: str
75
+ worker_kind: str
76
+ running: bool
77
+ healthy: bool
78
+ phase: str
79
+ pid: int | None
80
+ worker_id: str | None
81
+ replica_slot: int
82
+ target_count: int
83
+ last_heartbeat_at: str | None
84
+ state_path: str
85
+ heartbeat_path: str
86
+ lock_path: str
87
+ command: tuple[str, ...]
88
+ state: dict[str, Any] = field(default_factory=dict)
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class _CrashLoopPolicy:
93
+ max_failures: int = 5
94
+ failure_window_seconds: float = 60.0
95
+ base_backoff_seconds: float = 5.0
96
+ max_backoff_seconds: float = 300.0
97
+
98
+
99
+ def _replica_name(spec: ManagedWorkerSpec, replica_slot: int) -> str:
100
+ return spec.name if int(replica_slot) <= 1 else f"{spec.name}#{int(replica_slot)}"
101
+
102
+
103
+ def _read_mapping_member(container: Any, key: str) -> Any:
104
+ if container is None:
105
+ return None
106
+ if isinstance(container, dict):
107
+ return container.get(key)
108
+ return getattr(container, key, None)
109
+
110
+
111
+ def _foundation_app_settings(plan: RuntimePlan) -> dict[str, Any]:
112
+ try:
113
+ app_settings = getattr(getattr(plan, "settings", None), "app_settings", None)
114
+ payload = app_settings(_FOUNDATION_APP_ID) if callable(app_settings) else {}
115
+ except Exception:
116
+ return {}
117
+ return payload if isinstance(payload, dict) else {}
118
+
119
+
120
+ def _coerce_int(value: Any, *, default: int, minimum: int) -> int:
121
+ try:
122
+ parsed = int(value)
123
+ except (TypeError, ValueError):
124
+ parsed = int(default)
125
+ return max(int(minimum), parsed)
126
+
127
+
128
+ def _coerce_float(value: Any, *, default: float, minimum: float) -> float:
129
+ try:
130
+ parsed = float(value)
131
+ except (TypeError, ValueError):
132
+ parsed = float(default)
133
+ return max(float(minimum), parsed)
134
+
135
+
136
+ def _managed_worker_crash_loop_policy(plan: RuntimePlan) -> _CrashLoopPolicy:
137
+ managed_workers = _read_mapping_member(_foundation_app_settings(plan), "managed_workers")
138
+ crash_loop = _read_mapping_member(managed_workers, "crash_loop")
139
+ return _CrashLoopPolicy(
140
+ max_failures=_coerce_int(
141
+ _read_mapping_member(crash_loop, "max_failures"),
142
+ default=5,
143
+ minimum=1,
144
+ ),
145
+ failure_window_seconds=_coerce_float(
146
+ _read_mapping_member(crash_loop, "failure_window_seconds"),
147
+ default=60.0,
148
+ minimum=1.0,
149
+ ),
150
+ base_backoff_seconds=_coerce_float(
151
+ _read_mapping_member(crash_loop, "base_backoff_seconds"),
152
+ default=5.0,
153
+ minimum=0.0,
154
+ ),
155
+ max_backoff_seconds=_coerce_float(
156
+ _read_mapping_member(crash_loop, "max_backoff_seconds"),
157
+ default=300.0,
158
+ minimum=0.0,
159
+ ),
160
+ )
161
+
162
+
163
+ def discover_managed_workers(plan: RuntimePlan) -> tuple[ManagedWorkerSpec, ...]:
164
+ discovered: list[ManagedWorkerSpec] = []
165
+ names: set[str] = set()
166
+ for extension in plan.extension_registry.by_kind("worker.role"):
167
+ if not isinstance(extension, WorkerRoleSpec):
168
+ raise PzError(
169
+ code="PZ_FOUNDATION_MANAGED_WORKER_DECL_INVALID",
170
+ message="worker.role declarations must use WorkerRoleSpec.",
171
+ stage="worker",
172
+ owner=str(getattr(extension, "owner_app", "") or "pz_foundation"),
173
+ action="discover_managed_workers",
174
+ )
175
+ app_id = str(extension.owner_app).strip()
176
+ tool_name = str(extension.tool_name).strip()
177
+ name = str(extension.name or f"{app_id}.{tool_name}").strip() or f"{app_id}.{tool_name}"
178
+ if name in names:
179
+ raise PzError(
180
+ code="PZ_FOUNDATION_MANAGED_WORKER_NAME_CONFLICT",
181
+ message=f"Managed worker name `{name}` is declared more than once.",
182
+ stage="worker",
183
+ owner=str(getattr(extension, "owner_app", "") or "pz_foundation"),
184
+ action="discover_managed_workers",
185
+ )
186
+ names.add(name)
187
+ discovered.append(
188
+ ManagedWorkerSpec(
189
+ extension_id=str(getattr(extension, "id", "") or ""),
190
+ owner_app=str(getattr(extension, "owner_app", "") or ""),
191
+ name=name,
192
+ app_id=app_id,
193
+ tool_name=tool_name,
194
+ auto_start=bool(extension.auto_start),
195
+ auto_start_setting=str(getattr(extension, "auto_start_setting", "") or "").strip(),
196
+ restart=str(extension.restart or "always").strip() or "always",
197
+ worker_kind=(
198
+ str(getattr(extension, "worker_kind", "") or "infrastructure_worker").strip()
199
+ or "infrastructure_worker"
200
+ ),
201
+ replica_count_setting=str(getattr(extension, "replica_count_setting", "") or "").strip(),
202
+ description=str(extension.description or extension.title or "").strip(),
203
+ )
204
+ )
205
+ return tuple(discovered)
206
+
207
+
208
+ def _target_count_for_spec(plan: RuntimePlan, spec: ManagedWorkerSpec) -> int:
209
+ return WorkerReplicaCountResolver(plan).target_count(spec)
210
+
211
+
212
+ def _coerce_bool(value: object, default: bool) -> bool:
213
+ if isinstance(value, bool):
214
+ return value
215
+ if value is None:
216
+ return bool(default)
217
+ if isinstance(value, str):
218
+ text = value.strip().lower()
219
+ if not text:
220
+ return bool(default)
221
+ if text in {"1", "true", "yes", "on"}:
222
+ return True
223
+ if text in {"0", "false", "no", "off"}:
224
+ return False
225
+ return bool(value)
226
+
227
+
228
+ def _auto_start_for_spec(plan: RuntimePlan, spec: ManagedWorkerSpec) -> bool:
229
+ setting_name = str(spec.auto_start_setting or "").strip()
230
+ if not setting_name:
231
+ return bool(spec.auto_start)
232
+ try:
233
+ app_settings = getattr(getattr(plan, "settings", None), "app_settings", None)
234
+ if callable(app_settings):
235
+ payload = app_settings(spec.app_id) or {}
236
+ if isinstance(payload, dict) and setting_name in payload:
237
+ return _coerce_bool(payload.get(setting_name), bool(spec.auto_start))
238
+ except Exception:
239
+ pass
240
+ return bool(spec.auto_start)
241
+
242
+
243
+ def _worker_targets(plan: RuntimePlan, specs: tuple[ManagedWorkerSpec, ...]) -> list[dict[str, Any]]:
244
+ targets: list[dict[str, Any]] = []
245
+ for spec in specs:
246
+ target_count = _target_count_for_spec(plan, spec)
247
+ for replica_slot in range(1, target_count + 1):
248
+ targets.append(
249
+ {
250
+ "app_id": spec.app_id,
251
+ "base_name": spec.name,
252
+ "name": _replica_name(spec, replica_slot),
253
+ "replica_slot": replica_slot,
254
+ "target_count": target_count,
255
+ "tool_name": spec.tool_name,
256
+ "worker_kind": spec.worker_kind,
257
+ }
258
+ )
259
+ return targets
260
+
261
+
262
+ def _managed_worker_root(plan: RuntimePlan) -> Path:
263
+ return plan.project.runtime_dir / "managed_workers"
264
+
265
+
266
+ def _managed_worker_logs_dir(plan: RuntimePlan) -> Path:
267
+ return _managed_worker_root(plan) / "logs"
268
+
269
+
270
+ def _managed_worker_workspace_root(
271
+ plan: RuntimePlan,
272
+ spec: ManagedWorkerSpec,
273
+ *,
274
+ replica_slot: int = 1,
275
+ ) -> Path:
276
+ worker_root = _managed_worker_root(plan) / "workers" / spec.app_id / spec.tool_name
277
+ if int(replica_slot) > 1:
278
+ worker_root = worker_root / "replicas" / str(int(replica_slot))
279
+ return worker_root
280
+
281
+
282
+ def managed_worker_paths(
283
+ plan: RuntimePlan,
284
+ spec: ManagedWorkerSpec,
285
+ *,
286
+ replica_slot: int = 1,
287
+ ) -> ManagedWorkerPaths:
288
+ worker_root = _managed_worker_workspace_root(plan, spec, replica_slot=replica_slot)
289
+ return ManagedWorkerPaths(
290
+ worker_root=worker_root,
291
+ state_path=worker_root / "state.json",
292
+ heartbeat_path=worker_root / "heartbeat.json",
293
+ pid_path=worker_root / "worker.pid",
294
+ lock_path=worker_root / "worker.lock",
295
+ )
296
+
297
+
298
+ def _load_json(path: Path) -> dict[str, Any]:
299
+ if not path.is_file():
300
+ return {}
301
+ try:
302
+ payload = json.loads(path.read_text(encoding="utf-8"))
303
+ except Exception:
304
+ return {}
305
+ return payload if isinstance(payload, dict) else {}
306
+
307
+
308
+ def _read_pid(path: Path) -> int | None:
309
+ if not path.is_file():
310
+ return None
311
+ raw = path.read_text(encoding="utf-8").strip()
312
+ if not raw:
313
+ return None
314
+ try:
315
+ pid = int(raw)
316
+ except ValueError:
317
+ return None
318
+ return pid if pid > 0 else None
319
+
320
+
321
+ def _is_process_alive(pid: int | None) -> bool:
322
+ if pid is None:
323
+ return False
324
+ try:
325
+ os.kill(pid, 0)
326
+ except ProcessLookupError:
327
+ return False
328
+ except PermissionError:
329
+ return True
330
+ return True
331
+
332
+
333
+ def _reconcile_worker_runtime_files(
334
+ plan: RuntimePlan,
335
+ spec: ManagedWorkerSpec,
336
+ *,
337
+ replica_slot: int = 1,
338
+ pid: int | None,
339
+ reason: str,
340
+ ) -> None:
341
+ paths = managed_worker_paths(plan, spec, replica_slot=replica_slot)
342
+ state = _load_json(paths.state_path)
343
+ heartbeat = _load_json(paths.heartbeat_path)
344
+ worker_id = str(state.get("worker_id") or heartbeat.get("worker_id") or "").strip()
345
+ token = str(state.get("token") or heartbeat.get("token") or "").strip()
346
+ owner = str(state.get("owner") or heartbeat.get("owner") or spec.app_id).strip() or spec.app_id
347
+ stopped_state = {
348
+ "owner": owner,
349
+ "worker_id": worker_id,
350
+ "token": token,
351
+ "replica_slot": int(state.get("replica_slot") or heartbeat.get("replica_slot") or replica_slot),
352
+ "queue": state.get("queue") or spec.tool_name,
353
+ "kind": state.get("kind") or "default",
354
+ "ttl_seconds": state.get("ttl_seconds"),
355
+ "stale_seconds": state.get("stale_seconds"),
356
+ "max_wait_seconds": state.get("max_wait_seconds"),
357
+ "once": state.get("once"),
358
+ "concurrency": state.get("concurrency"),
359
+ "poll_interval_seconds": state.get("poll_interval_seconds"),
360
+ "shutdown_timeout_seconds": state.get("shutdown_timeout_seconds"),
361
+ "phase": "stopped",
362
+ "reason": reason,
363
+ }
364
+ paths.worker_root.mkdir(parents=True, exist_ok=True)
365
+ paths.state_path.write_text(
366
+ json.dumps(
367
+ {key: value for key, value in stopped_state.items() if value is not None},
368
+ ensure_ascii=True,
369
+ sort_keys=True,
370
+ )
371
+ + "\n",
372
+ encoding="utf-8",
373
+ )
374
+ stopped_heartbeat = {
375
+ "owner": owner,
376
+ "worker_id": worker_id,
377
+ "token": token,
378
+ "replica_slot": stopped_state["replica_slot"],
379
+ "phase": "stopped",
380
+ "pid": pid,
381
+ "timestamp": _utc_now().isoformat(),
382
+ }
383
+ paths.heartbeat_path.write_text(
384
+ json.dumps(
385
+ {key: value for key, value in stopped_heartbeat.items() if value not in (None, "")},
386
+ ensure_ascii=True,
387
+ sort_keys=True,
388
+ )
389
+ + "\n",
390
+ encoding="utf-8",
391
+ )
392
+ if paths.lock_path.exists():
393
+ paths.lock_path.unlink()
394
+ if paths.pid_path.exists():
395
+ paths.pid_path.unlink()
396
+
397
+
398
+ def _write_worker_crash_loop_runtime_files(
399
+ plan: RuntimePlan,
400
+ spec: ManagedWorkerSpec,
401
+ *,
402
+ replica_slot: int = 1,
403
+ pid: int | None,
404
+ return_code: int | None,
405
+ restart_count: int,
406
+ failure_count: int,
407
+ failure_window_seconds: float,
408
+ log_path: Path,
409
+ ) -> None:
410
+ paths = managed_worker_paths(plan, spec, replica_slot=replica_slot)
411
+ state = _load_json(paths.state_path)
412
+ heartbeat = _load_json(paths.heartbeat_path)
413
+ worker_id = str(state.get("worker_id") or heartbeat.get("worker_id") or "").strip()
414
+ token = str(state.get("token") or heartbeat.get("token") or "").strip()
415
+ owner = str(state.get("owner") or heartbeat.get("owner") or spec.app_id).strip() or spec.app_id
416
+ updated_at = _utc_now().isoformat()
417
+ crash_state = {
418
+ "owner": owner,
419
+ "worker_id": worker_id,
420
+ "token": token,
421
+ "replica_slot": int(state.get("replica_slot") or heartbeat.get("replica_slot") or replica_slot),
422
+ "queue": state.get("queue") or spec.tool_name,
423
+ "kind": state.get("kind") or "default",
424
+ "ttl_seconds": state.get("ttl_seconds"),
425
+ "stale_seconds": state.get("stale_seconds"),
426
+ "max_wait_seconds": state.get("max_wait_seconds"),
427
+ "once": state.get("once"),
428
+ "concurrency": state.get("concurrency"),
429
+ "poll_interval_seconds": state.get("poll_interval_seconds"),
430
+ "shutdown_timeout_seconds": state.get("shutdown_timeout_seconds"),
431
+ "phase": "crash_loop_backoff",
432
+ "reason": f"crash_loop:exit:{return_code}",
433
+ "pid": pid,
434
+ "return_code": return_code,
435
+ "restart_count": int(restart_count),
436
+ "crash_loop_failures": int(failure_count),
437
+ "crash_loop_window_seconds": float(failure_window_seconds),
438
+ "log_path": str(log_path),
439
+ "updated_at": updated_at,
440
+ }
441
+ paths.worker_root.mkdir(parents=True, exist_ok=True)
442
+ paths.state_path.write_text(
443
+ json.dumps(
444
+ {key: value for key, value in crash_state.items() if value is not None},
445
+ ensure_ascii=True,
446
+ sort_keys=True,
447
+ )
448
+ + "\n",
449
+ encoding="utf-8",
450
+ )
451
+ crash_heartbeat = {
452
+ "owner": owner,
453
+ "worker_id": worker_id,
454
+ "token": token,
455
+ "replica_slot": crash_state["replica_slot"],
456
+ "phase": "crash_loop_backoff",
457
+ "pid": pid,
458
+ "timestamp": updated_at,
459
+ }
460
+ paths.heartbeat_path.write_text(
461
+ json.dumps(
462
+ {key: value for key, value in crash_heartbeat.items() if value not in (None, "")},
463
+ ensure_ascii=True,
464
+ sort_keys=True,
465
+ )
466
+ + "\n",
467
+ encoding="utf-8",
468
+ )
469
+ if paths.lock_path.exists():
470
+ paths.lock_path.unlink()
471
+ if paths.pid_path.exists():
472
+ paths.pid_path.unlink()
473
+
474
+
475
+ def resolve_project_pz_executable(project_dir: Path) -> str:
476
+ try:
477
+ return _resolve_project_pz_executable(project_dir)
478
+ except PzError as exc:
479
+ raise PzError(
480
+ code="PZ_FOUNDATION_MANAGED_WORKER_PZ_MISSING",
481
+ message=str(exc.message),
482
+ detail=exc.detail,
483
+ stage="worker",
484
+ owner="pz_foundation",
485
+ action="run_managed_workers",
486
+ ) from exc
487
+
488
+
489
+ def managed_worker_subprocess_env(
490
+ plan: RuntimePlan,
491
+ *,
492
+ workspace_dir: Path | None = None,
493
+ ) -> dict[str, str]:
494
+ env = build_project_pz_child_env(plan.project.project_dir, pz_env=_project_pz_env(plan))
495
+ if workspace_dir is not None:
496
+ env[_MANAGED_WORKER_WORKSPACE_ENV] = str(workspace_dir)
497
+ return env
498
+
499
+
500
+ def managed_worker_command(
501
+ plan: RuntimePlan,
502
+ spec: ManagedWorkerSpec,
503
+ *,
504
+ replica_slot: int = 1,
505
+ ) -> tuple[str, ...]:
506
+ project_dir = str(plan.project.project_dir.resolve())
507
+ pz_executable = resolve_project_pz_executable(plan.project.project_dir)
508
+ return (
509
+ pz_executable,
510
+ spec.app_id,
511
+ spec.tool_name,
512
+ "--project-dir",
513
+ project_dir,
514
+ "--replica-slot",
515
+ str(int(replica_slot)),
516
+ )
517
+
518
+
519
+ def build_managed_worker_status(
520
+ plan: RuntimePlan,
521
+ spec: ManagedWorkerSpec,
522
+ *,
523
+ replica_slot: int = 1,
524
+ target_count: int = 1,
525
+ ) -> ManagedWorkerStatus:
526
+ paths = managed_worker_paths(plan, spec, replica_slot=replica_slot)
527
+ state = _load_json(paths.state_path)
528
+ heartbeat = _load_json(paths.heartbeat_path)
529
+ phase = str(state.get("phase") or "missing").strip() or "missing"
530
+ pid_raw = heartbeat.get("pid")
531
+ pid = pid_raw if isinstance(pid_raw, int) else _read_pid(paths.pid_path)
532
+ running = paths.lock_path.exists() and _is_process_alive(pid)
533
+ if not running and phase in {"starting", "started", "stopping", "bound"}:
534
+ phase = "stopped"
535
+ raw_worker_id = state.get("worker_id") or heartbeat.get("worker_id")
536
+ worker_id = str(raw_worker_id).strip() if isinstance(raw_worker_id, str) and raw_worker_id.strip() else None
537
+ raw_heartbeat_at = heartbeat.get("timestamp")
538
+ last_heartbeat_at = str(raw_heartbeat_at).strip() if isinstance(raw_heartbeat_at, str) and raw_heartbeat_at.strip() else None
539
+ healthy = bool(running and phase not in {"missing", "stopped"})
540
+ return ManagedWorkerStatus(
541
+ name=_replica_name(spec, replica_slot),
542
+ app_id=spec.app_id,
543
+ tool_name=spec.tool_name,
544
+ extension_id=spec.extension_id,
545
+ owner_app=spec.owner_app,
546
+ auto_start=_auto_start_for_spec(plan, spec),
547
+ restart=spec.restart,
548
+ worker_kind=spec.worker_kind,
549
+ running=running,
550
+ healthy=healthy,
551
+ phase=phase,
552
+ pid=pid,
553
+ worker_id=worker_id,
554
+ replica_slot=int(replica_slot),
555
+ target_count=max(1, int(target_count)),
556
+ last_heartbeat_at=last_heartbeat_at,
557
+ state_path=str(paths.state_path),
558
+ heartbeat_path=str(paths.heartbeat_path),
559
+ lock_path=str(paths.lock_path),
560
+ command=managed_worker_command(plan, spec, replica_slot=replica_slot),
561
+ state=state,
562
+ )
563
+
564
+
565
+ def _select_managed_workers(
566
+ plan: RuntimePlan,
567
+ *,
568
+ names: tuple[str, ...] = (),
569
+ auto_start_only: bool = False,
570
+ ) -> tuple[ManagedWorkerSpec, ...]:
571
+ specs = discover_managed_workers(plan)
572
+ if auto_start_only:
573
+ specs = tuple(spec for spec in specs if _auto_start_for_spec(plan, spec))
574
+ if not names:
575
+ return specs
576
+ wanted = {name.strip() for name in names if name.strip()}
577
+ selected = tuple(
578
+ spec
579
+ for spec in specs
580
+ if spec.name in wanted or spec.extension_id in wanted or f"{spec.app_id}.{spec.tool_name}" in wanted
581
+ )
582
+ missing = sorted(
583
+ wanted
584
+ - {spec.name for spec in selected}
585
+ - {spec.extension_id for spec in selected}
586
+ - {f"{spec.app_id}.{spec.tool_name}" for spec in selected}
587
+ )
588
+ if missing:
589
+ raise PzError(
590
+ code="PZ_FOUNDATION_MANAGED_WORKER_NOT_FOUND",
591
+ message=f"Unknown managed worker selection: {', '.join(missing)}",
592
+ stage="worker",
593
+ owner="pz_foundation",
594
+ action="run_managed_workers",
595
+ )
596
+ return selected
597
+
598
+
599
+ def select_managed_worker_specs(
600
+ plan: RuntimePlan,
601
+ *,
602
+ names: tuple[str, ...] = (),
603
+ auto_start_only: bool = False,
604
+ ) -> tuple[ManagedWorkerSpec, ...]:
605
+ return _select_managed_workers(plan, names=names, auto_start_only=auto_start_only)
606
+
607
+
608
+ def list_managed_worker_statuses(
609
+ plan: RuntimePlan,
610
+ *,
611
+ names: tuple[str, ...] = (),
612
+ auto_start_only: bool = False,
613
+ ) -> tuple[ManagedWorkerStatus, ...]:
614
+ statuses: list[ManagedWorkerStatus] = []
615
+ for spec in _select_managed_workers(plan, names=names, auto_start_only=auto_start_only):
616
+ target_count = _target_count_for_spec(plan, spec)
617
+ for replica_slot in range(1, target_count + 1):
618
+ statuses.append(
619
+ build_managed_worker_status(
620
+ plan,
621
+ spec,
622
+ replica_slot=replica_slot,
623
+ target_count=target_count,
624
+ )
625
+ )
626
+ return tuple(statuses)
627
+
628
+
629
+ @dataclass
630
+ class _RunningProcess:
631
+ spec: ManagedWorkerSpec
632
+ replica_slot: int
633
+ process: subprocess.Popen[Any]
634
+ log_path: Path
635
+ log_handle: Any
636
+ restart_count: int = 0
637
+
638
+ @property
639
+ def pid(self) -> int:
640
+ return int(self.process.pid)
641
+
642
+
643
+ class ManagedWorkerSupervisor:
644
+ def __init__(
645
+ self,
646
+ plan: RuntimePlan,
647
+ *,
648
+ names: tuple[str, ...] = (),
649
+ auto_start_only: bool = True,
650
+ poll_interval_seconds: float = 1.0,
651
+ owner_pid: int | None = None,
652
+ popen_factory=subprocess.Popen,
653
+ ) -> None:
654
+ self.plan = plan
655
+ self.specs = _select_managed_workers(plan, names=names, auto_start_only=auto_start_only)
656
+ self.poll_interval_seconds = max(0.2, float(poll_interval_seconds))
657
+ self.owner_pid = int(owner_pid) if owner_pid is not None and int(owner_pid) > 0 else None
658
+ self._popen_factory = popen_factory
659
+ self._shutdown_requested = False
660
+ self._running: dict[str, _RunningProcess] = {}
661
+ self._restart_counts: dict[str, int] = {}
662
+ self._failure_history: dict[str, list[float]] = {}
663
+ self._restart_after: dict[str, float] = {}
664
+ self._crash_looped: dict[str, dict[str, Any]] = {}
665
+ self._crash_loop_policy = _managed_worker_crash_loop_policy(plan)
666
+ self._logger = get_logger(_MANAGED_WORKER_LOGGER_NAME)
667
+ self._manager_root = _managed_worker_root(self.plan)
668
+ self._lock_path = self._manager_root / "supervisor.lock"
669
+ self._state_path = self._manager_root / "supervisor.state.json"
670
+ self._lock_fd: Any = None
671
+ self._previous_handlers: dict[int, Any] = {}
672
+
673
+ def run(self) -> int:
674
+ if not self.specs:
675
+ self._write_state(phase="idle")
676
+ return 0
677
+ self._acquire_lock()
678
+ self._install_signal_handlers()
679
+ try:
680
+ for spec in self.specs:
681
+ self._ensure_replica(spec)
682
+ self._write_state(phase="running")
683
+ while not self._shutdown_requested:
684
+ if self.owner_pid is not None and not _is_process_alive(self.owner_pid):
685
+ self._shutdown_requested = True
686
+ break
687
+ self._poll_once()
688
+ time.sleep(self.poll_interval_seconds)
689
+ return 0
690
+ finally:
691
+ self._shutdown_all()
692
+ self._write_state(phase="stopped")
693
+ self._restore_signal_handlers()
694
+ self._release_lock()
695
+
696
+ def _install_signal_handlers(self) -> None:
697
+ def _handle_signal(_signum: int, _frame) -> None:
698
+ self._shutdown_requested = True
699
+
700
+ for signum in (signal.SIGINT, signal.SIGTERM):
701
+ try:
702
+ self._previous_handlers[signum] = signal.getsignal(signum)
703
+ signal.signal(signum, _handle_signal)
704
+ except (OSError, ValueError):
705
+ continue
706
+
707
+ def _restore_signal_handlers(self) -> None:
708
+ for signum, handler in self._previous_handlers.items():
709
+ try:
710
+ signal.signal(signum, handler)
711
+ except (OSError, ValueError):
712
+ continue
713
+ self._previous_handlers.clear()
714
+
715
+ def _acquire_lock(self) -> None:
716
+ self._manager_root.mkdir(parents=True, exist_ok=True)
717
+ self._lock_fd = self._lock_path.open("a+", encoding="utf-8")
718
+ try:
719
+ fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
720
+ except BlockingIOError as exc:
721
+ raise PzError(
722
+ code="PZ_FOUNDATION_MANAGED_WORKER_SUPERVISOR_LOCKED",
723
+ message=f"Managed worker supervisor already holds lock at `{self._lock_path}`.",
724
+ stage="worker",
725
+ owner="pz_foundation",
726
+ action="run_managed_workers",
727
+ ) from exc
728
+ self._lock_fd.seek(0)
729
+ self._lock_fd.truncate()
730
+ self._lock_fd.write(f"{os.getpid()}\n")
731
+ self._lock_fd.flush()
732
+
733
+ def _release_lock(self) -> None:
734
+ if self._lock_fd is None:
735
+ return
736
+ try:
737
+ fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_UN)
738
+ except Exception:
739
+ pass
740
+ self._lock_fd.close()
741
+ self._lock_fd = None
742
+ if self._lock_path.exists():
743
+ self._lock_path.unlink()
744
+
745
+ def _write_state(self, *, phase: str) -> None:
746
+ payload = {
747
+ "phase": phase,
748
+ "pid": os.getpid(),
749
+ "owner_pid": self.owner_pid,
750
+ "pz_env": _project_pz_env(self.plan),
751
+ "updated_at": _utc_now().isoformat(),
752
+ "worker_targets": _worker_targets(self.plan, self.specs),
753
+ "workers": [
754
+ {
755
+ "name": _replica_name(item.spec, item.replica_slot),
756
+ "app_id": item.spec.app_id,
757
+ "tool_name": item.spec.tool_name,
758
+ "replica_slot": item.replica_slot,
759
+ "pid": item.process.pid,
760
+ "restart_count": item.restart_count,
761
+ "log_path": str(item.log_path),
762
+ }
763
+ for item in self._running.values()
764
+ ],
765
+ "restart_after": {
766
+ key: datetime.fromtimestamp(value, UTC).isoformat()
767
+ for key, value in sorted(self._restart_after.items())
768
+ },
769
+ "crash_looped_workers": [
770
+ {**payload, "name": key}
771
+ for key, payload in sorted(self._crash_looped.items())
772
+ ],
773
+ }
774
+ self._manager_root.mkdir(parents=True, exist_ok=True)
775
+ self._state_path.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8")
776
+
777
+ def _ensure_replica(self, spec: ManagedWorkerSpec) -> None:
778
+ target_count = _target_count_for_spec(self.plan, spec)
779
+ for replica_slot in range(1, target_count + 1):
780
+ key = _replica_name(spec, replica_slot)
781
+ current = self._running.get(key)
782
+ if current is not None and current.process.poll() is None:
783
+ continue
784
+ if key in self._crash_looped:
785
+ continue
786
+ restart_after = self._restart_after.get(key)
787
+ if restart_after is not None and time.time() < restart_after:
788
+ continue
789
+ status = build_managed_worker_status(
790
+ self.plan,
791
+ spec,
792
+ replica_slot=replica_slot,
793
+ target_count=target_count,
794
+ )
795
+ if status.healthy:
796
+ continue
797
+ restart_count = self._restart_counts.get(key, current.restart_count if current is not None else 0)
798
+ self._restart_after.pop(key, None)
799
+ self._spawn(spec, replica_slot=replica_slot, restart_count=restart_count)
800
+
801
+ def _spawn(self, spec: ManagedWorkerSpec, *, replica_slot: int = 1, restart_count: int = 0) -> None:
802
+ logs_dir = _managed_worker_logs_dir(self.plan)
803
+ logs_dir.mkdir(parents=True, exist_ok=True)
804
+ log_path = logs_dir / f"{_replica_name(spec, replica_slot)}.log"
805
+ command = managed_worker_command(self.plan, spec, replica_slot=replica_slot)
806
+ log_handle = log_path.open("a", encoding="utf-8")
807
+ env = managed_worker_subprocess_env(
808
+ self.plan,
809
+ workspace_dir=_managed_worker_workspace_root(self.plan, spec, replica_slot=replica_slot),
810
+ )
811
+ process = self._popen_factory(
812
+ command,
813
+ cwd=str(self.plan.project.project_dir),
814
+ stdout=log_handle,
815
+ stderr=subprocess.STDOUT,
816
+ text=True,
817
+ env=env,
818
+ )
819
+ self._running[_replica_name(spec, replica_slot)] = _RunningProcess(
820
+ spec=spec,
821
+ replica_slot=replica_slot,
822
+ process=process,
823
+ log_path=log_path,
824
+ log_handle=log_handle,
825
+ restart_count=restart_count,
826
+ )
827
+ self._write_state(phase="running")
828
+
829
+ def _record_worker_exit(self, item: _RunningProcess, *, return_code: int) -> bool:
830
+ key = _replica_name(item.spec, item.replica_slot)
831
+ restart_count = item.restart_count + 1
832
+ self._restart_counts[key] = restart_count
833
+ now = time.time()
834
+ policy = self._crash_loop_policy
835
+ window_start = now - policy.failure_window_seconds
836
+ history = [value for value in self._failure_history.get(key, []) if value >= window_start]
837
+ history.append(now)
838
+ self._failure_history[key] = history
839
+ if len(history) >= policy.max_failures:
840
+ payload = {
841
+ "app_id": item.spec.app_id,
842
+ "tool_name": item.spec.tool_name,
843
+ "replica_slot": item.replica_slot,
844
+ "pid": item.process.pid,
845
+ "return_code": return_code,
846
+ "restart_count": restart_count,
847
+ "failure_count": len(history),
848
+ "failure_window_seconds": policy.failure_window_seconds,
849
+ "log_path": str(item.log_path),
850
+ }
851
+ self._crash_looped[key] = payload
852
+ self._restart_after.pop(key, None)
853
+ _write_worker_crash_loop_runtime_files(
854
+ self.plan,
855
+ item.spec,
856
+ replica_slot=item.replica_slot,
857
+ pid=item.process.pid,
858
+ return_code=return_code,
859
+ restart_count=restart_count,
860
+ failure_count=len(history),
861
+ failure_window_seconds=policy.failure_window_seconds,
862
+ log_path=item.log_path,
863
+ )
864
+ self._logger.error(
865
+ "managed worker stopped after crash loop name=%s app=%s tool=%s replica_slot=%s "
866
+ "pid=%s return_code=%s restart_count=%s failure_count=%s failure_window_seconds=%s log_path=%s",
867
+ key,
868
+ item.spec.app_id,
869
+ item.spec.tool_name,
870
+ item.replica_slot,
871
+ item.process.pid,
872
+ return_code,
873
+ restart_count,
874
+ len(history),
875
+ policy.failure_window_seconds,
876
+ item.log_path,
877
+ )
878
+ return False
879
+ backoff = min(
880
+ policy.max_backoff_seconds,
881
+ policy.base_backoff_seconds * (2 ** max(0, len(history) - 1)),
882
+ )
883
+ self._restart_after[key] = now + backoff
884
+ self._logger.warning(
885
+ "managed worker exited; restart scheduled name=%s app=%s tool=%s replica_slot=%s "
886
+ "pid=%s return_code=%s restart_count=%s failure_count=%s backoff_seconds=%s log_path=%s",
887
+ key,
888
+ item.spec.app_id,
889
+ item.spec.tool_name,
890
+ item.replica_slot,
891
+ item.process.pid,
892
+ return_code,
893
+ restart_count,
894
+ len(history),
895
+ backoff,
896
+ item.log_path,
897
+ )
898
+ return True
899
+
900
+ def _poll_once(self) -> None:
901
+ for spec_name, item in tuple(self._running.items()):
902
+ return_code = item.process.poll()
903
+ if return_code is None:
904
+ continue
905
+ try:
906
+ item.log_handle.close()
907
+ except Exception:
908
+ pass
909
+ del self._running[spec_name]
910
+ _reconcile_worker_runtime_files(
911
+ self.plan,
912
+ item.spec,
913
+ replica_slot=item.replica_slot,
914
+ pid=item.process.pid,
915
+ reason=f"exit:{return_code}",
916
+ )
917
+ if not self._shutdown_requested and item.spec.restart != "never":
918
+ self._record_worker_exit(item, return_code=int(return_code))
919
+ if not self._shutdown_requested:
920
+ for spec in self.specs:
921
+ self._ensure_replica(spec)
922
+ self._write_state(phase="running")
923
+
924
+ def _shutdown_all(self) -> None:
925
+ for item in self._running.values():
926
+ if item.process.poll() is None:
927
+ item.process.terminate()
928
+ deadline = time.time() + 10.0
929
+ for item in self._running.values():
930
+ remaining = max(0.1, deadline - time.time())
931
+ try:
932
+ item.process.wait(timeout=remaining)
933
+ except subprocess.TimeoutExpired:
934
+ item.process.kill()
935
+ item.process.wait(timeout=1.0)
936
+ _reconcile_worker_runtime_files(
937
+ self.plan,
938
+ item.spec,
939
+ replica_slot=item.replica_slot,
940
+ pid=item.process.pid,
941
+ reason="supervisor_shutdown",
942
+ )
943
+ try:
944
+ item.log_handle.close()
945
+ except Exception:
946
+ pass
947
+ self._running.clear()
948
+ self._restart_after.clear()
949
+
950
+
951
+ def managed_worker_status_payload(
952
+ plan: RuntimePlan,
953
+ *,
954
+ names: tuple[str, ...] = (),
955
+ auto_start_only: bool = False,
956
+ ) -> dict[str, Any]:
957
+ statuses = list_managed_worker_statuses(plan, names=names, auto_start_only=auto_start_only)
958
+ supervisor_pid = _read_supervisor_pid(plan)
959
+ supervisor_state = _load_json(_supervisor_state_path(plan))
960
+ return {
961
+ "status": "ok",
962
+ "code": "PZ_FOUNDATION_MANAGED_WORKERS_STATUS_OK",
963
+ "stage": "worker",
964
+ "owner": "pz_foundation",
965
+ "action": "managed_workers_status",
966
+ "project_dir": str(plan.project.project_dir),
967
+ "supervisor": {
968
+ "running": bool(supervisor_pid and _is_process_alive(supervisor_pid)),
969
+ "pid": supervisor_pid,
970
+ "phase": str(supervisor_state.get("phase") or "missing"),
971
+ "owner_pid": supervisor_state.get("owner_pid"),
972
+ "pz_env": str(supervisor_state.get("pz_env") or ""),
973
+ "worker_targets": supervisor_state.get("worker_targets") if isinstance(supervisor_state.get("worker_targets"), list) else [],
974
+ },
975
+ "workers": [asdict(item) for item in statuses],
976
+ }
977
+
978
+
979
+ def managed_worker_supervisor_signature(
980
+ plan: RuntimePlan,
981
+ *,
982
+ names: tuple[str, ...] = (),
983
+ auto_start_only: bool = False,
984
+ ) -> dict[str, Any]:
985
+ specs = _select_managed_workers(plan, names=names, auto_start_only=auto_start_only)
986
+ return {
987
+ "pz_env": _project_pz_env(plan),
988
+ "worker_targets": _worker_targets(plan, specs),
989
+ }
990
+
991
+
992
+ def _supervisor_runtime_root(plan: RuntimePlan) -> Path:
993
+ return _managed_worker_root(plan)
994
+
995
+
996
+ def _supervisor_lock_path(plan: RuntimePlan) -> Path:
997
+ return _supervisor_runtime_root(plan) / "supervisor.lock"
998
+
999
+
1000
+ def _supervisor_state_path(plan: RuntimePlan) -> Path:
1001
+ return _supervisor_runtime_root(plan) / "supervisor.state.json"
1002
+
1003
+
1004
+ def _read_supervisor_pid(plan: RuntimePlan) -> int | None:
1005
+ state = _load_json(_supervisor_state_path(plan))
1006
+ pid = state.get("pid")
1007
+ if isinstance(pid, int) and pid > 0:
1008
+ return pid
1009
+ lock_path = _supervisor_lock_path(plan)
1010
+ if not lock_path.is_file():
1011
+ return None
1012
+ raw = lock_path.read_text(encoding="utf-8").strip()
1013
+ try:
1014
+ pid = int(raw)
1015
+ except ValueError:
1016
+ return None
1017
+ return pid if pid > 0 else None
1018
+
1019
+
1020
+ def _terminate_pid(pid: int, *, force: bool = False) -> None:
1021
+ os.kill(pid, signal.SIGKILL if force else signal.SIGTERM)
1022
+
1023
+
1024
+ def stop_managed_workers(
1025
+ plan: RuntimePlan,
1026
+ *,
1027
+ timeout_seconds: float = 15.0,
1028
+ names: tuple[str, ...] = (),
1029
+ auto_start_only: bool = False,
1030
+ stop_supervisor: bool = True,
1031
+ ) -> dict[str, Any]:
1032
+ specs = select_managed_worker_specs(plan, names=names, auto_start_only=auto_start_only)
1033
+ state_path = _supervisor_state_path(plan)
1034
+ lock_path = _supervisor_lock_path(plan)
1035
+ worker_statuses = list_managed_worker_statuses(plan, names=names, auto_start_only=auto_start_only)
1036
+ worker_pids = [
1037
+ status.pid
1038
+ for status in worker_statuses
1039
+ if isinstance(status.pid, int) and status.pid > 0 and status.running
1040
+ ]
1041
+ supervisor_pid = _read_supervisor_pid(plan) if stop_supervisor else None
1042
+ signaled: list[int] = []
1043
+ if supervisor_pid is not None and _is_process_alive(supervisor_pid):
1044
+ _terminate_pid(supervisor_pid)
1045
+ signaled.append(supervisor_pid)
1046
+ for pid in worker_pids:
1047
+ if _is_process_alive(pid):
1048
+ _terminate_pid(pid)
1049
+ signaled.append(pid)
1050
+ deadline = time.time() + max(0.5, timeout_seconds)
1051
+ tracked = [pid for pid in {*(worker_pids), *([supervisor_pid] if supervisor_pid else []), *signaled} if pid]
1052
+ while time.time() < deadline and any(_is_process_alive(pid) for pid in tracked):
1053
+ time.sleep(0.1)
1054
+ forced: list[int] = []
1055
+ for pid in tracked:
1056
+ if _is_process_alive(pid):
1057
+ _terminate_pid(pid, force=True)
1058
+ forced.append(pid)
1059
+ for spec in specs:
1060
+ target_count = _target_count_for_spec(plan, spec)
1061
+ for replica_slot in range(1, target_count + 1):
1062
+ _reconcile_worker_runtime_files(
1063
+ plan,
1064
+ spec,
1065
+ replica_slot=replica_slot,
1066
+ pid=None,
1067
+ reason="managed_stop",
1068
+ )
1069
+ if stop_supervisor:
1070
+ _supervisor_runtime_root(plan).mkdir(parents=True, exist_ok=True)
1071
+ state_path.write_text(
1072
+ json.dumps(
1073
+ {
1074
+ "phase": "stopped",
1075
+ "pid": supervisor_pid,
1076
+ "updated_at": _utc_now().isoformat(),
1077
+ "pz_env": _project_pz_env(plan),
1078
+ "worker_targets": _worker_targets(plan, specs),
1079
+ "workers": [],
1080
+ },
1081
+ ensure_ascii=True,
1082
+ sort_keys=True,
1083
+ )
1084
+ + "\n",
1085
+ encoding="utf-8",
1086
+ )
1087
+ if lock_path.exists():
1088
+ lock_path.unlink()
1089
+ return {
1090
+ "status": "ok",
1091
+ "code": "PZ_FOUNDATION_MANAGED_WORKERS_STOPPED",
1092
+ "stage": "worker",
1093
+ "owner": "pz_foundation",
1094
+ "action": "managed_workers_stop",
1095
+ "project_dir": str(plan.project.project_dir),
1096
+ "supervisor_pid": supervisor_pid,
1097
+ "supervisor_stopped": bool(stop_supervisor),
1098
+ "signaled_pids": sorted(signaled),
1099
+ "forced_pids": sorted(forced),
1100
+ "workers": [spec.name for spec in specs],
1101
+ }
1102
+
1103
+
1104
+ __all__ = [
1105
+ "ManagedWorkerSpec",
1106
+ "ManagedWorkerStatus",
1107
+ "ManagedWorkerSupervisor",
1108
+ "build_managed_worker_status",
1109
+ "discover_managed_workers",
1110
+ "list_managed_worker_statuses",
1111
+ "managed_worker_command",
1112
+ "managed_worker_paths",
1113
+ "managed_worker_supervisor_signature",
1114
+ "managed_worker_status_payload",
1115
+ "select_managed_worker_specs",
1116
+ "stop_managed_workers",
1117
+ ]