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,2132 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""StateStore — protocol and implementations for session persistence."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os as os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Collection
|
|
21
|
+
|
|
22
|
+
from chrys.foundation.config.settings import resolve_sessions_dir
|
|
23
|
+
from chrys.foundation.models.session_surface import SessionSurface
|
|
24
|
+
from chrys.foundation.platform.files import atomic_write_text as _common_atomic_write_text
|
|
25
|
+
from chrys.foundation.trajectory.ids import new_analytics_id
|
|
26
|
+
from chrys.service.state._fork import SessionForkMixin
|
|
27
|
+
from chrys.service.state._session_files import _MRU_SWEEP_SLACK as _MRU_SWEEP_SLACK
|
|
28
|
+
from chrys.service.state._session_files import RAW_HTTP_LOG_FILE_NAME as RAW_HTTP_LOG_FILE_NAME
|
|
29
|
+
from chrys.service.state._session_files import (
|
|
30
|
+
SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS as SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS,
|
|
31
|
+
)
|
|
32
|
+
from chrys.service.state._session_files import SESSION_BACKUP_FILE_NAME as SESSION_BACKUP_FILE_NAME
|
|
33
|
+
from chrys.service.state._session_files import SESSION_CHECKPOINT_ID_KEY as SESSION_CHECKPOINT_ID_KEY
|
|
34
|
+
from chrys.service.state._session_files import SESSION_FILE_NAME as SESSION_FILE_NAME
|
|
35
|
+
from chrys.service.state._session_files import SESSION_FORK_MAX_ID_ATTEMPTS as SESSION_FORK_MAX_ID_ATTEMPTS
|
|
36
|
+
from chrys.service.state._session_files import SESSION_RECOVERY_FILE_NAME as SESSION_RECOVERY_FILE_NAME
|
|
37
|
+
from chrys.service.state._session_files import SESSION_SCHEMA_VERSION as SESSION_SCHEMA_VERSION
|
|
38
|
+
from chrys.service.state._session_files import SESSION_WRITE_LOCK_TIMEOUT_SECONDS as SESSION_WRITE_LOCK_TIMEOUT_SECONDS
|
|
39
|
+
from chrys.service.state._session_files import FileLock as FileLock
|
|
40
|
+
from chrys.service.state._session_files import SessionCheckpoint as SessionCheckpoint
|
|
41
|
+
from chrys.service.state._session_files import SessionForkError as SessionForkError
|
|
42
|
+
from chrys.service.state._session_files import SessionNotFoundError as SessionNotFoundError
|
|
43
|
+
from chrys.service.state._session_files import _atomic_write_text as _atomic_write_text
|
|
44
|
+
from chrys.service.state._session_files import _ensure_lock_parent as _ensure_lock_parent
|
|
45
|
+
from chrys.service.state._session_files import _is_string_keyed_dict as _is_string_keyed_dict
|
|
46
|
+
from chrys.service.state._session_files import _session_short_id as _session_short_id
|
|
47
|
+
from chrys.service.state._session_files import atomic_copy_file as atomic_copy_file
|
|
48
|
+
from chrys.service.state._session_files import legacy_session_files as legacy_session_files
|
|
49
|
+
from chrys.service.state._session_files import make_junction_dropping_ignore as make_junction_dropping_ignore
|
|
50
|
+
from chrys.service.state._session_files import parse_snapshot_turn as parse_snapshot_turn
|
|
51
|
+
from chrys.service.state._session_files import session_active_lock_path as session_active_lock_path
|
|
52
|
+
from chrys.service.state._session_files import session_active_owner_path as session_active_owner_path
|
|
53
|
+
from chrys.service.state._session_files import session_checkpoint_of as session_checkpoint_of
|
|
54
|
+
from chrys.service.state._session_files import session_dir_candidates as session_dir_candidates
|
|
55
|
+
from chrys.service.state._session_files import session_dir_has_artifacts as session_dir_has_artifacts
|
|
56
|
+
from chrys.service.state._session_files import session_write_lock_path as session_write_lock_path
|
|
57
|
+
from chrys.service.state._session_meta import ChatSessionMeta as ChatSessionMeta
|
|
58
|
+
from chrys.service.state._session_meta import SessionMeta as SessionMeta
|
|
59
|
+
from chrys.service.state._session_meta import SessionMetaMixin, copy_session_meta, resolve_session_kind
|
|
60
|
+
from chrys.service.state._session_meta import WorkflowSessionMeta as WorkflowSessionMeta
|
|
61
|
+
from chrys.service.state._session_meta import _earliest_history_created_at as _earliest_history_created_at
|
|
62
|
+
from chrys.service.state._session_meta import _extract_title as _extract_title
|
|
63
|
+
from chrys.service.state._session_meta import _first_message_created_at as _first_message_created_at
|
|
64
|
+
from chrys.service.state._session_meta import _is_visible_message as _is_visible_message
|
|
65
|
+
from chrys.service.state._session_meta import _message_created_at as _message_created_at
|
|
66
|
+
from chrys.service.state._session_meta import _parse_session_timestamp as _parse_session_timestamp
|
|
67
|
+
from chrys.service.state._session_meta import recorded_surface as recorded_surface
|
|
68
|
+
from chrys.service.state.serializers import deserialize_state, serialize_state
|
|
69
|
+
from chrys.service.state.session_catalog import (
|
|
70
|
+
CatalogEntry,
|
|
71
|
+
FileSignature,
|
|
72
|
+
RunListing,
|
|
73
|
+
RunListingKey,
|
|
74
|
+
SessionCatalogFile,
|
|
75
|
+
file_signature,
|
|
76
|
+
is_recordable,
|
|
77
|
+
)
|
|
78
|
+
from chrys.service.state.session_listing import (
|
|
79
|
+
SESSION_PAGE_SIZE,
|
|
80
|
+
SessionListing,
|
|
81
|
+
SessionListingEntry,
|
|
82
|
+
SessionPage,
|
|
83
|
+
page_slice,
|
|
84
|
+
)
|
|
85
|
+
from chrys.service.state.session_mru import SessionMruEntry, SessionMruIndex, coerce_utc, sort_entries
|
|
86
|
+
from chrys.service.state.workflow import WorkflowSessionState
|
|
87
|
+
from chrys.service.workflows.history import read_workflow_meta, read_workflow_run
|
|
88
|
+
from chrys.service.workflows.layout import EVENTS_FILE, HEADER_FILE, run_dir
|
|
89
|
+
|
|
90
|
+
logger = logging.getLogger(__name__)
|
|
91
|
+
|
|
92
|
+
# The catalog is rewritten whole, so after its first merge a process merges a
|
|
93
|
+
# trickle of new entries (the session a turn just changed) at most this often;
|
|
94
|
+
# a batch worth sharing (a cold listing) merges at once. Unmerged entries are
|
|
95
|
+
# only a peer's cache miss. Pruning a gone session's entry is never deferred:
|
|
96
|
+
# it holds prompt excerpts.
|
|
97
|
+
SESSION_CATALOG_COMMIT_INTERVAL_SECONDS = 60.0
|
|
98
|
+
SESSION_CATALOG_EAGER_COMMIT_ENTRIES = 16
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@runtime_checkable
|
|
102
|
+
class StateStore(Protocol):
|
|
103
|
+
"""Protocol for session state persistence."""
|
|
104
|
+
|
|
105
|
+
def session_dir(self, session_id: str) -> Path: ...
|
|
106
|
+
async def save_session(
|
|
107
|
+
self,
|
|
108
|
+
session_id: str,
|
|
109
|
+
state: dict[str, Any],
|
|
110
|
+
*,
|
|
111
|
+
agent_profile: str = "",
|
|
112
|
+
agent_display_name: str = "",
|
|
113
|
+
agent_profile_id: str = "",
|
|
114
|
+
agent_profile_fingerprint: str = "",
|
|
115
|
+
primary_cwd: str = "",
|
|
116
|
+
working_dirs: list[str] | None = None,
|
|
117
|
+
title: str = "",
|
|
118
|
+
agent_profile_history: list[str] | None = None,
|
|
119
|
+
model_provider: str = "",
|
|
120
|
+
model_api_style: str | None = None,
|
|
121
|
+
model_id: str = "",
|
|
122
|
+
model_profile_id: str = "",
|
|
123
|
+
model_base_url: str = "",
|
|
124
|
+
model_profile_fingerprint: str | None = None,
|
|
125
|
+
service_session_id: str | None = None,
|
|
126
|
+
parent_session_id: str = "",
|
|
127
|
+
last_surface: SessionSurface | None = None,
|
|
128
|
+
) -> SessionCheckpoint | None: ...
|
|
129
|
+
async def save_workflow_session(
|
|
130
|
+
self, session_id: str, state: WorkflowSessionState, *, title: str = ""
|
|
131
|
+
) -> SessionCheckpoint | None: ...
|
|
132
|
+
|
|
133
|
+
def save_recovery_session(
|
|
134
|
+
self,
|
|
135
|
+
session_id: str,
|
|
136
|
+
state: dict[str, Any],
|
|
137
|
+
*,
|
|
138
|
+
agent_profile: str = "",
|
|
139
|
+
agent_display_name: str = "",
|
|
140
|
+
agent_profile_id: str = "",
|
|
141
|
+
agent_profile_fingerprint: str = "",
|
|
142
|
+
primary_cwd: str = "",
|
|
143
|
+
working_dirs: list[str] | None = None,
|
|
144
|
+
title: str = "",
|
|
145
|
+
agent_profile_history: list[str] | None = None,
|
|
146
|
+
model_provider: str = "",
|
|
147
|
+
model_api_style: str | None = None,
|
|
148
|
+
model_id: str = "",
|
|
149
|
+
model_profile_id: str = "",
|
|
150
|
+
model_base_url: str = "",
|
|
151
|
+
model_profile_fingerprint: str | None = None,
|
|
152
|
+
parent_session_id: str = "",
|
|
153
|
+
last_surface: SessionSurface | None = None,
|
|
154
|
+
) -> None: ...
|
|
155
|
+
async def load_recovery_session(self, session_id: str) -> dict[str, Any] | None: ...
|
|
156
|
+
async def load_recovery_session_meta(self, session_id: str) -> SessionMeta | None: ...
|
|
157
|
+
def delete_recovery_session(self, session_id: str) -> None: ...
|
|
158
|
+
async def recovery_session_wins(self, session_id: str) -> bool: ...
|
|
159
|
+
async def load_workflow_session(self, session_id: str) -> WorkflowSessionState | None: ...
|
|
160
|
+
async def load_session(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None: ...
|
|
161
|
+
async def load_session_raw(
|
|
162
|
+
self,
|
|
163
|
+
session_id: str,
|
|
164
|
+
*,
|
|
165
|
+
prefer_recovery: bool = False,
|
|
166
|
+
) -> list[dict[str, Any]] | None: ...
|
|
167
|
+
async def load_session_meta(
|
|
168
|
+
self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
|
|
169
|
+
) -> SessionMeta | None: ...
|
|
170
|
+
|
|
171
|
+
async def list_sessions(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]: ...
|
|
172
|
+
async def load_latest_session_id(self, *, chat_only: bool = False) -> str | None: ...
|
|
173
|
+
async def open_session_listing(self, *, kind: Literal["chat", "workflow"]) -> SessionListing: ...
|
|
174
|
+
async def load_session_page(
|
|
175
|
+
self,
|
|
176
|
+
listing: SessionListing,
|
|
177
|
+
*,
|
|
178
|
+
surfaces: Collection[SessionSurface],
|
|
179
|
+
page: int = 1,
|
|
180
|
+
page_size: int = SESSION_PAGE_SIZE,
|
|
181
|
+
) -> SessionPage: ...
|
|
182
|
+
async def update_session_titles(
|
|
183
|
+
self,
|
|
184
|
+
session_id: str,
|
|
185
|
+
*,
|
|
186
|
+
custom_title: str | None = None,
|
|
187
|
+
generated_title: str | None = None,
|
|
188
|
+
) -> SessionMeta | None: ...
|
|
189
|
+
def fork_session(self, parent_session_id: str, *, last_surface: SessionSurface | None = None) -> str: ...
|
|
190
|
+
async def delete_session(self, session_id: str, *, allow_active: bool = False) -> None: ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _dir_size(path: Path) -> int:
|
|
194
|
+
"""Total bytes of the regular files under *path*; unreadable parts count as empty.
|
|
195
|
+
|
|
196
|
+
An explicit ``os.scandir`` stack: entry types come from the directory
|
|
197
|
+
listing, so only files cost a ``stat``, and links and junctions are
|
|
198
|
+
never followed.
|
|
199
|
+
"""
|
|
200
|
+
total = 0
|
|
201
|
+
pending = [os.fspath(path)]
|
|
202
|
+
while pending:
|
|
203
|
+
try:
|
|
204
|
+
with os.scandir(pending.pop()) as entries:
|
|
205
|
+
for entry in entries:
|
|
206
|
+
try:
|
|
207
|
+
if entry.is_junction() or entry.is_symlink():
|
|
208
|
+
continue
|
|
209
|
+
if entry.is_dir(follow_symlinks=False):
|
|
210
|
+
pending.append(entry.path)
|
|
211
|
+
elif entry.is_file(follow_symlinks=False):
|
|
212
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
213
|
+
except OSError:
|
|
214
|
+
continue
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
return total
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_run_name(run_id: str) -> bool:
|
|
221
|
+
"""Run ids come from storage; a corrupt envelope must not escape its session folder."""
|
|
222
|
+
return bool(run_id) and Path(run_id).name == run_id and run_id not in (".", "..")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _newer_of(current: SessionMruEntry | None, candidate: SessionMruEntry) -> SessionMruEntry:
|
|
226
|
+
return candidate if current is None else sort_entries([current, candidate])[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class JsonFileStateStore(SessionForkMixin, SessionMetaMixin):
|
|
230
|
+
"""JSON file-based state store.
|
|
231
|
+
|
|
232
|
+
Stores sessions as ``{short_session_id}/session.json`` folders with a
|
|
233
|
+
sidecar backup and root-level locks for cross-process coordination.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, directory: str | Path | None = None) -> None:
|
|
237
|
+
self._dir = resolve_sessions_dir() if directory is None else Path(directory)
|
|
238
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
# Custom titles set before a session's first save. A never-saved
|
|
240
|
+
# session must not be materialized on disk just for its title (it
|
|
241
|
+
# would list as a ghost entry and may still be discarded), so the
|
|
242
|
+
# title waits here until the first full save merges it.
|
|
243
|
+
self._pending_custom_titles: dict[str, str] = {}
|
|
244
|
+
# Listing cache: folder name -> catalog entry (``session.json``
|
|
245
|
+
# signature + listing meta without size). Holds ONLY the small
|
|
246
|
+
# ``SessionMeta`` dataclasses — the multi-MB envelope payloads are
|
|
247
|
+
# parsed transiently and dropped — so memory stays bounded even with
|
|
248
|
+
# thousands of sessions. Entries also come from, and are merged back
|
|
249
|
+
# into, the persisted catalog (``session_catalog.py``); ``_catalog_pending``
|
|
250
|
+
# names the ones not yet written there, ``_catalog_removals_owed`` the
|
|
251
|
+
# deleted or vanished sessions not yet removed from it. Legacy flat files are cached
|
|
252
|
+
# separately and never persisted. Accessed from ``asyncio.to_thread``
|
|
253
|
+
# workers, so all reads/writes/evictions take ``_meta_cache_lock``; a
|
|
254
|
+
# lost check-then-set race costs at most one redundant re-parse.
|
|
255
|
+
self._meta_cache: dict[str, CatalogEntry] = {}
|
|
256
|
+
self._legacy_meta_cache: dict[str, tuple[FileSignature, SessionMeta]] = {}
|
|
257
|
+
self._meta_cache_lock = threading.Lock()
|
|
258
|
+
self._catalog = SessionCatalogFile(self._dir)
|
|
259
|
+
self._catalog_signature: FileSignature | None = None
|
|
260
|
+
self._catalog_pending: set[str] = set()
|
|
261
|
+
self._catalog_removals_owed: set[str] = set()
|
|
262
|
+
self._catalog_committed_at: float | None = None
|
|
263
|
+
# The surface-carry fields of each sidecar this store wrote, keyed by
|
|
264
|
+
# its folder (the short id: callers pass either id form) and matched
|
|
265
|
+
# on the file's signature so checkpoints never re-parse their own
|
|
266
|
+
# sidecar; one another process wrote (or rewrote) is parsed once.
|
|
267
|
+
# Only read and written under the session's write lock.
|
|
268
|
+
self._written_recovery_meta: dict[str, tuple[FileSignature, dict[str, Any]]] = {}
|
|
269
|
+
# Derived per-root MRU index (``session_mru.json``) so ``/resume``
|
|
270
|
+
# can find the newest session without parsing every envelope.
|
|
271
|
+
# Session files stay authoritative; every index update is
|
|
272
|
+
# best-effort via ``_note_mru`` and never fails a session operation.
|
|
273
|
+
# Writers record *before* committing the envelope (inside the
|
|
274
|
+
# session write lock) so a crash can only leave the index ahead of
|
|
275
|
+
# disk — a state the lookup's verification tolerates — never behind.
|
|
276
|
+
# Whatever still bypasses the index (an older chrys, a copied
|
|
277
|
+
# folder, a record that failed) is caught by the lookup's sweep of
|
|
278
|
+
# folders modified after the indexed winner.
|
|
279
|
+
self._mru = SessionMruIndex(self._dir)
|
|
280
|
+
self._sweep_trajectory_tombstones()
|
|
281
|
+
|
|
282
|
+
def _sweep_trajectory_tombstones(self) -> None:
|
|
283
|
+
"""Finish logical deletes whose writer has since released its lease."""
|
|
284
|
+
from chrys.service.trajectory.tombstone import sweep_tombstones
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
removed = sweep_tombstones(self._dir)
|
|
288
|
+
except Exception:
|
|
289
|
+
logger.debug("Trajectory tombstone sweep failed under %s", self._dir, exc_info=True)
|
|
290
|
+
return
|
|
291
|
+
if removed:
|
|
292
|
+
logger.info("Removed %d tombstoned session director%s", removed, "y" if removed == 1 else "ies")
|
|
293
|
+
|
|
294
|
+
def session_dir(self, session_id: str) -> Path:
|
|
295
|
+
"""Return the folder for a session.
|
|
296
|
+
|
|
297
|
+
Delegates folder-name derivation to :func:`chrys.foundation.util.session_ids.session_short_id`
|
|
298
|
+
so callers that don't hold a store instance (engine fallback,
|
|
299
|
+
agent_builder fallback) produce bit-identical paths.
|
|
300
|
+
"""
|
|
301
|
+
return self._dir / _session_short_id(session_id)
|
|
302
|
+
|
|
303
|
+
# Keep private alias for internal callers.
|
|
304
|
+
_session_dir = session_dir
|
|
305
|
+
|
|
306
|
+
def _session_file(self, session_id: str) -> Path:
|
|
307
|
+
"""Return the session.json path inside the session folder."""
|
|
308
|
+
return self._session_dir(session_id) / SESSION_FILE_NAME
|
|
309
|
+
|
|
310
|
+
def _backup_file(self, session_id: str) -> Path:
|
|
311
|
+
"""Return the session.json recovery backup path."""
|
|
312
|
+
return self._session_dir(session_id) / SESSION_BACKUP_FILE_NAME
|
|
313
|
+
|
|
314
|
+
def _recovery_file(self, session_id: str) -> Path:
|
|
315
|
+
"""Return the crash-recovery sidecar path."""
|
|
316
|
+
return self._session_dir(session_id) / SESSION_RECOVERY_FILE_NAME
|
|
317
|
+
|
|
318
|
+
def _write_lock_path(self, session_id: str) -> Path:
|
|
319
|
+
"""Return the write lock path for this store/session."""
|
|
320
|
+
path = session_write_lock_path(self._dir, session_id)
|
|
321
|
+
_ensure_lock_parent(path)
|
|
322
|
+
return path
|
|
323
|
+
|
|
324
|
+
def active_lock_path(self, session_id: str) -> Path:
|
|
325
|
+
"""Return the long-lived active-session lock path."""
|
|
326
|
+
path = session_active_lock_path(self._dir, session_id)
|
|
327
|
+
_ensure_lock_parent(path)
|
|
328
|
+
return path
|
|
329
|
+
|
|
330
|
+
def active_owner_path(self, session_id: str) -> Path:
|
|
331
|
+
"""Return the active-session owner metadata path."""
|
|
332
|
+
path = session_active_owner_path(self._dir, session_id)
|
|
333
|
+
_ensure_lock_parent(path)
|
|
334
|
+
return path
|
|
335
|
+
|
|
336
|
+
def _legacy_path(self, session_id: str) -> Path:
|
|
337
|
+
"""Return the old flat-file path for migration fallback."""
|
|
338
|
+
return self._dir / f"{_session_short_id(session_id)}.json"
|
|
339
|
+
|
|
340
|
+
def _legacy_session_files(self) -> list[Path]:
|
|
341
|
+
"""Root-level ``*.json`` legacy flat-file sessions (never the MRU index)."""
|
|
342
|
+
return legacy_session_files(self._dir)
|
|
343
|
+
|
|
344
|
+
def _note_mru[T](self, update: Callable[[], T]) -> T | None:
|
|
345
|
+
"""Apply a best-effort MRU index update; never fails the session operation.
|
|
346
|
+
|
|
347
|
+
On an I/O error the index is invalidated so the next ``/resume``
|
|
348
|
+
rebuilds it from a full scan. A lock timeout means a stuck peer:
|
|
349
|
+
invalidating would only wait on the same lock again inside the
|
|
350
|
+
session write lock, so the update is just dropped — the lookup's
|
|
351
|
+
modified-folder sweep still finds the committed session. ``None``
|
|
352
|
+
is returned in place of the update's result either way.
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
return update()
|
|
356
|
+
except TimeoutError:
|
|
357
|
+
logger.warning("Session MRU index %s is locked; skipping update", self._mru.path)
|
|
358
|
+
return None
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.warning("Failed to update session MRU index %s", self._mru.path, exc_info=True)
|
|
361
|
+
with contextlib.suppress(Exception):
|
|
362
|
+
self._mru.invalidate()
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def _record_mru(self, session_id: str, updated_at: datetime | None) -> None:
|
|
366
|
+
"""Index *session_id* at *updated_at*; call this before the envelope commit."""
|
|
367
|
+
if updated_at is None:
|
|
368
|
+
return
|
|
369
|
+
self._note_mru(lambda: self._mru.record(session_id, updated_at))
|
|
370
|
+
|
|
371
|
+
def _migrate_if_needed_unlocked(self, session_id: str) -> None:
|
|
372
|
+
"""Move a legacy flat-file session into folder format on first access."""
|
|
373
|
+
legacy = self._legacy_path(session_id)
|
|
374
|
+
session_file = self._session_file(session_id)
|
|
375
|
+
if legacy.exists() and not session_file.exists():
|
|
376
|
+
session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
# Record before the rename like every other commit: a backfill
|
|
378
|
+
# scan racing this migration can miss the file in both its
|
|
379
|
+
# folder and its legacy enumeration, and only the index entry
|
|
380
|
+
# keeps the session from vanishing behind a complete index.
|
|
381
|
+
self._record_mru(session_id, self._envelope_updated_at(self._read_json_file(legacy)))
|
|
382
|
+
legacy.rename(session_file)
|
|
383
|
+
|
|
384
|
+
def _migrate_if_needed(self, session_id: str) -> None:
|
|
385
|
+
"""Move a legacy flat-file session into folder format on first access."""
|
|
386
|
+
if not self._legacy_path(session_id).exists():
|
|
387
|
+
return
|
|
388
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
389
|
+
self._migrate_if_needed_unlocked(session_id)
|
|
390
|
+
|
|
391
|
+
def _resolve_session_file(self, session_id: str) -> Path | None:
|
|
392
|
+
"""Return the session.json path, migrating from legacy if needed."""
|
|
393
|
+
self._migrate_if_needed(session_id)
|
|
394
|
+
path = self._session_file(session_id)
|
|
395
|
+
return path if path.exists() else None
|
|
396
|
+
|
|
397
|
+
def resolve_session_file(self, session_id: str) -> Path | None:
|
|
398
|
+
"""The session's ``session.json`` path; None when it has none.
|
|
399
|
+
|
|
400
|
+
A session still in the legacy flat layout is migrated first, so this
|
|
401
|
+
may write to disk and raise the I/O and lock errors migration raises.
|
|
402
|
+
"""
|
|
403
|
+
return self._resolve_session_file(session_id)
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _read_json_file(path: Path) -> dict[str, Any] | None:
|
|
407
|
+
"""Read a JSON object from *path*, returning ``None`` on invalid input."""
|
|
408
|
+
try:
|
|
409
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
410
|
+
except json.JSONDecodeError:
|
|
411
|
+
logger.warning("Invalid JSON in session file %s", path)
|
|
412
|
+
return None
|
|
413
|
+
except OSError:
|
|
414
|
+
return None
|
|
415
|
+
return raw if isinstance(raw, dict) else None
|
|
416
|
+
|
|
417
|
+
def _snapshot_recovery_files(self, session_id: str) -> list[Path]:
|
|
418
|
+
"""Return rollback snapshots newest-first for last-ditch session recovery."""
|
|
419
|
+
snap_dir = self._session_dir(session_id) / "snapshots"
|
|
420
|
+
if not snap_dir.is_dir():
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
return sorted(
|
|
424
|
+
(p for p in snap_dir.glob("*.json") if parse_snapshot_turn(p) >= 1),
|
|
425
|
+
key=parse_snapshot_turn,
|
|
426
|
+
reverse=True,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def _read_session_envelope(
|
|
430
|
+
self,
|
|
431
|
+
session_id: str,
|
|
432
|
+
*,
|
|
433
|
+
heal: bool = True,
|
|
434
|
+
migrate: bool = True,
|
|
435
|
+
include_snapshots: bool = True,
|
|
436
|
+
) -> dict[str, Any] | None:
|
|
437
|
+
"""Read a session envelope, falling back to backup/snapshots if needed."""
|
|
438
|
+
envelope, _source = self._read_session_envelope_with_source(
|
|
439
|
+
session_id, heal=heal, migrate=migrate, include_snapshots=include_snapshots
|
|
440
|
+
)
|
|
441
|
+
return envelope
|
|
442
|
+
|
|
443
|
+
def _read_session_envelope_with_source(
|
|
444
|
+
self,
|
|
445
|
+
session_id: str,
|
|
446
|
+
*,
|
|
447
|
+
heal: bool = True,
|
|
448
|
+
migrate: bool = True,
|
|
449
|
+
include_snapshots: bool = True,
|
|
450
|
+
) -> tuple[dict[str, Any] | None, Literal["primary", "backup", "snapshot"]]:
|
|
451
|
+
"""Read a session envelope and name the file it came from."""
|
|
452
|
+
if migrate:
|
|
453
|
+
path = self._resolve_session_file(session_id)
|
|
454
|
+
else:
|
|
455
|
+
session_file = self._session_file(session_id)
|
|
456
|
+
path = session_file if session_file.exists() else None
|
|
457
|
+
backup = self._backup_file(session_id)
|
|
458
|
+
|
|
459
|
+
candidates: list[tuple[Path, Literal["primary", "backup", "snapshot"]]] = []
|
|
460
|
+
if path is not None:
|
|
461
|
+
candidates.append((path, "primary"))
|
|
462
|
+
if backup.exists():
|
|
463
|
+
candidates.append((backup, "backup"))
|
|
464
|
+
if include_snapshots:
|
|
465
|
+
candidates.extend((snapshot, "snapshot") for snapshot in self._snapshot_recovery_files(session_id))
|
|
466
|
+
if not candidates:
|
|
467
|
+
return None, "primary"
|
|
468
|
+
|
|
469
|
+
for candidate, source in candidates:
|
|
470
|
+
envelope = self._read_json_file(candidate)
|
|
471
|
+
if envelope is None:
|
|
472
|
+
continue
|
|
473
|
+
if heal and candidate != path:
|
|
474
|
+
self._heal_session_file_from_candidate(session_id, self._session_file(session_id), envelope, candidate)
|
|
475
|
+
return envelope, source
|
|
476
|
+
return None, "primary"
|
|
477
|
+
|
|
478
|
+
def _heal_session_file_from_candidate(
|
|
479
|
+
self,
|
|
480
|
+
session_id: str,
|
|
481
|
+
path: Path,
|
|
482
|
+
envelope: dict[str, Any],
|
|
483
|
+
candidate: Path,
|
|
484
|
+
) -> None:
|
|
485
|
+
"""Best-effort repair of a corrupt primary session file."""
|
|
486
|
+
try:
|
|
487
|
+
payload = json.dumps(envelope, indent=2, ensure_ascii=False)
|
|
488
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
489
|
+
current = self._read_json_file(path)
|
|
490
|
+
if current is None:
|
|
491
|
+
_atomic_write_text(path, payload)
|
|
492
|
+
_atomic_write_text(self._backup_file(session_id), payload)
|
|
493
|
+
logger.warning("Recovered corrupt session %s from %s", session_id, candidate)
|
|
494
|
+
except Exception:
|
|
495
|
+
logger.warning("Failed to heal session %s from %s", session_id, candidate, exc_info=True)
|
|
496
|
+
|
|
497
|
+
@staticmethod
|
|
498
|
+
def _inheritable_meta(
|
|
499
|
+
session_id: str,
|
|
500
|
+
envelope: dict[str, Any] | None,
|
|
501
|
+
) -> dict[str, Any] | None:
|
|
502
|
+
"""Return metadata when the live envelope can be attributed to the session."""
|
|
503
|
+
if envelope is None:
|
|
504
|
+
return None
|
|
505
|
+
meta = envelope.get("meta")
|
|
506
|
+
if not isinstance(meta, dict) or meta.get("session_id") != session_id:
|
|
507
|
+
return None
|
|
508
|
+
return meta
|
|
509
|
+
|
|
510
|
+
@classmethod
|
|
511
|
+
def _title_patch_meta(
|
|
512
|
+
cls,
|
|
513
|
+
session_id: str,
|
|
514
|
+
envelope: dict[str, Any] | None,
|
|
515
|
+
) -> dict[str, Any] | None:
|
|
516
|
+
"""Return attributable metadata whose required timestamps are parseable."""
|
|
517
|
+
meta = cls._inheritable_meta(session_id, envelope)
|
|
518
|
+
if meta is None:
|
|
519
|
+
return None
|
|
520
|
+
created_at = _parse_session_timestamp(meta.get("created_at"))
|
|
521
|
+
updated_at = _parse_session_timestamp(meta.get("updated_at"))
|
|
522
|
+
if created_at is None or updated_at is None:
|
|
523
|
+
return None
|
|
524
|
+
return meta
|
|
525
|
+
|
|
526
|
+
def _read_inherited_live_meta_unlocked(
|
|
527
|
+
self,
|
|
528
|
+
session_id: str,
|
|
529
|
+
) -> tuple[dict[str, Any], str | None, bool]:
|
|
530
|
+
"""Return live metadata, creation time, and whether primary supplied the meta.
|
|
531
|
+
|
|
532
|
+
This is deliberately separate from ``_read_session_envelope``: save
|
|
533
|
+
inheritance must continue past a parseable but malformed primary to a
|
|
534
|
+
healthy backup, while a timestamp defect repairs only that field. The
|
|
535
|
+
immutable creation time may come from the backup when the selected
|
|
536
|
+
primary meta lacks it. Rollback snapshots are never consulted.
|
|
537
|
+
"""
|
|
538
|
+
inherited_meta: dict[str, Any] | None = None
|
|
539
|
+
inherited_from_primary = False
|
|
540
|
+
created_at: str | None = None
|
|
541
|
+
primary_file = self._session_file(session_id)
|
|
542
|
+
for path in (primary_file, self._backup_file(session_id)):
|
|
543
|
+
envelope = self._read_json_file(path)
|
|
544
|
+
meta = self._inheritable_meta(session_id, envelope)
|
|
545
|
+
if meta is None:
|
|
546
|
+
continue
|
|
547
|
+
if inherited_meta is None:
|
|
548
|
+
inherited_meta = meta
|
|
549
|
+
inherited_from_primary = path == primary_file
|
|
550
|
+
value = meta.get("created_at")
|
|
551
|
+
if created_at is None and isinstance(value, str) and _parse_session_timestamp(value) is not None:
|
|
552
|
+
created_at = value
|
|
553
|
+
if inherited_meta is meta and created_at is not None:
|
|
554
|
+
break
|
|
555
|
+
return inherited_meta or {}, created_at, inherited_from_primary
|
|
556
|
+
|
|
557
|
+
def _read_recovery_meta_unlocked(self, session_id: str) -> dict[str, Any]:
|
|
558
|
+
"""Return current crash-recovery sidecar metadata, best-effort."""
|
|
559
|
+
envelope = self._read_json_file(self._recovery_file(session_id))
|
|
560
|
+
return self._inheritable_meta(session_id, envelope) or {}
|
|
561
|
+
|
|
562
|
+
def _effective_title_unlocked(
|
|
563
|
+
self,
|
|
564
|
+
session_id: str,
|
|
565
|
+
existing_meta: dict[str, Any],
|
|
566
|
+
recovery_meta: dict[str, Any],
|
|
567
|
+
*,
|
|
568
|
+
title_key: str,
|
|
569
|
+
prefer_recovery: bool,
|
|
570
|
+
) -> str:
|
|
571
|
+
"""Resolve one title without making healthy saves parse the sidecar.
|
|
572
|
+
|
|
573
|
+
Pending custom titles are explicit user actions made after the live
|
|
574
|
+
metadata was read, so they always win until a primary write consumes
|
|
575
|
+
them. Recovery titles outrank live titles only when the primary was
|
|
576
|
+
rejected and the live metadata came from backup (or did not exist).
|
|
577
|
+
"""
|
|
578
|
+
if title_key == "custom_title" and session_id in self._pending_custom_titles:
|
|
579
|
+
return self._pending_custom_titles[session_id]
|
|
580
|
+
|
|
581
|
+
existing_value = existing_meta.get(title_key)
|
|
582
|
+
recovery_value = recovery_meta.get(title_key)
|
|
583
|
+
candidates = (recovery_value, existing_value) if prefer_recovery else (existing_value, recovery_value)
|
|
584
|
+
return next((value for value in candidates if isinstance(value, str)), "")
|
|
585
|
+
|
|
586
|
+
def _carried_surface_unlocked(
|
|
587
|
+
self,
|
|
588
|
+
session_id: str,
|
|
589
|
+
existing_meta: dict[str, Any],
|
|
590
|
+
recovery_meta: dict[str, Any],
|
|
591
|
+
*,
|
|
592
|
+
inherited_from_primary: bool,
|
|
593
|
+
) -> str | None:
|
|
594
|
+
"""The recorded surface an unmarked save keeps; ``None`` when none was ever recorded.
|
|
595
|
+
|
|
596
|
+
Only a turn names the surface, so every other save carries the stored
|
|
597
|
+
value verbatim (one written by a newer version survives), as
|
|
598
|
+
``recorded_surface`` picks it; consulting the sidecar parses only one
|
|
599
|
+
this store did not write itself.
|
|
600
|
+
"""
|
|
601
|
+
if inherited_from_primary and not recovery_meta:
|
|
602
|
+
recovery_meta = self._recovery_carry_meta_unlocked(session_id)
|
|
603
|
+
return recorded_surface(existing_meta, recovery_meta, sidecar_first=not inherited_from_primary)
|
|
604
|
+
|
|
605
|
+
def _recovery_carry_meta_unlocked(self, session_id: str) -> dict[str, Any]:
|
|
606
|
+
"""The sidecar's ``updated_at``/``last_surface``; empty when there is no sidecar."""
|
|
607
|
+
signature = file_signature(self._recovery_file(session_id))
|
|
608
|
+
if signature is None:
|
|
609
|
+
return {}
|
|
610
|
+
written = self._written_recovery_meta.get(_session_short_id(session_id))
|
|
611
|
+
if written is not None and written[0] == signature:
|
|
612
|
+
return written[1]
|
|
613
|
+
return self._read_recovery_meta_unlocked(session_id)
|
|
614
|
+
|
|
615
|
+
def _remember_written_recovery_meta_unlocked(self, session_id: str, meta: dict[str, Any]) -> None:
|
|
616
|
+
signature = file_signature(self._recovery_file(session_id))
|
|
617
|
+
if signature is None:
|
|
618
|
+
self._written_recovery_meta.pop(_session_short_id(session_id), None)
|
|
619
|
+
return
|
|
620
|
+
carried = {key: meta[key] for key in ("updated_at", "last_surface") if key in meta}
|
|
621
|
+
self._written_recovery_meta[_session_short_id(session_id)] = (signature, carried)
|
|
622
|
+
|
|
623
|
+
@staticmethod
|
|
624
|
+
def _resolve_created_at(
|
|
625
|
+
live_created_at: str | None,
|
|
626
|
+
recovery_meta: dict[str, Any],
|
|
627
|
+
state: dict[str, Any],
|
|
628
|
+
*,
|
|
629
|
+
now: str,
|
|
630
|
+
) -> str:
|
|
631
|
+
"""Resolve the immutable session creation timestamp for a full save."""
|
|
632
|
+
if live_created_at is not None:
|
|
633
|
+
return live_created_at
|
|
634
|
+
|
|
635
|
+
recovery_created_at = _parse_session_timestamp(recovery_meta.get("created_at"))
|
|
636
|
+
history_created_at = _earliest_history_created_at(state)
|
|
637
|
+
fallback_candidates = [
|
|
638
|
+
timestamp for timestamp in (recovery_created_at, history_created_at) if timestamp is not None
|
|
639
|
+
]
|
|
640
|
+
if fallback_candidates:
|
|
641
|
+
return min(fallback_candidates).isoformat()
|
|
642
|
+
return now
|
|
643
|
+
|
|
644
|
+
def _build_session_envelope(
|
|
645
|
+
self,
|
|
646
|
+
session_id: str,
|
|
647
|
+
state: dict[str, Any],
|
|
648
|
+
*,
|
|
649
|
+
agent_profile: str = "",
|
|
650
|
+
agent_display_name: str = "",
|
|
651
|
+
agent_profile_id: str = "",
|
|
652
|
+
agent_profile_fingerprint: str = "",
|
|
653
|
+
primary_cwd: str = "",
|
|
654
|
+
working_dirs: list[str] | None = None,
|
|
655
|
+
title: str = "",
|
|
656
|
+
agent_profile_history: list[str] | None = None,
|
|
657
|
+
model_provider: str = "",
|
|
658
|
+
model_api_style: str | None = None,
|
|
659
|
+
model_id: str = "",
|
|
660
|
+
model_profile_id: str = "",
|
|
661
|
+
model_base_url: str = "",
|
|
662
|
+
model_profile_fingerprint: str | None = None,
|
|
663
|
+
service_session_id: str | None = None,
|
|
664
|
+
parent_session_id: str = "",
|
|
665
|
+
last_surface: SessionSurface | None = None,
|
|
666
|
+
kind: Literal["chat", "workflow"] = "chat",
|
|
667
|
+
force_updated_at: bool = False,
|
|
668
|
+
) -> dict[str, Any]:
|
|
669
|
+
"""Build the persisted session envelope shared by primary and recovery saves."""
|
|
670
|
+
now = datetime.now(UTC).isoformat()
|
|
671
|
+
existing_meta, live_created_at, inherited_from_primary = self._read_inherited_live_meta_unlocked(session_id)
|
|
672
|
+
|
|
673
|
+
if existing_meta and resolve_session_kind(existing_meta) != kind:
|
|
674
|
+
raise ValueError("A session cannot change between chat and workflow.")
|
|
675
|
+
|
|
676
|
+
# Healthy primary metadata already owns both title fields and the
|
|
677
|
+
# immutable creation time, so the common checkpoint path must not
|
|
678
|
+
# parse a potentially multi-megabyte recovery envelope. Consult it
|
|
679
|
+
# only for fallback data. A pending custom title always wins until a primary save.
|
|
680
|
+
needs_recovery_meta = (
|
|
681
|
+
not inherited_from_primary
|
|
682
|
+
or live_created_at is None
|
|
683
|
+
or "custom_title" not in existing_meta
|
|
684
|
+
or "generated_title" not in existing_meta
|
|
685
|
+
)
|
|
686
|
+
recovery_meta = self._read_recovery_meta_unlocked(session_id) if needs_recovery_meta else {}
|
|
687
|
+
carry_custom_title = self._effective_title_unlocked(
|
|
688
|
+
session_id,
|
|
689
|
+
existing_meta,
|
|
690
|
+
recovery_meta,
|
|
691
|
+
title_key="custom_title",
|
|
692
|
+
prefer_recovery=not inherited_from_primary,
|
|
693
|
+
)
|
|
694
|
+
carry_generated_title = self._effective_title_unlocked(
|
|
695
|
+
session_id,
|
|
696
|
+
existing_meta,
|
|
697
|
+
recovery_meta,
|
|
698
|
+
title_key="generated_title",
|
|
699
|
+
prefer_recovery=not inherited_from_primary,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
messages = state.get("messages", [])
|
|
703
|
+
new_count = sum(1 for m in messages if _is_visible_message(m))
|
|
704
|
+
old_count = existing_meta.get("message_count", 0)
|
|
705
|
+
created_at = self._resolve_created_at(
|
|
706
|
+
live_created_at,
|
|
707
|
+
recovery_meta,
|
|
708
|
+
state,
|
|
709
|
+
now=now,
|
|
710
|
+
)
|
|
711
|
+
# Normal saves preserve updated_at when visible count is unchanged.
|
|
712
|
+
# Recovery saves always advance it so recency can arbitrate sidecars.
|
|
713
|
+
existing_updated_at = existing_meta.get("updated_at")
|
|
714
|
+
parsed_existing_updated_at = _parse_session_timestamp(existing_updated_at)
|
|
715
|
+
preserve_updated_at = (
|
|
716
|
+
not force_updated_at
|
|
717
|
+
and kind == "chat"
|
|
718
|
+
and new_count == old_count
|
|
719
|
+
and isinstance(existing_updated_at, str)
|
|
720
|
+
and parsed_existing_updated_at is not None
|
|
721
|
+
)
|
|
722
|
+
updated_at = existing_updated_at if preserve_updated_at else now
|
|
723
|
+
parsed_created_at = _parse_session_timestamp(created_at)
|
|
724
|
+
parsed_updated_at = _parse_session_timestamp(updated_at)
|
|
725
|
+
updated_at_floor = parsed_created_at
|
|
726
|
+
if force_updated_at and parsed_existing_updated_at is not None:
|
|
727
|
+
# Recovery arbitration requires a strict ``>`` and stale-sidecar
|
|
728
|
+
# GC deletes on ``<=``. The 1µs advance is therefore intentional:
|
|
729
|
+
# do not simplify this to max(now, parsed_existing_updated_at),
|
|
730
|
+
# because equality would discard the new recovery checkpoint.
|
|
731
|
+
recovery_floor = parsed_existing_updated_at + timedelta(microseconds=1)
|
|
732
|
+
updated_at_floor = max(updated_at_floor, recovery_floor) if updated_at_floor is not None else recovery_floor
|
|
733
|
+
if updated_at_floor is not None and parsed_updated_at is not None and parsed_updated_at < updated_at_floor:
|
|
734
|
+
updated_at = updated_at_floor.isoformat()
|
|
735
|
+
# Import lazily to avoid a circular import at module load:
|
|
736
|
+
# ``chrys/__init__.py`` resolves the installed package version,
|
|
737
|
+
# which would otherwise be pulled in at state-store import time.
|
|
738
|
+
from chrys import __version__ as _app_version
|
|
739
|
+
from chrys.foundation.platform import get_platform
|
|
740
|
+
|
|
741
|
+
# Platform snapshot is derived here (not taken from the caller)
|
|
742
|
+
# so every save stamps the *actual* runtime the file was written
|
|
743
|
+
# on — the cached ``get_platform()`` is cheap and consistent
|
|
744
|
+
# across saves within a process.
|
|
745
|
+
plat = get_platform()
|
|
746
|
+
|
|
747
|
+
meta = {
|
|
748
|
+
"schema_version": SESSION_SCHEMA_VERSION,
|
|
749
|
+
"app_version": _app_version,
|
|
750
|
+
"os_name": plat.os_name,
|
|
751
|
+
"arch": plat.arch,
|
|
752
|
+
"session_id": session_id,
|
|
753
|
+
"created_at": created_at,
|
|
754
|
+
"updated_at": updated_at,
|
|
755
|
+
"kind": kind,
|
|
756
|
+
"primary_cwd": primary_cwd or existing_meta.get("primary_cwd", ""),
|
|
757
|
+
"working_dirs": working_dirs if working_dirs is not None else existing_meta.get("working_dirs", []),
|
|
758
|
+
"title": title or _extract_title(messages) or existing_meta.get("title", ""),
|
|
759
|
+
"custom_title": carry_custom_title,
|
|
760
|
+
"generated_title": carry_generated_title,
|
|
761
|
+
}
|
|
762
|
+
if kind == "chat":
|
|
763
|
+
meta.update(
|
|
764
|
+
{
|
|
765
|
+
"model_provider": model_provider or existing_meta.get("model_provider", ""),
|
|
766
|
+
"model_api_style": model_api_style
|
|
767
|
+
if model_api_style is not None
|
|
768
|
+
else existing_meta.get("model_api_style", ""),
|
|
769
|
+
"model_id": model_id or existing_meta.get("model_id", ""),
|
|
770
|
+
"model_profile_id": model_profile_id,
|
|
771
|
+
"model_base_url": model_base_url or existing_meta.get("model_base_url", ""),
|
|
772
|
+
"model_profile_fingerprint": model_profile_fingerprint
|
|
773
|
+
if model_profile_fingerprint is not None
|
|
774
|
+
else existing_meta.get("model_profile_fingerprint", ""),
|
|
775
|
+
"service_session_id": service_session_id
|
|
776
|
+
if service_session_id is not None
|
|
777
|
+
else existing_meta.get("service_session_id", ""),
|
|
778
|
+
"agent_profile": agent_profile or existing_meta.get("agent_profile", ""),
|
|
779
|
+
"agent_display_name": agent_display_name
|
|
780
|
+
or existing_meta.get("agent_display_name")
|
|
781
|
+
or existing_meta.get("display_name", ""),
|
|
782
|
+
"agent_profile_id": agent_profile_id,
|
|
783
|
+
"agent_profile_fingerprint": agent_profile_fingerprint
|
|
784
|
+
or existing_meta.get("agent_profile_fingerprint", ""),
|
|
785
|
+
"message_count": new_count,
|
|
786
|
+
"agent_profile_history": agent_profile_history
|
|
787
|
+
if agent_profile_history is not None
|
|
788
|
+
else existing_meta.get("agent_profile_history", existing_meta.get("profile_history", [])),
|
|
789
|
+
"parent_session_id": parent_session_id or existing_meta.get("parent_session_id", ""),
|
|
790
|
+
}
|
|
791
|
+
)
|
|
792
|
+
surface = (
|
|
793
|
+
last_surface.value
|
|
794
|
+
if last_surface is not None
|
|
795
|
+
else self._carried_surface_unlocked(
|
|
796
|
+
session_id, existing_meta, recovery_meta, inherited_from_primary=inherited_from_primary
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
if surface is not None:
|
|
800
|
+
meta["last_surface"] = surface
|
|
801
|
+
return {
|
|
802
|
+
"meta": meta,
|
|
803
|
+
"state": state if kind == "workflow" else serialize_state(state),
|
|
804
|
+
# A derived trajectory summary names the revision via this id and content hash.
|
|
805
|
+
SESSION_CHECKPOINT_ID_KEY: new_analytics_id(),
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
def _delete_recovery_session_unlocked(self, session_id: str) -> None:
|
|
809
|
+
"""Best-effort deletion of the recovery sidecar."""
|
|
810
|
+
self._written_recovery_meta.pop(_session_short_id(session_id), None)
|
|
811
|
+
with contextlib.suppress(OSError):
|
|
812
|
+
self._recovery_file(session_id).unlink()
|
|
813
|
+
|
|
814
|
+
def _delete_recovery_session_if_not_newer(
|
|
815
|
+
self,
|
|
816
|
+
session_id: str,
|
|
817
|
+
*,
|
|
818
|
+
primary_updated_at: datetime | None,
|
|
819
|
+
) -> None:
|
|
820
|
+
"""Best-effort stale sidecar GC, rechecking under the write lock."""
|
|
821
|
+
try:
|
|
822
|
+
with FileLock(self._write_lock_path(session_id), timeout=0.0):
|
|
823
|
+
current = self._read_json_file(self._recovery_file(session_id))
|
|
824
|
+
current_updated_at = self._envelope_updated_at(current)
|
|
825
|
+
if (
|
|
826
|
+
current is None
|
|
827
|
+
or current_updated_at is None
|
|
828
|
+
or (primary_updated_at is not None and current_updated_at <= primary_updated_at)
|
|
829
|
+
):
|
|
830
|
+
self._delete_recovery_session_unlocked(session_id)
|
|
831
|
+
except TimeoutError:
|
|
832
|
+
logger.debug("Timed out deleting stale recovery sidecar for %s", session_id, exc_info=True)
|
|
833
|
+
except OSError:
|
|
834
|
+
logger.debug("Failed to delete stale recovery sidecar for %s", session_id, exc_info=True)
|
|
835
|
+
|
|
836
|
+
def _write_session_envelope_unlocked(self, session_id: str, data: dict[str, Any]) -> SessionCheckpoint:
|
|
837
|
+
"""Write primary + backup session envelopes atomically.
|
|
838
|
+
|
|
839
|
+
The primary write is authoritative: failures propagate so callers know
|
|
840
|
+
the save did not complete. The backup is best-effort and can lag by one
|
|
841
|
+
successful write, but is itself updated atomically when it succeeds.
|
|
842
|
+
Returns the checkpoint identity of the bytes written.
|
|
843
|
+
"""
|
|
844
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
|
845
|
+
written = _atomic_write_text(self._session_file(session_id), payload)
|
|
846
|
+
self._delete_recovery_session_unlocked(session_id)
|
|
847
|
+
try:
|
|
848
|
+
_atomic_write_text(self._backup_file(session_id), payload)
|
|
849
|
+
except OSError:
|
|
850
|
+
logger.warning("Failed to update session backup for %s", session_id, exc_info=True)
|
|
851
|
+
return session_checkpoint_of(data, written)
|
|
852
|
+
|
|
853
|
+
def _save_session_sync(
|
|
854
|
+
self,
|
|
855
|
+
session_id: str,
|
|
856
|
+
state: dict[str, Any] | WorkflowSessionState,
|
|
857
|
+
*,
|
|
858
|
+
agent_profile: str = "",
|
|
859
|
+
agent_display_name: str = "",
|
|
860
|
+
agent_profile_id: str = "",
|
|
861
|
+
agent_profile_fingerprint: str = "",
|
|
862
|
+
primary_cwd: str = "",
|
|
863
|
+
working_dirs: list[str] | None = None,
|
|
864
|
+
title: str = "",
|
|
865
|
+
agent_profile_history: list[str] | None = None,
|
|
866
|
+
model_provider: str = "",
|
|
867
|
+
model_api_style: str | None = None,
|
|
868
|
+
model_id: str = "",
|
|
869
|
+
model_profile_id: str = "",
|
|
870
|
+
model_base_url: str = "",
|
|
871
|
+
model_profile_fingerprint: str | None = None,
|
|
872
|
+
service_session_id: str | None = None,
|
|
873
|
+
parent_session_id: str = "",
|
|
874
|
+
last_surface: SessionSurface | None = None,
|
|
875
|
+
kind: Literal["chat", "workflow"] = "chat",
|
|
876
|
+
) -> SessionCheckpoint:
|
|
877
|
+
"""Sync implementation of session save (runs in a thread)."""
|
|
878
|
+
if isinstance(state, WorkflowSessionState):
|
|
879
|
+
state = state.encode()
|
|
880
|
+
elif kind == "workflow":
|
|
881
|
+
raise TypeError("Workflow saves require WorkflowSessionState.")
|
|
882
|
+
path = self._session_file(session_id)
|
|
883
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
884
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
885
|
+
self._migrate_if_needed_unlocked(session_id)
|
|
886
|
+
data = self._build_session_envelope(
|
|
887
|
+
session_id,
|
|
888
|
+
state,
|
|
889
|
+
agent_profile=agent_profile,
|
|
890
|
+
agent_display_name=agent_display_name,
|
|
891
|
+
agent_profile_id=agent_profile_id,
|
|
892
|
+
agent_profile_fingerprint=agent_profile_fingerprint,
|
|
893
|
+
primary_cwd=primary_cwd,
|
|
894
|
+
working_dirs=working_dirs,
|
|
895
|
+
title=title,
|
|
896
|
+
agent_profile_history=agent_profile_history,
|
|
897
|
+
model_provider=model_provider,
|
|
898
|
+
model_api_style=model_api_style,
|
|
899
|
+
model_id=model_id,
|
|
900
|
+
model_profile_id=model_profile_id,
|
|
901
|
+
model_base_url=model_base_url,
|
|
902
|
+
model_profile_fingerprint=model_profile_fingerprint,
|
|
903
|
+
service_session_id=service_session_id,
|
|
904
|
+
parent_session_id=parent_session_id,
|
|
905
|
+
last_surface=last_surface,
|
|
906
|
+
kind=kind,
|
|
907
|
+
force_updated_at=self._recovery_file(session_id).exists(),
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
# Index first, commit second: a save that kept ``updated_at``
|
|
911
|
+
# (unchanged visible count) is a no-op for the index.
|
|
912
|
+
self._record_mru(session_id, self._envelope_updated_at(data))
|
|
913
|
+
checkpoint = self._write_session_envelope_unlocked(session_id, data)
|
|
914
|
+
# The primary envelope now owns the title; a stale pending entry
|
|
915
|
+
# must not resurrect it after a later clear.
|
|
916
|
+
self._pending_custom_titles.pop(session_id, None)
|
|
917
|
+
return checkpoint
|
|
918
|
+
|
|
919
|
+
async def save_session(
|
|
920
|
+
self,
|
|
921
|
+
session_id: str,
|
|
922
|
+
state: dict[str, Any],
|
|
923
|
+
*,
|
|
924
|
+
agent_profile: str = "",
|
|
925
|
+
agent_display_name: str = "",
|
|
926
|
+
agent_profile_id: str = "",
|
|
927
|
+
agent_profile_fingerprint: str = "",
|
|
928
|
+
primary_cwd: str = "",
|
|
929
|
+
working_dirs: list[str] | None = None,
|
|
930
|
+
title: str = "",
|
|
931
|
+
agent_profile_history: list[str] | None = None,
|
|
932
|
+
model_provider: str = "",
|
|
933
|
+
model_api_style: str | None = None,
|
|
934
|
+
model_id: str = "",
|
|
935
|
+
model_profile_id: str = "",
|
|
936
|
+
model_base_url: str = "",
|
|
937
|
+
model_profile_fingerprint: str | None = None,
|
|
938
|
+
service_session_id: str | None = None,
|
|
939
|
+
parent_session_id: str = "",
|
|
940
|
+
last_surface: SessionSurface | None = None,
|
|
941
|
+
) -> SessionCheckpoint:
|
|
942
|
+
"""Checkpoint Chat history and model metadata into the session folder.
|
|
943
|
+
|
|
944
|
+
The session kind is fixed on its first save.
|
|
945
|
+
"""
|
|
946
|
+
return await asyncio.to_thread(
|
|
947
|
+
self._save_session_sync,
|
|
948
|
+
session_id,
|
|
949
|
+
state,
|
|
950
|
+
agent_profile=agent_profile,
|
|
951
|
+
agent_display_name=agent_display_name,
|
|
952
|
+
agent_profile_id=agent_profile_id,
|
|
953
|
+
agent_profile_fingerprint=agent_profile_fingerprint,
|
|
954
|
+
primary_cwd=primary_cwd,
|
|
955
|
+
working_dirs=working_dirs,
|
|
956
|
+
title=title,
|
|
957
|
+
agent_profile_history=agent_profile_history,
|
|
958
|
+
model_provider=model_provider,
|
|
959
|
+
model_api_style=model_api_style,
|
|
960
|
+
model_id=model_id,
|
|
961
|
+
model_profile_id=model_profile_id,
|
|
962
|
+
model_base_url=model_base_url,
|
|
963
|
+
model_profile_fingerprint=model_profile_fingerprint,
|
|
964
|
+
service_session_id=service_session_id,
|
|
965
|
+
parent_session_id=parent_session_id,
|
|
966
|
+
last_surface=last_surface,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
async def save_workflow_session(
|
|
970
|
+
self, session_id: str, state: WorkflowSessionState, *, title: str = ""
|
|
971
|
+
) -> SessionCheckpoint:
|
|
972
|
+
"""Checkpoint a Workflow's typed state without Chat metadata or a second workspace input."""
|
|
973
|
+
return await asyncio.to_thread(
|
|
974
|
+
self._save_session_sync,
|
|
975
|
+
session_id,
|
|
976
|
+
state,
|
|
977
|
+
primary_cwd=state.workspace.primary_cwd,
|
|
978
|
+
working_dirs=[directory.path for directory in state.workspace.working_dirs],
|
|
979
|
+
title=title,
|
|
980
|
+
kind="workflow",
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
def save_recovery_session(
|
|
984
|
+
self,
|
|
985
|
+
session_id: str,
|
|
986
|
+
state: dict[str, Any],
|
|
987
|
+
*,
|
|
988
|
+
agent_profile: str = "",
|
|
989
|
+
agent_display_name: str = "",
|
|
990
|
+
agent_profile_id: str = "",
|
|
991
|
+
agent_profile_fingerprint: str = "",
|
|
992
|
+
primary_cwd: str = "",
|
|
993
|
+
working_dirs: list[str] | None = None,
|
|
994
|
+
title: str = "",
|
|
995
|
+
agent_profile_history: list[str] | None = None,
|
|
996
|
+
model_provider: str = "",
|
|
997
|
+
model_api_style: str | None = None,
|
|
998
|
+
model_id: str = "",
|
|
999
|
+
model_profile_id: str = "",
|
|
1000
|
+
model_base_url: str = "",
|
|
1001
|
+
model_profile_fingerprint: str | None = None,
|
|
1002
|
+
parent_session_id: str = "",
|
|
1003
|
+
last_surface: SessionSurface | None = None,
|
|
1004
|
+
) -> None:
|
|
1005
|
+
"""Synchronously write a crash-recovery sidecar for an in-flight turn."""
|
|
1006
|
+
recovery_file = self._recovery_file(session_id)
|
|
1007
|
+
recovery_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1008
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
1009
|
+
self._migrate_if_needed_unlocked(session_id)
|
|
1010
|
+
data = self._build_session_envelope(
|
|
1011
|
+
session_id,
|
|
1012
|
+
state,
|
|
1013
|
+
agent_profile=agent_profile,
|
|
1014
|
+
agent_display_name=agent_display_name,
|
|
1015
|
+
agent_profile_id=agent_profile_id,
|
|
1016
|
+
agent_profile_fingerprint=agent_profile_fingerprint,
|
|
1017
|
+
primary_cwd=primary_cwd,
|
|
1018
|
+
working_dirs=working_dirs,
|
|
1019
|
+
title=title,
|
|
1020
|
+
agent_profile_history=agent_profile_history,
|
|
1021
|
+
model_provider=model_provider,
|
|
1022
|
+
model_api_style=model_api_style,
|
|
1023
|
+
model_id=model_id,
|
|
1024
|
+
model_profile_id=model_profile_id,
|
|
1025
|
+
model_base_url=model_base_url,
|
|
1026
|
+
model_profile_fingerprint=model_profile_fingerprint,
|
|
1027
|
+
service_session_id="",
|
|
1028
|
+
parent_session_id=parent_session_id,
|
|
1029
|
+
last_surface=last_surface,
|
|
1030
|
+
force_updated_at=True,
|
|
1031
|
+
)
|
|
1032
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
|
1033
|
+
self._record_mru(session_id, self._envelope_updated_at(data))
|
|
1034
|
+
_common_atomic_write_text(recovery_file, payload)
|
|
1035
|
+
self._remember_written_recovery_meta_unlocked(session_id, data["meta"])
|
|
1036
|
+
|
|
1037
|
+
@staticmethod
|
|
1038
|
+
def _envelope_updated_at(envelope: dict[str, Any] | None) -> datetime | None:
|
|
1039
|
+
"""Return the envelope updated_at timestamp when it is parseable."""
|
|
1040
|
+
if envelope is None:
|
|
1041
|
+
return None
|
|
1042
|
+
meta = envelope.get("meta", {})
|
|
1043
|
+
if not isinstance(meta, dict):
|
|
1044
|
+
return None
|
|
1045
|
+
return _parse_session_timestamp(meta.get("updated_at"))
|
|
1046
|
+
|
|
1047
|
+
def _resolve_effective_envelope_with_source(
|
|
1048
|
+
self,
|
|
1049
|
+
session_id: str,
|
|
1050
|
+
*,
|
|
1051
|
+
prefer_recovery: bool = False,
|
|
1052
|
+
) -> tuple[dict[str, Any] | None, Literal["recovery", "primary", "backup", "snapshot"]]:
|
|
1053
|
+
"""Return the effective envelope and the file it came from, without healing recovery into primary."""
|
|
1054
|
+
self._migrate_if_needed(session_id)
|
|
1055
|
+
recovery_path = self._recovery_file(session_id)
|
|
1056
|
+
if prefer_recovery and recovery_path.exists():
|
|
1057
|
+
recovery = self._read_json_file(recovery_path)
|
|
1058
|
+
if recovery is not None:
|
|
1059
|
+
primary_path = self._session_file(session_id)
|
|
1060
|
+
primary = self._read_json_file(primary_path) if primary_path.exists() else None
|
|
1061
|
+
recovery_updated_at = self._envelope_updated_at(recovery)
|
|
1062
|
+
primary_updated_at = self._envelope_updated_at(primary)
|
|
1063
|
+
if primary is None:
|
|
1064
|
+
return recovery, "recovery"
|
|
1065
|
+
if recovery_updated_at is not None and (
|
|
1066
|
+
primary_updated_at is None or recovery_updated_at > primary_updated_at
|
|
1067
|
+
):
|
|
1068
|
+
return recovery, "recovery"
|
|
1069
|
+
self._delete_recovery_session_if_not_newer(session_id, primary_updated_at=primary_updated_at)
|
|
1070
|
+
else:
|
|
1071
|
+
self._delete_recovery_session_if_not_newer(session_id, primary_updated_at=None)
|
|
1072
|
+
return self._read_session_envelope_with_source(session_id)
|
|
1073
|
+
|
|
1074
|
+
def _resolve_effective_envelope(
|
|
1075
|
+
self,
|
|
1076
|
+
session_id: str,
|
|
1077
|
+
*,
|
|
1078
|
+
prefer_recovery: bool = False,
|
|
1079
|
+
) -> dict[str, Any] | None:
|
|
1080
|
+
"""Return the effective envelope, optionally preferring a fresh recovery sidecar."""
|
|
1081
|
+
envelope, _source = self._resolve_effective_envelope_with_source(
|
|
1082
|
+
session_id,
|
|
1083
|
+
prefer_recovery=prefer_recovery,
|
|
1084
|
+
)
|
|
1085
|
+
return envelope
|
|
1086
|
+
|
|
1087
|
+
def _recovery_session_wins_sync(self, session_id: str) -> bool:
|
|
1088
|
+
"""Return whether the crash-recovery sidecar is the winning effective source."""
|
|
1089
|
+
_envelope, source = self._resolve_effective_envelope_with_source(session_id, prefer_recovery=True)
|
|
1090
|
+
return source == "recovery"
|
|
1091
|
+
|
|
1092
|
+
async def recovery_session_wins(self, session_id: str) -> bool:
|
|
1093
|
+
"""Return whether the crash-recovery sidecar would win when recovery is allowed."""
|
|
1094
|
+
return await asyncio.to_thread(self._recovery_session_wins_sync, session_id)
|
|
1095
|
+
|
|
1096
|
+
def _active_lock_is_held(self, session_id: str) -> bool:
|
|
1097
|
+
"""Return True when another live owner, or this process, holds the active-session lock."""
|
|
1098
|
+
lock = FileLock(self.active_lock_path(session_id), timeout=0.0)
|
|
1099
|
+
try:
|
|
1100
|
+
lock.acquire()
|
|
1101
|
+
except TimeoutError:
|
|
1102
|
+
return True
|
|
1103
|
+
else:
|
|
1104
|
+
lock.release()
|
|
1105
|
+
return False
|
|
1106
|
+
|
|
1107
|
+
def _load_recovery_session_sync(self, session_id: str) -> dict[str, Any] | None:
|
|
1108
|
+
"""Sync implementation of direct recovery sidecar load."""
|
|
1109
|
+
try:
|
|
1110
|
+
raw = self._read_json_file(self._recovery_file(session_id))
|
|
1111
|
+
if raw is None:
|
|
1112
|
+
return None
|
|
1113
|
+
if resolve_session_kind(raw["meta"]) != "chat":
|
|
1114
|
+
raise ValueError("Chat loading requires a Chat session.")
|
|
1115
|
+
return deserialize_state(raw.get("state", {}))
|
|
1116
|
+
except KeyError:
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
async def load_recovery_session(self, session_id: str) -> dict[str, Any] | None:
|
|
1120
|
+
"""Load only the recovery sidecar state, if present."""
|
|
1121
|
+
return await asyncio.to_thread(self._load_recovery_session_sync, session_id)
|
|
1122
|
+
|
|
1123
|
+
def _load_recovery_session_meta_sync(self, session_id: str) -> SessionMeta | None:
|
|
1124
|
+
"""Sync implementation of direct recovery sidecar metadata load."""
|
|
1125
|
+
try:
|
|
1126
|
+
raw = self._read_json_file(self._recovery_file(session_id))
|
|
1127
|
+
if raw is None or "meta" not in raw:
|
|
1128
|
+
return None
|
|
1129
|
+
directory = self._session_dir(session_id)
|
|
1130
|
+
return self._session_meta_from_envelope(
|
|
1131
|
+
raw,
|
|
1132
|
+
size_bytes=_dir_size(directory),
|
|
1133
|
+
)
|
|
1134
|
+
except KeyError, OSError:
|
|
1135
|
+
return None
|
|
1136
|
+
|
|
1137
|
+
async def load_recovery_session_meta(self, session_id: str) -> SessionMeta | None:
|
|
1138
|
+
"""Load only the recovery sidecar metadata, if present."""
|
|
1139
|
+
return await asyncio.to_thread(self._load_recovery_session_meta_sync, session_id)
|
|
1140
|
+
|
|
1141
|
+
def delete_recovery_session(self, session_id: str) -> None:
|
|
1142
|
+
"""Delete the recovery sidecar, if present."""
|
|
1143
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
1144
|
+
self._delete_recovery_session_unlocked(session_id)
|
|
1145
|
+
|
|
1146
|
+
def _load_workflow_session_sync(self, session_id: str) -> WorkflowSessionState | None:
|
|
1147
|
+
raw = self._resolve_effective_envelope(session_id, prefer_recovery=False)
|
|
1148
|
+
if raw is None:
|
|
1149
|
+
return None
|
|
1150
|
+
if resolve_session_kind(raw["meta"]) != "workflow":
|
|
1151
|
+
raise ValueError("Workflow loading requires a Workflow session.")
|
|
1152
|
+
return WorkflowSessionState.decode(raw.get("state"))
|
|
1153
|
+
|
|
1154
|
+
async def load_workflow_session(self, session_id: str) -> WorkflowSessionState | None:
|
|
1155
|
+
"""Validate the envelope once, then return typed workflow state directly."""
|
|
1156
|
+
return await asyncio.to_thread(self._load_workflow_session_sync, session_id)
|
|
1157
|
+
|
|
1158
|
+
def _load_session_sync(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None:
|
|
1159
|
+
"""Sync implementation of session load (runs in a thread)."""
|
|
1160
|
+
try:
|
|
1161
|
+
raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
|
|
1162
|
+
if raw is None:
|
|
1163
|
+
return None
|
|
1164
|
+
if resolve_session_kind(raw["meta"]) != "chat":
|
|
1165
|
+
raise ValueError("Chat loading requires a Chat session.")
|
|
1166
|
+
return deserialize_state(raw.get("state", {}))
|
|
1167
|
+
except KeyError:
|
|
1168
|
+
return None
|
|
1169
|
+
|
|
1170
|
+
async def load_session(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None:
|
|
1171
|
+
"""Load session state from the session folder."""
|
|
1172
|
+
return await asyncio.to_thread(self._load_session_sync, session_id, prefer_recovery=prefer_recovery)
|
|
1173
|
+
|
|
1174
|
+
def _load_session_raw_sync(
|
|
1175
|
+
self,
|
|
1176
|
+
session_id: str,
|
|
1177
|
+
*,
|
|
1178
|
+
prefer_recovery: bool = False,
|
|
1179
|
+
) -> list[dict[str, Any]] | None:
|
|
1180
|
+
"""Sync implementation of raw session load (runs in a thread)."""
|
|
1181
|
+
try:
|
|
1182
|
+
raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
|
|
1183
|
+
if raw is None:
|
|
1184
|
+
return None
|
|
1185
|
+
state = raw.get("state", {})
|
|
1186
|
+
messages = state.get("messages", [])
|
|
1187
|
+
# Normalize: ensure each message has role + contents as dicts.
|
|
1188
|
+
# Skip every message compaction excluded, deliberately: tool
|
|
1189
|
+
# calls it summarized or removed, a dropped current turn's
|
|
1190
|
+
# in-between text, and turns folded into a compressed block.
|
|
1191
|
+
# Tool summaries stay in this raw list as the model's context;
|
|
1192
|
+
# transcript replays drop them too (``is_compaction_tool_summary``),
|
|
1193
|
+
# so the reopened session transcript shows neither a summary nor
|
|
1194
|
+
# the messages it replaced. Compressed blocks keep their own
|
|
1195
|
+
# archived copy and render it separately.
|
|
1196
|
+
result = []
|
|
1197
|
+
for msg in messages:
|
|
1198
|
+
ap = msg.get("additional_properties", {})
|
|
1199
|
+
if ap.get("_excluded", False):
|
|
1200
|
+
continue
|
|
1201
|
+
contents = []
|
|
1202
|
+
for c in msg.get("contents", []):
|
|
1203
|
+
if isinstance(c, str):
|
|
1204
|
+
# Legacy plain-string format
|
|
1205
|
+
if c.startswith("Content(type="):
|
|
1206
|
+
contents.append({"type": "legacy", "raw": c})
|
|
1207
|
+
else:
|
|
1208
|
+
contents.append({"type": "text", "text": c})
|
|
1209
|
+
elif isinstance(c, dict):
|
|
1210
|
+
contents.append(c)
|
|
1211
|
+
entry: dict[str, Any] = {"role": msg.get("role", ""), "contents": contents}
|
|
1212
|
+
if msg.get("additional_properties"):
|
|
1213
|
+
entry["additional_properties"] = msg["additional_properties"]
|
|
1214
|
+
result.append(entry)
|
|
1215
|
+
return result
|
|
1216
|
+
except KeyError:
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
async def load_session_raw(
|
|
1220
|
+
self,
|
|
1221
|
+
session_id: str,
|
|
1222
|
+
*,
|
|
1223
|
+
prefer_recovery: bool = False,
|
|
1224
|
+
) -> list[dict[str, Any]] | None:
|
|
1225
|
+
"""Load raw serialized messages for replay.
|
|
1226
|
+
|
|
1227
|
+
Returns:
|
|
1228
|
+
A list of message dicts or ``None`` if the session does not
|
|
1229
|
+
exist. Intermediate text (agent text returned alongside tool
|
|
1230
|
+
calls) is embedded per-message in
|
|
1231
|
+
``additional_properties["_intermediate_text"]``.
|
|
1232
|
+
"""
|
|
1233
|
+
return await asyncio.to_thread(self._load_session_raw_sync, session_id, prefer_recovery=prefer_recovery)
|
|
1234
|
+
|
|
1235
|
+
def _load_session_meta_sync(
|
|
1236
|
+
self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
|
|
1237
|
+
) -> SessionMeta | None:
|
|
1238
|
+
"""Sync implementation of single-session meta load (runs in a thread)."""
|
|
1239
|
+
try:
|
|
1240
|
+
raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
|
|
1241
|
+
if raw is None:
|
|
1242
|
+
if strict and session_dir_has_artifacts(self._session_dir(session_id)):
|
|
1243
|
+
raise ValueError(f"Session {session_id!r} has no readable checkpoint.")
|
|
1244
|
+
return None
|
|
1245
|
+
if "meta" not in raw:
|
|
1246
|
+
raise ValueError(f"Session {session_id!r} is missing metadata.")
|
|
1247
|
+
directory = self._session_dir(session_id)
|
|
1248
|
+
return self._session_meta_from_envelope(
|
|
1249
|
+
raw,
|
|
1250
|
+
size_bytes=_dir_size(directory),
|
|
1251
|
+
)
|
|
1252
|
+
except KeyError, OSError, ValueError, TypeError:
|
|
1253
|
+
if strict:
|
|
1254
|
+
raise
|
|
1255
|
+
return None
|
|
1256
|
+
|
|
1257
|
+
async def load_session_meta(
|
|
1258
|
+
self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
|
|
1259
|
+
) -> SessionMeta | None:
|
|
1260
|
+
"""Load metadata for one specific session by id.
|
|
1261
|
+
|
|
1262
|
+
Unlike :py:meth:`list_sessions`, this resolves the session file
|
|
1263
|
+
directly via :py:meth:`pathlib.Path.exists`, avoiding the Windows
|
|
1264
|
+
``iterdir`` directory cache that can briefly hide a just-written
|
|
1265
|
+
session folder.
|
|
1266
|
+
|
|
1267
|
+
Explicit restore can request strict validation: missing sessions return
|
|
1268
|
+
None, while unreadable checkpoints and invalid state retain their error.
|
|
1269
|
+
Lists and legacy Chat callers keep their tolerant behavior.
|
|
1270
|
+
"""
|
|
1271
|
+
return await asyncio.to_thread(
|
|
1272
|
+
self._load_session_meta_sync, session_id, prefer_recovery=prefer_recovery, strict=strict
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
def _patch_recovery_titles_unlocked(
|
|
1276
|
+
self,
|
|
1277
|
+
session_id: str,
|
|
1278
|
+
*,
|
|
1279
|
+
custom_title: str | None,
|
|
1280
|
+
generated_title: str | None,
|
|
1281
|
+
) -> None:
|
|
1282
|
+
"""Mirror title patches into a live crash-recovery sidecar, best-effort."""
|
|
1283
|
+
recovery_file = self._recovery_file(session_id)
|
|
1284
|
+
if not recovery_file.exists():
|
|
1285
|
+
return
|
|
1286
|
+
try:
|
|
1287
|
+
envelope = self._read_json_file(recovery_file)
|
|
1288
|
+
if envelope is None:
|
|
1289
|
+
return
|
|
1290
|
+
meta = self._inheritable_meta(session_id, envelope)
|
|
1291
|
+
if meta is None:
|
|
1292
|
+
return
|
|
1293
|
+
if custom_title is not None:
|
|
1294
|
+
meta["custom_title"] = custom_title
|
|
1295
|
+
if generated_title is not None:
|
|
1296
|
+
meta["generated_title"] = generated_title
|
|
1297
|
+
payload = json.dumps(envelope, indent=2, ensure_ascii=False)
|
|
1298
|
+
_common_atomic_write_text(recovery_file, payload)
|
|
1299
|
+
self._remember_written_recovery_meta_unlocked(session_id, meta)
|
|
1300
|
+
except OSError:
|
|
1301
|
+
logger.debug("Failed to mirror titles into recovery sidecar for %s", session_id, exc_info=True)
|
|
1302
|
+
|
|
1303
|
+
def _update_session_titles_sync(
|
|
1304
|
+
self,
|
|
1305
|
+
session_id: str,
|
|
1306
|
+
*,
|
|
1307
|
+
custom_title: str | None = None,
|
|
1308
|
+
generated_title: str | None = None,
|
|
1309
|
+
) -> SessionMeta | None:
|
|
1310
|
+
"""Sync implementation of the meta-only title patch (runs in a thread)."""
|
|
1311
|
+
session_file = self._session_file(session_id)
|
|
1312
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
1313
|
+
self._migrate_if_needed_unlocked(session_id)
|
|
1314
|
+
envelope = self._read_session_envelope(
|
|
1315
|
+
session_id,
|
|
1316
|
+
heal=False,
|
|
1317
|
+
migrate=False,
|
|
1318
|
+
include_snapshots=False,
|
|
1319
|
+
)
|
|
1320
|
+
meta = self._title_patch_meta(session_id, envelope)
|
|
1321
|
+
if envelope is None or meta is None:
|
|
1322
|
+
# Never materialize or rewrite a session file just for a title
|
|
1323
|
+
# patch when no semantically valid live envelope is available.
|
|
1324
|
+
# Auto-generated titles are simply dropped (the next turn
|
|
1325
|
+
# regenerates one); a custom title is held in memory until
|
|
1326
|
+
# a later full save can merge it — or discarded with the
|
|
1327
|
+
# session if it never saves. A recovery-only session
|
|
1328
|
+
# additionally gets the patch mirrored into its sidecar so
|
|
1329
|
+
# the edit survives the process (the envelope builder reads
|
|
1330
|
+
# it back on the next save).
|
|
1331
|
+
if custom_title is not None:
|
|
1332
|
+
has_persisted_target = any(
|
|
1333
|
+
path.exists()
|
|
1334
|
+
for path in (
|
|
1335
|
+
session_file,
|
|
1336
|
+
self._backup_file(session_id),
|
|
1337
|
+
self._recovery_file(session_id),
|
|
1338
|
+
)
|
|
1339
|
+
)
|
|
1340
|
+
if custom_title or has_persisted_target:
|
|
1341
|
+
self._pending_custom_titles[session_id] = custom_title
|
|
1342
|
+
else:
|
|
1343
|
+
self._pending_custom_titles.pop(session_id, None)
|
|
1344
|
+
self._patch_recovery_titles_unlocked(
|
|
1345
|
+
session_id,
|
|
1346
|
+
custom_title=custom_title,
|
|
1347
|
+
generated_title=None,
|
|
1348
|
+
)
|
|
1349
|
+
return None
|
|
1350
|
+
session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1351
|
+
effective_custom = self._effective_title_unlocked(
|
|
1352
|
+
session_id,
|
|
1353
|
+
meta,
|
|
1354
|
+
{},
|
|
1355
|
+
title_key="custom_title",
|
|
1356
|
+
prefer_recovery=False,
|
|
1357
|
+
)
|
|
1358
|
+
effective_generated = self._effective_title_unlocked(
|
|
1359
|
+
session_id,
|
|
1360
|
+
meta,
|
|
1361
|
+
{},
|
|
1362
|
+
title_key="generated_title",
|
|
1363
|
+
prefer_recovery=False,
|
|
1364
|
+
)
|
|
1365
|
+
if custom_title is None and generated_title is not None and effective_custom:
|
|
1366
|
+
# A custom title permanently disables auto-generated updates.
|
|
1367
|
+
return None
|
|
1368
|
+
if custom_title is None and generated_title is None:
|
|
1369
|
+
return self._meta_from_patched_envelope(session_id, envelope)
|
|
1370
|
+
|
|
1371
|
+
final_custom = effective_custom
|
|
1372
|
+
if custom_title is not None and custom_title != effective_custom:
|
|
1373
|
+
final_custom = custom_title
|
|
1374
|
+
final_generated = effective_generated
|
|
1375
|
+
if generated_title is not None and generated_title != effective_generated:
|
|
1376
|
+
final_generated = generated_title
|
|
1377
|
+
|
|
1378
|
+
custom_needs_write = meta.get("custom_title") != final_custom
|
|
1379
|
+
generated_needs_write = meta.get("generated_title") != final_generated
|
|
1380
|
+
if not custom_needs_write and not generated_needs_write:
|
|
1381
|
+
# The durable envelope already holds the effective winners;
|
|
1382
|
+
# any pending custom candidate is therefore stale or synced.
|
|
1383
|
+
self._pending_custom_titles.pop(session_id, None)
|
|
1384
|
+
return self._meta_from_patched_envelope(session_id, envelope)
|
|
1385
|
+
if custom_needs_write:
|
|
1386
|
+
meta["custom_title"] = final_custom
|
|
1387
|
+
if generated_needs_write:
|
|
1388
|
+
meta["generated_title"] = final_generated
|
|
1389
|
+
payload = json.dumps(envelope, indent=2, ensure_ascii=False)
|
|
1390
|
+
_atomic_write_text(session_file, payload)
|
|
1391
|
+
try:
|
|
1392
|
+
_atomic_write_text(self._backup_file(session_id), payload)
|
|
1393
|
+
except OSError:
|
|
1394
|
+
logger.warning("Failed to update session backup for %s", session_id, exc_info=True)
|
|
1395
|
+
self._patch_recovery_titles_unlocked(
|
|
1396
|
+
session_id,
|
|
1397
|
+
custom_title=final_custom if custom_needs_write else None,
|
|
1398
|
+
generated_title=final_generated if generated_needs_write else None,
|
|
1399
|
+
)
|
|
1400
|
+
# The primary now durably contains the effective custom winner,
|
|
1401
|
+
# so no in-memory candidate may override a later full save.
|
|
1402
|
+
self._pending_custom_titles.pop(session_id, None)
|
|
1403
|
+
return self._meta_from_patched_envelope(session_id, envelope)
|
|
1404
|
+
|
|
1405
|
+
def _meta_from_patched_envelope(self, session_id: str, envelope: dict[str, Any]) -> SessionMeta | None:
|
|
1406
|
+
"""Best-effort SessionMeta for a title patch's return value.
|
|
1407
|
+
|
|
1408
|
+
The patch itself has already succeeded (or been skipped as a no-op)
|
|
1409
|
+
by the time this runs, so malformed meta must degrade to ``None``
|
|
1410
|
+
rather than escape as a false failure. ``TypeError`` covers
|
|
1411
|
+
``datetime.fromisoformat`` on non-string timestamps.
|
|
1412
|
+
"""
|
|
1413
|
+
try:
|
|
1414
|
+
directory = self._session_dir(session_id)
|
|
1415
|
+
return self._session_meta_from_envelope(
|
|
1416
|
+
envelope,
|
|
1417
|
+
size_bytes=_dir_size(directory),
|
|
1418
|
+
)
|
|
1419
|
+
except KeyError, ValueError, TypeError:
|
|
1420
|
+
return None
|
|
1421
|
+
|
|
1422
|
+
async def update_session_titles(
|
|
1423
|
+
self,
|
|
1424
|
+
session_id: str,
|
|
1425
|
+
*,
|
|
1426
|
+
custom_title: str | None = None,
|
|
1427
|
+
generated_title: str | None = None,
|
|
1428
|
+
) -> SessionMeta | None:
|
|
1429
|
+
"""Patch the session's title overlay fields without touching state.
|
|
1430
|
+
|
|
1431
|
+
``None`` leaves a field unchanged; empty string clears it. A
|
|
1432
|
+
``generated_title``-only patch is refused (returns ``None``) when a
|
|
1433
|
+
custom title is already set. This never creates a session file: for
|
|
1434
|
+
a session that has not been saved yet a custom title is held in
|
|
1435
|
+
memory (and ``None`` is returned) until the first full save merges
|
|
1436
|
+
it, while a generated title is dropped. Returns the updated
|
|
1437
|
+
``SessionMeta`` on success.
|
|
1438
|
+
"""
|
|
1439
|
+
return await asyncio.to_thread(
|
|
1440
|
+
self._update_session_titles_sync,
|
|
1441
|
+
session_id,
|
|
1442
|
+
custom_title=custom_title,
|
|
1443
|
+
generated_title=generated_title,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
def _session_dir_candidates(self) -> list[Path]:
|
|
1447
|
+
"""Session folders that plausibly hold a restorable session."""
|
|
1448
|
+
from chrys.service.trajectory.tombstone import pending_delete_intents
|
|
1449
|
+
|
|
1450
|
+
candidates = session_dir_candidates(self._dir)
|
|
1451
|
+
# A folder a delete could not finish removing (its trajectory file was
|
|
1452
|
+
# still open) is already gone as far as the user is concerned; listing
|
|
1453
|
+
# its remains would resurrect a deleted session.
|
|
1454
|
+
pending = pending_delete_intents(self._dir)
|
|
1455
|
+
if not pending:
|
|
1456
|
+
return candidates
|
|
1457
|
+
return [candidate for candidate in candidates if candidate.name not in pending]
|
|
1458
|
+
|
|
1459
|
+
@staticmethod
|
|
1460
|
+
def _has_session_artifacts(session_dir: Path) -> bool:
|
|
1461
|
+
"""Whether *session_dir* holds anything a restore could start from."""
|
|
1462
|
+
return session_dir_has_artifacts(session_dir)
|
|
1463
|
+
|
|
1464
|
+
@staticmethod
|
|
1465
|
+
def _file_signature(path: Path) -> tuple[int, int]:
|
|
1466
|
+
"""(mtime_ns, size) change-signature for *path*; ``(-1, -1)`` if absent."""
|
|
1467
|
+
try:
|
|
1468
|
+
stat = path.stat()
|
|
1469
|
+
except OSError:
|
|
1470
|
+
return (-1, -1)
|
|
1471
|
+
return (stat.st_mtime_ns, stat.st_size)
|
|
1472
|
+
|
|
1473
|
+
def _meta_for_session_dir(self, session_dir: Path) -> SessionMeta | None:
|
|
1474
|
+
"""One folder-format session's listing meta (``size_bytes`` unset), via the listing caches."""
|
|
1475
|
+
try:
|
|
1476
|
+
resolved = self._listing_meta(session_dir)
|
|
1477
|
+
except KeyError, ValueError, TypeError, OSError:
|
|
1478
|
+
return None
|
|
1479
|
+
return resolved[0] if resolved is not None else None
|
|
1480
|
+
|
|
1481
|
+
def _listing_meta(self, session_dir: Path) -> tuple[SessionMeta, CatalogEntry | None] | None:
|
|
1482
|
+
"""Resolve listing meta and, when it is cacheable, its catalog entry.
|
|
1483
|
+
|
|
1484
|
+
Only a stable read of a valid primary with no recovery sidecar is
|
|
1485
|
+
cached: the sidecar's eligibility depends on the active lock, and a
|
|
1486
|
+
backup or snapshot fallback on files outside the signature. Those
|
|
1487
|
+
sessions are resolved live, probing the lock only when a sidecar
|
|
1488
|
+
exists.
|
|
1489
|
+
"""
|
|
1490
|
+
short_id = session_dir.name
|
|
1491
|
+
primary = session_dir / SESSION_FILE_NAME
|
|
1492
|
+
recovery = session_dir / SESSION_RECOVERY_FILE_NAME
|
|
1493
|
+
signature = file_signature(primary)
|
|
1494
|
+
recovery_present = file_signature(recovery) is not None
|
|
1495
|
+
if signature is not None and not recovery_present:
|
|
1496
|
+
with self._meta_cache_lock:
|
|
1497
|
+
cached = self._meta_cache.get(short_id)
|
|
1498
|
+
if cached is not None and cached.signature == signature:
|
|
1499
|
+
return copy_session_meta(cached.meta), cached
|
|
1500
|
+
lock_held = recovery_present and self._active_lock_is_held(short_id)
|
|
1501
|
+
# A sidecar that appears after the probe is a live writer's checkpoint: never read it.
|
|
1502
|
+
prefer_recovery = recovery_present and not lock_held
|
|
1503
|
+
raw, source = self._resolve_effective_envelope_with_source(short_id, prefer_recovery=prefer_recovery)
|
|
1504
|
+
if raw is None:
|
|
1505
|
+
return None
|
|
1506
|
+
meta = self._session_meta_from_envelope(raw, size_bytes=0)
|
|
1507
|
+
stable = (
|
|
1508
|
+
source == "primary"
|
|
1509
|
+
and signature is not None
|
|
1510
|
+
and not recovery_present
|
|
1511
|
+
and file_signature(primary) == signature
|
|
1512
|
+
and file_signature(recovery) is None
|
|
1513
|
+
)
|
|
1514
|
+
if not stable or signature is None:
|
|
1515
|
+
return meta, None
|
|
1516
|
+
entry = CatalogEntry(signature, meta)
|
|
1517
|
+
with self._meta_cache_lock:
|
|
1518
|
+
self._meta_cache[short_id] = entry
|
|
1519
|
+
# A malformed envelope's meta would be dropped on every load: keep it in memory only.
|
|
1520
|
+
if is_recordable(entry):
|
|
1521
|
+
self._catalog_pending.add(short_id)
|
|
1522
|
+
return copy_session_meta(meta), entry
|
|
1523
|
+
|
|
1524
|
+
def _with_workflow_status(self, meta: SessionMeta) -> SessionMeta:
|
|
1525
|
+
if meta.kind != "workflow" or not _is_run_name(meta.latest_run_id):
|
|
1526
|
+
return meta
|
|
1527
|
+
return replace(
|
|
1528
|
+
meta,
|
|
1529
|
+
latest_run=read_workflow_meta(
|
|
1530
|
+
run_dir(self._session_dir(meta.session_id), meta.latest_run_id),
|
|
1531
|
+
active=self._active_lock_is_held(meta.session_id),
|
|
1532
|
+
),
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
def _workflow_listed_at(
|
|
1536
|
+
self, session_dir: Path, meta: WorkflowSessionMeta, entry: CatalogEntry | None
|
|
1537
|
+
) -> datetime | None:
|
|
1538
|
+
"""The latest run's listing time (``None``: no displayable run), cached on its run files.
|
|
1539
|
+
|
|
1540
|
+
Matches ``_with_workflow_status``: the time does not depend on the
|
|
1541
|
+
live status, so it can be kept without probing the active lock.
|
|
1542
|
+
"""
|
|
1543
|
+
run_id = meta.latest_run_id
|
|
1544
|
+
if not _is_run_name(run_id):
|
|
1545
|
+
return None
|
|
1546
|
+
directory = run_dir(session_dir, run_id)
|
|
1547
|
+
|
|
1548
|
+
def run_key() -> RunListingKey:
|
|
1549
|
+
return RunListingKey(
|
|
1550
|
+
run_id, file_signature(directory / HEADER_FILE), file_signature(directory / EVENTS_FILE)
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
key = run_key()
|
|
1554
|
+
if entry is not None and entry.run is not None and entry.run.key == key:
|
|
1555
|
+
return entry.run.listed_at
|
|
1556
|
+
read = read_workflow_run(directory, active=False)
|
|
1557
|
+
listed_at = read.meta.updated_at if read.meta is not None else None
|
|
1558
|
+
# A read an I/O error shaped would stick until the run files change.
|
|
1559
|
+
if read.settled and entry is not None and run_key() == key:
|
|
1560
|
+
listed = replace(entry, run=RunListing(key, listed_at))
|
|
1561
|
+
recordable = is_recordable(listed)
|
|
1562
|
+
with self._meta_cache_lock:
|
|
1563
|
+
if self._meta_cache.get(session_dir.name) is entry:
|
|
1564
|
+
self._meta_cache[session_dir.name] = listed
|
|
1565
|
+
# As in ``_listing_meta``: an entry no load would read back stays in memory.
|
|
1566
|
+
if recordable:
|
|
1567
|
+
self._catalog_pending.add(session_dir.name)
|
|
1568
|
+
return listed_at
|
|
1569
|
+
|
|
1570
|
+
# ------------------------------------------------------------------ #
|
|
1571
|
+
# Persisted listing catalog
|
|
1572
|
+
# ------------------------------------------------------------------ #
|
|
1573
|
+
|
|
1574
|
+
def _refresh_catalog(self) -> None:
|
|
1575
|
+
"""Adopt the persisted catalog's entries when its file changed since the last look."""
|
|
1576
|
+
signature = file_signature(self._catalog.path)
|
|
1577
|
+
with self._meta_cache_lock:
|
|
1578
|
+
if signature == self._catalog_signature:
|
|
1579
|
+
return
|
|
1580
|
+
loaded = self._catalog.load() if signature is not None else {}
|
|
1581
|
+
if loaded is None:
|
|
1582
|
+
return # Unreadable right now: look again next time.
|
|
1583
|
+
with self._meta_cache_lock:
|
|
1584
|
+
self._catalog_signature = signature
|
|
1585
|
+
for short_id, entry in loaded.items():
|
|
1586
|
+
current = self._meta_cache.get(short_id)
|
|
1587
|
+
# A differing in-memory entry not yet persisted was derived
|
|
1588
|
+
# here after the peer's write, or will simply miss and re-parse.
|
|
1589
|
+
if current is None or (current.signature != entry.signature and short_id not in self._catalog_pending):
|
|
1590
|
+
self._meta_cache[short_id] = entry
|
|
1591
|
+
|
|
1592
|
+
def _commit_catalog(self) -> None:
|
|
1593
|
+
"""Settle owed removals, then merge newly derived entries into the persisted catalog; best-effort.
|
|
1594
|
+
|
|
1595
|
+
Removals never wait for the commit interval. A skipped or failed
|
|
1596
|
+
merge keeps the entries pending for a later listing.
|
|
1597
|
+
"""
|
|
1598
|
+
self._settle_catalog_removals()
|
|
1599
|
+
now = time.monotonic()
|
|
1600
|
+
with self._meta_cache_lock:
|
|
1601
|
+
self._catalog_pending.intersection_update(self._meta_cache.keys())
|
|
1602
|
+
updates = {short_id: self._meta_cache[short_id] for short_id in self._catalog_pending}
|
|
1603
|
+
if not updates:
|
|
1604
|
+
return
|
|
1605
|
+
last = self._catalog_committed_at
|
|
1606
|
+
if (
|
|
1607
|
+
len(updates) < SESSION_CATALOG_EAGER_COMMIT_ENTRIES
|
|
1608
|
+
and last is not None
|
|
1609
|
+
and now - last < SESSION_CATALOG_COMMIT_INTERVAL_SECONDS
|
|
1610
|
+
):
|
|
1611
|
+
return
|
|
1612
|
+
self._catalog_committed_at = now
|
|
1613
|
+
outcome = self._catalog.commit(
|
|
1614
|
+
updates, still_valid=self._catalog_entry_still_valid, is_live=self._catalog_liveness()
|
|
1615
|
+
)
|
|
1616
|
+
if not outcome.settled:
|
|
1617
|
+
return
|
|
1618
|
+
with self._meta_cache_lock:
|
|
1619
|
+
for short_id, entry in updates.items():
|
|
1620
|
+
if self._meta_cache.get(short_id) is entry:
|
|
1621
|
+
self._catalog_pending.discard(short_id)
|
|
1622
|
+
if outcome.signature is not None:
|
|
1623
|
+
# Our own write needs no reload; peers' entries it merged are
|
|
1624
|
+
# only a missed cache hit away.
|
|
1625
|
+
self._catalog_signature = outcome.signature
|
|
1626
|
+
|
|
1627
|
+
def _catalog_entry_still_valid(self, short_id: str, entry: CatalogEntry) -> bool:
|
|
1628
|
+
session_dir = self._dir / short_id
|
|
1629
|
+
return (
|
|
1630
|
+
file_signature(session_dir / SESSION_FILE_NAME) == entry.signature
|
|
1631
|
+
and file_signature(session_dir / SESSION_RECOVERY_FILE_NAME) is None
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
def _catalog_liveness(self) -> Callable[[str], bool]:
|
|
1635
|
+
"""Whether the session an entry describes still exists.
|
|
1636
|
+
|
|
1637
|
+
It needs its primary file (a reset keeps the folder) and no recorded
|
|
1638
|
+
delete intent (a logical delete may leave the primary pinned). The
|
|
1639
|
+
intents are read on first use: a delete records its intent before it
|
|
1640
|
+
removes its entry under the catalog lock, so a check made under that
|
|
1641
|
+
lock sees every delete whose removal went first.
|
|
1642
|
+
"""
|
|
1643
|
+
from chrys.service.trajectory.tombstone import pending_delete_intents
|
|
1644
|
+
|
|
1645
|
+
deleted: frozenset[str] | None = None
|
|
1646
|
+
|
|
1647
|
+
def is_live(short_id: str) -> bool:
|
|
1648
|
+
nonlocal deleted
|
|
1649
|
+
if not short_id or short_id.startswith(".") or "/" in short_id or "\\" in short_id:
|
|
1650
|
+
return False
|
|
1651
|
+
if deleted is None:
|
|
1652
|
+
deleted = pending_delete_intents(self._dir)
|
|
1653
|
+
return short_id not in deleted and file_signature(self._dir / short_id / SESSION_FILE_NAME) is not None
|
|
1654
|
+
|
|
1655
|
+
return is_live
|
|
1656
|
+
|
|
1657
|
+
def _forget_catalog_entry(self, session_id: str) -> None:
|
|
1658
|
+
"""Drop a deleted session's listing entry from memory and from the persisted catalog.
|
|
1659
|
+
|
|
1660
|
+
A removal the catalog lock or a write failure stops stays owed and is
|
|
1661
|
+
retried before each later commit, until it lands (as is one for a
|
|
1662
|
+
session a scan found gone).
|
|
1663
|
+
"""
|
|
1664
|
+
short_id = _session_short_id(session_id)
|
|
1665
|
+
with self._meta_cache_lock:
|
|
1666
|
+
self._meta_cache.pop(short_id, None)
|
|
1667
|
+
self._catalog_pending.discard(short_id)
|
|
1668
|
+
self._catalog_removals_owed.add(short_id)
|
|
1669
|
+
self._settle_catalog_removals()
|
|
1670
|
+
|
|
1671
|
+
def _settle_catalog_removals(self) -> None:
|
|
1672
|
+
with self._meta_cache_lock:
|
|
1673
|
+
owed = frozenset(self._catalog_removals_owed)
|
|
1674
|
+
if not owed or not self._catalog.remove(owed):
|
|
1675
|
+
return
|
|
1676
|
+
with self._meta_cache_lock:
|
|
1677
|
+
self._catalog_removals_owed.difference_update(owed)
|
|
1678
|
+
|
|
1679
|
+
def _legacy_session_metas(self, seen_session_ids: set[str]) -> list[SessionMeta]:
|
|
1680
|
+
"""Metas for legacy flat-file sessions not yet migrated to folders."""
|
|
1681
|
+
return [meta for _path, meta in self._legacy_session_metas_with_paths(seen_session_ids)]
|
|
1682
|
+
|
|
1683
|
+
def _legacy_session_metas_with_paths(self, seen_session_ids: set[str]) -> list[tuple[Path, SessionMeta]]:
|
|
1684
|
+
"""Legacy flat-file sessions not yet migrated to folders, with their files.
|
|
1685
|
+
|
|
1686
|
+
Duplicate ``meta.session_id`` values — across flat files, or against
|
|
1687
|
+
the folder-format ids in *seen_session_ids* — keep only the first
|
|
1688
|
+
occurrence, matching the pre-cache listing behavior.
|
|
1689
|
+
"""
|
|
1690
|
+
sessions: list[tuple[Path, SessionMeta]] = []
|
|
1691
|
+
seen = set(seen_session_ids)
|
|
1692
|
+
for path in self._legacy_session_files():
|
|
1693
|
+
meta = self._legacy_meta_for_file(path)
|
|
1694
|
+
if meta is None or meta.session_id in seen:
|
|
1695
|
+
continue
|
|
1696
|
+
seen.add(meta.session_id)
|
|
1697
|
+
sessions.append((path, meta))
|
|
1698
|
+
return sessions
|
|
1699
|
+
|
|
1700
|
+
def _legacy_meta_for_file(self, path: Path) -> SessionMeta | None:
|
|
1701
|
+
"""Load one legacy flat-file session's meta, via its in-process cache."""
|
|
1702
|
+
try:
|
|
1703
|
+
signature = file_signature(path)
|
|
1704
|
+
with self._meta_cache_lock:
|
|
1705
|
+
cached = self._legacy_meta_cache.get(path.name)
|
|
1706
|
+
if signature is not None and cached is not None and cached[0] == signature:
|
|
1707
|
+
return copy_session_meta(cached[1])
|
|
1708
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
1709
|
+
meta = self._session_meta_from_envelope(raw, size_bytes=path.stat().st_size)
|
|
1710
|
+
if signature is not None and file_signature(path) == signature:
|
|
1711
|
+
with self._meta_cache_lock:
|
|
1712
|
+
self._legacy_meta_cache[path.name] = (signature, meta)
|
|
1713
|
+
return copy_session_meta(meta)
|
|
1714
|
+
return meta
|
|
1715
|
+
except json.JSONDecodeError, KeyError, ValueError, TypeError, OSError:
|
|
1716
|
+
return None
|
|
1717
|
+
|
|
1718
|
+
def _evict_stale_meta_cache_sync(self) -> None:
|
|
1719
|
+
"""Drop cache entries whose session file is gone (deleted, or reset in place).
|
|
1720
|
+
|
|
1721
|
+
An evicted folder entry is owed its catalog removal: once out of
|
|
1722
|
+
memory, no later scan would find it again.
|
|
1723
|
+
"""
|
|
1724
|
+
with self._meta_cache_lock:
|
|
1725
|
+
cached = list(self._meta_cache)
|
|
1726
|
+
is_live = self._catalog_liveness()
|
|
1727
|
+
gone = [key for key in cached if not is_live(key)]
|
|
1728
|
+
try:
|
|
1729
|
+
live_legacy: set[str] | None = {p.name for p in self._legacy_session_files()}
|
|
1730
|
+
except OSError:
|
|
1731
|
+
live_legacy = None
|
|
1732
|
+
with self._meta_cache_lock:
|
|
1733
|
+
dead = [key for key in gone if self._meta_cache.pop(key, None) is not None]
|
|
1734
|
+
self._catalog_pending.difference_update(dead)
|
|
1735
|
+
self._catalog_removals_owed.update(dead)
|
|
1736
|
+
if live_legacy is not None:
|
|
1737
|
+
for key in [key for key in self._legacy_meta_cache if key not in live_legacy]:
|
|
1738
|
+
del self._legacy_meta_cache[key]
|
|
1739
|
+
|
|
1740
|
+
def _settle_listing_caches(self) -> None:
|
|
1741
|
+
"""After a scan: evict gone sessions, then remove them from, and merge new entries into, the catalog."""
|
|
1742
|
+
self._evict_stale_meta_cache_sync()
|
|
1743
|
+
self._commit_catalog()
|
|
1744
|
+
|
|
1745
|
+
def _folder_metas(self) -> list[tuple[Path, SessionMeta]]:
|
|
1746
|
+
"""Every session folder's listing meta, without size or run status."""
|
|
1747
|
+
self._refresh_catalog()
|
|
1748
|
+
return [
|
|
1749
|
+
(session_dir, meta)
|
|
1750
|
+
for session_dir in self._session_dir_candidates()
|
|
1751
|
+
if (meta := self._meta_for_session_dir(session_dir)) is not None
|
|
1752
|
+
]
|
|
1753
|
+
|
|
1754
|
+
def _scan_session_metas_sync(self) -> list[SessionMeta]:
|
|
1755
|
+
"""Every listed session's meta without folder size or run status (for ranking by time)."""
|
|
1756
|
+
folders = self._folder_metas()
|
|
1757
|
+
sessions = [meta for _session_dir, meta in folders]
|
|
1758
|
+
sessions.extend(self._legacy_session_metas({meta.session_id for meta in sessions}))
|
|
1759
|
+
self._settle_listing_caches()
|
|
1760
|
+
return sessions
|
|
1761
|
+
|
|
1762
|
+
def _list_sessions_sync(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]:
|
|
1763
|
+
"""Sync implementation of session listing (runs in a thread)."""
|
|
1764
|
+
folders = self._folder_metas()
|
|
1765
|
+
sessions = [
|
|
1766
|
+
replace(meta, size_bytes=_dir_size(session_dir))
|
|
1767
|
+
for session_dir, meta in folders
|
|
1768
|
+
if kind is None or meta.kind == kind
|
|
1769
|
+
]
|
|
1770
|
+
legacy = self._legacy_session_metas({meta.session_id for _session_dir, meta in folders})
|
|
1771
|
+
sessions.extend(meta for meta in legacy if kind is None or meta.kind == kind)
|
|
1772
|
+
self._settle_listing_caches()
|
|
1773
|
+
return [self._with_workflow_status(meta) for meta in sessions]
|
|
1774
|
+
|
|
1775
|
+
async def list_sessions(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]:
|
|
1776
|
+
"""List all saved sessions with metadata."""
|
|
1777
|
+
return await asyncio.to_thread(self._list_sessions_sync, kind=kind)
|
|
1778
|
+
|
|
1779
|
+
# ------------------------------------------------------------------ #
|
|
1780
|
+
# Latest-session lookup via the MRU index
|
|
1781
|
+
# ------------------------------------------------------------------ #
|
|
1782
|
+
|
|
1783
|
+
def _rescan_latest_session_id(self) -> str | None:
|
|
1784
|
+
"""Full listing scan; rebuilds the MRU index and returns the newest id.
|
|
1785
|
+
|
|
1786
|
+
The rebuild keeps index entries newer than the scan (a save whose
|
|
1787
|
+
pre-commit record beat our read of its envelope, or a stale entry the
|
|
1788
|
+
scan already contradicts). The merged ranking — with the scan's top
|
|
1789
|
+
as the floor — goes through the same verification as an indexed
|
|
1790
|
+
lookup: a matching or newer envelope means the save has landed and
|
|
1791
|
+
wins; an unavailable (or, racing a delete, vanished) or older one is
|
|
1792
|
+
skipped/re-ranked and the next candidate stands. The modified-folder
|
|
1793
|
+
sweep then covers whatever committed during the scan without leaving
|
|
1794
|
+
a rankable record behind, including a first session that landed
|
|
1795
|
+
after its in-flight record failed verification.
|
|
1796
|
+
"""
|
|
1797
|
+
scan_started = datetime.now(UTC)
|
|
1798
|
+
sessions = self._scan_session_metas_sync()
|
|
1799
|
+
entries = sort_entries(SessionMruEntry(m.session_id, coerce_utc(m.updated_at)) for m in sessions)
|
|
1800
|
+
scanned = entries[0] if entries else None
|
|
1801
|
+
merged = self._note_mru(lambda: self._mru.rebuild(entries))
|
|
1802
|
+
candidates = list(merged) if merged else entries
|
|
1803
|
+
if scanned is not None and all(entry.session_id != scanned.session_id for entry in candidates):
|
|
1804
|
+
candidates.append(scanned) # ranked below every record: keep it as the floor
|
|
1805
|
+
winner = self._verify_ranked(sort_entries(candidates))
|
|
1806
|
+
since = scan_started if winner is None else min(winner.last_updated_at, scan_started)
|
|
1807
|
+
best = self._newest_modified_after(winner, since=since)
|
|
1808
|
+
return best.session_id if best is not None else None
|
|
1809
|
+
|
|
1810
|
+
def _verify_ranked(self, entries: list[SessionMruEntry]) -> SessionMruEntry | None:
|
|
1811
|
+
"""Verify *entries* (sorted newest first) against disk; return the winner.
|
|
1812
|
+
|
|
1813
|
+
The top entry is checked against its envelope: an unavailable session
|
|
1814
|
+
is dropped, a differing on-disk stamp re-ranks it in memory (only a
|
|
1815
|
+
newer stamp is written back — an older one is either transient (a
|
|
1816
|
+
live owner keeping the primary ahead of a sidecar, a save between its
|
|
1817
|
+
record and its commit) or a rollback that the session's next save
|
|
1818
|
+
overwrites, and persisting it would break the crash-recovery case
|
|
1819
|
+
where the sidecar must win) and the loop repeats until the top has
|
|
1820
|
+
been verified. ``None`` when every entry is unavailable.
|
|
1821
|
+
"""
|
|
1822
|
+
verified: set[str] = set()
|
|
1823
|
+
while entries:
|
|
1824
|
+
top = entries[0]
|
|
1825
|
+
if top.session_id in verified:
|
|
1826
|
+
return top
|
|
1827
|
+
actual = self._mru_verify(top.session_id)
|
|
1828
|
+
if actual is None:
|
|
1829
|
+
entries.pop(0)
|
|
1830
|
+
continue
|
|
1831
|
+
verified.add(top.session_id)
|
|
1832
|
+
if actual != top.last_updated_at:
|
|
1833
|
+
entries[0] = SessionMruEntry(top.session_id, actual)
|
|
1834
|
+
self._record_mru(top.session_id, actual)
|
|
1835
|
+
entries = sort_entries(entries)
|
|
1836
|
+
return None
|
|
1837
|
+
|
|
1838
|
+
def _mru_verify(self, session_id: str) -> datetime | None:
|
|
1839
|
+
"""Return the on-disk ``updated_at`` for an indexed session, or ``None``.
|
|
1840
|
+
|
|
1841
|
+
Uses ``_meta_for_session_dir`` so active-lock, recovery-sidecar and
|
|
1842
|
+
backup/snapshot fallback rules match ``list_sessions()`` exactly. A
|
|
1843
|
+
session with nothing restorable on disk — no folder, or a folder a
|
|
1844
|
+
pre-commit crash left without envelope/backup/sidecar/snapshots — is
|
|
1845
|
+
dropped from the index (see ``_prune_ghost_entry``); one that has
|
|
1846
|
+
artifacts but cannot be read right now is only skipped in memory.
|
|
1847
|
+
"""
|
|
1848
|
+
session_dir = self._session_dir(session_id)
|
|
1849
|
+
if not self._has_session_artifacts(session_dir) and not self._legacy_path(session_id).exists():
|
|
1850
|
+
legacy = self._legacy_meta_for_id(session_id)
|
|
1851
|
+
if legacy is not None: # a legacy flat file under a non-canonical name
|
|
1852
|
+
return coerce_utc(legacy.updated_at)
|
|
1853
|
+
self._prune_ghost_entry(session_id)
|
|
1854
|
+
return None
|
|
1855
|
+
self._migrate_if_needed(session_id)
|
|
1856
|
+
meta = self._meta_for_session_dir(session_dir)
|
|
1857
|
+
if meta is None: # unreadable folder: the listing may still attribute a legacy file to the id
|
|
1858
|
+
meta = self._legacy_meta_for_id(session_id)
|
|
1859
|
+
return None if meta is None else coerce_utc(meta.updated_at)
|
|
1860
|
+
|
|
1861
|
+
def _legacy_meta_for_id(self, session_id: str) -> SessionMeta | None:
|
|
1862
|
+
"""The legacy flat file the listing would attribute to *session_id*, if any.
|
|
1863
|
+
|
|
1864
|
+
Legacy files are discovered by ``*.json``, not by name, so a session
|
|
1865
|
+
may live under a file that is not ``<id>.json``; the listing takes
|
|
1866
|
+
the first sorted readable file embedding the id, and so does this.
|
|
1867
|
+
"""
|
|
1868
|
+
for path in self._legacy_session_files():
|
|
1869
|
+
meta = self._legacy_meta_for_file(path)
|
|
1870
|
+
if meta is not None and meta.session_id == session_id:
|
|
1871
|
+
return meta
|
|
1872
|
+
return None
|
|
1873
|
+
|
|
1874
|
+
def _prune_ghost_entry(self, session_id: str) -> None:
|
|
1875
|
+
"""Drop an indexed session with nothing restorable — decided under its write lock.
|
|
1876
|
+
|
|
1877
|
+
Saves and forks record their id before the commit that makes the
|
|
1878
|
+
envelope (or the directory) appear, so "nothing on disk" alone is
|
|
1879
|
+
ambiguous: a busy write lock means a writer is committing it right
|
|
1880
|
+
now, and the absence must be re-checked while holding the lock so a
|
|
1881
|
+
commit that lands between the first look and this one is not
|
|
1882
|
+
mistaken for a ghost. What survives is a real ghost: a folder a
|
|
1883
|
+
pre-commit crash left empty, or a session deleted behind the index.
|
|
1884
|
+
"""
|
|
1885
|
+
try:
|
|
1886
|
+
lock = FileLock(self._write_lock_path(session_id), timeout=0.0)
|
|
1887
|
+
lock.acquire()
|
|
1888
|
+
except TimeoutError:
|
|
1889
|
+
return
|
|
1890
|
+
except OSError: # unwritable/odd lock leaf: pruning is optional, the lookup is not
|
|
1891
|
+
logger.debug("Skipping ghost prune of %s: write lock unavailable", session_id, exc_info=True)
|
|
1892
|
+
return
|
|
1893
|
+
try:
|
|
1894
|
+
if (
|
|
1895
|
+
not self._has_session_artifacts(self._session_dir(session_id))
|
|
1896
|
+
and not self._legacy_path(session_id).exists()
|
|
1897
|
+
and self._legacy_meta_for_id(session_id) is None
|
|
1898
|
+
):
|
|
1899
|
+
self._note_mru(lambda: self._mru.remove(session_id))
|
|
1900
|
+
finally:
|
|
1901
|
+
lock.release()
|
|
1902
|
+
|
|
1903
|
+
def _newest_modified_after(self, winner: SessionMruEntry | None, *, since: datetime) -> SessionMruEntry | None:
|
|
1904
|
+
"""Safety net for writers that bypass the index: re-rank *winner* against
|
|
1905
|
+
every session whose folder (or legacy file) was modified after *since*
|
|
1906
|
+
(the winner's stamp, or the start of a scan that found no winner).
|
|
1907
|
+
|
|
1908
|
+
A session that outranks the winner was necessarily written after the
|
|
1909
|
+
winner's ``updated_at``, and writing it touches its folder's mtime —
|
|
1910
|
+
so an older chrys, a copied folder, a save whose record failed, or a
|
|
1911
|
+
recovery-only session that was hidden behind its owner's active lock
|
|
1912
|
+
during the backfill scan all surface here at the cost of one stat per
|
|
1913
|
+
folder plus a parse for the (normally zero) recently touched ones.
|
|
1914
|
+
Folders are ranked one by one; a recently touched legacy flat file
|
|
1915
|
+
defers to the full listing so duplicate-id precedence matches it.
|
|
1916
|
+
"""
|
|
1917
|
+
try:
|
|
1918
|
+
cutoff_ns = int((since - _MRU_SWEEP_SLACK).timestamp() * 1_000_000_000)
|
|
1919
|
+
except OverflowError, OSError, ValueError:
|
|
1920
|
+
cutoff_ns = -1 # absurd stamp (datetime.min/max): parse everything rather than guess
|
|
1921
|
+
winner_dir = self._session_dir(winner.session_id) if winner is not None else None
|
|
1922
|
+
best = winner
|
|
1923
|
+
try:
|
|
1924
|
+
candidates = self._session_dir_candidates()
|
|
1925
|
+
except OSError:
|
|
1926
|
+
return best
|
|
1927
|
+
for session_dir in candidates:
|
|
1928
|
+
if session_dir == winner_dir or self._file_signature(session_dir)[0] <= cutoff_ns:
|
|
1929
|
+
continue
|
|
1930
|
+
meta = self._meta_for_session_dir(session_dir)
|
|
1931
|
+
if meta is not None:
|
|
1932
|
+
best = _newer_of(best, SessionMruEntry(meta.session_id, coerce_utc(meta.updated_at)))
|
|
1933
|
+
if any(self._file_signature(legacy)[0] > cutoff_ns for legacy in self._legacy_session_files()):
|
|
1934
|
+
# A freshly touched legacy flat file: which copy of a session id
|
|
1935
|
+
# counts (folder over legacy, first sorted legacy otherwise) is the
|
|
1936
|
+
# listing's rule, so let the listing rank it — legacy roots are
|
|
1937
|
+
# transitional and the meta cache makes the repeat cheap.
|
|
1938
|
+
for meta in self._scan_session_metas_sync():
|
|
1939
|
+
best = _newer_of(best, SessionMruEntry(meta.session_id, coerce_utc(meta.updated_at)))
|
|
1940
|
+
if best is not None and best != winner:
|
|
1941
|
+
self._record_mru(best.session_id, best.last_updated_at)
|
|
1942
|
+
return best
|
|
1943
|
+
|
|
1944
|
+
def _load_latest_session_id_sync(self) -> str | None:
|
|
1945
|
+
"""Sync implementation of the MRU-backed latest-session lookup."""
|
|
1946
|
+
snapshot = self._mru.load()
|
|
1947
|
+
if snapshot is None or not snapshot.complete:
|
|
1948
|
+
return self._rescan_latest_session_id()
|
|
1949
|
+
winner = self._verify_ranked(list(snapshot.sessions))
|
|
1950
|
+
if winner is None:
|
|
1951
|
+
# Every indexed entry is gone (or the index is empty): older
|
|
1952
|
+
# sessions may still exist below the index horizon, so scan once
|
|
1953
|
+
# and rebuild.
|
|
1954
|
+
return self._rescan_latest_session_id()
|
|
1955
|
+
if snapshot.horizon is not None and winner.last_updated_at < snapshot.horizon:
|
|
1956
|
+
# A downgrade dropped the winner below sessions that were trimmed
|
|
1957
|
+
# out of the index; only a full scan can rank those.
|
|
1958
|
+
return self._rescan_latest_session_id()
|
|
1959
|
+
best = self._newest_modified_after(winner, since=winner.last_updated_at)
|
|
1960
|
+
return best.session_id if best is not None else None
|
|
1961
|
+
|
|
1962
|
+
async def load_latest_session_id(self, *, chat_only: bool = False) -> str | None:
|
|
1963
|
+
"""Return the id of the most recently updated restorable session.
|
|
1964
|
+
|
|
1965
|
+
Backed by the ``session_mru.json`` index; a valid index costs one
|
|
1966
|
+
small read, verification of the newest session's envelope and a
|
|
1967
|
+
stat sweep of the session folders (parsing only those modified after
|
|
1968
|
+
the winner), while a missing/corrupt/incomplete index falls back to
|
|
1969
|
+
a full listing scan that also rebuilds the index.
|
|
1970
|
+
"""
|
|
1971
|
+
latest_id = await asyncio.to_thread(self._load_latest_session_id_sync)
|
|
1972
|
+
if latest_id is None or not chat_only:
|
|
1973
|
+
return latest_id
|
|
1974
|
+
latest = await asyncio.to_thread(self._meta_for_session_dir, self._session_dir(latest_id))
|
|
1975
|
+
if latest is not None and latest.kind == "chat":
|
|
1976
|
+
return latest_id
|
|
1977
|
+
# Keep the MRU fast path for ordinary chat use; only a workflow-only
|
|
1978
|
+
# winner requires finding the newest eligible chat among older sessions.
|
|
1979
|
+
latest_chat = max(
|
|
1980
|
+
(meta for meta in await self.list_sessions(kind="chat")),
|
|
1981
|
+
key=lambda meta: (coerce_utc(meta.updated_at), meta.session_id),
|
|
1982
|
+
default=None,
|
|
1983
|
+
)
|
|
1984
|
+
return latest_chat.session_id if latest_chat is not None else None
|
|
1985
|
+
|
|
1986
|
+
# ------------------------------------------------------------------ #
|
|
1987
|
+
# Paged listing
|
|
1988
|
+
# ------------------------------------------------------------------ #
|
|
1989
|
+
|
|
1990
|
+
def _listed_at(self, source: Path, meta: SessionMeta, entry: CatalogEntry | None) -> datetime | None:
|
|
1991
|
+
"""A session's listing time; ``None`` for a workflow session with no displayable run."""
|
|
1992
|
+
if isinstance(meta, WorkflowSessionMeta):
|
|
1993
|
+
return self._workflow_listed_at(source, meta, entry)
|
|
1994
|
+
return meta.updated_at
|
|
1995
|
+
|
|
1996
|
+
def _open_session_listing_sync(self, kind: Literal["chat", "workflow"]) -> SessionListing:
|
|
1997
|
+
self._refresh_catalog()
|
|
1998
|
+
entries: list[SessionListingEntry] = []
|
|
1999
|
+
seen: set[str] = set()
|
|
2000
|
+
for session_dir in self._session_dir_candidates():
|
|
2001
|
+
try:
|
|
2002
|
+
resolved = self._listing_meta(session_dir)
|
|
2003
|
+
except KeyError, ValueError, TypeError, OSError:
|
|
2004
|
+
continue
|
|
2005
|
+
if resolved is None:
|
|
2006
|
+
continue
|
|
2007
|
+
meta, entry = resolved
|
|
2008
|
+
if meta.session_id in seen:
|
|
2009
|
+
continue # a copied folder: the first one lists, as when streaming
|
|
2010
|
+
seen.add(meta.session_id)
|
|
2011
|
+
if meta.kind == kind and (listed_at := self._listed_at(session_dir, meta, entry)) is not None:
|
|
2012
|
+
entries.append(
|
|
2013
|
+
SessionListingEntry(
|
|
2014
|
+
meta.session_id, coerce_utc(listed_at), meta.last_surface or SessionSurface.TUI, session_dir
|
|
2015
|
+
)
|
|
2016
|
+
)
|
|
2017
|
+
for path, meta in self._legacy_session_metas_with_paths(seen):
|
|
2018
|
+
if meta.kind != kind:
|
|
2019
|
+
continue
|
|
2020
|
+
# A legacy file is migrated to its folder on first load; until then
|
|
2021
|
+
# any workflow run lives under the folder its id names.
|
|
2022
|
+
listed_at = self._listed_at(self._session_dir(meta.session_id), meta, None)
|
|
2023
|
+
if listed_at is not None:
|
|
2024
|
+
entries.append(
|
|
2025
|
+
SessionListingEntry(
|
|
2026
|
+
meta.session_id,
|
|
2027
|
+
coerce_utc(listed_at),
|
|
2028
|
+
meta.last_surface or SessionSurface.TUI,
|
|
2029
|
+
path,
|
|
2030
|
+
legacy=True,
|
|
2031
|
+
)
|
|
2032
|
+
)
|
|
2033
|
+
self._settle_listing_caches()
|
|
2034
|
+
entries.sort(key=lambda item: (item.listed_at, item.session_id), reverse=True)
|
|
2035
|
+
return SessionListing(kind, tuple(entries))
|
|
2036
|
+
|
|
2037
|
+
async def open_session_listing(self, *, kind: Literal["chat", "workflow"]) -> SessionListing:
|
|
2038
|
+
"""Snapshot the displayable sessions of *kind*, newest first, for paging.
|
|
2039
|
+
|
|
2040
|
+
Costs a ``stat`` per session folder plus a parse for each session
|
|
2041
|
+
changed since it was last listed (by any process, through the
|
|
2042
|
+
persisted catalog); folder sizes and run status are left to
|
|
2043
|
+
:meth:`load_session_page`.
|
|
2044
|
+
"""
|
|
2045
|
+
return await asyncio.to_thread(self._open_session_listing_sync, kind)
|
|
2046
|
+
|
|
2047
|
+
def _load_session_page_sync(
|
|
2048
|
+
self, listing: SessionListing, surfaces: Collection[SessionSurface], page: int, page_size: int
|
|
2049
|
+
) -> SessionPage:
|
|
2050
|
+
from chrys.service.trajectory.tombstone import pending_delete_intents
|
|
2051
|
+
|
|
2052
|
+
selected, current, page_count, total = page_slice(listing, surfaces, page, page_size=page_size)
|
|
2053
|
+
deleted = pending_delete_intents(self._dir) if selected else frozenset()
|
|
2054
|
+
metas: list[SessionMeta] = []
|
|
2055
|
+
for item in selected:
|
|
2056
|
+
meta = self._legacy_meta_for_file(item.source) if item.legacy else None
|
|
2057
|
+
# Loading a legacy session since the snapshot moved it into its folder.
|
|
2058
|
+
folder = self._session_dir(item.session_id) if item.legacy else item.source
|
|
2059
|
+
# A logical delete may leave the primary pinned.
|
|
2060
|
+
if meta is None and folder.name not in deleted:
|
|
2061
|
+
meta = self._meta_for_session_dir(folder)
|
|
2062
|
+
if meta is not None:
|
|
2063
|
+
meta = replace(meta, size_bytes=_dir_size(folder))
|
|
2064
|
+
# A session deleted, or replaced by another, since the snapshot drops out of its page.
|
|
2065
|
+
if meta is not None and meta.session_id == item.session_id and meta.kind == listing.kind:
|
|
2066
|
+
metas.append(self._with_workflow_status(meta))
|
|
2067
|
+
self._commit_catalog()
|
|
2068
|
+
return SessionPage(tuple(metas), current, page_count, total)
|
|
2069
|
+
|
|
2070
|
+
async def load_session_page(
|
|
2071
|
+
self,
|
|
2072
|
+
listing: SessionListing,
|
|
2073
|
+
*,
|
|
2074
|
+
surfaces: Collection[SessionSurface],
|
|
2075
|
+
page: int = 1,
|
|
2076
|
+
page_size: int = SESSION_PAGE_SIZE,
|
|
2077
|
+
) -> SessionPage:
|
|
2078
|
+
"""Load one page of *listing* filtered to *surfaces*: fresh metas with sizes and run status.
|
|
2079
|
+
|
|
2080
|
+
Rows keep the snapshot's order and page; their contents (title,
|
|
2081
|
+
surface, run status) are read now, so a session changed since the
|
|
2082
|
+
snapshot shows its current state where the snapshot placed it.
|
|
2083
|
+
"""
|
|
2084
|
+
return await asyncio.to_thread(self._load_session_page_sync, listing, surfaces, page, page_size)
|
|
2085
|
+
|
|
2086
|
+
def _delete_session_sync(self, session_id: str, *, allow_active: bool = False) -> None:
|
|
2087
|
+
"""Sync implementation of session delete (runs in a thread)."""
|
|
2088
|
+
session_dir = self._session_dir(session_id)
|
|
2089
|
+
legacy = self._legacy_path(session_id)
|
|
2090
|
+
if not session_dir.exists() and not legacy.exists():
|
|
2091
|
+
return
|
|
2092
|
+
|
|
2093
|
+
active_lock: FileLock | None = None
|
|
2094
|
+
if not allow_active:
|
|
2095
|
+
active_lock = FileLock(self.active_lock_path(session_id), timeout=SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS)
|
|
2096
|
+
active_lock.acquire()
|
|
2097
|
+
try:
|
|
2098
|
+
with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
|
|
2099
|
+
if session_dir.is_dir():
|
|
2100
|
+
# A live trajectory writer (a stuck worker here, a writer
|
|
2101
|
+
# in another process) turns this into a logical delete:
|
|
2102
|
+
# the directory moves to a tombstone now and is removed
|
|
2103
|
+
# once the writer's lease is free.
|
|
2104
|
+
from chrys.service.trajectory.tombstone import DeleteOutcome, delete_session_directory
|
|
2105
|
+
|
|
2106
|
+
delete_result = delete_session_directory(session_dir, sessions_root=self._dir)
|
|
2107
|
+
if delete_result.outcome == DeleteOutcome.INTENT_FAILED and session_dir.exists():
|
|
2108
|
+
# The rename failed, the durable delete intent failed,
|
|
2109
|
+
# and best-effort removal left something behind. No
|
|
2110
|
+
# sweeper owns that directory, so reporting success
|
|
2111
|
+
# would let it reappear after its MRU entry was removed.
|
|
2112
|
+
raise OSError(f"Session directory could not be deleted or scheduled: {session_dir}")
|
|
2113
|
+
# Also clean up legacy flat file if it exists
|
|
2114
|
+
if legacy.exists():
|
|
2115
|
+
legacy.unlink()
|
|
2116
|
+
with contextlib.suppress(OSError):
|
|
2117
|
+
self.active_owner_path(session_id).unlink()
|
|
2118
|
+
# Still under the write lock: a same-id save recreating the
|
|
2119
|
+
# session records before it can commit, so it cannot slip in
|
|
2120
|
+
# between the deletion and this removal and lose its entry.
|
|
2121
|
+
self._note_mru(lambda: self._mru.remove(session_id))
|
|
2122
|
+
# The catalog holds prompt excerpts: they go with the session.
|
|
2123
|
+
self._forget_catalog_entry(session_id)
|
|
2124
|
+
self._pending_custom_titles.pop(session_id, None)
|
|
2125
|
+
self._written_recovery_meta.pop(_session_short_id(session_id), None)
|
|
2126
|
+
finally:
|
|
2127
|
+
if active_lock is not None:
|
|
2128
|
+
active_lock.release()
|
|
2129
|
+
|
|
2130
|
+
async def delete_session(self, session_id: str, *, allow_active: bool = False) -> None:
|
|
2131
|
+
"""Delete a saved session (folder or legacy file)."""
|
|
2132
|
+
await asyncio.to_thread(self._delete_session_sync, session_id, allow_active=allow_active)
|