painapple-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shadow DB - DuckDB metadata store for shadow git turns.
|
|
3
|
+
|
|
4
|
+
Turn-centric schema: each turn (user prompt → Claude response → result) is the hero.
|
|
5
|
+
Turns are created immediately on prompt send (never lost), updated on completion.
|
|
6
|
+
|
|
7
|
+
Database: ~/.painapple-code/shadow.duckdb (global, cross-project)
|
|
8
|
+
|
|
9
|
+
Module layout:
|
|
10
|
+
shadow_db.py — ShadowDB core (connection, CRUD, async wrappers, file review)
|
|
11
|
+
shadow_db_schema.py — SCHEMA_SQL, init/migration/FTS helpers (_SchemaMixin)
|
|
12
|
+
shadow_db_queries.py — list_turns / search_turns / query_turns / stats (_QueriesMixin)
|
|
13
|
+
shadow_db_plans.py — plan create/link/list/get/update (_PlansMixin)
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
db = get_shadow_db()
|
|
17
|
+
turn_id = db.start_turn(session_id, project_hash, prompt, git_branch)
|
|
18
|
+
# ... Claude processes ...
|
|
19
|
+
db.complete_turn(turn_id, result_msg, structured_data, commit_hash, ...)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
import duckdb
|
|
33
|
+
|
|
34
|
+
from painapple_code import paths
|
|
35
|
+
from painapple_code.shadow_db_plans import _PlansMixin
|
|
36
|
+
from painapple_code.shadow_db_queries import _QueriesMixin
|
|
37
|
+
from painapple_code.shadow_db_schema import _SchemaMixin
|
|
38
|
+
from painapple_code.utils.agent_cli import short_model_name
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("painapple-code.shadow-db")
|
|
41
|
+
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
# Database location
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
DB_PATH = paths.DATA_HOME / "shadow.duckdb"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
# ShadowDB class
|
|
51
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
class ShadowDB(_SchemaMixin, _QueriesMixin, _PlansMixin):
|
|
54
|
+
"""DuckDB metadata store for shadow git turns.
|
|
55
|
+
|
|
56
|
+
Thread-safe: all writes go through _execute() which uses a lock.
|
|
57
|
+
Async-safe: use the async wrappers (astart_turn, acomplete_turn) from async code.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Checkpoint WAL every N completed turns or every CHECKPOINT_INTERVAL_SEC seconds
|
|
61
|
+
CHECKPOINT_EVERY_TURNS = 10
|
|
62
|
+
CHECKPOINT_INTERVAL_SEC = 3600 # 1 hour
|
|
63
|
+
|
|
64
|
+
def __init__(self, db_path: Optional[Path] = None):
|
|
65
|
+
self.db_path = db_path or DB_PATH
|
|
66
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
self._con: Optional[duckdb.DuckDBPyConnection] = None
|
|
69
|
+
self._init_done = False
|
|
70
|
+
self._fts_ready = False # FTS indexes built and current
|
|
71
|
+
self._fts_dirty = False # New data since last FTS rebuild
|
|
72
|
+
self._turns_since_checkpoint = 0
|
|
73
|
+
self._last_checkpoint = time.monotonic()
|
|
74
|
+
|
|
75
|
+
def _get_con(self) -> duckdb.DuckDBPyConnection:
|
|
76
|
+
"""Get or create DuckDB connection with automatic corruption recovery.
|
|
77
|
+
|
|
78
|
+
Recovery strategies (tried in order on connect or init failure):
|
|
79
|
+
1. Normal connect + init
|
|
80
|
+
2. WAL corruption → back up WAL, reconnect
|
|
81
|
+
3. ART index corruption → drop and recreate all indexes
|
|
82
|
+
"""
|
|
83
|
+
if self._con is None:
|
|
84
|
+
self._con = self._open_db()
|
|
85
|
+
if not self._init_done:
|
|
86
|
+
try:
|
|
87
|
+
self._init_schema()
|
|
88
|
+
except (duckdb.FatalException, duckdb.InternalException, duckdb.TransactionException) as e:
|
|
89
|
+
logger.error(f"Schema init hit DB corruption: {e}")
|
|
90
|
+
try:
|
|
91
|
+
self._con.close()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
self._con = self._recover_indexes()
|
|
95
|
+
self._init_schema()
|
|
96
|
+
self._init_done = True
|
|
97
|
+
try:
|
|
98
|
+
self._con.execute("CHECKPOINT")
|
|
99
|
+
logger.info("Startup WAL checkpoint")
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
self._lock_down_external_access()
|
|
103
|
+
return self._con
|
|
104
|
+
|
|
105
|
+
def _lock_down_external_access(self):
|
|
106
|
+
"""Disable DuckDB external file access once FTS is installed.
|
|
107
|
+
|
|
108
|
+
The ad-hoc SQL endpoint (POST /api/shadow-db/sql) runs on this same
|
|
109
|
+
connection instance. enable_external_access=false is instance-wide and
|
|
110
|
+
one-way, so it must be set AFTER INSTALL/LOAD fts + index build (those
|
|
111
|
+
need external access on first run). Afterwards FTS queries and index
|
|
112
|
+
rebuilds still work (the extension is already loaded), but read_csv/
|
|
113
|
+
read_parquet, the `FROM 'file'` replacement scan, and `COPY ... TO 'file'`
|
|
114
|
+
are all blocked engine-wide — closing the file-read/exfil vectors that a
|
|
115
|
+
string-level denylist can't fully cover. The server never legitimately
|
|
116
|
+
reads external files through DuckDB, so nothing else is affected.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
self._con.execute("SET enable_external_access=false")
|
|
120
|
+
logger.info("DuckDB external file access locked down (SQL endpoint hardening)")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning(f"Could not lock down DuckDB external access: {e}")
|
|
123
|
+
|
|
124
|
+
def _open_db(self) -> duckdb.DuckDBPyConnection:
|
|
125
|
+
"""Open DuckDB, recovering from WAL corruption if needed."""
|
|
126
|
+
import time as _time
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
con = duckdb.connect(str(self.db_path))
|
|
130
|
+
con.execute("SELECT 1") # triggers WAL replay
|
|
131
|
+
logger.info(f"Connected to shadow DB: {self.db_path}")
|
|
132
|
+
return con
|
|
133
|
+
except duckdb.IOException as e:
|
|
134
|
+
# Lock contention is not recoverable here — re-raise immediately
|
|
135
|
+
if "lock" in str(e).lower():
|
|
136
|
+
raise
|
|
137
|
+
logger.error(f"DuckDB connect failed: {e}")
|
|
138
|
+
try:
|
|
139
|
+
con.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
except (duckdb.FatalException, duckdb.InternalException, duckdb.TransactionException) as e:
|
|
143
|
+
logger.error(f"DuckDB connect failed: {e}")
|
|
144
|
+
try:
|
|
145
|
+
con.close()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# WAL corruption → back up and retry without it
|
|
150
|
+
wal_path = Path(f"{self.db_path}.wal")
|
|
151
|
+
if wal_path.exists():
|
|
152
|
+
backup = wal_path.with_suffix(f".wal.corrupt-{int(_time.time())}")
|
|
153
|
+
# os.replace: Path.rename raises on win32 if the dest exists
|
|
154
|
+
# (same-second double recovery would collide on the timestamp).
|
|
155
|
+
os.replace(wal_path, backup)
|
|
156
|
+
logger.warning(f"Moved corrupt WAL to {backup.name} — some recent turns may be lost")
|
|
157
|
+
con = duckdb.connect(str(self.db_path))
|
|
158
|
+
con.execute("SELECT 1")
|
|
159
|
+
logger.info(f"Connected to shadow DB (WAL recovered): {self.db_path}")
|
|
160
|
+
return con
|
|
161
|
+
raise # no WAL to remove, re-raise
|
|
162
|
+
|
|
163
|
+
def _maybe_checkpoint(self):
|
|
164
|
+
"""Checkpoint WAL if enough turns or time have elapsed. Call with _lock held."""
|
|
165
|
+
if self._con is None:
|
|
166
|
+
return
|
|
167
|
+
elapsed = time.monotonic() - self._last_checkpoint
|
|
168
|
+
if (self._turns_since_checkpoint >= self.CHECKPOINT_EVERY_TURNS
|
|
169
|
+
or elapsed >= self.CHECKPOINT_INTERVAL_SEC):
|
|
170
|
+
try:
|
|
171
|
+
self._con.execute("CHECKPOINT")
|
|
172
|
+
logger.debug(f"WAL checkpoint ({self._turns_since_checkpoint} turns, {elapsed:.0f}s)")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f"Checkpoint failed: {e}")
|
|
175
|
+
self._turns_since_checkpoint = 0
|
|
176
|
+
self._last_checkpoint = time.monotonic()
|
|
177
|
+
|
|
178
|
+
def _execute(self, sql: str, params: list = None) -> duckdb.DuckDBPyConnection:
|
|
179
|
+
"""Execute SQL with thread lock. For fire-and-forget statements only.
|
|
180
|
+
|
|
181
|
+
WARNING: Do NOT chain .fetchone()/.fetchall() on the result — the lock
|
|
182
|
+
is released on return, so another thread can invalidate the cursor.
|
|
183
|
+
Use _fetch_one/_fetch_all/_fetch_columns instead.
|
|
184
|
+
"""
|
|
185
|
+
with self._lock:
|
|
186
|
+
con = self._get_con()
|
|
187
|
+
if params:
|
|
188
|
+
return con.execute(sql, params)
|
|
189
|
+
return con.execute(sql)
|
|
190
|
+
|
|
191
|
+
def _fetch_one(self, sql: str, params: list = None) -> Optional[tuple]:
|
|
192
|
+
"""Execute SQL and fetchone() atomically under the lock."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
con = self._get_con()
|
|
195
|
+
result = con.execute(sql, params) if params else con.execute(sql)
|
|
196
|
+
return result.fetchone()
|
|
197
|
+
|
|
198
|
+
def _fetch_all(self, sql: str, params: list = None) -> list:
|
|
199
|
+
"""Execute SQL and fetchall() atomically under the lock."""
|
|
200
|
+
with self._lock:
|
|
201
|
+
con = self._get_con()
|
|
202
|
+
result = con.execute(sql, params) if params else con.execute(sql)
|
|
203
|
+
return result.fetchall()
|
|
204
|
+
|
|
205
|
+
def _fetch_columns(self, sql: str, params: list = None) -> tuple[list[str], list]:
|
|
206
|
+
"""Execute SQL and return (column_names, rows) atomically under the lock."""
|
|
207
|
+
with self._lock:
|
|
208
|
+
con = self._get_con()
|
|
209
|
+
result = con.execute(sql, params) if params else con.execute(sql)
|
|
210
|
+
columns = [desc[0] for desc in result.description]
|
|
211
|
+
return columns, result.fetchall()
|
|
212
|
+
|
|
213
|
+
# Hard deadline for caller-written SQL. Long enough that no honest query
|
|
214
|
+
# against this schema reaches it; short enough to bound shutdown.
|
|
215
|
+
ADHOC_TIMEOUT_SEC = 30.0
|
|
216
|
+
|
|
217
|
+
def fetch_columns_adhoc(self, sql: str, timeout: float = None) -> tuple[list[str], list]:
|
|
218
|
+
"""Run CALLER-SUPPLIED SQL under a hard deadline, off the shared connection.
|
|
219
|
+
|
|
220
|
+
Every other query in this class is one we wrote, with a bounded shape.
|
|
221
|
+
Ad-hoc SQL is the only path where the user picks the query, so it is the
|
|
222
|
+
only one that can run unbounded — a `WITH RECURSIVE` typo is enough, and
|
|
223
|
+
it passes the endpoint's keyword validator because it is a plain SELECT.
|
|
224
|
+
Two deliberate deviations from `_fetch_columns`:
|
|
225
|
+
|
|
226
|
+
- **Its own cursor**, not the shared `_con` under `_lock`. A slow read
|
|
227
|
+
therefore cannot stall turn recording, and — the load-bearing part —
|
|
228
|
+
the watchdog below can only ever interrupt *this* query. Interrupting
|
|
229
|
+
the shared connection would race a turn write that acquired the lock
|
|
230
|
+
just as the deadline fired, and kill the write instead.
|
|
231
|
+
- **A watchdog that calls `interrupt()`** at the deadline. This is the
|
|
232
|
+
only thing that actually ends a runaway query: Python cannot kill a
|
|
233
|
+
thread, and DuckDB has no statement timeout. Without it an in-flight
|
|
234
|
+
request blocks uvicorn's graceful shutdown indefinitely, so SIGTERM
|
|
235
|
+
closes the listening socket but never exits — `pkill` appears to do
|
|
236
|
+
nothing and recovery needs `kill -9`.
|
|
237
|
+
|
|
238
|
+
`interrupt()` is cooperative (DuckDB polls a flag between operators), so
|
|
239
|
+
this is the best available mechanism rather than a guarantee.
|
|
240
|
+
"""
|
|
241
|
+
with self._lock:
|
|
242
|
+
cursor = self._get_con().cursor()
|
|
243
|
+
watchdog = threading.Timer(timeout or self.ADHOC_TIMEOUT_SEC, cursor.interrupt)
|
|
244
|
+
watchdog.start()
|
|
245
|
+
try:
|
|
246
|
+
result = cursor.execute(sql)
|
|
247
|
+
columns = [desc[0] for desc in result.description]
|
|
248
|
+
return columns, result.fetchall()
|
|
249
|
+
finally:
|
|
250
|
+
watchdog.cancel()
|
|
251
|
+
cursor.close()
|
|
252
|
+
|
|
253
|
+
def _fetch_one_dict(self, sql: str, params: list = None) -> Optional[dict]:
|
|
254
|
+
"""Execute SQL and return the first row as {column: value} (or None).
|
|
255
|
+
|
|
256
|
+
Prefer this over `_fetch_one` + `row[N]` indexing — positional access
|
|
257
|
+
breaks silently when columns are added/reordered.
|
|
258
|
+
"""
|
|
259
|
+
columns, rows = self._fetch_columns(sql, params)
|
|
260
|
+
if not rows:
|
|
261
|
+
return None
|
|
262
|
+
return dict(zip(columns, rows[0]))
|
|
263
|
+
|
|
264
|
+
def _executemany(self, sql: str, params_list: list) -> None:
|
|
265
|
+
"""Execute SQL with multiple parameter sets."""
|
|
266
|
+
with self._lock:
|
|
267
|
+
con = self._get_con()
|
|
268
|
+
con.executemany(sql, params_list)
|
|
269
|
+
|
|
270
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
271
|
+
# Project identity (one project per local path; SHA256 of abspath)
|
|
272
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
def get_project(self, project_id: str) -> Optional[dict]:
|
|
275
|
+
"""Return project metadata as a dict, or None if not found."""
|
|
276
|
+
return self._fetch_one_dict(
|
|
277
|
+
"SELECT id, name, merged_into, created_at FROM projects WHERE id = ?",
|
|
278
|
+
[project_id]
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def create_project(self, project_id: str, name: str) -> None:
|
|
282
|
+
"""Insert a new project row keyed by the path-derived hash."""
|
|
283
|
+
self._execute(
|
|
284
|
+
"INSERT INTO projects (id, name) VALUES (?, ?) "
|
|
285
|
+
"ON CONFLICT (id) DO NOTHING",
|
|
286
|
+
[project_id, name]
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def rename_project(self, project_id: str, name: str) -> None:
|
|
290
|
+
"""Update the display name."""
|
|
291
|
+
self._execute(
|
|
292
|
+
"UPDATE projects SET name = ? WHERE id = ?", [name, project_id]
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def merge_project(self, source: str, target: str) -> None:
|
|
296
|
+
"""Fold source project into target. Reversible by setting
|
|
297
|
+
merged_into = NULL on the source row.
|
|
298
|
+
|
|
299
|
+
Reserved for the future merge widget — no callers today.
|
|
300
|
+
"""
|
|
301
|
+
if source == target:
|
|
302
|
+
return
|
|
303
|
+
self._execute(
|
|
304
|
+
"UPDATE projects SET merged_into = ? WHERE id = ?", [target, source]
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def list_projects(self, include_merged: bool = False) -> list[dict]:
|
|
308
|
+
"""All projects with turn counts."""
|
|
309
|
+
sql = (
|
|
310
|
+
"SELECT p.id, p.name, p.merged_into, p.created_at, "
|
|
311
|
+
" (SELECT COUNT(*) FROM turns t WHERE t.project_hash = p.id) AS turn_count "
|
|
312
|
+
"FROM projects p"
|
|
313
|
+
)
|
|
314
|
+
if not include_merged:
|
|
315
|
+
sql += " WHERE p.merged_into IS NULL"
|
|
316
|
+
sql += " ORDER BY p.created_at DESC"
|
|
317
|
+
rows = self._fetch_all(sql)
|
|
318
|
+
return [
|
|
319
|
+
{
|
|
320
|
+
"id": r[0], "name": r[1], "merged_into": r[2],
|
|
321
|
+
"created_at": r[3], "turn_count": r[4],
|
|
322
|
+
}
|
|
323
|
+
for r in rows
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
def close(self):
|
|
327
|
+
"""Close the database connection, checkpointing WAL first."""
|
|
328
|
+
if self._con:
|
|
329
|
+
try:
|
|
330
|
+
self._con.execute("CHECKPOINT")
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
self._con.close()
|
|
334
|
+
self._con = None
|
|
335
|
+
self._init_done = False
|
|
336
|
+
|
|
337
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
338
|
+
# Session management
|
|
339
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
340
|
+
|
|
341
|
+
def ensure_session(self, session_id: str, project_hash: str,
|
|
342
|
+
name: Optional[str] = None, model: Optional[str] = None,
|
|
343
|
+
git_repo_hash: Optional[str] = None,
|
|
344
|
+
provider: Optional[str] = None):
|
|
345
|
+
"""Create session if it doesn't exist, update if it does."""
|
|
346
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
347
|
+
self._execute(
|
|
348
|
+
"""INSERT INTO sessions (id, project_hash, git_repo_hash, name, created_at, updated_at, model, provider)
|
|
349
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
350
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
351
|
+
updated_at = EXCLUDED.updated_at,
|
|
352
|
+
name = COALESCE(EXCLUDED.name, sessions.name),
|
|
353
|
+
model = COALESCE(EXCLUDED.model, sessions.model),
|
|
354
|
+
git_repo_hash = COALESCE(EXCLUDED.git_repo_hash, sessions.git_repo_hash),
|
|
355
|
+
provider = COALESCE(EXCLUDED.provider, sessions.provider)""",
|
|
356
|
+
[session_id, project_hash, git_repo_hash, name, now, now, model,
|
|
357
|
+
provider] # None upserts to NULL only on a fresh row; COALESCE
|
|
358
|
+
# keeps an existing row's label (never downgrades it)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
362
|
+
# Turn lifecycle
|
|
363
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
364
|
+
|
|
365
|
+
def start_turn(
|
|
366
|
+
self,
|
|
367
|
+
session_id: str,
|
|
368
|
+
project_hash: str,
|
|
369
|
+
user_prompt: str,
|
|
370
|
+
git_branch: Optional[str] = None,
|
|
371
|
+
shadow_branch: Optional[str] = None,
|
|
372
|
+
has_images: bool = False,
|
|
373
|
+
has_files: bool = False,
|
|
374
|
+
turn_number: Optional[int] = None,
|
|
375
|
+
git_repo_hash: Optional[str] = None,
|
|
376
|
+
is_plan: bool = False,
|
|
377
|
+
provider: Optional[str] = None,
|
|
378
|
+
) -> str:
|
|
379
|
+
"""Record turn start. Returns turn_id (UUID).
|
|
380
|
+
|
|
381
|
+
Called immediately when user sends a message - prompt is never lost.
|
|
382
|
+
"""
|
|
383
|
+
turn_id = str(uuid.uuid4())
|
|
384
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
385
|
+
|
|
386
|
+
self._execute(
|
|
387
|
+
"""INSERT INTO turns (id, session_id, project_hash, git_repo_hash, turn_number, status,
|
|
388
|
+
user_prompt, has_images, has_files, started_at,
|
|
389
|
+
git_branch, shadow_branch, is_plan, provider)
|
|
390
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
391
|
+
[turn_id, session_id, project_hash, git_repo_hash, turn_number,
|
|
392
|
+
user_prompt, has_images, has_files, now,
|
|
393
|
+
git_branch, shadow_branch, is_plan,
|
|
394
|
+
provider or "claude"] # mirror the column's legacy DEFAULT
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Update session
|
|
398
|
+
self.ensure_session(session_id, project_hash, git_repo_hash=git_repo_hash,
|
|
399
|
+
provider=provider)
|
|
400
|
+
|
|
401
|
+
logger.debug(f"Turn started: {turn_id[:8]} session={session_id[:8]} branch={git_branch}")
|
|
402
|
+
return turn_id
|
|
403
|
+
|
|
404
|
+
def complete_turn(
|
|
405
|
+
self,
|
|
406
|
+
turn_id: str,
|
|
407
|
+
*,
|
|
408
|
+
# From result message
|
|
409
|
+
result_msg: Optional[dict] = None,
|
|
410
|
+
# From shadow git
|
|
411
|
+
git_hash: Optional[str] = None,
|
|
412
|
+
git_branch: Optional[str] = None,
|
|
413
|
+
shadow_branch: Optional[str] = None,
|
|
414
|
+
# From the summary fork
|
|
415
|
+
structured_data: Optional[dict] = None,
|
|
416
|
+
summary_cost: float = 0.0,
|
|
417
|
+
# Tracker data
|
|
418
|
+
modified_files: Optional[list[str]] = None,
|
|
419
|
+
tools_summary: Optional[dict[str, int]] = None,
|
|
420
|
+
# Model behind the main chat thread, captured live during the turn
|
|
421
|
+
# (TurnTracker.main_thread_model). Authoritative when present.
|
|
422
|
+
main_thread_model: Optional[str] = None,
|
|
423
|
+
# Override status
|
|
424
|
+
status: str = "completed",
|
|
425
|
+
error_message: Optional[str] = None,
|
|
426
|
+
):
|
|
427
|
+
"""Finalize a turn with result data.
|
|
428
|
+
|
|
429
|
+
Called after shadow git commit completes (has structured_data + git_hash).
|
|
430
|
+
"""
|
|
431
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
432
|
+
|
|
433
|
+
# Extract fields from result message
|
|
434
|
+
cost = 0.0
|
|
435
|
+
tokens_in = 0
|
|
436
|
+
tokens_out = 0
|
|
437
|
+
duration_ms = 0
|
|
438
|
+
duration_api_ms = 0
|
|
439
|
+
model = main_thread_model
|
|
440
|
+
provider_session_id = None
|
|
441
|
+
provider_turn_uuid = None
|
|
442
|
+
result_subtype = None
|
|
443
|
+
is_error = False
|
|
444
|
+
num_tool_loops = 0
|
|
445
|
+
service_tier = None
|
|
446
|
+
cache_read = 0
|
|
447
|
+
cache_creation = 0
|
|
448
|
+
web_search = 0
|
|
449
|
+
web_fetch = 0
|
|
450
|
+
permission_denials = None
|
|
451
|
+
model_usage_data = {}
|
|
452
|
+
|
|
453
|
+
if result_msg:
|
|
454
|
+
cost = result_msg.get("total_cost_usd", 0)
|
|
455
|
+
duration_ms = result_msg.get("duration_ms", 0)
|
|
456
|
+
duration_api_ms = result_msg.get("duration_api_ms", 0)
|
|
457
|
+
num_tool_loops = result_msg.get("num_turns", 0)
|
|
458
|
+
result_subtype = result_msg.get("subtype")
|
|
459
|
+
is_error = result_msg.get("is_error", False)
|
|
460
|
+
provider_session_id = result_msg.get("session_id")
|
|
461
|
+
provider_turn_uuid = result_msg.get("uuid")
|
|
462
|
+
permission_denials = result_msg.get("permission_denials") or None
|
|
463
|
+
|
|
464
|
+
usage = result_msg.get("usage", {})
|
|
465
|
+
tokens_in = usage.get("input_tokens", 0)
|
|
466
|
+
tokens_out = usage.get("output_tokens", 0)
|
|
467
|
+
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
468
|
+
cache_creation = usage.get("cache_creation_input_tokens", 0)
|
|
469
|
+
service_tier = usage.get("service_tier")
|
|
470
|
+
|
|
471
|
+
model_usage_data = result_msg.get("modelUsage", {})
|
|
472
|
+
if model_usage_data:
|
|
473
|
+
# Cost is only a fallback: modelUsage aggregates Task-tool
|
|
474
|
+
# subagents with the main thread, so the highest spender is
|
|
475
|
+
# often a subagent, not the model that answered. Prefer the
|
|
476
|
+
# main-thread model captured live during the turn.
|
|
477
|
+
if not model:
|
|
478
|
+
model = max(model_usage_data.keys(),
|
|
479
|
+
key=lambda m: model_usage_data[m].get("costUSD", 0))
|
|
480
|
+
# Aggregate web search/fetch across models
|
|
481
|
+
for mu in model_usage_data.values():
|
|
482
|
+
web_search += mu.get("webSearchRequests", 0)
|
|
483
|
+
web_fetch += mu.get("webFetchRequests", 0)
|
|
484
|
+
|
|
485
|
+
if is_error and not error_message:
|
|
486
|
+
# Try to extract error from result text
|
|
487
|
+
error_message = result_msg.get("result", "")[:500] if result_msg else None
|
|
488
|
+
status = "failed"
|
|
489
|
+
|
|
490
|
+
# Update the turn
|
|
491
|
+
self._execute(
|
|
492
|
+
"""UPDATE turns SET
|
|
493
|
+
status = ?,
|
|
494
|
+
completed_at = ?,
|
|
495
|
+
git_hash = ?,
|
|
496
|
+
git_branch = COALESCE(?, git_branch),
|
|
497
|
+
shadow_branch = COALESCE(?, shadow_branch),
|
|
498
|
+
cost = ?,
|
|
499
|
+
summary_cost = ?,
|
|
500
|
+
tokens_in = ?,
|
|
501
|
+
tokens_out = ?,
|
|
502
|
+
duration_ms = ?,
|
|
503
|
+
model = ?,
|
|
504
|
+
provider_session_id = ?,
|
|
505
|
+
provider_turn_uuid = ?,
|
|
506
|
+
result_subtype = ?,
|
|
507
|
+
is_error = ?,
|
|
508
|
+
num_tool_loops = ?,
|
|
509
|
+
duration_api_ms = ?,
|
|
510
|
+
service_tier = ?,
|
|
511
|
+
cache_read_tokens = ?,
|
|
512
|
+
cache_creation_tokens = ?,
|
|
513
|
+
web_search_count = ?,
|
|
514
|
+
web_fetch_count = ?,
|
|
515
|
+
error_message = ?,
|
|
516
|
+
permission_denials = ?
|
|
517
|
+
WHERE id = ?""",
|
|
518
|
+
[status, now, git_hash, git_branch, shadow_branch, cost, summary_cost,
|
|
519
|
+
tokens_in, tokens_out, duration_ms, model,
|
|
520
|
+
provider_session_id, provider_turn_uuid, result_subtype,
|
|
521
|
+
is_error, num_tool_loops, duration_api_ms, service_tier,
|
|
522
|
+
cache_read, cache_creation, web_search, web_fetch,
|
|
523
|
+
error_message, permission_denials, turn_id]
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Insert per-model usage
|
|
527
|
+
if model_usage_data:
|
|
528
|
+
for model_id, mu in model_usage_data.items():
|
|
529
|
+
self._execute(
|
|
530
|
+
"""INSERT INTO turn_model_usage
|
|
531
|
+
(id, turn_id, model_id, model_name, input_tokens, output_tokens,
|
|
532
|
+
cache_read_tokens, cache_creation_tokens, cost, context_window, web_search_count)
|
|
533
|
+
VALUES (nextval('seq_model_usage'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
534
|
+
ON CONFLICT (turn_id, model_id) DO NOTHING""",
|
|
535
|
+
[turn_id, model_id, short_model_name(model_id),
|
|
536
|
+
mu.get("inputTokens", 0), mu.get("outputTokens", 0),
|
|
537
|
+
mu.get("cacheReadInputTokens", 0), mu.get("cacheCreationInputTokens", 0),
|
|
538
|
+
mu.get("costUSD", 0), mu.get("contextWindow"),
|
|
539
|
+
mu.get("webSearchRequests", 0)]
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Insert structured fields from the summary fork
|
|
543
|
+
if structured_data:
|
|
544
|
+
self._insert_structured_fields(turn_id, structured_data)
|
|
545
|
+
|
|
546
|
+
# Insert files
|
|
547
|
+
if modified_files:
|
|
548
|
+
for fp in modified_files:
|
|
549
|
+
self._execute(
|
|
550
|
+
"""INSERT INTO turn_files (id, turn_id, file_path, change_type)
|
|
551
|
+
VALUES (nextval('seq_turn_files'), ?, ?, 'modified')
|
|
552
|
+
ON CONFLICT (turn_id, file_path) DO NOTHING""",
|
|
553
|
+
[turn_id, fp]
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Insert tools
|
|
557
|
+
if tools_summary:
|
|
558
|
+
for tool_name, count in tools_summary.items():
|
|
559
|
+
self._execute(
|
|
560
|
+
"""INSERT INTO turn_tools (id, turn_id, tool_name, call_count)
|
|
561
|
+
VALUES (nextval('seq_turn_tools'), ?, ?, ?)
|
|
562
|
+
ON CONFLICT (turn_id, tool_name) DO NOTHING""",
|
|
563
|
+
[turn_id, tool_name, count]
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# Plan detection: if EnterPlanMode/ExitPlanMode used, create plan record
|
|
567
|
+
if tools_summary and {'EnterPlanMode', 'ExitPlanMode'} & set(tools_summary.keys()):
|
|
568
|
+
self._execute("UPDATE turns SET is_plan = true WHERE id = ?", [turn_id])
|
|
569
|
+
self._create_plan_from_turn(turn_id, modified_files, structured_data)
|
|
570
|
+
else:
|
|
571
|
+
# Non-plan turn: link to active plan in same session as implementation
|
|
572
|
+
self._link_turn_to_plan(turn_id)
|
|
573
|
+
|
|
574
|
+
# Update session aggregates
|
|
575
|
+
self._execute(
|
|
576
|
+
"""UPDATE sessions SET
|
|
577
|
+
turn_count = turn_count + 1,
|
|
578
|
+
total_cost = total_cost + ?,
|
|
579
|
+
updated_at = ?
|
|
580
|
+
WHERE id = (SELECT session_id FROM turns WHERE id = ?)""",
|
|
581
|
+
[cost, now, turn_id]
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
self._fts_dirty = True # FTS indexes need rebuild
|
|
585
|
+
|
|
586
|
+
logger.debug(f"Turn completed: {turn_id[:8]} git={git_hash[:8] if git_hash else 'none'} "
|
|
587
|
+
f"cost=${cost:.4f}")
|
|
588
|
+
|
|
589
|
+
# Periodic WAL checkpoint
|
|
590
|
+
with self._lock:
|
|
591
|
+
self._turns_since_checkpoint += 1
|
|
592
|
+
self._maybe_checkpoint()
|
|
593
|
+
|
|
594
|
+
def _insert_structured_fields(self, turn_id: str, data: dict):
|
|
595
|
+
"""Insert summary-fork structured output as turn_fields rows."""
|
|
596
|
+
# Known non-field keys to skip
|
|
597
|
+
skip_keys = {"session_title", "tags"}
|
|
598
|
+
|
|
599
|
+
for key, value in data.items():
|
|
600
|
+
if key in skip_keys:
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
value_text = None
|
|
604
|
+
value_list = None
|
|
605
|
+
search_text = ""
|
|
606
|
+
|
|
607
|
+
if isinstance(value, list):
|
|
608
|
+
value_list = value
|
|
609
|
+
search_text = " ".join(str(v) for v in value)
|
|
610
|
+
elif isinstance(value, str):
|
|
611
|
+
value_text = value
|
|
612
|
+
search_text = value
|
|
613
|
+
else:
|
|
614
|
+
value_text = str(value)
|
|
615
|
+
search_text = value_text
|
|
616
|
+
|
|
617
|
+
self._execute(
|
|
618
|
+
"""INSERT INTO turn_fields (id, turn_id, field_key, value_text, value_list, search_text)
|
|
619
|
+
VALUES (nextval('seq_turn_fields'), ?, ?, ?, ?, ?)
|
|
620
|
+
ON CONFLICT (turn_id, field_key) DO UPDATE SET
|
|
621
|
+
value_text = EXCLUDED.value_text,
|
|
622
|
+
value_list = EXCLUDED.value_list,
|
|
623
|
+
search_text = EXCLUDED.search_text""",
|
|
624
|
+
[turn_id, key, value_text, value_list, search_text]
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Insert tags if present
|
|
628
|
+
tags = data.get("tags", [])
|
|
629
|
+
if isinstance(tags, list):
|
|
630
|
+
for tag in tags:
|
|
631
|
+
if isinstance(tag, str) and tag.strip():
|
|
632
|
+
tag_clean = tag.strip().lstrip("#").lower()
|
|
633
|
+
self._execute(
|
|
634
|
+
"""INSERT INTO tags (id, turn_id, tag, source)
|
|
635
|
+
VALUES (nextval('seq_tags'), ?, ?, 'auto')
|
|
636
|
+
ON CONFLICT (turn_id, tag) DO NOTHING""",
|
|
637
|
+
[turn_id, tag_clean]
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def fail_turn(self, turn_id: str, error_message: str = ""):
|
|
641
|
+
"""Mark a turn as failed (crash, abort, etc.)."""
|
|
642
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
643
|
+
self._execute(
|
|
644
|
+
"""UPDATE turns SET status = 'failed', completed_at = ?, error_message = ?
|
|
645
|
+
WHERE id = ? AND status = 'pending'""",
|
|
646
|
+
[now, error_message, turn_id]
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
650
|
+
# Tags (user-editable)
|
|
651
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
652
|
+
|
|
653
|
+
def add_tag(self, turn_id: str, tag: str, source: str = "manual"):
|
|
654
|
+
"""Add a tag to a turn."""
|
|
655
|
+
tag = tag.strip().lstrip("#").lower()
|
|
656
|
+
self._execute(
|
|
657
|
+
"""INSERT INTO tags (id, turn_id, tag, source)
|
|
658
|
+
VALUES (nextval('seq_tags'), ?, ?, ?)
|
|
659
|
+
ON CONFLICT (turn_id, tag) DO NOTHING""",
|
|
660
|
+
[turn_id, tag, source]
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def remove_tag(self, turn_id: str, tag: str):
|
|
664
|
+
"""Remove a tag from a turn."""
|
|
665
|
+
self._execute("DELETE FROM tags WHERE turn_id = ? AND tag = ?", [turn_id, tag])
|
|
666
|
+
|
|
667
|
+
def rename_tag(self, old_tag: str, new_tag: str):
|
|
668
|
+
"""Rename a tag globally."""
|
|
669
|
+
new_tag = new_tag.strip().lstrip("#").lower()
|
|
670
|
+
self._execute("UPDATE tags SET tag = ? WHERE tag = ?", [new_tag, old_tag])
|
|
671
|
+
|
|
672
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
673
|
+
# Async wrappers (for use from async code)
|
|
674
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
675
|
+
|
|
676
|
+
async def astart_turn(self, **kwargs) -> str:
|
|
677
|
+
"""Async wrapper for start_turn."""
|
|
678
|
+
return await asyncio.to_thread(self.start_turn, **kwargs)
|
|
679
|
+
|
|
680
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
681
|
+
# File review acceptances
|
|
682
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
683
|
+
|
|
684
|
+
def mark_file_reviewed(
|
|
685
|
+
self,
|
|
686
|
+
session_id: str,
|
|
687
|
+
project_hash: str,
|
|
688
|
+
file_path: str,
|
|
689
|
+
shadow_commit: str,
|
|
690
|
+
status: str = "reviewed",
|
|
691
|
+
note: Optional[str] = None,
|
|
692
|
+
) -> str:
|
|
693
|
+
"""Upsert a file acceptance record. Returns acceptance ID."""
|
|
694
|
+
acceptance_id = str(uuid.uuid4())
|
|
695
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
696
|
+
self._execute(
|
|
697
|
+
"""INSERT INTO file_acceptances (id, session_id, project_hash, file_path, shadow_commit, status, note, created_at, updated_at)
|
|
698
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
699
|
+
ON CONFLICT (session_id, file_path) DO UPDATE SET
|
|
700
|
+
shadow_commit = EXCLUDED.shadow_commit,
|
|
701
|
+
status = EXCLUDED.status,
|
|
702
|
+
note = EXCLUDED.note,
|
|
703
|
+
updated_at = EXCLUDED.updated_at""",
|
|
704
|
+
[acceptance_id, session_id, project_hash, file_path, shadow_commit, status, note, now, now]
|
|
705
|
+
)
|
|
706
|
+
return acceptance_id
|
|
707
|
+
|
|
708
|
+
def mark_files_reviewed(
|
|
709
|
+
self,
|
|
710
|
+
session_id: str,
|
|
711
|
+
project_hash: str,
|
|
712
|
+
file_paths: list[str],
|
|
713
|
+
shadow_commit: str,
|
|
714
|
+
):
|
|
715
|
+
"""Batch mark files as reviewed."""
|
|
716
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
717
|
+
rows = [
|
|
718
|
+
(str(uuid.uuid4()), session_id, project_hash, fp, shadow_commit, "reviewed", None, now, now)
|
|
719
|
+
for fp in file_paths
|
|
720
|
+
]
|
|
721
|
+
self._executemany(
|
|
722
|
+
"""INSERT INTO file_acceptances (id, session_id, project_hash, file_path, shadow_commit, status, note, created_at, updated_at)
|
|
723
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
724
|
+
ON CONFLICT (session_id, file_path) DO UPDATE SET
|
|
725
|
+
shadow_commit = EXCLUDED.shadow_commit,
|
|
726
|
+
status = EXCLUDED.status,
|
|
727
|
+
note = EXCLUDED.note,
|
|
728
|
+
updated_at = EXCLUDED.updated_at""",
|
|
729
|
+
rows
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def get_file_acceptances(self, session_id: str) -> list[dict]:
|
|
733
|
+
"""Get all file acceptances for a session."""
|
|
734
|
+
rows = self._fetch_all(
|
|
735
|
+
"SELECT id, file_path, shadow_commit, status, note, created_at, updated_at FROM file_acceptances WHERE session_id = ? ORDER BY file_path",
|
|
736
|
+
[session_id]
|
|
737
|
+
)
|
|
738
|
+
return [
|
|
739
|
+
{"id": r[0], "file_path": r[1], "shadow_commit": r[2], "status": r[3], "note": r[4], "created_at": r[5], "updated_at": r[6]}
|
|
740
|
+
for r in rows
|
|
741
|
+
]
|
|
742
|
+
|
|
743
|
+
def delete_file_acceptance(self, session_id: str, file_path: str):
|
|
744
|
+
"""Remove a file acceptance (undo review)."""
|
|
745
|
+
self._execute(
|
|
746
|
+
"DELETE FROM file_acceptances WHERE session_id = ? AND file_path = ?",
|
|
747
|
+
[session_id, file_path]
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def get_recent_files(
|
|
751
|
+
self,
|
|
752
|
+
*,
|
|
753
|
+
project_hash: Optional[str] = None,
|
|
754
|
+
git_repo_hash: Optional[str] = None,
|
|
755
|
+
limit: int = 100,
|
|
756
|
+
) -> list[dict]:
|
|
757
|
+
"""Files recently touched in a project/repo, newest first.
|
|
758
|
+
|
|
759
|
+
Aggregates `turn_files` joined to `turns`, returning one row per
|
|
760
|
+
unique `file_path` with last-touched timestamp and touch count.
|
|
761
|
+
"""
|
|
762
|
+
conditions = []
|
|
763
|
+
params: list = []
|
|
764
|
+
if git_repo_hash:
|
|
765
|
+
conditions.append("t.git_repo_hash = ?")
|
|
766
|
+
params.append(git_repo_hash)
|
|
767
|
+
elif project_hash:
|
|
768
|
+
conditions.append("t.project_hash = ?")
|
|
769
|
+
params.append(project_hash)
|
|
770
|
+
else:
|
|
771
|
+
return []
|
|
772
|
+
|
|
773
|
+
where = " AND ".join(conditions)
|
|
774
|
+
rows = self._fetch_all(
|
|
775
|
+
f"""SELECT tf.file_path,
|
|
776
|
+
COUNT(DISTINCT tf.turn_id) AS touch_count,
|
|
777
|
+
MAX(COALESCE(t.completed_at, t.started_at)) AS last_touched_at
|
|
778
|
+
FROM turn_files tf
|
|
779
|
+
JOIN turns t ON tf.turn_id = t.id
|
|
780
|
+
WHERE {where}
|
|
781
|
+
GROUP BY tf.file_path
|
|
782
|
+
ORDER BY last_touched_at DESC
|
|
783
|
+
LIMIT ?""",
|
|
784
|
+
params + [limit],
|
|
785
|
+
)
|
|
786
|
+
return [
|
|
787
|
+
{
|
|
788
|
+
"path": r[0],
|
|
789
|
+
"touch_count": int(r[1]) if r[1] is not None else 0,
|
|
790
|
+
"last_touched_at": str(r[2]) if r[2] is not None else None,
|
|
791
|
+
}
|
|
792
|
+
for r in rows
|
|
793
|
+
]
|
|
794
|
+
|
|
795
|
+
async def acomplete_turn(self, turn_id: str, **kwargs):
|
|
796
|
+
"""Async wrapper for complete_turn."""
|
|
797
|
+
return await asyncio.to_thread(self.complete_turn, turn_id, **kwargs)
|
|
798
|
+
|
|
799
|
+
async def afail_turn(self, turn_id: str, error_message: str = ""):
|
|
800
|
+
"""Async wrapper for fail_turn."""
|
|
801
|
+
return await asyncio.to_thread(self.fail_turn, turn_id, error_message)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
805
|
+
# Singleton
|
|
806
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
807
|
+
|
|
808
|
+
_instance: Optional[ShadowDB] = None
|
|
809
|
+
_instance_lock = threading.Lock()
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def init_shadow_db(db_path: Path) -> ShadowDB:
|
|
813
|
+
"""Initialize the global ShadowDB with a custom path. Must be called before get_shadow_db()."""
|
|
814
|
+
global _instance
|
|
815
|
+
with _instance_lock:
|
|
816
|
+
if _instance is not None:
|
|
817
|
+
logger.warning(f"Re-initializing ShadowDB (was {_instance.db_path}, now {db_path})")
|
|
818
|
+
_instance.close()
|
|
819
|
+
_instance = ShadowDB(db_path=db_path)
|
|
820
|
+
return _instance
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def get_shadow_db() -> ShadowDB:
|
|
824
|
+
"""Get the global ShadowDB instance (creates on first call)."""
|
|
825
|
+
global _instance
|
|
826
|
+
if _instance is None:
|
|
827
|
+
with _instance_lock:
|
|
828
|
+
if _instance is None:
|
|
829
|
+
_instance = ShadowDB()
|
|
830
|
+
return _instance
|