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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- 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()
|