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,512 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shadow DB API - REST endpoints for the turn-centric metadata database.
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
GET /api/turns - List/search turns
|
|
6
|
+
GET /api/turns/{id} - Get turn details
|
|
7
|
+
POST /api/turns/{id}/tags - Add tag to turn
|
|
8
|
+
DELETE /api/turns/{id}/tags/{tag} - Remove tag from turn
|
|
9
|
+
POST /api/tags/rename - Rename tag globally
|
|
10
|
+
GET /api/turns/tags - Tag cloud
|
|
11
|
+
GET /api/turns/branches - Branch activity
|
|
12
|
+
GET /api/turns/stats - Database statistics
|
|
13
|
+
GET /api/shadow-db/search - Rich FTS search with section scoping + field selection
|
|
14
|
+
GET /api/shadow-db/file-history - File change history with structured context
|
|
15
|
+
GET /api/shadow-db/recent-files - Files recently touched by Claude in a project
|
|
16
|
+
POST /api/shadow-db/sql - Read-only SQL passthrough (replaces db-query CLI)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from fastapi import APIRouter, Query, Request
|
|
23
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("api_shadow_db")
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_db():
|
|
31
|
+
"""Lazy import to avoid circular deps."""
|
|
32
|
+
from painapple_code.shadow_db import get_shadow_db
|
|
33
|
+
return get_shadow_db()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
# Turn listing & search
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
@router.get("/api/turns")
|
|
41
|
+
async def list_turns(
|
|
42
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
43
|
+
repo: str = Query(None, description="Filter by git repo hash (cross-worktree)"),
|
|
44
|
+
session: str = Query(None, description="Filter by session ID"),
|
|
45
|
+
branch: str = Query(None, description="Filter by git branch"),
|
|
46
|
+
status: str = Query(None, description="Filter by status"),
|
|
47
|
+
tag: str = Query(None, description="Filter by tag"),
|
|
48
|
+
q: str = Query(None, description="Search prompt and fields"),
|
|
49
|
+
limit: int = Query(50, ge=1, le=200),
|
|
50
|
+
offset: int = Query(0, ge=0),
|
|
51
|
+
):
|
|
52
|
+
"""List turns with optional filters."""
|
|
53
|
+
try:
|
|
54
|
+
db = _get_db()
|
|
55
|
+
result = db.list_turns(
|
|
56
|
+
project_hash=project,
|
|
57
|
+
git_repo_hash=repo,
|
|
58
|
+
session_id=session,
|
|
59
|
+
git_branch=branch,
|
|
60
|
+
status=status,
|
|
61
|
+
tag=tag,
|
|
62
|
+
search=q,
|
|
63
|
+
limit=limit,
|
|
64
|
+
offset=offset,
|
|
65
|
+
)
|
|
66
|
+
# Serialize timestamps
|
|
67
|
+
for t in result["turns"]:
|
|
68
|
+
for key in ("started_at", "completed_at"):
|
|
69
|
+
if t.get(key):
|
|
70
|
+
t[key] = str(t[key])
|
|
71
|
+
if t.get("cost"):
|
|
72
|
+
t["cost"] = float(t["cost"])
|
|
73
|
+
return result
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.exception(f"list_turns failed: {e}")
|
|
76
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/api/turns/stats")
|
|
80
|
+
async def get_stats(
|
|
81
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
82
|
+
repo: str = Query(None, description="Filter by git repo hash (cross-worktree)"),
|
|
83
|
+
):
|
|
84
|
+
"""Get database statistics."""
|
|
85
|
+
try:
|
|
86
|
+
db = _get_db()
|
|
87
|
+
stats = db.get_stats(project_hash=project, git_repo_hash=repo)
|
|
88
|
+
# Serialize timestamps
|
|
89
|
+
for key in ("first_turn", "last_turn"):
|
|
90
|
+
if stats.get(key):
|
|
91
|
+
stats[key] = str(stats[key])
|
|
92
|
+
return stats
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.exception(f"get_stats failed: {e}")
|
|
95
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/api/turns/tags")
|
|
99
|
+
async def get_tag_cloud(
|
|
100
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
101
|
+
):
|
|
102
|
+
"""Get tag cloud with usage statistics."""
|
|
103
|
+
try:
|
|
104
|
+
db = _get_db()
|
|
105
|
+
return {"tags": db.get_tag_cloud(project_hash=project)}
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.exception(f"get_tag_cloud failed: {e}")
|
|
108
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/api/turns/branches")
|
|
112
|
+
async def get_branch_stats(
|
|
113
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
114
|
+
):
|
|
115
|
+
"""Get branch activity overview."""
|
|
116
|
+
try:
|
|
117
|
+
db = _get_db()
|
|
118
|
+
branches = db.get_branch_stats(project_hash=project)
|
|
119
|
+
for b in branches:
|
|
120
|
+
for key in ("first_turn", "last_turn"):
|
|
121
|
+
if b.get(key):
|
|
122
|
+
b[key] = str(b[key])
|
|
123
|
+
return {"branches": branches}
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.exception(f"get_branch_stats failed: {e}")
|
|
126
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
130
|
+
# Individual turn
|
|
131
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
@router.get("/api/turns/{turn_id}")
|
|
134
|
+
async def get_turn(turn_id: str):
|
|
135
|
+
"""Get full turn details with fields, tags, files, and tools."""
|
|
136
|
+
try:
|
|
137
|
+
db = _get_db()
|
|
138
|
+
turn = db.get_turn(turn_id)
|
|
139
|
+
if not turn:
|
|
140
|
+
return JSONResponse({"error": "Turn not found"}, status_code=404)
|
|
141
|
+
# Serialize
|
|
142
|
+
for key in ("started_at", "completed_at"):
|
|
143
|
+
if turn.get(key):
|
|
144
|
+
turn[key] = str(turn[key])
|
|
145
|
+
for key in ("cost", "summary_cost"):
|
|
146
|
+
if turn.get(key):
|
|
147
|
+
turn[key] = float(turn[key])
|
|
148
|
+
return turn
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.exception(f"get_turn failed: {e}")
|
|
151
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
155
|
+
# Tag management
|
|
156
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
157
|
+
|
|
158
|
+
@router.post("/api/turns/{turn_id}/tags")
|
|
159
|
+
async def add_tag(turn_id: str, tag: str = Query(..., description="Tag name")):
|
|
160
|
+
"""Add a tag to a turn."""
|
|
161
|
+
try:
|
|
162
|
+
db = _get_db()
|
|
163
|
+
db.add_tag(turn_id, tag)
|
|
164
|
+
return {"ok": True, "tag": tag.strip().lstrip("#").lower()}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.exception(f"add_tag failed: {e}")
|
|
167
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.delete("/api/turns/{turn_id}/tags/{tag}")
|
|
171
|
+
async def remove_tag(turn_id: str, tag: str):
|
|
172
|
+
"""Remove a tag from a turn."""
|
|
173
|
+
try:
|
|
174
|
+
db = _get_db()
|
|
175
|
+
db.remove_tag(turn_id, tag)
|
|
176
|
+
return {"ok": True}
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.exception(f"remove_tag failed: {e}")
|
|
179
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.post("/api/tags/rename")
|
|
183
|
+
async def rename_tag(
|
|
184
|
+
old: str = Query(..., description="Old tag name"),
|
|
185
|
+
new: str = Query(..., description="New tag name"),
|
|
186
|
+
):
|
|
187
|
+
"""Rename a tag globally across all turns."""
|
|
188
|
+
try:
|
|
189
|
+
db = _get_db()
|
|
190
|
+
db.rename_tag(old, new)
|
|
191
|
+
return {"ok": True, "old": old, "new": new.strip().lstrip("#").lower()}
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.exception(f"rename_tag failed: {e}")
|
|
194
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
198
|
+
# Rich FTS search (for agents / CLI)
|
|
199
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
200
|
+
|
|
201
|
+
def _serialize_turns(turns: list[dict]):
|
|
202
|
+
"""Serialize timestamps and decimals in turn dicts."""
|
|
203
|
+
for t in turns:
|
|
204
|
+
for key in ("started_at", "completed_at"):
|
|
205
|
+
if t.get(key):
|
|
206
|
+
t[key] = str(t[key])
|
|
207
|
+
if t.get("cost"):
|
|
208
|
+
t["cost"] = float(t["cost"])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@router.get("/api/shadow-db/search")
|
|
212
|
+
async def search_turns(
|
|
213
|
+
q: str = Query(None, description="Full-text search across prompts and fields"),
|
|
214
|
+
in_sections: str = Query(
|
|
215
|
+
None,
|
|
216
|
+
description="Restrict FTS to these sections (comma-separated field_keys, "
|
|
217
|
+
"e.g. decisions,learnings,problems_solved)"
|
|
218
|
+
),
|
|
219
|
+
fields: str = Query(
|
|
220
|
+
"summary",
|
|
221
|
+
description="Which fields to return per turn (comma-separated, "
|
|
222
|
+
"e.g. summary,decisions,learnings)"
|
|
223
|
+
),
|
|
224
|
+
file: str = Query(None, description="Exact file path filter"),
|
|
225
|
+
file_pattern: str = Query(None, description="File path ILIKE pattern (e.g. %server.py)"),
|
|
226
|
+
tag: str = Query(None, description="Filter by tag"),
|
|
227
|
+
branch: str = Query(None, description="Filter by git branch"),
|
|
228
|
+
session: str = Query(None, description="Filter by session ID"),
|
|
229
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
230
|
+
repo: str = Query(None, description="Filter by git repo hash"),
|
|
231
|
+
status: str = Query("completed", description="Filter by status (default: completed)"),
|
|
232
|
+
since: str = Query(None, description="ISO datetime lower bound"),
|
|
233
|
+
until: str = Query(None, description="ISO datetime upper bound"),
|
|
234
|
+
include_tags: bool = Query(True, description="Include tags array"),
|
|
235
|
+
include_files: bool = Query(False, description="Include changed files array"),
|
|
236
|
+
include_tools: bool = Query(False, description="Include tools dict"),
|
|
237
|
+
limit: int = Query(20, ge=1, le=100),
|
|
238
|
+
offset: int = Query(0, ge=0),
|
|
239
|
+
):
|
|
240
|
+
"""Rich FTS search with section scoping and field selection.
|
|
241
|
+
|
|
242
|
+
Designed for agent/CLI consumption. Returns only the fields you ask for,
|
|
243
|
+
avoiding the token waste of fetching all 15+ summary sections per turn.
|
|
244
|
+
|
|
245
|
+
Section/field keys: summary, work_done, decisions, problems_solved, learnings,
|
|
246
|
+
context_for_resume, investigation, findings, verification, tools_used,
|
|
247
|
+
commands, entities
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
db = _get_db()
|
|
251
|
+
field_list = [f.strip() for f in fields.split(",") if f.strip()] if fields else None
|
|
252
|
+
section_list = (
|
|
253
|
+
[s.strip() for s in in_sections.split(",") if s.strip()]
|
|
254
|
+
if in_sections else None
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
result = db.search_turns(
|
|
258
|
+
query=q,
|
|
259
|
+
search_sections=section_list,
|
|
260
|
+
file_path=file,
|
|
261
|
+
file_pattern=file_pattern,
|
|
262
|
+
tag=tag,
|
|
263
|
+
git_branch=branch,
|
|
264
|
+
session_id=session,
|
|
265
|
+
project_hash=project,
|
|
266
|
+
git_repo_hash=repo,
|
|
267
|
+
status=status,
|
|
268
|
+
since=since,
|
|
269
|
+
until=until,
|
|
270
|
+
fields=field_list,
|
|
271
|
+
include_tags=include_tags,
|
|
272
|
+
include_files=include_files,
|
|
273
|
+
include_tools=include_tools,
|
|
274
|
+
limit=limit,
|
|
275
|
+
offset=offset,
|
|
276
|
+
)
|
|
277
|
+
_serialize_turns(result["turns"])
|
|
278
|
+
return result
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.exception(f"search_turns failed: {e}")
|
|
281
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.get("/api/shadow-db/query")
|
|
285
|
+
async def query_turns(
|
|
286
|
+
q: str = Query(..., description="TurnQL query string"),
|
|
287
|
+
repo: str = Query(None, description="Filter by git repo hash"),
|
|
288
|
+
project: str = Query(None, description="Filter by project hash"),
|
|
289
|
+
fields: str = Query(
|
|
290
|
+
"summary",
|
|
291
|
+
description="Which fields to return per turn (comma-separated)"
|
|
292
|
+
),
|
|
293
|
+
include_tags: bool = Query(True, description="Include tags array"),
|
|
294
|
+
include_files: bool = Query(False, description="Include changed files array"),
|
|
295
|
+
include_tools: bool = Query(False, description="Include tools dict"),
|
|
296
|
+
limit: int = Query(20, ge=1, le=100),
|
|
297
|
+
offset: int = Query(0, ge=0),
|
|
298
|
+
):
|
|
299
|
+
"""TurnQL: Lucene-inspired query language for searching turns.
|
|
300
|
+
|
|
301
|
+
Supports section-scoped FTS, boolean logic, parenthesized grouping,
|
|
302
|
+
numeric ranges, date filters, and metadata filters.
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
deploy - text search across all
|
|
306
|
+
decisions:refactor - section-scoped FTS
|
|
307
|
+
(summary:X OR work_done:X) AND branch:main
|
|
308
|
+
file:server.py cost:>1 since:week
|
|
309
|
+
is:error model:opus -tag:test
|
|
310
|
+
"exact phrase"
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
db = _get_db()
|
|
314
|
+
field_list = [f.strip() for f in fields.split(",") if f.strip()] if fields else None
|
|
315
|
+
|
|
316
|
+
result = db.query_turns(
|
|
317
|
+
q,
|
|
318
|
+
git_repo_hash=repo,
|
|
319
|
+
project_hash=project,
|
|
320
|
+
fields=field_list,
|
|
321
|
+
include_tags=include_tags,
|
|
322
|
+
include_files=include_files,
|
|
323
|
+
include_tools=include_tools,
|
|
324
|
+
limit=limit,
|
|
325
|
+
offset=offset,
|
|
326
|
+
)
|
|
327
|
+
_serialize_turns(result["turns"])
|
|
328
|
+
return result
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.exception(f"query_turns failed: {e}")
|
|
331
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/api/shadow-db/recent-files")
|
|
335
|
+
async def recent_files(
|
|
336
|
+
cwd: str = Query(None, description="Project working directory (converted to project_hash)"),
|
|
337
|
+
project: str = Query(None, description="Project hash (alternative to cwd)"),
|
|
338
|
+
repo: str = Query(None, description="Filter by git repo hash (cross-worktree)"),
|
|
339
|
+
limit: int = Query(100, ge=1, le=500),
|
|
340
|
+
):
|
|
341
|
+
"""Files recently touched by Claude in this project, newest first.
|
|
342
|
+
|
|
343
|
+
One of `cwd`, `project`, or `repo` is required. Returns a list sorted
|
|
344
|
+
by last-touched timestamp descending, with touch counts.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
project_hash = project
|
|
348
|
+
if not project_hash and not repo and cwd:
|
|
349
|
+
from painapple_code.paths import get_project_hash
|
|
350
|
+
project_hash = get_project_hash(cwd)
|
|
351
|
+
if not project_hash and not repo:
|
|
352
|
+
return JSONResponse({"error": "cwd, project, or repo is required"}, status_code=400)
|
|
353
|
+
|
|
354
|
+
db = _get_db()
|
|
355
|
+
files = db.get_recent_files(
|
|
356
|
+
project_hash=project_hash,
|
|
357
|
+
git_repo_hash=repo,
|
|
358
|
+
limit=limit,
|
|
359
|
+
)
|
|
360
|
+
return {"files": files, "project_hash": project_hash, "cwd": cwd}
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.exception(f"recent_files failed: {e}")
|
|
363
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@router.get("/api/shadow-db/file-history")
|
|
367
|
+
async def file_history(
|
|
368
|
+
path: str = Query(..., description="File path (exact or ILIKE pattern with %)"),
|
|
369
|
+
fields: str = Query(
|
|
370
|
+
"summary,work_done,decisions",
|
|
371
|
+
description="Fields to include per turn"
|
|
372
|
+
),
|
|
373
|
+
repo: str = Query(None, description="Filter by git repo hash"),
|
|
374
|
+
limit: int = Query(30, ge=1, le=100),
|
|
375
|
+
):
|
|
376
|
+
"""History of changes to a file with structured context.
|
|
377
|
+
|
|
378
|
+
Returns turns that modified the given file, newest first,
|
|
379
|
+
with selected structured fields inline.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
db = _get_db()
|
|
383
|
+
field_list = [f.strip() for f in fields.split(",") if f.strip()] if fields else None
|
|
384
|
+
is_pattern = "%" in path
|
|
385
|
+
|
|
386
|
+
result = db.search_turns(
|
|
387
|
+
file_path=None if is_pattern else path,
|
|
388
|
+
file_pattern=path if is_pattern else None,
|
|
389
|
+
git_repo_hash=repo,
|
|
390
|
+
status="completed",
|
|
391
|
+
fields=field_list,
|
|
392
|
+
include_tags=True,
|
|
393
|
+
include_files=True,
|
|
394
|
+
limit=limit,
|
|
395
|
+
)
|
|
396
|
+
_serialize_turns(result["turns"])
|
|
397
|
+
return result
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.exception(f"file_history failed: {e}")
|
|
400
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
404
|
+
# Raw SQL passthrough (read-only) — replaces the external `db-query` CLI
|
|
405
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
406
|
+
|
|
407
|
+
_MUTATION_KEYWORDS = re.compile(
|
|
408
|
+
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|DETACH|COPY|"
|
|
409
|
+
r"INSTALL|LOAD|CALL|TRUNCATE|VACUUM|CHECKPOINT|BEGIN|COMMIT|"
|
|
410
|
+
r"ROLLBACK|REPLACE|MERGE|GRANT|REVOKE|EXPORT|IMPORT|SET|RESET|USE)\b",
|
|
411
|
+
re.IGNORECASE,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# DuckDB table functions that read arbitrary host files/URLs. A plain SELECT
|
|
415
|
+
# using these (e.g. `SELECT * FROM read_csv('/etc/passwd')`) would exfiltrate
|
|
416
|
+
# files despite passing the mutation check, so reject them explicitly.
|
|
417
|
+
# This is now defense-in-depth with a friendly error: the connection itself
|
|
418
|
+
# runs with `enable_external_access=false` (set in ShadowDB._lock_down_external_access
|
|
419
|
+
# after FTS init), which blocks these functions AND the `FROM 'file'` replacement
|
|
420
|
+
# scan / `COPY ... TO 'file'` engine-wide. This denylist just catches the common
|
|
421
|
+
# read_* case before the query runs, with a clearer message than DuckDB's
|
|
422
|
+
# PermissionException.
|
|
423
|
+
_FILE_ACCESS_FUNCS = re.compile(
|
|
424
|
+
r"\b(read_csv|read_csv_auto|read_parquet|parquet_scan|read_json|"
|
|
425
|
+
r"read_json_auto|read_ndjson|read_ndjson_auto|read_text|read_blob|"
|
|
426
|
+
r"glob|sniff_csv|parquet_metadata|parquet_schema)\s*\(",
|
|
427
|
+
re.IGNORECASE,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _sanitize_for_validation(sql: str) -> str:
|
|
432
|
+
"""Strip comments and string/identifier literals so keyword detection
|
|
433
|
+
doesn't trip on values like `'INSERT INTO foo'` inside a SELECT."""
|
|
434
|
+
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
|
|
435
|
+
sql = re.sub(r"--[^\n]*", " ", sql)
|
|
436
|
+
sql = re.sub(r"'(?:[^']|'')*'", "''", sql)
|
|
437
|
+
sql = re.sub(r'"(?:[^"]|"")*"', '""', sql)
|
|
438
|
+
sql = re.sub(r"\$([A-Za-z_]\w*)?\$.*?\$\1\$", " ", sql, flags=re.DOTALL)
|
|
439
|
+
return sql
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@router.post("/api/shadow-db/sql")
|
|
443
|
+
async def execute_sql(request: Request, format: str = Query("json", pattern="^(json|tsv)$")):
|
|
444
|
+
"""Read-only SQL passthrough against the shadow DuckDB.
|
|
445
|
+
|
|
446
|
+
Body: raw SQL (Content-Type: text/plain) or `{"sql": "..."}` JSON.
|
|
447
|
+
Query: `format=json` (default) returns `{columns, rows, count}`;
|
|
448
|
+
`format=tsv` returns tab-separated text with header row.
|
|
449
|
+
|
|
450
|
+
Rejects any query containing mutation keywords (INSERT/UPDATE/DROP/etc.).
|
|
451
|
+
Runs through the server's existing connection so reads are consistent
|
|
452
|
+
with concurrent writes.
|
|
453
|
+
"""
|
|
454
|
+
raw = await request.body()
|
|
455
|
+
sql = raw.decode("utf-8", errors="replace").strip()
|
|
456
|
+
if sql.startswith("{"):
|
|
457
|
+
try:
|
|
458
|
+
import json as _json
|
|
459
|
+
payload = _json.loads(sql)
|
|
460
|
+
sql = (payload.get("sql") or "").strip()
|
|
461
|
+
format = payload.get("format", format)
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
if not sql:
|
|
465
|
+
return JSONResponse({"error": "SQL body is required"}, status_code=400)
|
|
466
|
+
|
|
467
|
+
cleaned = _sanitize_for_validation(sql)
|
|
468
|
+
file_match = _FILE_ACCESS_FUNCS.search(cleaned)
|
|
469
|
+
if file_match:
|
|
470
|
+
fn = file_match.group(1).lower()
|
|
471
|
+
return JSONResponse(
|
|
472
|
+
{"error": f"File-access function '{fn}' is not allowed"},
|
|
473
|
+
status_code=400,
|
|
474
|
+
)
|
|
475
|
+
match = _MUTATION_KEYWORDS.search(cleaned)
|
|
476
|
+
if match:
|
|
477
|
+
return JSONResponse(
|
|
478
|
+
{"error": f"Mutation keyword '{match.group(0).upper()}' is not allowed"},
|
|
479
|
+
status_code=400,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
db = _get_db()
|
|
484
|
+
# Off the event loop AND on a deadline. Both halves are required, and
|
|
485
|
+
# neither is sufficient alone:
|
|
486
|
+
#
|
|
487
|
+
# to_thread — keeps the loop serving. Run inline, one slow SELECT
|
|
488
|
+
# freezes every WebSocket, every streaming turn and the
|
|
489
|
+
# compact heartbeat.
|
|
490
|
+
# the deadline (inside fetch_columns_adhoc) — ends the query. Without
|
|
491
|
+
# it the request never completes, uvicorn's graceful
|
|
492
|
+
# shutdown waits on it forever, and SIGTERM closes the
|
|
493
|
+
# socket without exiting: `pkill` looks like a no-op and
|
|
494
|
+
# recovery needs `kill -9`.
|
|
495
|
+
#
|
|
496
|
+
# Note `asyncio.wait_for` here would be worse than nothing — it frees
|
|
497
|
+
# the caller while the query keeps burning a core, so a retry stacks
|
|
498
|
+
# runaway queries. Cancellation has to happen at the DuckDB level.
|
|
499
|
+
columns, rows = await asyncio.to_thread(db.fetch_columns_adhoc, sql)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.warning(f"execute_sql query failed: {e}")
|
|
502
|
+
return JSONResponse({"error": str(e)}, status_code=400)
|
|
503
|
+
|
|
504
|
+
str_rows = [["" if v is None else str(v) for v in row] for row in rows]
|
|
505
|
+
|
|
506
|
+
if format == "tsv":
|
|
507
|
+
lines = ["\t".join(columns)]
|
|
508
|
+
for row in str_rows:
|
|
509
|
+
lines.append("\t".join(row))
|
|
510
|
+
return PlainTextResponse("\n".join(lines) + "\n")
|
|
511
|
+
|
|
512
|
+
return {"columns": columns, "rows": str_rows, "count": len(rows)}
|