iCode-TUI 0.29.1__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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,1820 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Workflow worker host: the standalone process that runs a user's workflow file.
4
+
5
+ Started as ``<interpreter> worker_host.py <sdk_dir>`` by the main process, on
6
+ any Python >= 3.9. This file is deliberately self-contained: standard library
7
+ only, no import of chrys, 3.9 syntax and runtime APIs only, because it has to
8
+ run inside a user's own interpreter. Its protocol constants are a copy of
9
+ ``chrys.service.workflows.protocol``; a contract test keeps the two in step.
10
+
11
+ Bootstrap order matters and is fixed:
12
+
13
+ 1. ``protocol_fd = dup(1)``: the protocol writer holds a private descriptor.
14
+ 2. ``dup2`` fd 1 and fd 2 onto a capture pipe drained into a bounded tail, so
15
+ ``os.write(1, ...)``, C extensions and grandchildren can never reach the
16
+ protocol channel.
17
+ 3. Replace ``sys.stdout``/``sys.stderr`` with a context-local capture that
18
+ attributes Python-level output to the running attempt.
19
+ 4. Prepend the injected SDK artifact to ``sys.path`` and import
20
+ ``chrys.workflows``; the hello frame reports whether that import resolved
21
+ to the artifact (a stale ``chrys`` in the user's environment fails here).
22
+ 5. Serve requests: one reader thread, one writer thread, an asyncio loop for
23
+ async bodies, a pool for sync bodies/offloads and a separate evaluation pool. Every attempt-bound message
24
+ carries an ``AttemptRef``; a message for a terminal attempt is dropped.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import codecs
31
+ import contextlib
32
+ import contextvars
33
+ import inspect
34
+ import io
35
+ import json
36
+ import linecache
37
+ import math
38
+ import os
39
+ import platform
40
+ import queue
41
+ import re
42
+ import sys
43
+ import threading
44
+ import time
45
+ import traceback
46
+ import types
47
+ import unicodedata
48
+ import warnings
49
+ from collections import deque
50
+ from collections.abc import Coroutine
51
+ from concurrent.futures import Future, ThreadPoolExecutor
52
+ from typing import Any, Callable, ClassVar, Deque, Dict, List, Optional, Set, Tuple, Union
53
+
54
+ PROTOCOL_VERSION = 2
55
+
56
+ METHOD_HELLO = "hello"
57
+ METHOD_LOAD = "load"
58
+ METHOD_RUN_PYTHON = "run_python"
59
+ METHOD_EVAL_OUTGOING = "eval_outgoing"
60
+ METHOD_EVAL_LOOP_UNTIL = "eval_loop_until"
61
+ METHOD_COMBINE = "combine"
62
+ METHOD_CANCEL = "cancel"
63
+ METHOD_NATIVE_OUTPUT = "native_output"
64
+ METHOD_SHUTDOWN = "shutdown"
65
+ METHOD_ASK = "ask"
66
+ METHOD_EMIT = "emit"
67
+
68
+ ERROR_USER_EXCEPTION = "user_exception"
69
+ ERROR_VALUE_NOT_SERIALIZABLE = "value_not_serializable"
70
+ ERROR_VALUE_TOO_LARGE = "value_too_large"
71
+ ERROR_PROTOCOL_LIMIT = "protocol_limit"
72
+ ERROR_ASK_UNAVAILABLE = "ask_unavailable"
73
+ ERROR_ATTEMPT_TERMINATED = "attempt_terminated"
74
+ ERROR_LOAD_FAILED = "load_failed"
75
+ ERROR_INVALID_PARAMS = "invalid_params"
76
+ ERROR_UNKNOWN_METHOD = "unknown_method"
77
+ ERROR_INTERNAL = "internal"
78
+
79
+ _MIB = 1024 * 1024
80
+
81
+ LIMITS = {
82
+ "max_frame_bytes": 16 * _MIB,
83
+ "max_payload_depth": 64,
84
+ "max_payload_items": 200_000,
85
+ "max_string_chars": 4 * _MIB,
86
+ "max_pending_requests": 256,
87
+ "max_run_frames": 1_000_000,
88
+ "max_run_bytes": 4 * 1024 * _MIB,
89
+ "max_join_retained_bytes": 12 * _MIB,
90
+ "emit_rate_per_s": 200,
91
+ "max_emits_per_attempt": 10_000,
92
+ "max_emit_bytes_per_attempt": 8 * _MIB,
93
+ "eval_deadline": 30.0, # seconds
94
+ "worker_thread_pool_size": 8,
95
+ "eval_thread_pool_size": 2,
96
+ "worker_leak_budget": 8,
97
+ "eval_leak_budget": 2,
98
+ "load_timeout": 60.0, # seconds
99
+ "hello_timeout": 15.0, # seconds
100
+ "shutdown_grace": 3.0, # seconds
101
+ "cancel_drain": 1.0, # seconds
102
+ "captured_output_bytes": 64 * 1024,
103
+ "native_output_tail_bytes": 64 * 1024,
104
+ }
105
+
106
+ SDK_EXPORTS = (
107
+ "Answer",
108
+ "BuilderScope",
109
+ "NodeContext",
110
+ "NodeHandle",
111
+ "Option",
112
+ "Question",
113
+ "Retry",
114
+ "SourceValue",
115
+ "Workflow",
116
+ "WorkflowBuilder",
117
+ "WorkflowValue",
118
+ )
119
+
120
+ MODULE_NAME = "__chrys_workflow__"
121
+ ENTRY_ENV = "CHRYS_WORKFLOW_ENTRY" # the entry path, for multiprocessing children of the host
122
+ _LOAD_PHASE = "load"
123
+ _CURRENT_ATTEMPT: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("chrys_workflow_attempt", default=None)
124
+
125
+
126
+ def _canonical(payload: Any) -> bytes:
127
+ # surrogatepass mirrors the protocol's frame codec: every str crosses byte for byte, values are checked upstream.
128
+ text = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
129
+ return text.encode("utf-8", "surrogatepass")
130
+
131
+
132
+ def _dumps(payload: Any) -> bytes:
133
+ return _canonical(payload) + b"\n"
134
+
135
+
136
+ def _clean(text: str) -> str:
137
+ """Diagnostic text (messages, tracebacks) must always fit a UTF-8 frame."""
138
+ return text.encode("utf-8", "replace").decode("utf-8")
139
+
140
+
141
+ def _answers_fit(answers: List[Any], count: int) -> bool:
142
+ """Whether an ask reply's ``answers`` has the shape the SDK builds ``Answer`` objects from."""
143
+ return len(answers) == count and all(
144
+ isinstance(answer, dict)
145
+ and isinstance(answer.get("selected"), list)
146
+ and all(isinstance(label, str) for label in answer["selected"])
147
+ and isinstance(answer.get("text"), str)
148
+ for answer in answers
149
+ )
150
+
151
+
152
+ def _diagnostic(text: str, *, tail: bool = False) -> str:
153
+ """Cap diagnostic text at captured_output_bytes characters so an envelope always fits a frame."""
154
+ text = _clean(text)
155
+ limit = int(LIMITS["captured_output_bytes"])
156
+ if len(text) <= limit:
157
+ return text
158
+ note = "... [" + str(len(text) - limit) + " chars dropped]"
159
+ return (note + "\n" + text[-limit:]) if tail else (text[:limit] + note) # a traceback keeps its end
160
+
161
+
162
+ def _verdict(result: Any, what: str) -> bool:
163
+ """Conditions are sync: an awaitable is a bug in the workflow file, never a truthy value."""
164
+ if inspect.isawaitable(result):
165
+ if inspect.iscoroutine(result):
166
+ result.close()
167
+ raise TypeError(what + " returned an awaitable; conditions must be sync functions.")
168
+ return bool(result)
169
+
170
+
171
+ class _Invalid(Exception):
172
+ """A request the host cannot act on; answered with ``invalid_params``."""
173
+
174
+
175
+ class _NotSerializable(Exception):
176
+ pass
177
+
178
+
179
+ class _TooLarge(Exception):
180
+ pass
181
+
182
+
183
+ class _AskError(Exception):
184
+ def __init__(self, code: str, message: str) -> None:
185
+ super().__init__(message)
186
+ self.code = code
187
+
188
+
189
+ class _Terminated(Exception):
190
+ """The attempt was cancelled earlier; answered with ``attempt_terminated``."""
191
+
192
+
193
+ class _EmitLimit(Exception):
194
+ pass
195
+
196
+
197
+ class _LoopExit(Exception):
198
+ """SystemExit or KeyboardInterrupt raised inside a body task.
199
+
200
+ asyncio lets those two escape the event loop itself, which would take the whole worker down with one
201
+ node; the body task re-raises them as this ordinary exception so the attempt fails like any other.
202
+ """
203
+
204
+ def __init__(self, exc: BaseException) -> None:
205
+ super().__init__(type(exc).__name__ + ": " + str(exc))
206
+ self.formatted = traceback.format_exc()
207
+
208
+
209
+ async def _awaited(awaitable: Any) -> Any:
210
+ return await awaitable
211
+
212
+
213
+ class _GuardedCoroutine(Coroutine[Any, Any, Any]):
214
+ """The coroutine a task drives, with SystemExit/KeyboardInterrupt turned into _LoopExit at every step.
215
+
216
+ A wrapper object rather than a wrapping coroutine: Task.throw() on a task cancelled before its first step
217
+ must reach the original coroutine and close it, exactly as the default task factory would.
218
+ """
219
+
220
+ def __init__(self, coro: Any) -> None:
221
+ self._coro = coro
222
+
223
+ def send(self, value: Any) -> Any:
224
+ return self._step(self._coro.send, value)
225
+
226
+ def throw(self, typ: Any, val: Any = None, tb: Any = None) -> Any:
227
+ args = (typ,) if val is None and tb is None else (typ, val, tb)
228
+ return self._step(self._coro.throw, *args)
229
+
230
+ def close(self) -> None:
231
+ self._coro.close()
232
+
233
+ def __await__(self) -> Any:
234
+ return self._coro.__await__()
235
+
236
+ def __getattr__(self, name: str) -> Any:
237
+ # cr_frame, cr_code and the names: asyncio reads them for task reprs and get_stack().
238
+ return getattr(self._coro, name)
239
+
240
+ @staticmethod
241
+ def _step(advance: Callable[..., Any], *args: Any) -> Any:
242
+ try:
243
+ return advance(*args)
244
+ except (SystemExit, KeyboardInterrupt) as exc:
245
+ raise _LoopExit(exc) from None
246
+
247
+
248
+ def _task_factory(loop: "asyncio.AbstractEventLoop", coro: Any, **kwargs: Any) -> "asyncio.Task[Any]":
249
+ """Wrap every task the loop creates in _GuardedCoroutine.
250
+
251
+ A child task (asyncio.gather, create_task, ensure_future) that raises SystemExit re-raises it out of
252
+ Task.__step, past run_forever, killing the worker; wrapping the coroutine turns it into an ordinary failure.
253
+ """
254
+ return asyncio.Task(_GuardedCoroutine(coro), loop=loop, **kwargs)
255
+
256
+
257
+ def _is_finalizer(task: "asyncio.Task[Any]") -> bool:
258
+ """Whether the task is an async generator's aclose(), scheduled by the loop when the generator was dropped."""
259
+ coro = task.get_coro()
260
+ inner = getattr(coro, "_coro", coro) # through the _GuardedCoroutine wrapper
261
+ return type(inner).__name__ == "async_generator_athrow"
262
+
263
+
264
+ # ---------------------------------------------------------------- values
265
+
266
+
267
+ def _value_from_wire(payload: Any, sdk: Any) -> Any:
268
+ if not isinstance(payload, dict) or not isinstance(payload.get("text"), str):
269
+ raise _Invalid("value must be an object with a text field.")
270
+ return sdk.WorkflowValue(text=payload["text"], data=payload.get("data"))
271
+
272
+
273
+ def _value_to_wire(value: Any) -> Dict[str, Any]:
274
+ return {"text": value.text, "data": value.data}
275
+
276
+
277
+ def _sources_from_wire(payload: Any, sdk: Any) -> List[Any]:
278
+ if not isinstance(payload, list):
279
+ raise _Invalid("sources must be a list.")
280
+ sources = []
281
+ for item in payload:
282
+ if not isinstance(item, dict):
283
+ raise _Invalid("source must be an object.")
284
+ sources.append(
285
+ sdk.SourceValue(
286
+ node_id=item["node_id"],
287
+ activation_id=item["activation_id"],
288
+ value=_value_from_wire(item["value"], sdk),
289
+ )
290
+ )
291
+ return sources
292
+
293
+
294
+ class _ShapeCounter:
295
+ def __init__(self) -> None:
296
+ self.items = 0
297
+
298
+
299
+ def _check_json(payload: Any, depth: int, counter: _ShapeCounter) -> None:
300
+ """Strict JSON plus the payload limits; raises _NotSerializable / _TooLarge."""
301
+ counter.items += 1
302
+ if counter.items > LIMITS["max_payload_items"]:
303
+ raise _TooLarge("data has more than max_payload_items members.")
304
+ if depth > LIMITS["max_payload_depth"]:
305
+ raise _TooLarge("data is nested deeper than max_payload_depth.")
306
+ if payload is None or isinstance(payload, (bool, int)):
307
+ return
308
+ if isinstance(payload, float):
309
+ if not math.isfinite(payload):
310
+ raise _NotSerializable("data contains a non-finite float.")
311
+ return
312
+ if isinstance(payload, str):
313
+ if len(payload) > LIMITS["max_string_chars"]:
314
+ raise _TooLarge("data contains a string longer than max_string_chars.")
315
+ _require_utf8(payload, "data contains a string")
316
+ return
317
+ if isinstance(payload, list):
318
+ for item in payload:
319
+ _check_json(item, depth + 1, counter)
320
+ return
321
+ if isinstance(payload, dict):
322
+ for key, item in payload.items():
323
+ if not isinstance(key, str):
324
+ raise _NotSerializable("data keys must be strings.")
325
+ _require_utf8(key, "data contains a key")
326
+ _check_json(item, depth + 1, counter)
327
+ return
328
+ raise _NotSerializable("data contains a non-JSON value: " + type(payload).__name__)
329
+
330
+
331
+ def _require_utf8(text: str, what: str) -> None:
332
+ try:
333
+ text.encode("utf-8")
334
+ except UnicodeEncodeError:
335
+ raise _NotSerializable(what + " that is not valid Unicode (lone surrogate).") from None
336
+
337
+
338
+ def _coerce_value(result: Any, sdk: Any, what: str) -> Any:
339
+ """Apply the return-value ABI: ``str`` is sugar for ``WorkflowValue(text=str)``."""
340
+ if isinstance(result, str):
341
+ result = sdk.WorkflowValue(text=result)
342
+ elif not isinstance(result, sdk.WorkflowValue):
343
+ raise _NotSerializable(what + " must return str or WorkflowValue, got " + type(result).__name__ + ".")
344
+ if not isinstance(result.text, str):
345
+ raise _NotSerializable(what + " returned a WorkflowValue whose text is not a str.")
346
+ if len(result.text) > LIMITS["max_string_chars"]:
347
+ raise _TooLarge(what + " returned text longer than max_string_chars.")
348
+ _require_utf8(result.text, what + " returned text")
349
+ _check_json(result.data, 0, _ShapeCounter())
350
+ return result
351
+
352
+
353
+ # ---------------------------------------------------------------- output capture
354
+
355
+
356
+ class _BoundedText:
357
+ """Text sink that keeps the first ``limit`` bytes and remembers it overflowed.
358
+
359
+ Bytes are decoded here, per sink, so a character split across two byte writes of one attempt
360
+ is reassembled by that attempt's own decoder and never completed with another attempt's bytes.
361
+ A byte sequence still open when text is written, or when the snapshot is taken, shows as the
362
+ replacement character where it was written; it is never completed by bytes written later.
363
+ """
364
+
365
+ def __init__(self, limit: int) -> None:
366
+ self._limit = limit
367
+ self._chunks: List[str] = []
368
+ self._size = 0
369
+ self.truncated = False
370
+ self._lock = threading.Lock()
371
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
372
+
373
+ def write(self, chunk: Union[str, bytes]) -> None:
374
+ with self._lock:
375
+ if self.truncated:
376
+ return
377
+ if isinstance(chunk, bytes):
378
+ self._append(self._decoder.decode(chunk))
379
+ else:
380
+ self._append(self._decoder.decode(b"", final=True) + chunk)
381
+
382
+ def _append(self, text: str) -> None:
383
+ if not text:
384
+ return
385
+ data = text.encode("utf-8", "replace")
386
+ if self._size + len(data) > self._limit:
387
+ self.truncated = True
388
+ data = data[: self._limit - self._size]
389
+ self._chunks.append(data.decode("utf-8", "ignore"))
390
+ self._size += len(data)
391
+
392
+ def snapshot(self) -> Dict[str, Any]:
393
+ with self._lock:
394
+ if not self.truncated:
395
+ self._append(self._decoder.decode(b"", final=True))
396
+ return {"text": "".join(self._chunks), "truncated": self.truncated}
397
+
398
+
399
+ class _NativeTail:
400
+ """Drains the fd 1/2 capture pipe into a bounded tail; never blocks a writer for long."""
401
+
402
+ def __init__(self, read_fd: int, limit: int) -> None:
403
+ self._fd = read_fd
404
+ self._limit = limit
405
+ self._tail = bytearray()
406
+ self._barrier = b""
407
+ self._drained: Optional[Callable[[], None]] = None
408
+ self.dropped = 0
409
+ self._lock = threading.Lock()
410
+ self._thread = threading.Thread(target=self._run, name="chrys-workflow-native-tail", daemon=True)
411
+
412
+ def start(self) -> None:
413
+ self._thread.start()
414
+
415
+ def _run(self) -> None:
416
+ while True:
417
+ try:
418
+ chunk = os.read(self._fd, 65536)
419
+ except OSError:
420
+ return
421
+ if not chunk:
422
+ return
423
+ with self._lock:
424
+ self._tail.extend(chunk)
425
+ if self._barrier:
426
+ offset = self._tail.find(self._barrier)
427
+ if offset >= 0:
428
+ del self._tail[offset : offset + len(self._barrier)]
429
+ self._barrier = b""
430
+ if self._drained is not None:
431
+ self._drained()
432
+ retained = self._limit + len(self._barrier)
433
+ if len(self._tail) > retained:
434
+ self.dropped += len(self._tail) - retained
435
+ del self._tail[: len(self._tail) - retained]
436
+
437
+ async def snapshot(self) -> Dict[str, Any]:
438
+ # A pipe barrier proves all previously written native bytes reached the reader,
439
+ # even when inherited write descriptors keep the pipe open in grandchildren.
440
+ loop = asyncio.get_running_loop()
441
+ drained = asyncio.Event()
442
+
443
+ def notify_drained() -> None:
444
+ loop.call_soon_threadsafe(drained.set)
445
+
446
+ barrier = b"\x00chrys-drain-" + os.urandom(32) + b"\x00"
447
+ with self._lock:
448
+ self._barrier = barrier
449
+ self._drained = notify_drained
450
+ view = memoryview(barrier)
451
+ while view:
452
+ view = view[os.write(2, view) :]
453
+ try:
454
+ await asyncio.wait_for(drained.wait(), 1.0)
455
+ finally:
456
+ with self._lock:
457
+ self._drained = None
458
+ with self._lock:
459
+ return {"text": bytes(self._tail).decode("utf-8", "replace"), "dropped_bytes": self.dropped}
460
+
461
+
462
+ class _CaptureStream(io.TextIOBase):
463
+ """``sys.stdout``/``sys.stderr`` replacement attributing writes to the current attempt.
464
+
465
+ ``fileno()`` is the descriptor the stream stands in for (already redirected to the native tail), so a
466
+ child given ``stdout=sys.stdout`` inherits the tail; ``buffer`` takes bytes into the same attribution.
467
+ """
468
+
469
+ def __init__(self, route: Callable[[Union[str, bytes]], None], fd: int) -> None:
470
+ super().__init__()
471
+ self._route = route
472
+ self._fd = fd
473
+ self.buffer = _CaptureBuffer(route, fd)
474
+
475
+ @property
476
+ def encoding(self) -> str: # type: ignore[override]
477
+ return "utf-8"
478
+
479
+ def writable(self) -> bool:
480
+ return True
481
+
482
+ def fileno(self) -> int:
483
+ return self._fd
484
+
485
+ def write(self, text: str) -> int:
486
+ if not isinstance(text, str):
487
+ raise TypeError("write() argument must be str")
488
+ self._route(text)
489
+ return len(text)
490
+
491
+ def flush(self) -> None:
492
+ return None
493
+
494
+
495
+ class _CaptureBuffer(io.BufferedIOBase):
496
+ """The ``buffer`` of a _CaptureStream: bytes routed to the same owner, decoded by that owner's sink."""
497
+
498
+ def __init__(self, route: Callable[[Union[str, bytes]], None], fd: int) -> None:
499
+ super().__init__()
500
+ self._route = route
501
+ self._fd = fd
502
+
503
+ def writable(self) -> bool:
504
+ return True
505
+
506
+ def fileno(self) -> int:
507
+ return self._fd
508
+
509
+ def write(self, data: Any) -> int:
510
+ view = memoryview(data)
511
+ self._route(view.tobytes())
512
+ return view.nbytes
513
+
514
+ def flush(self) -> None:
515
+ return None
516
+
517
+
518
+ # ---------------------------------------------------------------- attempts
519
+
520
+
521
+ class _Attempt:
522
+ def __init__(self, key: str, ref: Dict[str, Any], ref_key: str, request_id: int) -> None:
523
+ self.key = key
524
+ self.ref = ref
525
+ self.ref_key = ref_key
526
+ self.request_id = request_id
527
+ self.terminal = False
528
+ self.task: Optional[asyncio.Task] = None
529
+ self.work: Optional["Future[Any]"] = None # the pool entry; withdrawable while still queued
530
+ self.work_pool = "body"
531
+ self.offloads: List[Tuple["Future[Any]", str]] = [] # future and its actual executor pool
532
+ self.children: Set["asyncio.Task[Any]"] = set() # tasks created in the attempt's context, still pending
533
+ self.unwound: Optional["asyncio.Future[int]"] = None # the one unwind of those tasks (see _unwind)
534
+ self.leaked_by_pool = {"body": 0, "eval": 0}
535
+ self.stdout = _BoundedText(int(LIMITS["captured_output_bytes"]))
536
+ self.last_emit_ordinal = 0
537
+ self.emit_bytes = 0
538
+ self.emit_times: Deque[float] = deque()
539
+ self.limit_failure: Optional[str] = None
540
+
541
+
542
+ def _failure_of(exc: BaseException) -> Tuple[str, str, Optional[str]]:
543
+ if isinstance(exc, _NotSerializable):
544
+ return ERROR_VALUE_NOT_SERIALIZABLE, str(exc), None
545
+ if isinstance(exc, _TooLarge):
546
+ return ERROR_VALUE_TOO_LARGE, str(exc), None
547
+ if isinstance(exc, _LoopExit):
548
+ return ERROR_USER_EXCEPTION, str(exc), exc.formatted
549
+ return ERROR_USER_EXCEPTION, type(exc).__name__ + ": " + str(exc), traceback.format_exc()
550
+
551
+
552
+ def _ref_of(params: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
553
+ ref = params.get("ref")
554
+ if not isinstance(ref, dict):
555
+ raise _Invalid("ref must be an object.")
556
+ return ref, _ref_key(ref)
557
+
558
+
559
+ def _ref_key(ref: Any) -> str:
560
+ if not isinstance(ref, dict):
561
+ raise _Invalid("ref must be an object.")
562
+ try:
563
+ parts = (ref["run_id"], ref["node_id"], ref["activation_id"], ref["attempt"])
564
+ except KeyError as exc:
565
+ raise _Invalid("ref is missing " + repr(exc.args[0]) + ".") from exc
566
+ if not all(isinstance(part, str) and part for part in parts[:3]):
567
+ raise _Invalid("ref ids must be non-empty strings.")
568
+ if not isinstance(parts[3], int) or isinstance(parts[3], bool) or parts[3] < 1:
569
+ raise _Invalid("ref attempt must be an int >= 1.")
570
+ return "|".join((parts[0], parts[1], parts[2], str(parts[3])))
571
+
572
+
573
+ # ---------------------------------------------------------------- host
574
+
575
+
576
+ class _Pool(ThreadPoolExecutor):
577
+ """One bounded pool, with submission ownership retained for leak accounting.
578
+
579
+ The body pool is also the default executor for ``asyncio.to_thread``. Evaluation work uses a
580
+ separate pool, but any offloads it creates still belong to the executor they were submitted to.
581
+ """
582
+
583
+ def __init__(
584
+ self, max_workers: int, loop: asyncio.AbstractEventLoop, charge: Callable[["Future[Any]", str], None], kind: str
585
+ ) -> None:
586
+ super().__init__(max_workers=max_workers)
587
+ self._loop = loop
588
+ self._charge = charge
589
+ self.kind = kind
590
+
591
+ def submit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> "Future[Any]":
592
+ future = super().submit(self._on_thread, fn, *args, **kwargs)
593
+ self._charge(future, self.kind)
594
+ return future
595
+
596
+ def _on_thread(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
597
+ # Python 3.9 binds an asyncio primitive to the thread's current loop when it is built, so a workflow
598
+ # file's module-level Semaphore or Queue (built on a pool thread by the load) must see the loop that
599
+ # will run the async bodies. Set per task: a body's own asyncio.run() clears the thread's loop.
600
+ asyncio.set_event_loop(self._loop)
601
+ return fn(*args, **kwargs)
602
+
603
+
604
+ class Host:
605
+ def __init__(self, sdk_dir: str, input_fd: int, protocol_fd: int, diag_fd: int, native: _NativeTail) -> None:
606
+ self.sdk_dir = sdk_dir
607
+ self.pid = os.getpid()
608
+ self.input_fd = input_fd
609
+ self.protocol_fd = protocol_fd
610
+ self.diag_fd = diag_fd
611
+ self.native = native
612
+ self.loop = asyncio.new_event_loop()
613
+ self.loop.set_task_factory(self._new_task) # SystemExit inside any task fails the attempt, not the worker
614
+ self.pool = _Pool(int(LIMITS["worker_thread_pool_size"]), self.loop, self._charge, "body")
615
+ self.eval_pool = _Pool(int(LIMITS["eval_thread_pool_size"]), self.loop, self._charge, "eval")
616
+ self.loop.set_default_executor(self.pool) # asyncio.to_thread shares (and is bounded by) the budget
617
+ self.outbox: queue.Queue = queue.Queue()
618
+ self.writer = threading.Thread(target=self._write_loop, name="chrys-workflow-writer", daemon=True)
619
+ self.reader = threading.Thread(target=self._read_loop, name="chrys-workflow-reader", daemon=True)
620
+ self.sdk: Any = None
621
+ self.definition: Any = None
622
+ self.load_stdout = _BoundedText(int(LIMITS["captured_output_bytes"]))
623
+ self.attempts: Dict[str, _Attempt] = {}
624
+ self.charging: Dict[str, _Attempt] = {} # attempts whose leak verdict is not settled yet
625
+ self.settled_keys: Set[str] = set()
626
+ self.cancelled_refs: Set[str] = set()
627
+ self.cancelling: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {} # ref key -> the drain under way
628
+ self.emit_lock = threading.Lock()
629
+ self.next_id = 2
630
+ self.pending_asks: Dict[int, asyncio.Future] = {}
631
+
632
+ # -- wiring --------------------------------------------------------------
633
+
634
+ def send(self, frame: Dict[str, Any]) -> None:
635
+ self.outbox.put(_dumps(frame))
636
+
637
+ def _write_loop(self) -> None:
638
+ while True:
639
+ data = self.outbox.get()
640
+ if data is None:
641
+ return
642
+ view = memoryview(data)
643
+ try:
644
+ while view:
645
+ written = os.write(self.protocol_fd, view)
646
+ view = view[written:]
647
+ except OSError:
648
+ os._exit(1)
649
+
650
+ def _read_loop(self) -> None:
651
+ buffer = bytearray()
652
+ limit = LIMITS["max_frame_bytes"]
653
+ while True:
654
+ try:
655
+ chunk = os.read(self.input_fd, 65536)
656
+ except OSError:
657
+ chunk = b""
658
+ if not chunk:
659
+ self.loop.call_soon_threadsafe(self._on_eof)
660
+ return
661
+ buffer.extend(chunk)
662
+ while True:
663
+ cut = buffer.find(b"\n")
664
+ if cut < 0:
665
+ break
666
+ line = bytes(buffer[:cut])
667
+ del buffer[: cut + 1]
668
+ self.loop.call_soon_threadsafe(self._on_line, line)
669
+ if len(buffer) > limit:
670
+ self.loop.call_soon_threadsafe(self._fatal, "incoming frame exceeds max_frame_bytes")
671
+ return
672
+
673
+ def _fatal(self, message: str) -> None:
674
+ with contextlib.suppress(OSError):
675
+ os.write(self.diag_fd, ("chrys workflow host: " + message + "\n").encode("utf-8", "replace"))
676
+ os._exit(2)
677
+
678
+ def _on_eof(self) -> None:
679
+ # The main process is gone or closed the channel: nothing left to answer.
680
+ os._exit(0)
681
+
682
+ def _on_line(self, line: bytes) -> None:
683
+ try:
684
+ frame = json.loads(line.decode("utf-8", "surrogatepass"))
685
+ except (UnicodeDecodeError, ValueError):
686
+ self._fatal("incoming frame is not UTF-8 JSON")
687
+ return
688
+ if not isinstance(frame, dict):
689
+ self._fatal("incoming frame is not an object")
690
+ return
691
+ if "method" in frame:
692
+ request_id = frame.get("id")
693
+ if not isinstance(request_id, int) or isinstance(request_id, bool):
694
+ self._fatal("incoming request has no id")
695
+ return
696
+ params = frame.get("params")
697
+ if not isinstance(params, dict):
698
+ self.send(_error(request_id, ERROR_INVALID_PARAMS, "params must be an object."))
699
+ return
700
+ self.loop.create_task(self._serve(request_id, frame["method"], params))
701
+ return
702
+ future = self.pending_asks.pop(frame.get("id"), None)
703
+ if future is not None and not future.done():
704
+ future.set_result(frame)
705
+
706
+ async def _serve(self, request_id: int, method: str, params: Dict[str, Any]) -> None:
707
+ handler = self._handlers.get(method)
708
+ if handler is None:
709
+ self.send(_error(request_id, ERROR_UNKNOWN_METHOD, "unknown method " + repr(method) + "."))
710
+ return
711
+ try:
712
+ result = await handler(self, request_id, params)
713
+ except _Invalid as exc:
714
+ self.send(_error(request_id, ERROR_INVALID_PARAMS, str(exc)))
715
+ except _Terminated as exc:
716
+ self.send(_error(request_id, ERROR_ATTEMPT_TERMINATED, str(exc)))
717
+ except Exception as exc:
718
+ data = {"traceback": _diagnostic(traceback.format_exc(), tail=True)}
719
+ self.send(_error(request_id, ERROR_INTERNAL, _diagnostic("host failure: " + repr(exc)), data))
720
+ else:
721
+ if result is not None:
722
+ self._respond(request_id, result)
723
+
724
+ def _respond(self, request_id: int, result: Dict[str, Any]) -> None:
725
+ data = _dumps({"id": request_id, "result": result})
726
+ if len(data) > LIMITS["max_frame_bytes"]:
727
+ self.send(_error(request_id, ERROR_VALUE_TOO_LARGE, "result frame exceeds max_frame_bytes."))
728
+ return
729
+ self.outbox.put(data)
730
+
731
+ # -- output routing --------------------------------------------------------
732
+
733
+ def _route_output(self, chunk: Union[str, bytes]) -> None:
734
+ if os.getpid() != self.pid:
735
+ # A forked child writes into its own copy of the capture sinks, which nobody reads.
736
+ self._write_native(chunk)
737
+ return
738
+ key = _CURRENT_ATTEMPT.get()
739
+ if key == _LOAD_PHASE:
740
+ self.load_stdout.write(chunk)
741
+ return
742
+ attempt = self.attempts.get(key) if key is not None else None
743
+ if attempt is not None and not attempt.terminal:
744
+ attempt.stdout.write(chunk)
745
+ return
746
+ if key is not None:
747
+ # A terminal attempt (cancelled, timed out) keeps writing: dropped.
748
+ return
749
+ self._write_native(chunk) # nobody owns this write
750
+
751
+ @staticmethod
752
+ def _write_native(chunk: Union[str, bytes]) -> None:
753
+ """Write through fd 1, which joins the run-level native tail."""
754
+ with contextlib.suppress(OSError):
755
+ os.write(1, chunk if isinstance(chunk, bytes) else chunk.encode("utf-8", "replace"))
756
+
757
+ # -- handlers ------------------------------------------------------------------
758
+
759
+ async def _load(self, request_id: int, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
760
+ if self.definition is not None:
761
+ raise _Invalid("workflow already loaded.")
762
+ source, filename, workspace = params.get("source"), params.get("filename"), params.get("workspace")
763
+ if not isinstance(source, str) or not isinstance(filename, str) or not isinstance(workspace, str):
764
+ raise _Invalid("load needs source, filename and workspace strings.")
765
+ bytecode_cache, package_dir = params.get("bytecode_cache"), params.get("package_dir")
766
+ if not isinstance(bytecode_cache, str) or not isinstance(package_dir, (str, type(None))):
767
+ raise _Invalid("load needs a bytecode_cache string and an optional package_dir string.")
768
+ diagnose, precompile = params.get("diagnose", False), params.get("precompile", [])
769
+ if not isinstance(diagnose, bool) or not (
770
+ isinstance(precompile, list) and all(isinstance(path, str) for path in precompile)
771
+ ):
772
+ raise _Invalid("load takes an optional diagnose boolean and an optional precompile list of strings.")
773
+ problem = _bytecode_cache_problem(bytecode_cache, package_dir)
774
+ if problem is not None:
775
+ self.send(_error(request_id, ERROR_LOAD_FAILED, problem, {"stdout": self.load_stdout.snapshot()}))
776
+ return None
777
+ context = contextvars.copy_context()
778
+ context.run(_CURRENT_ATTEMPT.set, _LOAD_PHASE)
779
+ try:
780
+ os.chdir(workspace)
781
+ except OSError as exc:
782
+ raise _Invalid("workspace is not usable: " + str(exc)) from exc
783
+ # Before the workflow's folder joins sys.path: a file of it named like a stdlib module must not shadow it here.
784
+ places = _Places(filename, self.sdk_dir) if diagnose else None
785
+ sys.path.insert(0, os.path.dirname(filename))
786
+ os.environ[ENTRY_ENV] = filename
787
+ if places is not None:
788
+ broken = await self.loop.run_in_executor(self.pool, _precompile, source, precompile, places)
789
+ if broken:
790
+ data = {"traceback": "", "stdout": self.load_stdout.snapshot()}
791
+ data.update(_diagnostics_data(broken))
792
+ self.send(_error(request_id, ERROR_LOAD_FAILED, broken[0]["message"], data))
793
+ return None
794
+ module = _entry_module(filename)
795
+
796
+ def run_module() -> None:
797
+ _exec_entry(source, filename, module)
798
+
799
+ try:
800
+ await self.loop.run_in_executor(self.pool, context.run, run_module)
801
+ except BaseException as exc:
802
+ data: Dict[str, Any] = {
803
+ "traceback": _diagnostic(traceback.format_exc(), tail=True),
804
+ "stdout": self.load_stdout.snapshot(),
805
+ }
806
+ to_dict = getattr(exc, "to_dict", None)
807
+ if isinstance(exc, self.sdk.WorkflowValidationError) and callable(to_dict):
808
+ data["validation"] = {
809
+ field: _diagnostic(text) if isinstance(text, str) else text
810
+ for field, text in to_dict().items()
811
+ if field in ("message", "location")
812
+ }
813
+ message = _diagnostic(type(exc).__name__ + ": " + str(exc))
814
+ if places is None:
815
+ self.send(_error(request_id, ERROR_LOAD_FAILED, message, data))
816
+ return None
817
+ data["traceback"] = _diagnostic(_user_traceback(exc, places), tail=True)
818
+ data.update(_diagnostics_data([_failure_diagnostic(exc, places, self.sdk.WorkflowValidationError)]))
819
+ self.send(_error(request_id, ERROR_LOAD_FAILED, message, data))
820
+ return None
821
+ found = module.__dict__.get("workflow", _MISSING)
822
+ if not isinstance(found, self.sdk.Workflow):
823
+ message = "the workflow file must bind a module-level `workflow` to the result of build()."
824
+ data = {"stdout": self.load_stdout.snapshot()}
825
+ if diagnose:
826
+ data.update(_diagnostics_data([_missing_workflow_diagnostic(found, filename)]))
827
+ self.send(_error(request_id, ERROR_LOAD_FAILED, message, data))
828
+ return None
829
+ self.definition = found.definition
830
+ result = {"manifest": found.manifest(), "stdout": self.load_stdout.snapshot()}
831
+ if diagnose:
832
+ return _with_sites(request_id, result, _declaration_sites(found.definition), int(LIMITS["max_frame_bytes"]))
833
+ return result
834
+
835
+ async def _run_python(self, request_id: int, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
836
+ ref, ref_key = _ref_of(params)
837
+ node = self._node(ref, "python")
838
+ attempt = self._begin(ref, ref_key, request_id, "body")
839
+ value = _value_from_wire(params.get("value"), self.sdk)
840
+ ctx = self.sdk.NodeContext(
841
+ emit=lambda text: self._emit(attempt, text),
842
+ ask=lambda questions: self._ask(attempt, questions),
843
+ )
844
+ args = (value,) if node.fn_arity == 1 else (value, ctx)
845
+ context = contextvars.copy_context()
846
+ context.run(_CURRENT_ATTEMPT.set, attempt.key)
847
+ result: Any = None
848
+ failure: Optional[Tuple[str, str, Optional[str]]] = None
849
+ try:
850
+ if node.fn_is_async:
851
+ attempt.task = context.run(self.loop.create_task, node.fn(*args)) # wrapped by _task_factory
852
+ result = await attempt.task
853
+ else:
854
+ result = await self._submit(attempt, context.run, node.fn, *args)
855
+ if inspect.isawaitable(result):
856
+ if attempt.terminal:
857
+ # Cancelled while the thread ran: its late awaitable must not start now.
858
+ if inspect.iscoroutine(result):
859
+ result.close()
860
+ return None
861
+ # A sync wrapper around an async body: the awaitable is awaited on the loop.
862
+ attempt.task = context.run(self.loop.create_task, _awaited(result))
863
+ result = await attempt.task
864
+ result = _coerce_value(result, self.sdk, "python node " + repr(node.node_id))
865
+ except asyncio.CancelledError:
866
+ failure = (ERROR_USER_EXCEPTION, "body was cancelled from inside user code.", None)
867
+ except _AskError as exc:
868
+ failure = (exc.code, str(exc), None)
869
+ except _EmitLimit as exc:
870
+ failure = (ERROR_PROTOCOL_LIMIT, str(exc), None)
871
+ except BaseException as exc:
872
+ failure = _failure_of(exc)
873
+ if attempt.terminal:
874
+ return None
875
+ stuck = await self._unwind(attempt) # the body's tasks end with it, charged while they unwind
876
+ if attempt.terminal: # a cancel landed meanwhile: its drain settles the attempt and counts them
877
+ return None
878
+ attempt.leaked_by_pool["body"] += stuck
879
+ if failure is not None:
880
+ self._finish(attempt, *failure)
881
+ elif attempt.limit_failure is not None:
882
+ self._finish(attempt, ERROR_PROTOCOL_LIMIT, attempt.limit_failure)
883
+ else:
884
+ self._finish_ok(attempt, {"value": _value_to_wire(result)}, barrier=True)
885
+ return None
886
+
887
+ async def _eval_outgoing(self, request_id: int, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
888
+ self._require_loaded()
889
+ ref, ref_key = _ref_of(params)
890
+ edge_ids = params.get("edge_ids")
891
+ if not isinstance(edge_ids, list) or not all(isinstance(edge_id, str) for edge_id in edge_ids):
892
+ raise _Invalid("edge_ids must be a list of strings.")
893
+ edges = []
894
+ for edge_id in edge_ids:
895
+ edge = self.definition.edges.get(edge_id)
896
+ if edge is None:
897
+ raise _Invalid("unknown edge " + repr(edge_id) + ".")
898
+ edges.append(edge)
899
+ value = _value_from_wire(params.get("value"), self.sdk)
900
+
901
+ def evaluate() -> Dict[str, bool]:
902
+ decisions: Dict[str, bool] = {}
903
+ cases: Dict[str, List[Any]] = {}
904
+ for edge in edges:
905
+ if edge.switch_group is None:
906
+ decisions[edge.edge_id] = (
907
+ _verdict(edge.predicate(value), "edge condition") if edge.predicate is not None else False
908
+ )
909
+ elif edge.switch_default:
910
+ decisions[edge.edge_id] = False # the scheduler opens the default when no case hit
911
+ else:
912
+ cases.setdefault(edge.switch_group, []).append(edge)
913
+ for group in cases.values():
914
+ # Switch cases are evaluated in declared order and stop at the first hit.
915
+ matched = False
916
+ for edge in sorted(group, key=lambda edge: edge.switch_position or 0):
917
+ hit = not matched and _verdict(edge.predicate(value), "switch case")
918
+ decisions[edge.edge_id] = hit
919
+ matched = matched or hit
920
+ return decisions
921
+
922
+ return await self._evaluate(
923
+ ref, ref_key, request_id, "outgoing", evaluate, lambda decisions: {"decisions": decisions}
924
+ )
925
+
926
+ async def _eval_loop_until(self, request_id: int, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
927
+ ref, ref_key = _ref_of(params)
928
+ node = self._node(ref, "loop")
929
+ iteration = params.get("iteration")
930
+ if not isinstance(iteration, int) or isinstance(iteration, bool) or iteration < 1:
931
+ raise _Invalid("iteration must be an int >= 1.")
932
+ value = _value_from_wire(params.get("value"), self.sdk)
933
+ phase = "until#" + str(iteration) # the loop keeps its ref across iterations
934
+ return await self._evaluate(
935
+ ref,
936
+ ref_key,
937
+ request_id,
938
+ phase,
939
+ lambda: _verdict(node.loop.until(value), "loop until"),
940
+ lambda verdict: {"verdict": verdict},
941
+ )
942
+
943
+ async def _combine(self, request_id: int, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
944
+ ref, ref_key = _ref_of(params)
945
+ node = self._node(ref, "join")
946
+ if node.fn is None:
947
+ raise _Invalid("join " + repr(node.node_id) + " has no user combine.")
948
+ sources = _sources_from_wire(params.get("sources"), self.sdk)
949
+ what = "join combine " + repr(node.node_id)
950
+ return await self._evaluate(
951
+ ref,
952
+ ref_key,
953
+ request_id,
954
+ "combine",
955
+ lambda: _coerce_value(node.fn(sources), self.sdk, what),
956
+ lambda value: {"value": _value_to_wire(value)},
957
+ )
958
+
959
+ async def _evaluate(
960
+ self,
961
+ ref: Dict[str, Any],
962
+ ref_key: str,
963
+ request_id: int,
964
+ phase: str,
965
+ compute: Callable[[], Any],
966
+ shape: Callable[[Any], Dict[str, Any]],
967
+ ) -> Optional[Dict[str, Any]]:
968
+ attempt = self._begin(ref, ref_key, request_id, phase)
969
+ context = contextvars.copy_context()
970
+ context.run(_CURRENT_ATTEMPT.set, attempt.key)
971
+ try:
972
+ result = await self._submit(attempt, context.run, compute, pool=self.eval_pool)
973
+ except BaseException as exc:
974
+ self._finish(attempt, *_failure_of(exc))
975
+ return None
976
+ if attempt.terminal:
977
+ return None
978
+ self._finish_ok(attempt, shape(result))
979
+ return None
980
+
981
+ async def _cancel(self, request_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
982
+ key = _ref_key(params.get("ref"))
983
+ self.cancelled_refs.add(key)
984
+ drain = self.cancelling.get(key)
985
+ if drain is None:
986
+ # Overlapping cancels of one ref (a direct cancel and the node timeout) share one drain and one verdict.
987
+ drain = self.loop.create_task(self._drain(key))
988
+ self.cancelling[key] = drain
989
+ drain.add_done_callback(lambda _task: self.cancelling.pop(key, None))
990
+ return await drain
991
+
992
+ async def _drain(self, key: str) -> Dict[str, Any]:
993
+ live = [attempt for attempt in self.attempts.values() if attempt.ref_key == key]
994
+ message = "attempt cancelled by the main process."
995
+ draining: List[Tuple[_Attempt, "asyncio.Task[Any]", int]] = []
996
+ for attempt in live:
997
+ if attempt.task is not None:
998
+ # Fenced now, answered later: the body's envelope and the ack both wait for its
999
+ # cleanup to unwind, so nobody moves on while the finally blocks still run.
1000
+ draining.append((attempt, attempt.task, self._settle(attempt)))
1001
+ attempt.task.cancel()
1002
+ continue
1003
+ self._finish(attempt, ERROR_ATTEMPT_TERMINATED, message) # withdraws a queued body, counts a running one
1004
+ if draining:
1005
+ _done, pending = await asyncio.wait([task for _, task, _ in draining], timeout=LIMITS["cancel_drain"])
1006
+ for attempt, task, ordinal in draining:
1007
+ # A coroutine that keeps swallowing the cancellation is as leaked as a stuck thread. The body's
1008
+ # tasks are unwound after it, so a finally block that awaits its own child still can.
1009
+ stuck = int(task in pending) + await self._unwind(attempt)
1010
+ attempt.leaked_by_pool["body"] += stuck
1011
+ self._send_failure(attempt, ordinal, ERROR_ATTEMPT_TERMINATED, message, None)
1012
+ return {
1013
+ "cancelled": bool(live),
1014
+ "leaked_threads_by_pool": {
1015
+ pool: sum(attempt.leaked_by_pool[pool] for attempt in live) for pool in ("body", "eval")
1016
+ },
1017
+ }
1018
+
1019
+ async def _native_output(self, request_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
1020
+ return await self.native.snapshot()
1021
+
1022
+ async def _shutdown(self, request_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
1023
+ self.loop.call_soon(self.loop.stop)
1024
+ return {"ok": True}
1025
+
1026
+ _handlers: ClassVar[Dict[str, Callable[..., Any]]] = {}
1027
+
1028
+ # -- attempt lifecycle -----------------------------------------------------
1029
+
1030
+ def _require_loaded(self) -> None:
1031
+ if self.definition is None:
1032
+ raise _Invalid("no workflow loaded.")
1033
+
1034
+ def _node(self, ref: Dict[str, Any], kind: str) -> Any:
1035
+ self._require_loaded()
1036
+ node = self.definition.nodes.get(ref["node_id"])
1037
+ if node is None or node.kind != kind:
1038
+ raise _Invalid("ref does not name a " + kind + " node.")
1039
+ return node
1040
+
1041
+ def _begin(self, ref: Dict[str, Any], ref_key: str, request_id: int, phase: str) -> _Attempt:
1042
+ if ref_key in self.cancelled_refs:
1043
+ raise _Terminated("attempt " + ref_key + " was cancelled.")
1044
+ key = phase + "|" + ref_key
1045
+ if key in self.attempts or key in self.settled_keys:
1046
+ raise _Invalid("attempt " + key + " was already started.")
1047
+ attempt = _Attempt(key, ref, ref_key, request_id)
1048
+ self.attempts[key] = attempt
1049
+ self.charging[key] = attempt
1050
+ return attempt
1051
+
1052
+ def _submit(
1053
+ self, attempt: _Attempt, fn: Callable[..., Any], *args: Any, pool: Optional[_Pool] = None
1054
+ ) -> "asyncio.Future[Any]":
1055
+ executor = self.pool if pool is None else pool
1056
+ attempt.work_pool = executor.kind
1057
+ attempt.work = executor.submit(fn, *args)
1058
+ return asyncio.wrap_future(attempt.work, loop=self.loop)
1059
+
1060
+ def _new_task(self, loop: "asyncio.AbstractEventLoop", coro: Any, **kwargs: Any) -> "asyncio.Task[Any]":
1061
+ """A task created while an attempt is current is its child until it finishes (see _withdraw)."""
1062
+ task = _task_factory(loop, coro, **kwargs)
1063
+ key = _CURRENT_ATTEMPT.get()
1064
+ attempt = self.charging.get(key) if key is not None else None
1065
+ if attempt is not None:
1066
+ attempt.children.add(task)
1067
+ task.add_done_callback(attempt.children.discard)
1068
+ return task
1069
+
1070
+ def _charge(self, future: "Future[Any]", pool: str) -> None:
1071
+ """Pool work submitted while an attempt is current (an async body offloading) is that attempt's thread.
1072
+
1073
+ Charged until the verdict is settled, so an offload made while a cancelled body unwinds still counts.
1074
+ """
1075
+ key = _CURRENT_ATTEMPT.get()
1076
+ attempt = self.charging.get(key) if key is not None else None
1077
+ if attempt is not None:
1078
+ attempt.offloads.append((future, pool))
1079
+
1080
+ async def _unwind(self, attempt: _Attempt) -> int:
1081
+ """Cancel the attempt's surviving child tasks (a failed gather's siblings, a fire-and-forget task) and wait
1082
+ for them to unwind, bounded like the body's own drain; returns how many would not.
1083
+
1084
+ The attempt is still charged meanwhile, so a child's cleanup offload (``finally: await to_thread(...)``)
1085
+ is counted by the verdict like the body's own would be. A cancel that lands during the unwind joins it:
1086
+ a second cancellation would cut the child's finally block short. Tasks a finally block starts meanwhile
1087
+ are unwound in turn, within the same allowance. An async generator's aclose() is cleanup already under
1088
+ way: it gets the allowance instead of a cancel, as it would under plain asyncio.
1089
+ """
1090
+ if attempt.unwound is not None:
1091
+ return await attempt.unwound
1092
+ attempt.unwound = self.loop.create_future()
1093
+ deadline = self.loop.time() + float(LIMITS["cancel_drain"])
1094
+ stuck = 0
1095
+ try:
1096
+ while True:
1097
+ children = [task for task in attempt.children if task is not attempt.task and not task.done()]
1098
+ if not children:
1099
+ break
1100
+ for task in children:
1101
+ if not _is_finalizer(task):
1102
+ task.cancel()
1103
+ _done, pending = await asyncio.wait(children, timeout=max(deadline - self.loop.time(), 0.0))
1104
+ if pending:
1105
+ stuck = len(pending)
1106
+ break
1107
+ finally:
1108
+ attempt.unwound.set_result(stuck)
1109
+ return stuck
1110
+
1111
+ def _withdraw(self, attempt: _Attempt) -> None:
1112
+ """Withdraw the attempt's queued pool work; the entries already on a thread stay there and are counted.
1113
+
1114
+ A child task never unwound (an evaluation's, see _unwind) is cancelled here: nothing of a settled
1115
+ attempt runs on. The ones an unwind gave up on are left alone; a second cancellation would only cut
1116
+ their cleanup short.
1117
+ """
1118
+ self.charging.pop(attempt.key, None)
1119
+ if attempt.unwound is None:
1120
+ for task in list(attempt.children):
1121
+ if task is not attempt.task and not _is_finalizer(task):
1122
+ task.cancel()
1123
+ futures = list(attempt.offloads)
1124
+ if attempt.work is not None:
1125
+ futures.append((attempt.work, attempt.work_pool))
1126
+ attempt.work, attempt.offloads = None, []
1127
+ for future, pool in futures:
1128
+ if not future.cancel() and not future.done():
1129
+ attempt.leaked_by_pool[pool] += 1
1130
+
1131
+ def _settle(self, attempt: _Attempt) -> int:
1132
+ """Close the attempt to new emits; returns the ordinal of its last queued emit."""
1133
+ # Under the emit lock: an emit that took an ordinal has queued its frame, and every
1134
+ # later emit sees the attempt terminal, so the envelope queued next covers exactly this ordinal.
1135
+ with self.emit_lock:
1136
+ attempt.terminal = True
1137
+ ordinal = attempt.last_emit_ordinal
1138
+ self.attempts.pop(attempt.key, None)
1139
+ self.settled_keys.add(attempt.key)
1140
+ return ordinal
1141
+
1142
+ def _finish(self, attempt: _Attempt, code: str, message: str, tb: Optional[str] = None) -> None:
1143
+ """Send the terminal error envelope once; later completions of this attempt are dropped."""
1144
+ if attempt.terminal:
1145
+ return
1146
+ self._send_failure(attempt, self._settle(attempt), code, message, tb)
1147
+
1148
+ def _send_failure(self, attempt: _Attempt, ordinal: int, code: str, message: str, tb: Optional[str]) -> None:
1149
+ self._withdraw(attempt)
1150
+ data: Dict[str, Any] = {
1151
+ "stdout": attempt.stdout.snapshot(),
1152
+ "last_emit_ordinal": ordinal,
1153
+ "leaked_threads_by_pool": dict(attempt.leaked_by_pool),
1154
+ }
1155
+ if tb is not None:
1156
+ data["traceback"] = _diagnostic(tb, tail=True)
1157
+ self.send(_error(attempt.request_id, code, _diagnostic(message), data))
1158
+
1159
+ def _finish_ok(self, attempt: _Attempt, result: Dict[str, Any], *, barrier: bool = False) -> None:
1160
+ """Send the success envelope, or the same-shaped failure when it cannot be framed.
1161
+
1162
+ Every envelope carries the attempt's leak verdict (a predicate or combine can leave an offload on a
1163
+ thread too); every phase retains its captured stdout. ``barrier`` adds the body's emit high-water mark.
1164
+ """
1165
+ ordinal = self._settle(attempt)
1166
+ self._withdraw(attempt)
1167
+ result = {
1168
+ **result,
1169
+ "leaked_threads_by_pool": dict(attempt.leaked_by_pool),
1170
+ "stdout": attempt.stdout.snapshot(),
1171
+ }
1172
+ if barrier:
1173
+ result = {**result, "last_emit_ordinal": ordinal}
1174
+ try:
1175
+ data = _dumps({"id": attempt.request_id, "result": result})
1176
+ except (TypeError, ValueError, OverflowError) as exc:
1177
+ self._send_failure(
1178
+ attempt, ordinal, ERROR_VALUE_NOT_SERIALIZABLE, "result cannot be serialized: " + str(exc), None
1179
+ )
1180
+ return
1181
+ if len(data) > LIMITS["max_frame_bytes"]:
1182
+ self._send_failure(attempt, ordinal, ERROR_VALUE_TOO_LARGE, "result frame exceeds max_frame_bytes.", None)
1183
+ return
1184
+ self.outbox.put(data)
1185
+
1186
+ def _emit(self, attempt: _Attempt, text: str) -> None:
1187
+ if not isinstance(text, str):
1188
+ raise TypeError("ctx.emit() takes a str.")
1189
+ with self.emit_lock:
1190
+ if attempt.terminal:
1191
+ return
1192
+ now = time.monotonic()
1193
+ while attempt.emit_times and now - attempt.emit_times[0] >= 1.0:
1194
+ attempt.emit_times.popleft()
1195
+ attempt.emit_bytes += len(text.encode("utf-8", "replace"))
1196
+ if len(attempt.emit_times) >= LIMITS["emit_rate_per_s"]:
1197
+ attempt.limit_failure = "ctx.emit() exceeded emit_rate_per_s."
1198
+ elif attempt.last_emit_ordinal >= LIMITS["max_emits_per_attempt"]:
1199
+ attempt.limit_failure = "ctx.emit() exceeded max_emits_per_attempt."
1200
+ elif attempt.emit_bytes > LIMITS["max_emit_bytes_per_attempt"]:
1201
+ attempt.limit_failure = "ctx.emit() exceeded max_emit_bytes_per_attempt."
1202
+ if attempt.limit_failure is not None:
1203
+ raise _EmitLimit(attempt.limit_failure)
1204
+ ordinal = attempt.last_emit_ordinal + 1
1205
+ try:
1206
+ text.encode("utf-8") # the value rule, not the frame's: what leaves the worker is valid Unicode
1207
+ except UnicodeEncodeError:
1208
+ raise ValueError("ctx.emit() text is not valid Unicode (lone surrogate).") from None
1209
+ frame = {"method": METHOD_EMIT, "params": {"ref": attempt.ref, "ordinal": ordinal, "text": text}}
1210
+ data = _dumps(frame) # before the ordinal is taken: the barrier counts only queued emits
1211
+ if len(data) > LIMITS["max_frame_bytes"]:
1212
+ attempt.limit_failure = "ctx.emit() frame exceeds max_frame_bytes."
1213
+ raise _EmitLimit(attempt.limit_failure)
1214
+ attempt.emit_times.append(now)
1215
+ attempt.last_emit_ordinal = ordinal
1216
+ self.outbox.put(data)
1217
+
1218
+ async def _ask(self, attempt: _Attempt, questions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1219
+ """Transport one SDK-serialized ``ctx.ask``; the SDK owns the question schema."""
1220
+ try:
1221
+ running = asyncio.get_running_loop()
1222
+ except RuntimeError:
1223
+ running = None
1224
+ if running is not self.loop:
1225
+ raise _AskError(ERROR_ASK_UNAVAILABLE, "ctx.ask() can only be awaited inside an async def node body.")
1226
+ if attempt.terminal:
1227
+ raise _AskError(ERROR_ATTEMPT_TERMINATED, "attempt is already terminal.")
1228
+ request_id = self.next_id
1229
+ self.next_id += 2
1230
+ frame = {"id": request_id, "method": METHOD_ASK, "params": {"ref": attempt.ref, "questions": questions}}
1231
+ data = _dumps(frame) # checked before anything is registered or queued
1232
+ if len(data) > LIMITS["max_frame_bytes"]:
1233
+ raise _AskError(ERROR_PROTOCOL_LIMIT, "ctx.ask() questions exceed max_frame_bytes.")
1234
+ future: asyncio.Future = self.loop.create_future()
1235
+ self.pending_asks[request_id] = future
1236
+ self.outbox.put(data)
1237
+ try:
1238
+ reply = await future
1239
+ finally:
1240
+ self.pending_asks.pop(request_id, None)
1241
+ error = reply.get("error")
1242
+ if isinstance(error, dict):
1243
+ raise _AskError(str(error.get("code", ERROR_ASK_UNAVAILABLE)), str(error.get("message", "")))
1244
+ result = reply.get("result")
1245
+ answers = result.get("answers") if isinstance(result, dict) else None
1246
+ if not isinstance(answers, list) or not _answers_fit(answers, len(questions)):
1247
+ raise _AskError(ERROR_ASK_UNAVAILABLE, "ask reply carried no answers.")
1248
+ return answers
1249
+
1250
+ # -- bootstrap ------------------------------------------------------------------
1251
+
1252
+ def import_sdk(self) -> Tuple[bool, Optional[str]]:
1253
+ sys.path.insert(0, self.sdk_dir)
1254
+ try:
1255
+ import chrys.workflows as sdk
1256
+ except BaseException as exc:
1257
+ return False, "import chrys.workflows failed: " + type(exc).__name__ + ": " + str(exc)
1258
+ origin = os.path.realpath(getattr(sdk, "__file__", None) or "")
1259
+ expected = os.path.realpath(self.sdk_dir)
1260
+ if not origin.startswith(expected + os.sep):
1261
+ return False, "chrys.workflows resolved to " + repr(origin) + " instead of the injected SDK."
1262
+ missing = [name for name in SDK_EXPORTS if not hasattr(sdk, name)]
1263
+ if missing:
1264
+ return False, "injected SDK lacks exports: " + ", ".join(missing)
1265
+ self.sdk = sdk
1266
+ return True, None
1267
+
1268
+ def hello(self, sdk_origin_ok: bool, sdk_origin_error: Optional[str]) -> Dict[str, Any]:
1269
+ return {
1270
+ "method": METHOD_HELLO,
1271
+ "params": {
1272
+ "protocol_version": PROTOCOL_VERSION,
1273
+ "python_version": platform.python_version(),
1274
+ "implementation": platform.python_implementation(),
1275
+ "platform": sys.platform,
1276
+ "sdk_origin_ok": sdk_origin_ok,
1277
+ "sdk_origin_error": sdk_origin_error,
1278
+ },
1279
+ }
1280
+
1281
+ def serve_forever(self) -> None:
1282
+ asyncio.set_event_loop(self.loop)
1283
+ self.writer.start()
1284
+ self.reader.start()
1285
+ self.loop.run_forever()
1286
+ # A stuck sync body keeps its pool thread forever; 3.9's asyncio.run
1287
+ # would wait for it. Drain the writer, then leave without joining.
1288
+ self.outbox.put(None)
1289
+ self.writer.join(timeout=LIMITS["shutdown_grace"])
1290
+ os._exit(0)
1291
+
1292
+
1293
+ Host._handlers = {
1294
+ METHOD_LOAD: Host._load,
1295
+ METHOD_RUN_PYTHON: Host._run_python,
1296
+ METHOD_EVAL_OUTGOING: Host._eval_outgoing,
1297
+ METHOD_EVAL_LOOP_UNTIL: Host._eval_loop_until,
1298
+ METHOD_COMBINE: Host._combine,
1299
+ METHOD_CANCEL: Host._cancel,
1300
+ METHOD_NATIVE_OUTPUT: Host._native_output,
1301
+ METHOD_SHUTDOWN: Host._shutdown,
1302
+ }
1303
+
1304
+
1305
+ def _error(request_id: int, code: str, message: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1306
+ return {"id": request_id, "error": {"code": code, "message": message, "data": data or {}}}
1307
+
1308
+
1309
+ # -- load diagnostics ----------------------------------------------------------------
1310
+ # A load with ``diagnose`` reports failures as a compiler would: a structured
1311
+ # diagnostic with the file, line and character column at fault, the source line,
1312
+ # notes saying how the entry led there, and a hint for common mistakes. Every
1313
+ # field and the number of diagnostics and notes are capped, so the error frame
1314
+ # stays far below the frame limit whatever user code raised.
1315
+
1316
+ _MISSING = object()
1317
+ _MAX_DIAGNOSTICS = 20
1318
+ _MAX_NOTES = 5
1319
+ _MESSAGE_CHARS = 4096
1320
+ _SOURCE_LINE_CHARS = 1024
1321
+ _NOTE_CHARS = 512
1322
+ _HINT_CHARS = 512
1323
+ _PATH_CHARS = 4096
1324
+ _FILE_TOO_LONG = " (file name too long to report)"
1325
+ _RELATIVE_IMPORT = re.compile(r"\s*from\s+\.+([\w.]*)\s+import\b")
1326
+
1327
+
1328
+ def _capped(text: str, limit: int) -> str:
1329
+ """*text* made frame-safe and cut at *limit* characters, saying how many were dropped."""
1330
+ text = _clean(text)
1331
+ if len(text) <= limit:
1332
+ return text
1333
+ return text[:limit] + "... [" + str(len(text) - limit) + " chars dropped]"
1334
+
1335
+
1336
+ class _Places:
1337
+ """Tells the workflow author's own files from the SDK, this host, installed packages and made-up names."""
1338
+
1339
+ def __init__(self, entry: str, sdk_dir: str) -> None:
1340
+ import sysconfig
1341
+
1342
+ self.entry = entry
1343
+ paths = sysconfig.get_paths()
1344
+ roots = [sdk_dir] + [paths[key] for key in ("stdlib", "platstdlib", "purelib", "platlib") if key in paths]
1345
+ # Real paths on both sides: an interpreter may name its stdlib through a symlink its frames don't use.
1346
+ self.roots = tuple(os.path.join(os.path.normcase(os.path.realpath(root)), "") for root in roots)
1347
+ self.host = os.path.normcase(os.path.realpath(__file__))
1348
+
1349
+ def is_file(self, name: Any) -> bool:
1350
+ """Whether *name* is the entry or an existing file; anything else, such as ``<generated>``, is a label."""
1351
+ return isinstance(name, str) and (name == self.entry or (os.path.isabs(name) and os.path.isfile(name)))
1352
+
1353
+ def is_user(self, name: Any) -> bool:
1354
+ if name == self.entry:
1355
+ return True
1356
+ if not self.is_file(name):
1357
+ return False
1358
+ folded = os.path.normcase(os.path.realpath(name))
1359
+ parts = folded.split(os.sep)
1360
+ installed = "site-packages" in parts or "dist-packages" in parts or folded.startswith(self.roots)
1361
+ return folded != self.host and not installed
1362
+
1363
+
1364
+ class _Spot:
1365
+ """A place in a file: 1-based line, 1-based character columns (``end_column`` is just past the end)."""
1366
+
1367
+ def __init__(
1368
+ self,
1369
+ file: str,
1370
+ line: Optional[int],
1371
+ column: Optional[int] = None,
1372
+ end_line: Optional[int] = None,
1373
+ end_column: Optional[int] = None,
1374
+ module: bool = False,
1375
+ ) -> None:
1376
+ self.file = file
1377
+ self.line = line
1378
+ self.column = column
1379
+ self.end_line = end_line if end_column is not None else None
1380
+ self.end_column = end_column
1381
+ self.module = module # the code running here is a module's top level, so reaching it was an import
1382
+
1383
+
1384
+ def _line_number(value: Any) -> Optional[int]:
1385
+ return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
1386
+
1387
+
1388
+ def _line_text(file: str, line: Optional[int]) -> str:
1389
+ return linecache.getline(file, line) if line is not None else ""
1390
+
1391
+
1392
+ def _byte_column(text: str, offset: Any) -> Optional[int]:
1393
+ """The 1-based character column of 0-based UTF-8 byte *offset* into *text*, if it lands on a character."""
1394
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
1395
+ return None
1396
+ raw = text.encode("utf-8", "surrogatepass")
1397
+ if offset > len(raw.rstrip(b"\r\n")):
1398
+ return None
1399
+ try:
1400
+ return len(raw[:offset].decode("utf-8", "surrogatepass")) + 1
1401
+ except UnicodeDecodeError:
1402
+ return None
1403
+
1404
+
1405
+ def _syntax_column(offset: Any) -> Optional[int]:
1406
+ """A ``SyntaxError`` offset as a character column; before Python 3.11 some count bytes, so none is given."""
1407
+ if sys.version_info < (3, 11) or not isinstance(offset, int) or isinstance(offset, bool) or offset < 1:
1408
+ return None
1409
+ return offset
1410
+
1411
+
1412
+ def _frame_spot(frame: traceback.FrameSummary) -> _Spot:
1413
+ line = _line_number(frame.lineno)
1414
+ text = _line_text(frame.filename, line)
1415
+ column = _byte_column(text, getattr(frame, "colno", None)) if text else None # Python 3.11+
1416
+ end_line = _line_number(getattr(frame, "end_lineno", None))
1417
+ end_column = None
1418
+ if column is not None and line is not None and end_line is not None and end_line >= line:
1419
+ end_text = text if end_line == line else _line_text(frame.filename, end_line)
1420
+ end_column = _byte_column(end_text, getattr(frame, "end_colno", None)) if end_text else None
1421
+ return _Spot(frame.filename, line, column, end_line, end_column, frame.name == "<module>")
1422
+
1423
+
1424
+ def _syntax_spot(exc: SyntaxError) -> _Spot:
1425
+ line = _line_number(exc.lineno)
1426
+ column = _syntax_column(exc.offset) if line is not None else None
1427
+ end_line = _line_number(getattr(exc, "end_lineno", None))
1428
+ end_column = None
1429
+ if column is not None and line is not None and end_line is not None and end_line >= line:
1430
+ end_column = _syntax_column(getattr(exc, "end_offset", None))
1431
+ if end_line == line and end_column is not None and end_column <= column:
1432
+ end_column = None
1433
+ return _Spot(str(exc.filename), line, column, end_line, end_column, module=True)
1434
+
1435
+
1436
+ def _describe(exc: BaseException) -> str:
1437
+ try:
1438
+ text = str(exc)
1439
+ except Exception:
1440
+ text = "<str() of the exception failed>"
1441
+ name = type(exc).__name__
1442
+ return name + ": " + text if text else name
1443
+
1444
+
1445
+ def _label(name: Any) -> str:
1446
+ """A made-up file name, kept on one line for a message."""
1447
+ text = name if isinstance(name, str) else repr(name)
1448
+ return _capped("".join(char if char.isprintable() else "�" for char in text), _NOTE_CHARS)
1449
+
1450
+
1451
+ def _reported_file(file: Optional[str]) -> Tuple[Optional[str], str]:
1452
+ """The file name to report, raw, and the message suffix that says when it was too long to."""
1453
+ if file is not None and len(file) > _PATH_CHARS:
1454
+ return None, _FILE_TOO_LONG
1455
+ return file, ""
1456
+
1457
+
1458
+ def _record(
1459
+ code: str,
1460
+ message: str,
1461
+ spot: Optional[_Spot],
1462
+ *,
1463
+ node: Any = None,
1464
+ notes: Optional[List[Dict[str, Any]]] = None,
1465
+ hint: Optional[str] = None,
1466
+ ) -> Dict[str, Any]:
1467
+ file, suffix = _reported_file(spot.file if spot is not None else None)
1468
+ placed = spot if file is not None else None
1469
+ source_line = None
1470
+ if placed is not None:
1471
+ text = _line_text(placed.file, placed.line)
1472
+ text = text.removesuffix("\n")
1473
+ if text and len(text) <= _SOURCE_LINE_CHARS: # a longer line is not quoted: a cut excerpt misplaces the caret
1474
+ source_line = _clean(text)
1475
+ return {
1476
+ "code": code,
1477
+ "message": _capped(message + suffix, _MESSAGE_CHARS),
1478
+ "file": file,
1479
+ "line": placed.line if placed is not None else None,
1480
+ "column": placed.column if placed is not None else None,
1481
+ "end_line": placed.end_line if placed is not None else None,
1482
+ "end_column": placed.end_column if placed is not None else None,
1483
+ "node": node if isinstance(node, str) and len(node) <= _MESSAGE_CHARS else None,
1484
+ "source_line": source_line,
1485
+ "notes": notes or [],
1486
+ "hint": _capped(hint, _HINT_CHARS) if hint is not None else None,
1487
+ }
1488
+
1489
+
1490
+ def _note(message: str, spot: _Spot) -> Dict[str, Any]:
1491
+ file, suffix = _reported_file(spot.file)
1492
+ return {
1493
+ "message": _capped(message + suffix, _NOTE_CHARS),
1494
+ "file": file,
1495
+ "line": spot.line if file is not None else None,
1496
+ }
1497
+
1498
+
1499
+ def _notes(chain: List[_Spot], lead: Optional[str]) -> List[Dict[str, Any]]:
1500
+ """How the entry led to ``chain[0]``, one note per outer place; a long chain keeps its innermost and outermost."""
1501
+ notes = []
1502
+ for index in range(1, len(chain)):
1503
+ if index == 1 and lead is not None:
1504
+ message = lead
1505
+ else:
1506
+ message = "imported from" if chain[index - 1].module else "called from"
1507
+ notes.append(_note(message, chain[index]))
1508
+ if len(notes) <= _MAX_NOTES:
1509
+ return notes
1510
+ gap = {"message": str(len(notes) - _MAX_NOTES + 1) + " more frames not shown", "file": None, "line": None}
1511
+ return [*notes[: _MAX_NOTES - 2], gap, *notes[-1:]]
1512
+
1513
+
1514
+ def _failure_diagnostic(exc: BaseException, places: _Places, validation_error: Any) -> Dict[str, Any]:
1515
+ """The diagnostic of a load that raised *exc*: placed at the innermost line of the author's own code."""
1516
+ try:
1517
+ return _placed_failure(exc, places, validation_error)
1518
+ except Exception: # a diagnostic that can't be placed still reports the failure
1519
+ return _record("load_error", _describe(exc), None)
1520
+
1521
+
1522
+ def _user_traceback(exc: BaseException, places: _Places) -> str:
1523
+ """*exc*'s traceback, chained exceptions included, with only the frames of the author's own code."""
1524
+ try:
1525
+ shown = traceback.TracebackException.from_exception(exc, limit=sys.maxsize)
1526
+ pending = [shown]
1527
+ while pending:
1528
+ current = pending.pop()
1529
+ current.stack = traceback.StackSummary.from_list(
1530
+ [frame for frame in current.stack if places.is_user(frame.filename)]
1531
+ )
1532
+ pending.extend(chained for chained in (current.__cause__, current.__context__) if chained is not None)
1533
+ pending.extend(getattr(current, "exceptions", None) or ()) # an exception group's members, Python 3.11+
1534
+ return "".join(shown.format())
1535
+ except Exception: # the whole traceback still beats none
1536
+ return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
1537
+
1538
+
1539
+ def _placed_failure(exc: BaseException, places: _Places, validation_error: Any) -> Dict[str, Any]:
1540
+ # extract_tb, not walk_tb: only it carries the column ranges of Python 3.11+; an explicit limit ignores
1541
+ # whatever sys.tracebacklimit the workflow set.
1542
+ frames = traceback.extract_tb(exc.__traceback__, limit=sys.maxsize)
1543
+ chain: List[_Spot] = []
1544
+ for frame in reversed(frames):
1545
+ if places.is_user(frame.filename):
1546
+ spot = _frame_spot(frame)
1547
+ if not chain or (chain[-1].file, chain[-1].line) != (spot.file, spot.line): # recursion repeats a line
1548
+ chain.append(spot)
1549
+ message = _describe(exc)
1550
+ node: Any = None
1551
+ lead: Optional[str] = None
1552
+ hint: Optional[str] = None
1553
+ if isinstance(exc, SyntaxError):
1554
+ code = "syntax_error"
1555
+ message = type(exc).__name__ + ": " + str(exc.msg)
1556
+ if places.is_file(exc.filename):
1557
+ chain.insert(0, _syntax_spot(exc))
1558
+ else: # code compiled under a made-up name is placed at the line that compiled it
1559
+ line = _line_number(exc.lineno)
1560
+ message += " (in " + _label(exc.filename) + (", line " + str(line) if line is not None else "") + ")"
1561
+ elif validation_error is not None and isinstance(exc, validation_error):
1562
+ code = "sdk_validation_error"
1563
+ error: Any = exc # the SDK's WorkflowValidationError, loaded by the user's interpreter
1564
+ node = error.location
1565
+ site = error.site
1566
+ if isinstance(site, tuple) and len(site) == 2 and places.is_file(site[0]) and _line_number(site[1]) is not None:
1567
+ chain.insert(0, _Spot(site[0], site[1]))
1568
+ lead = "build() was called at"
1569
+ else:
1570
+ code = "load_error"
1571
+ # Python's own wording for a relative import outside a package; inside a real subpackage one is fine.
1572
+ if isinstance(exc, ImportError) and chain and str(exc).startswith("attempted relative import"):
1573
+ match = _RELATIVE_IMPORT.match(_line_text(chain[0].file, chain[0].line))
1574
+ if match is not None:
1575
+ written = "from " + match.group(1) + " import ..." if match.group(1) else "import ..."
1576
+ hint = "files of a workflow import each other by name: write '" + written + "'"
1577
+ return _record(code, message, chain[0] if chain else None, node=node, notes=_notes(chain, lead), hint=hint)
1578
+
1579
+
1580
+ def _missing_workflow_diagnostic(found: Any, entry: str) -> Dict[str, Any]:
1581
+ if found is _MISSING:
1582
+ message = "the workflow file defines no module-level `workflow`"
1583
+ else:
1584
+ message = "module-level `workflow` is a " + type(found).__name__ + ", not the Workflow that build() returns"
1585
+ return _record("missing_workflow", message, _Spot(entry, None), hint="assign workflow = wf.build() at module level")
1586
+
1587
+
1588
+ def _precompile(source: str, paths: List[str], places: _Places) -> List[Dict[str, Any]]:
1589
+ """The syntax errors of a folder's Python files, the entry's first, found without running any of them.
1590
+
1591
+ Each file compiles from its bytes as an import would (honouring its
1592
+ coding declaration). When any is broken, the entry is compiled too, so
1593
+ the report lists every syntax error at once.
1594
+ """
1595
+ import importlib.machinery
1596
+
1597
+ suffixes = tuple(_fold(suffix) for suffix in importlib.machinery.SOURCE_SUFFIXES)
1598
+ found: List[Dict[str, Any]] = []
1599
+ with warnings.catch_warnings():
1600
+ warnings.simplefilter("ignore") # the import reports a file's warnings; compiling it here must not twice
1601
+ for path in paths:
1602
+ if path == places.entry or not _fold(path).endswith(suffixes):
1603
+ continue
1604
+ try:
1605
+ with open(path, "rb") as handle:
1606
+ payload = handle.read()
1607
+ except OSError:
1608
+ continue # a file that can't be read now fails its import, if anything imports it
1609
+ try:
1610
+ compile(payload, path, "exec", dont_inherit=True)
1611
+ except (SyntaxError, ValueError) as exc: # ValueError: a null byte, before Python 3.12
1612
+ found.append(_compile_failure(exc, path, places))
1613
+ if found:
1614
+ try:
1615
+ _compile_entry(source, places.entry)
1616
+ except (SyntaxError, ValueError) as exc:
1617
+ found.insert(0, _compile_failure(exc, places.entry, places))
1618
+ return found
1619
+
1620
+
1621
+ def _compile_failure(exc: Exception, path: str, places: _Places) -> Dict[str, Any]:
1622
+ if isinstance(exc, SyntaxError):
1623
+ if exc.filename is None: # a null byte, such as a UTF-16 file has: Python names no file
1624
+ exc.filename = path
1625
+ return _failure_diagnostic(exc, places, None)
1626
+ return _record("syntax_error", _describe(exc), _Spot(path, None))
1627
+
1628
+
1629
+ def _diagnostics_data(found: List[Dict[str, Any]]) -> Dict[str, Any]:
1630
+ """The ``diagnostics`` of a failed load, at most ``_MAX_DIAGNOSTICS``, and whether any were dropped."""
1631
+ return {"diagnostics": found[:_MAX_DIAGNOSTICS], "diagnostics_truncated": len(found) > _MAX_DIAGNOSTICS}
1632
+
1633
+
1634
+ def _declaration_sites(definition: Any) -> Dict[str, Any]:
1635
+ """Where each node was declared: ``{"files": [file, ...], "nodes": {node_id: [file_index, line]}}``."""
1636
+ files: List[Optional[str]] = []
1637
+ index: Dict[str, int] = {}
1638
+ nodes: Dict[str, List[int]] = {}
1639
+ for node_id in definition.node_order:
1640
+ site = definition.nodes[node_id].site
1641
+ if site is None:
1642
+ continue
1643
+ file, line = site
1644
+ if file not in index:
1645
+ index[file] = len(files)
1646
+ files.append(_reported_file(file)[0])
1647
+ nodes[node_id] = [index[file], line]
1648
+ return {"files": files, "nodes": nodes}
1649
+
1650
+
1651
+ def _with_sites(request_id: int, result: Dict[str, Any], sites: Dict[str, Any], limit: int) -> Dict[str, Any]:
1652
+ """*result* with the declaration sites, unless they would push the frame past *limit* bytes."""
1653
+ with_sites = dict(result, sites=sites)
1654
+ if len(_dumps({"id": request_id, "result": with_sites})) <= limit:
1655
+ return with_sites
1656
+ return dict(result, sites_truncated=True)
1657
+
1658
+
1659
+ def _take_stdio() -> Tuple[int, int, int, _NativeTail]:
1660
+ """Move fd 0/1/2 off the protocol channel; return the private protocol in/out fds, a diagnostics fd and the tail."""
1661
+ input_fd = os.dup(0)
1662
+ os.set_inheritable(input_fd, False)
1663
+ protocol_fd = os.dup(1)
1664
+ os.set_inheritable(protocol_fd, False)
1665
+ diag_fd = os.dup(2)
1666
+ os.set_inheritable(diag_fd, False)
1667
+ if sys.platform == "win32":
1668
+ # CRT fds default to text mode: newline translation and Ctrl-Z EOF would corrupt frames.
1669
+ import msvcrt
1670
+
1671
+ msvcrt.setmode(input_fd, os.O_BINARY)
1672
+ msvcrt.setmode(protocol_fd, os.O_BINARY)
1673
+ # fd 0 becomes DEVNULL: user code that reads stdin, and grandchildren that inherit it, get EOF, never
1674
+ # the protocol's own request bytes off the shared channel.
1675
+ devnull = os.open(os.devnull, os.O_RDWR)
1676
+ os.dup2(devnull, 0)
1677
+ read_fd, write_fd = os.pipe()
1678
+ os.dup2(write_fd, 1)
1679
+ os.dup2(write_fd, 2)
1680
+ os.close(write_fd)
1681
+ os.close(devnull)
1682
+ native = _NativeTail(read_fd, int(LIMITS["native_output_tail_bytes"]))
1683
+ native.start()
1684
+ return input_fd, protocol_fd, diag_fd, native
1685
+
1686
+
1687
+ def main(argv: List[str]) -> int:
1688
+ if len(argv) != 2:
1689
+ os.write(2, b"usage: worker_host.py <sdk_dir>\n")
1690
+ return 2
1691
+ input_fd, protocol_fd, diag_fd, native = _take_stdio()
1692
+ host = Host(argv[1], input_fd, protocol_fd, diag_fd, native)
1693
+ sys.stdout = _CaptureStream(host._route_output, 1)
1694
+ sys.stderr = _CaptureStream(host._route_output, 2)
1695
+ ok, error = host.import_sdk()
1696
+ host.send(host.hello(ok, error))
1697
+ if not ok:
1698
+ host.outbox.put(None)
1699
+ host.writer.start()
1700
+ host.writer.join(timeout=LIMITS["shutdown_grace"])
1701
+ return 3
1702
+ host.serve_forever()
1703
+ return 0
1704
+
1705
+
1706
+ def _entry_module(filename: str) -> types.ModuleType:
1707
+ """The module the entry runs in, registered under ``MODULE_NAME`` so its functions pickle by reference."""
1708
+ module = types.ModuleType(MODULE_NAME)
1709
+ module.__file__ = filename
1710
+ sys.modules[MODULE_NAME] = module
1711
+ return module
1712
+
1713
+
1714
+ def _exec_entry(source: str, filename: str, module: types.ModuleType) -> None:
1715
+ """Run the entry's text as the program: the host and its multiprocessing children share this one rule."""
1716
+ exec(_compile_entry(source, filename), module.__dict__) # noqa: S102 - running the user's workflow file is this process's purpose
1717
+
1718
+
1719
+ def _compile_entry(source: str, filename: str) -> types.CodeType:
1720
+ # A BOM belongs to the file bytes (and the entry digest) but not to the program.
1721
+ program = source.removeprefix("\ufeff")
1722
+ # Tracebacks quote the text that ran, not whatever the file holds by then; no mtime keeps checkcache off it.
1723
+ # Lines split as Python reads a file: str.splitlines() would also break at a U+2028 inside a string.
1724
+ lines = io.StringIO(program, newline=None).readlines()
1725
+ linecache.cache[filename] = (len(program), None, lines, filename)
1726
+ try:
1727
+ # dont_inherit: the file gets its own future flags, not this module's (PEP 563 annotations).
1728
+ return compile(program, filename, "exec", dont_inherit=True)
1729
+ except SyntaxError as exc:
1730
+ if exc.filename is None: # a null byte: Python names no file
1731
+ exc.filename = filename
1732
+ raise
1733
+
1734
+
1735
+ def _fold(name: str) -> str:
1736
+ """One spelling per file name on a case-insensitive or normalizing file system."""
1737
+ return unicodedata.normalize("NFC", name).casefold()
1738
+
1739
+
1740
+ def _bytecode_cache_problem(bytecode_cache: str, package_dir: Optional[str]) -> Optional[str]:
1741
+ """Why loading must not go ahead with this cache, after clearing what *package_dir* left in it.
1742
+
1743
+ The main process starts this interpreter with ``PYTHONPYCACHEPREFIX`` so
1744
+ no bytecode cached next to a workflow's source (which nobody confirmed)
1745
+ can run instead of the source. That cache is still keyed by whole-second
1746
+ mtimes and sizes, so a folder's own entries are cleared on every load and
1747
+ its modules compile from the source that was confirmed.
1748
+ """
1749
+ prefix = sys.pycache_prefix
1750
+ if prefix is None or os.path.normcase(os.path.abspath(prefix)) != os.path.normcase(os.path.abspath(bytecode_cache)):
1751
+ return (
1752
+ "the workflow interpreter ignores PYTHONPYCACHEPREFIX (is it started with -E or -I?), so bytecode "
1753
+ "cached next to the workflow source could run instead of the source."
1754
+ )
1755
+ if package_dir is None:
1756
+ return None
1757
+ try:
1758
+ _clear_cached_bytecode(package_dir)
1759
+ except OSError as exc:
1760
+ return "could not clear the cached bytecode of the workflow folder: " + _clean(str(exc))
1761
+ return None
1762
+
1763
+
1764
+ def _clear_cached_bytecode(package_dir: str) -> None:
1765
+ """Delete every cached variant (any optimization level, any interpreter tag) of the folder's source files.
1766
+
1767
+ Hidden entries are skipped, as the confirmation skips them; a
1768
+ ``__pycache__`` folder is searched like any other, since it can hold
1769
+ sources. A name matches by its stem whatever its case or normalization,
1770
+ and deleting a cache too many only costs one compilation.
1771
+ """
1772
+ import importlib.machinery
1773
+ import importlib.util
1774
+
1775
+ suffixes = tuple(_fold(suffix) for suffix in importlib.machinery.SOURCE_SUFFIXES)
1776
+ stems: Dict[str, Set[str]] = {}
1777
+ for current, folders, files in os.walk(package_dir):
1778
+ folders[:] = [name for name in folders if not name.startswith(".")]
1779
+ for name in files:
1780
+ folded = _fold(name)
1781
+ suffix = next((suffix for suffix in suffixes if folded.endswith(suffix)), None)
1782
+ if name.startswith(".") or suffix is None:
1783
+ continue
1784
+ try:
1785
+ cached = importlib.util.cache_from_source(os.path.join(current, name))
1786
+ except NotImplementedError: # no cache tag: this interpreter never caches bytecode
1787
+ return
1788
+ stems.setdefault(os.path.dirname(cached), set()).add(folded[: len(folded) - len(suffix)] + ".")
1789
+ for mirror, prefixes in stems.items():
1790
+ try:
1791
+ with os.scandir(mirror) as listing:
1792
+ entries = list(listing)
1793
+ except (FileNotFoundError, NotADirectoryError):
1794
+ continue
1795
+ for entry in entries:
1796
+ folded = _fold(entry.name)
1797
+ if folded.endswith(".pyc") and folded.startswith(tuple(prefixes)) and entry.is_file(follow_symlinks=False):
1798
+ with contextlib.suppress(FileNotFoundError):
1799
+ os.unlink(entry.path)
1800
+
1801
+
1802
+ def _import_entry_in_child() -> None:
1803
+ """Re-import the workflow module in a multiprocessing child of the host.
1804
+
1805
+ A spawned or forkserver child re-imports the parent's main module, which here is this file, not the
1806
+ user's; functions pickled by reference from the workflow file need its module importable by name. The
1807
+ entry runs from its UTF-8 text exactly as the host ran it, whatever coding declaration it carries.
1808
+ """
1809
+ entry = os.environ.get(ENTRY_ENV)
1810
+ if not entry:
1811
+ return
1812
+ with open(entry, "rb") as handle:
1813
+ source = handle.read().decode("utf-8")
1814
+ _exec_entry(source, entry, _entry_module(entry))
1815
+
1816
+
1817
+ if __name__ == "__main__":
1818
+ sys.exit(main(sys.argv))
1819
+ elif __name__ == "__mp_main__":
1820
+ _import_entry_in_child()