marvisx-cli 0.1.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.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
core/api/db.py
ADDED
|
@@ -0,0 +1,1516 @@
|
|
|
1
|
+
# v1.6.0 - 2026-03-15 - Enterprise prerequisites: _column_exists, backup, connection pool + acquire_db() context manager
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
import uuid as uuid_mod
|
|
13
|
+
from collections import Counter, defaultdict, deque
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import Lock
|
|
17
|
+
from typing import Any, AsyncGenerator
|
|
18
|
+
|
|
19
|
+
import aiosqlite
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
|
|
22
|
+
from core.api.config import settings
|
|
23
|
+
from core.api.paths import repo_path
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_migrations_dir() -> Path:
|
|
29
|
+
"""Locate the ``migrations`` data directory in both layouts.
|
|
30
|
+
|
|
31
|
+
In an installed wheel the ``*.sql`` files are shipped as the top-level
|
|
32
|
+
``migrations`` package-data; ``importlib.resources.files`` resolves them
|
|
33
|
+
correctly regardless of where site-packages lives. ``repo_path`` walking up
|
|
34
|
+
from ``__file__`` only works when the runtime tree mirrors the repo layout
|
|
35
|
+
(learning 9e527cfa: the wheel shipped 0 migrations and the runtime found an
|
|
36
|
+
empty dir without failing). Prefer the resources lookup, fall back to the
|
|
37
|
+
repo layout for an editable/source checkout.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
import importlib.resources as _res
|
|
41
|
+
|
|
42
|
+
candidate = Path(str(_res.files("migrations")))
|
|
43
|
+
if candidate.is_dir():
|
|
44
|
+
return candidate
|
|
45
|
+
except (ModuleNotFoundError, FileNotFoundError, TypeError, AttributeError):
|
|
46
|
+
pass
|
|
47
|
+
return repo_path(__file__, "migrations")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
MIGRATIONS_DIR = _resolve_migrations_dir()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
54
|
+
"""Check if a column exists in a table. Used for migration idempotency (partial failure recovery)."""
|
|
55
|
+
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
56
|
+
return any(c[1] == column for c in cols)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_sync_connection() -> sqlite3.Connection:
|
|
60
|
+
"""Synchronous connection for startup migrations."""
|
|
61
|
+
conn = sqlite3.connect(settings.db_path)
|
|
62
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
63
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
64
|
+
conn.row_factory = sqlite3.Row
|
|
65
|
+
return conn
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
|
69
|
+
"""Shared PRAGMAs for all async connections."""
|
|
70
|
+
await db.execute("PRAGMA journal_mode=WAL")
|
|
71
|
+
await db.execute("PRAGMA foreign_keys=ON")
|
|
72
|
+
await db.execute("PRAGMA synchronous=NORMAL") # 2-3x faster writes vs FULL
|
|
73
|
+
await db.execute(
|
|
74
|
+
"PRAGMA busy_timeout=15000"
|
|
75
|
+
) # 15s (5s too aggressive, 30s masks issues)
|
|
76
|
+
await db.execute("PRAGMA cache_size=-64000") # 64MB
|
|
77
|
+
await db.execute("PRAGMA temp_store=MEMORY")
|
|
78
|
+
await db.execute("PRAGMA mmap_size=536870912") # 512MB (covers 300MB DB)
|
|
79
|
+
await db.execute("PRAGMA wal_autocheckpoint=1000") # ~4MB WAL trigger
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Single-Writer Architecture ---
|
|
83
|
+
# SQLite WAL: one writer + many readers. All writes (router + background) go
|
|
84
|
+
# through a single dedicated writer connection serialized by asyncio.Lock.
|
|
85
|
+
# Pool connections are read-only (PRAGMA query_only=ON) — writes via pool
|
|
86
|
+
# raise OperationalError immediately instead of causing lock contention.
|
|
87
|
+
#
|
|
88
|
+
# Four primitives:
|
|
89
|
+
# get_db() — read-only pool (FastAPI DI for GET endpoints)
|
|
90
|
+
# get_write_db() — writer + lock (FastAPI DI for POST/PATCH/DELETE endpoints)
|
|
91
|
+
# write_db() — writer + lock + auto-commit (background tasks)
|
|
92
|
+
# acquire_write_db() — writer + lock (WebSocket/non-DI writers)
|
|
93
|
+
# acquire_db() — read-only pool (WebSocket/non-DI readers)
|
|
94
|
+
#
|
|
95
|
+
# See: "database is locked" incident 2026-04-12, plan 2026-04-13.
|
|
96
|
+
|
|
97
|
+
_pool: asyncio.Queue[aiosqlite.Connection] | None = None
|
|
98
|
+
_pool_size: int = 0
|
|
99
|
+
_writer: aiosqlite.Connection | None = None
|
|
100
|
+
_write_lock = asyncio.Lock()
|
|
101
|
+
|
|
102
|
+
WRITER_LOCK_EVENT_LIMIT = 500
|
|
103
|
+
WRITER_LOCK_SLOW_WAIT_MS = 50.0
|
|
104
|
+
_writer_metrics_lock = Lock()
|
|
105
|
+
_writer_wait_events: deque[dict[str, Any]] = deque(maxlen=WRITER_LOCK_EVENT_LIMIT)
|
|
106
|
+
_writer_hold_events: deque[dict[str, Any]] = deque(maxlen=WRITER_LOCK_EVENT_LIMIT)
|
|
107
|
+
_writer_current_holder: dict[str, Any] | None = None
|
|
108
|
+
_writer_sequence = 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def init_pool(size: int = 2) -> None:
|
|
112
|
+
"""Create read-only connection pool + dedicated writer. Call once in lifespan startup.
|
|
113
|
+
|
|
114
|
+
Pool connections have PRAGMA query_only=ON — any INSERT/UPDATE/DELETE via
|
|
115
|
+
get_db() or acquire_db() raises OperationalError immediately. All writes
|
|
116
|
+
must go through get_write_db(), write_db(), or acquire_write_db().
|
|
117
|
+
|
|
118
|
+
Pool size=8 supports concurrent deep=true requests (4 parallel KG subqueries × 2 concurrent requests).
|
|
119
|
+
"""
|
|
120
|
+
global _pool, _pool_size, _writer
|
|
121
|
+
actual_size = 8 # read-only pool: expanded for KG lens 4-subquery parallel pattern (Phase 7.0)
|
|
122
|
+
_pool = asyncio.Queue(maxsize=actual_size)
|
|
123
|
+
_pool_size = actual_size
|
|
124
|
+
for _ in range(actual_size):
|
|
125
|
+
db = await aiosqlite.connect(settings.db_path)
|
|
126
|
+
await _configure_connection(db)
|
|
127
|
+
await db.execute("PRAGMA query_only=ON")
|
|
128
|
+
db.row_factory = aiosqlite.Row
|
|
129
|
+
await _pool.put(db)
|
|
130
|
+
# Dedicated writer for ALL writes (router + background), serialized by _write_lock
|
|
131
|
+
_writer = await aiosqlite.connect(settings.db_path)
|
|
132
|
+
await _configure_connection(_writer)
|
|
133
|
+
_writer.row_factory = aiosqlite.Row
|
|
134
|
+
logger.info(
|
|
135
|
+
"DB initialized: %d read-only pool + 1 dedicated writer (single-writer enforced)",
|
|
136
|
+
actual_size,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def close_pool() -> None:
|
|
141
|
+
"""Close all connections. Call in lifespan shutdown."""
|
|
142
|
+
global _pool, _pool_size, _writer
|
|
143
|
+
if _pool:
|
|
144
|
+
closed = 0
|
|
145
|
+
while not _pool.empty():
|
|
146
|
+
try:
|
|
147
|
+
db = _pool.get_nowait()
|
|
148
|
+
await db.close()
|
|
149
|
+
closed += 1
|
|
150
|
+
except asyncio.QueueEmpty:
|
|
151
|
+
break
|
|
152
|
+
_pool = None
|
|
153
|
+
_pool_size = 0
|
|
154
|
+
if _writer:
|
|
155
|
+
await _writer.close()
|
|
156
|
+
_writer = None
|
|
157
|
+
logger.info("DB connections closed")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
161
|
+
"""Read-only pool connection for request handlers. For writes use get_write_db()."""
|
|
162
|
+
if _pool is not None:
|
|
163
|
+
db = await _pool.get()
|
|
164
|
+
try:
|
|
165
|
+
yield db
|
|
166
|
+
finally:
|
|
167
|
+
try:
|
|
168
|
+
await _pool.put(db)
|
|
169
|
+
except asyncio.QueueFull:
|
|
170
|
+
await db.close()
|
|
171
|
+
else:
|
|
172
|
+
db = await aiosqlite.connect(settings.db_path)
|
|
173
|
+
await _configure_connection(db)
|
|
174
|
+
db.row_factory = aiosqlite.Row
|
|
175
|
+
try:
|
|
176
|
+
yield db
|
|
177
|
+
finally:
|
|
178
|
+
await db.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@asynccontextmanager
|
|
182
|
+
async def acquire_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
183
|
+
"""Read-only pool access outside FastAPI DI (e.g. WebSocket handlers). For writes use acquire_write_db()."""
|
|
184
|
+
if _pool is not None:
|
|
185
|
+
db = await _pool.get()
|
|
186
|
+
try:
|
|
187
|
+
yield db
|
|
188
|
+
finally:
|
|
189
|
+
try:
|
|
190
|
+
await _pool.put(db)
|
|
191
|
+
except asyncio.QueueFull:
|
|
192
|
+
await db.close()
|
|
193
|
+
else:
|
|
194
|
+
db = await aiosqlite.connect(settings.db_path)
|
|
195
|
+
await _configure_connection(db)
|
|
196
|
+
db.row_factory = aiosqlite.Row
|
|
197
|
+
try:
|
|
198
|
+
yield db
|
|
199
|
+
finally:
|
|
200
|
+
await db.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _percentile(values: list[float], percentile: float) -> float | None:
|
|
204
|
+
if not values:
|
|
205
|
+
return None
|
|
206
|
+
ordered = sorted(values)
|
|
207
|
+
index = max(0, min(len(ordered) - 1, round((len(ordered) - 1) * percentile)))
|
|
208
|
+
return ordered[index]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _summary(values: list[float]) -> dict[str, float | int | None]:
|
|
212
|
+
return {
|
|
213
|
+
"count": len(values),
|
|
214
|
+
"p50": _percentile(values, 0.50),
|
|
215
|
+
"p95": _percentile(values, 0.95),
|
|
216
|
+
"p99": _percentile(values, 0.99),
|
|
217
|
+
"max": max(values) if values else None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _writer_owner_label(label: str | None = None) -> str:
|
|
222
|
+
if label:
|
|
223
|
+
return label
|
|
224
|
+
|
|
225
|
+
repo_root = Path(__file__).parent.parent
|
|
226
|
+
current_file = Path(__file__).resolve()
|
|
227
|
+
frame = inspect.currentframe()
|
|
228
|
+
if frame is not None:
|
|
229
|
+
frame = frame.f_back
|
|
230
|
+
try:
|
|
231
|
+
while frame is not None:
|
|
232
|
+
frame_path = Path(frame.f_code.co_filename).resolve()
|
|
233
|
+
if frame_path != current_file:
|
|
234
|
+
try:
|
|
235
|
+
rel_path = frame_path.relative_to(repo_root)
|
|
236
|
+
except ValueError:
|
|
237
|
+
frame = frame.f_back
|
|
238
|
+
continue
|
|
239
|
+
return f"{rel_path}:{frame.f_code.co_name}:{frame.f_lineno}"
|
|
240
|
+
frame = frame.f_back
|
|
241
|
+
finally:
|
|
242
|
+
del frame
|
|
243
|
+
return "unknown"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _current_writer_blocker(now_perf: float) -> dict[str, Any] | None:
|
|
247
|
+
with _writer_metrics_lock:
|
|
248
|
+
if not _writer_current_holder:
|
|
249
|
+
return None
|
|
250
|
+
return {
|
|
251
|
+
"label": _writer_current_holder["label"],
|
|
252
|
+
"task_name": _writer_current_holder.get("task_name"),
|
|
253
|
+
"held_ms": max(
|
|
254
|
+
0.0, (now_perf - _writer_current_holder["started_perf"]) * 1000
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _start_writer_hold(
|
|
260
|
+
*,
|
|
261
|
+
label: str,
|
|
262
|
+
task_name: str | None,
|
|
263
|
+
queued_at: float,
|
|
264
|
+
acquired_at: float,
|
|
265
|
+
wait_ms: float,
|
|
266
|
+
blocked_by: dict[str, Any] | None,
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
global _writer_current_holder, _writer_sequence
|
|
269
|
+
with _writer_metrics_lock:
|
|
270
|
+
_writer_sequence += 1
|
|
271
|
+
holder = {
|
|
272
|
+
"id": _writer_sequence,
|
|
273
|
+
"label": label,
|
|
274
|
+
"task_name": task_name,
|
|
275
|
+
"queued_at": queued_at,
|
|
276
|
+
"acquired_at": acquired_at,
|
|
277
|
+
"started_perf": time.perf_counter(),
|
|
278
|
+
"wait_ms": wait_ms,
|
|
279
|
+
"blocked_by": blocked_by,
|
|
280
|
+
}
|
|
281
|
+
_writer_current_holder = holder
|
|
282
|
+
_writer_wait_events.append(
|
|
283
|
+
{
|
|
284
|
+
"label": label,
|
|
285
|
+
"task_name": task_name,
|
|
286
|
+
"queued_at": queued_at,
|
|
287
|
+
"acquired_at": acquired_at,
|
|
288
|
+
"wait_ms": wait_ms,
|
|
289
|
+
"contended": wait_ms >= 1.0,
|
|
290
|
+
"slow": wait_ms >= WRITER_LOCK_SLOW_WAIT_MS,
|
|
291
|
+
"blocked_by": blocked_by,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return holder
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _finish_writer_hold(holder: dict[str, Any]) -> None:
|
|
298
|
+
global _writer_current_holder
|
|
299
|
+
ended_at = time.time()
|
|
300
|
+
hold_ms = max(0.0, (time.perf_counter() - holder["started_perf"]) * 1000)
|
|
301
|
+
with _writer_metrics_lock:
|
|
302
|
+
if _writer_current_holder and _writer_current_holder.get("id") == holder["id"]:
|
|
303
|
+
_writer_current_holder = None
|
|
304
|
+
_writer_hold_events.append(
|
|
305
|
+
{
|
|
306
|
+
"label": holder["label"],
|
|
307
|
+
"task_name": holder.get("task_name"),
|
|
308
|
+
"acquired_at": holder["acquired_at"],
|
|
309
|
+
"ended_at": ended_at,
|
|
310
|
+
"hold_ms": hold_ms,
|
|
311
|
+
"wait_ms": holder["wait_ms"],
|
|
312
|
+
"blocked_by": holder.get("blocked_by"),
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_writer_lock_snapshot(window_seconds: float = 60.0) -> dict[str, Any]:
|
|
318
|
+
"""Return rolling telemetry for the global SQLite writer lock."""
|
|
319
|
+
now = time.time()
|
|
320
|
+
now_perf = time.perf_counter()
|
|
321
|
+
cutoff = now - window_seconds
|
|
322
|
+
with _writer_metrics_lock:
|
|
323
|
+
waits = [
|
|
324
|
+
event for event in _writer_wait_events if event["queued_at"] >= cutoff
|
|
325
|
+
]
|
|
326
|
+
holds = [
|
|
327
|
+
event for event in _writer_hold_events if event["ended_at"] >= cutoff
|
|
328
|
+
]
|
|
329
|
+
current_holder = dict(_writer_current_holder) if _writer_current_holder else None
|
|
330
|
+
|
|
331
|
+
wait_values = [float(event["wait_ms"]) for event in waits]
|
|
332
|
+
hold_values = [float(event["hold_ms"]) for event in holds]
|
|
333
|
+
wait_by_label: dict[str, list[float]] = defaultdict(list)
|
|
334
|
+
hold_by_label: dict[str, list[float]] = defaultdict(list)
|
|
335
|
+
blocked_by_labels: Counter[str] = Counter()
|
|
336
|
+
for event in waits:
|
|
337
|
+
wait_by_label[str(event["label"])].append(float(event["wait_ms"]))
|
|
338
|
+
blocked_by = event.get("blocked_by")
|
|
339
|
+
if isinstance(blocked_by, dict) and blocked_by.get("label"):
|
|
340
|
+
blocked_by_labels[str(blocked_by["label"])] += 1
|
|
341
|
+
for event in holds:
|
|
342
|
+
hold_by_label[str(event["label"])].append(float(event["hold_ms"]))
|
|
343
|
+
|
|
344
|
+
if current_holder:
|
|
345
|
+
current_holder["held_ms"] = max(
|
|
346
|
+
0.0, (now_perf - current_holder.pop("started_perf")) * 1000
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
"window_seconds": window_seconds,
|
|
351
|
+
"locked": _write_lock.locked(),
|
|
352
|
+
"current_holder": current_holder,
|
|
353
|
+
"wait_ms": _summary(wait_values),
|
|
354
|
+
"hold_ms": _summary(hold_values),
|
|
355
|
+
"contended_wait_count": sum(1 for value in wait_values if value >= 1.0),
|
|
356
|
+
"slow_wait_count": sum(
|
|
357
|
+
1 for value in wait_values if value >= WRITER_LOCK_SLOW_WAIT_MS
|
|
358
|
+
),
|
|
359
|
+
"wait_by_label": {
|
|
360
|
+
label: _summary(values) for label, values in sorted(wait_by_label.items())
|
|
361
|
+
},
|
|
362
|
+
"hold_by_label": {
|
|
363
|
+
label: _summary(values) for label, values in sorted(hold_by_label.items())
|
|
364
|
+
},
|
|
365
|
+
"blocked_by_label_counts": dict(blocked_by_labels),
|
|
366
|
+
"last_wait_events": waits[-10:],
|
|
367
|
+
"last_hold_events": holds[-10:],
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def reset_writer_lock_metrics_for_tests() -> None:
|
|
372
|
+
global _writer_current_holder
|
|
373
|
+
with _writer_metrics_lock:
|
|
374
|
+
_writer_wait_events.clear()
|
|
375
|
+
_writer_hold_events.clear()
|
|
376
|
+
_writer_current_holder = None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _request_writer_label(request: Request | None) -> str:
|
|
380
|
+
if request is None:
|
|
381
|
+
return "get_write_db"
|
|
382
|
+
|
|
383
|
+
scope = request.scope
|
|
384
|
+
route = scope.get("route")
|
|
385
|
+
route_path = getattr(route, "path", None) or scope.get("path") or "unknown"
|
|
386
|
+
method = scope.get("method") or request.method or "REQUEST"
|
|
387
|
+
endpoint = scope.get("endpoint")
|
|
388
|
+
if endpoint is None:
|
|
389
|
+
return f"{method} {route_path}"
|
|
390
|
+
|
|
391
|
+
module = getattr(endpoint, "__module__", None)
|
|
392
|
+
qualname = getattr(endpoint, "__qualname__", None) or getattr(
|
|
393
|
+
endpoint, "__name__", None
|
|
394
|
+
)
|
|
395
|
+
endpoint_name = ".".join(part for part in (module, qualname) if part)
|
|
396
|
+
if not endpoint_name:
|
|
397
|
+
return f"{method} {route_path}"
|
|
398
|
+
return f"{method} {route_path} -> {endpoint_name}"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@asynccontextmanager
|
|
402
|
+
async def _acquire_writer(
|
|
403
|
+
*, label: str | None = None
|
|
404
|
+
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
405
|
+
"""Internal: acquire write lock, yield writer, rollback on error.
|
|
406
|
+
|
|
407
|
+
All public write primitives (write_db, get_write_db, acquire_write_db)
|
|
408
|
+
delegate here for consistent lock + rollback semantics.
|
|
409
|
+
"""
|
|
410
|
+
owner_label = _writer_owner_label(label)
|
|
411
|
+
task = asyncio.current_task()
|
|
412
|
+
task_name = task.get_name() if task else None
|
|
413
|
+
queued_at = time.time()
|
|
414
|
+
queued_perf = time.perf_counter()
|
|
415
|
+
blocked_by = _current_writer_blocker(queued_perf) if _write_lock.locked() else None
|
|
416
|
+
await _write_lock.acquire()
|
|
417
|
+
acquired_at = time.time()
|
|
418
|
+
holder = _start_writer_hold(
|
|
419
|
+
label=owner_label,
|
|
420
|
+
task_name=task_name,
|
|
421
|
+
queued_at=queued_at,
|
|
422
|
+
acquired_at=acquired_at,
|
|
423
|
+
wait_ms=(time.perf_counter() - queued_perf) * 1000,
|
|
424
|
+
blocked_by=blocked_by,
|
|
425
|
+
)
|
|
426
|
+
try:
|
|
427
|
+
if not _writer:
|
|
428
|
+
raise RuntimeError("DB not initialized — call init_pool() first")
|
|
429
|
+
try:
|
|
430
|
+
yield _writer
|
|
431
|
+
except Exception:
|
|
432
|
+
await _writer.rollback()
|
|
433
|
+
raise
|
|
434
|
+
finally:
|
|
435
|
+
_finish_writer_hold(holder)
|
|
436
|
+
_write_lock.release()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@asynccontextmanager
|
|
440
|
+
async def write_db(
|
|
441
|
+
label: str | None = None,
|
|
442
|
+
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
443
|
+
"""Background tasks: auto-commit on success, auto-rollback on error.
|
|
444
|
+
|
|
445
|
+
Use for metrics_collector, cost_service, security_collector,
|
|
446
|
+
event_dispatcher — any periodic background write.
|
|
447
|
+
|
|
448
|
+
Do NOT do slow work (HTTP calls, computation) inside this context.
|
|
449
|
+
Gather data first, then write in a fast batch.
|
|
450
|
+
"""
|
|
451
|
+
async with _acquire_writer(label=label) as w:
|
|
452
|
+
yield w
|
|
453
|
+
await w.commit()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def get_write_db(
|
|
457
|
+
request: Request,
|
|
458
|
+
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
459
|
+
"""Router write endpoints: caller must commit. Auto-rollback on error.
|
|
460
|
+
|
|
461
|
+
Use Depends(get_write_db) for any endpoint that does INSERT/UPDATE/DELETE.
|
|
462
|
+
The pool connection (get_db) is read-only — writes will fail with
|
|
463
|
+
OperationalError: attempt to write a readonly database.
|
|
464
|
+
"""
|
|
465
|
+
async with _acquire_writer(label=_request_writer_label(request)) as w:
|
|
466
|
+
yield w
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@asynccontextmanager
|
|
470
|
+
async def acquire_write_db(
|
|
471
|
+
label: str | None = None,
|
|
472
|
+
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
473
|
+
"""WebSocket/non-DI writers: caller must commit. Auto-rollback on error.
|
|
474
|
+
|
|
475
|
+
Use this for code that needs to write outside FastAPI dependency injection
|
|
476
|
+
(e.g. WebSocket handlers, terminal upload).
|
|
477
|
+
"""
|
|
478
|
+
async with _acquire_writer(label=label) as w:
|
|
479
|
+
yield w
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def wal_checkpoint() -> tuple[int, int, int]:
|
|
483
|
+
"""Run PRAGMA wal_checkpoint(TRUNCATE) via the writer connection.
|
|
484
|
+
|
|
485
|
+
Returns (busy, log, checkpointed) — same as SQLite's checkpoint result row.
|
|
486
|
+
busy>0 means active readers blocked a full truncate (partial checkpoint still ran).
|
|
487
|
+
"""
|
|
488
|
+
async with _acquire_writer(label="wal_checkpoint") as writer:
|
|
489
|
+
cursor = await writer.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
490
|
+
row = await cursor.fetchone()
|
|
491
|
+
return (row[0], row[1], row[2])
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# sqlite-vec support
|
|
495
|
+
_vec_table_ready = False
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def ensure_vec_documents(db: aiosqlite.Connection) -> bool:
|
|
499
|
+
"""Load sqlite-vec on an existing connection and ensure vec_documents exists."""
|
|
500
|
+
global _vec_table_ready
|
|
501
|
+
vec_path = Path(settings.vec0_path)
|
|
502
|
+
vec_so = vec_path.with_suffix(".so") if not vec_path.suffix else vec_path
|
|
503
|
+
if not vec_so.exists():
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
if not getattr(db, "_pir_vec_extension_loaded", False):
|
|
507
|
+
await db._execute(db._conn.enable_load_extension, True)
|
|
508
|
+
await db.execute("SELECT load_extension(?)", [str(vec_path)])
|
|
509
|
+
setattr(db, "_pir_vec_extension_loaded", True)
|
|
510
|
+
if not _vec_table_ready:
|
|
511
|
+
await db.execute("""
|
|
512
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_documents USING vec0(
|
|
513
|
+
doc_id INTEGER PRIMARY KEY,
|
|
514
|
+
embedding float[512]
|
|
515
|
+
)
|
|
516
|
+
""")
|
|
517
|
+
_vec_table_ready = True
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
async def get_vec_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
522
|
+
"""Dedicated dependency for sqlite-vec endpoints. Mirrors get_db() PRAGMAs + loads vec0."""
|
|
523
|
+
db = await aiosqlite.connect(settings.db_path)
|
|
524
|
+
await _configure_connection(db)
|
|
525
|
+
db.row_factory = aiosqlite.Row
|
|
526
|
+
await ensure_vec_documents(db)
|
|
527
|
+
try:
|
|
528
|
+
yield db
|
|
529
|
+
finally:
|
|
530
|
+
await db.close()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def run_migrations() -> None:
|
|
534
|
+
"""Apply pending SQL migrations in order."""
|
|
535
|
+
conn = get_sync_connection()
|
|
536
|
+
try:
|
|
537
|
+
# Ensure schema_versions exists for first run
|
|
538
|
+
conn.execute(
|
|
539
|
+
"CREATE TABLE IF NOT EXISTS schema_versions "
|
|
540
|
+
"(version INTEGER PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
|
541
|
+
)
|
|
542
|
+
cursor = conn.execute("SELECT MAX(version) FROM schema_versions")
|
|
543
|
+
row = cursor.fetchone()
|
|
544
|
+
current_version = row[0] if row[0] is not None else 0
|
|
545
|
+
|
|
546
|
+
migration_files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
|
547
|
+
pending = [
|
|
548
|
+
f
|
|
549
|
+
for f in migration_files
|
|
550
|
+
if not f.stem.endswith("_down")
|
|
551
|
+
and int(f.stem.split("_")[0]) > current_version
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
# Pre-migration backup (enterprise rollback strategy)
|
|
555
|
+
# Keep only last 2 backups to prevent disk fill (was unbounded)
|
|
556
|
+
if pending:
|
|
557
|
+
backup_path = f"{settings.db_path}.backup-v{current_version}"
|
|
558
|
+
try:
|
|
559
|
+
shutil.copy2(settings.db_path, backup_path)
|
|
560
|
+
logger.info("Pre-migration backup: %s", backup_path)
|
|
561
|
+
# Rotate: keep only the 2 most recent backups
|
|
562
|
+
import glob
|
|
563
|
+
|
|
564
|
+
db_dir = os.path.dirname(settings.db_path) or "."
|
|
565
|
+
db_name = os.path.basename(settings.db_path)
|
|
566
|
+
backups = sorted(
|
|
567
|
+
glob.glob(os.path.join(db_dir, f"{db_name}.backup-v*")),
|
|
568
|
+
key=os.path.getmtime,
|
|
569
|
+
)
|
|
570
|
+
for old_backup in backups[:-2]:
|
|
571
|
+
try:
|
|
572
|
+
os.remove(old_backup)
|
|
573
|
+
logger.info("Rotated old backup: %s", old_backup)
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
except OSError as e:
|
|
577
|
+
logger.warning("Pre-migration backup failed (continuing): %s", e)
|
|
578
|
+
|
|
579
|
+
for migration_file in migration_files:
|
|
580
|
+
if migration_file.stem.endswith("_down"):
|
|
581
|
+
continue
|
|
582
|
+
version = int(migration_file.stem.split("_")[0])
|
|
583
|
+
if version > current_version:
|
|
584
|
+
logger.info("Applying migration %s", migration_file.name)
|
|
585
|
+
sql = migration_file.read_text()
|
|
586
|
+
conn.executescript(sql)
|
|
587
|
+
# executescript() resets PRAGMA foreign_keys; re-enable
|
|
588
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
589
|
+
conn.execute(
|
|
590
|
+
"INSERT OR IGNORE INTO schema_versions (version) VALUES (?)",
|
|
591
|
+
(version,),
|
|
592
|
+
)
|
|
593
|
+
conn.commit()
|
|
594
|
+
|
|
595
|
+
# Post-migration hooks
|
|
596
|
+
if version == 8:
|
|
597
|
+
_backfill_session_uuids(conn)
|
|
598
|
+
if version == 16:
|
|
599
|
+
_seed_users_and_migrate_owner(conn)
|
|
600
|
+
if version == 18:
|
|
601
|
+
_seed_agents(conn)
|
|
602
|
+
if version == 45:
|
|
603
|
+
_add_documents_columns(conn)
|
|
604
|
+
if version == 46:
|
|
605
|
+
_add_salience_columns(conn)
|
|
606
|
+
if version == 47:
|
|
607
|
+
_seed_missing_agents(conn)
|
|
608
|
+
if version == 48:
|
|
609
|
+
_fix_agent_paths_and_roles(conn)
|
|
610
|
+
if version == 49:
|
|
611
|
+
_migration_049_agent_role_and_learnings(conn)
|
|
612
|
+
if version == 58:
|
|
613
|
+
_backfill_inbox_status_from_treatment(conn)
|
|
614
|
+
if version == 59:
|
|
615
|
+
_add_deep_research_column(conn)
|
|
616
|
+
_cleanup_generic_source_scores(conn)
|
|
617
|
+
if version == 60:
|
|
618
|
+
_add_sent_in_newsletter_column(conn)
|
|
619
|
+
if version == 61:
|
|
620
|
+
_migration_061_backfill_sources(conn)
|
|
621
|
+
if version == 62:
|
|
622
|
+
_migration_062_backfill_from_urls(conn)
|
|
623
|
+
if version == 63:
|
|
624
|
+
_add_task_completion_mode(conn)
|
|
625
|
+
if version == 70:
|
|
626
|
+
_migration_070_digest_ranking_inputs_recovery(conn)
|
|
627
|
+
if version == 71:
|
|
628
|
+
_migration_071_digest_selection_recovery(conn)
|
|
629
|
+
if version == 72:
|
|
630
|
+
_migration_072_digest_app_settings_recovery(conn)
|
|
631
|
+
if version == 102:
|
|
632
|
+
_promote_llm_costs_columns(conn)
|
|
633
|
+
if version == 135:
|
|
634
|
+
_migration_135_graph_edges_provider(conn)
|
|
635
|
+
if version == 136:
|
|
636
|
+
_backfill_documents_fts(conn)
|
|
637
|
+
|
|
638
|
+
logger.info("Migration %s applied", migration_file.name)
|
|
639
|
+
|
|
640
|
+
if not _column_exists(conn, "sessions_meta", "theme_mode"):
|
|
641
|
+
_add_session_theme_mode_column(conn)
|
|
642
|
+
|
|
643
|
+
logger.info("Database at version %d", max(current_version, 0))
|
|
644
|
+
finally:
|
|
645
|
+
conn.close()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _backfill_session_uuids(conn: sqlite3.Connection) -> None:
|
|
649
|
+
"""Backfill session_uuid for existing sessions (migration 008 post-hook)."""
|
|
650
|
+
cursor = conn.execute("SELECT name FROM sessions_meta WHERE session_uuid IS NULL")
|
|
651
|
+
rows = cursor.fetchall()
|
|
652
|
+
if not rows:
|
|
653
|
+
return
|
|
654
|
+
for row in rows:
|
|
655
|
+
name = row["name"] if isinstance(row, sqlite3.Row) else row[0]
|
|
656
|
+
conn.execute(
|
|
657
|
+
"UPDATE sessions_meta SET session_uuid = ? WHERE name = ?",
|
|
658
|
+
(str(uuid_mod.uuid4()), name),
|
|
659
|
+
)
|
|
660
|
+
conn.commit()
|
|
661
|
+
logger.info("Backfilled UUIDs for %d sessions", len(rows))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _seed_users_and_migrate_owner(conn: sqlite3.Connection) -> None:
|
|
665
|
+
"""Migration 016 post-hook: seed the admin user + data-migrate tasks.owner_id.
|
|
666
|
+
|
|
667
|
+
Runs synchronously inside run_migrations() — do NOT use await here.
|
|
668
|
+
Accepts either PIR_ADMIN_PASSWORD_HASH (pre-hashed, preferred in production)
|
|
669
|
+
or PIR_PASSWORD (plaintext, will be bcrypt-hashed here).
|
|
670
|
+
At least one must be set or the migration aborts.
|
|
671
|
+
|
|
672
|
+
The admin identity is config-driven via the same MARVIS_ADMIN_* vars used by
|
|
673
|
+
the deploy bootstrap (scripts/init.sh), with generic defaults, so a fresh
|
|
674
|
+
install seeds no hardcoded name. On an existing deployment this seed is
|
|
675
|
+
skipped (users already exist), so the defaults never affect a running install.
|
|
676
|
+
"""
|
|
677
|
+
# Prefer pre-hashed password (production .env has PIR_ADMIN_PASSWORD_HASH)
|
|
678
|
+
hashed = os.environ.get("PIR_ADMIN_PASSWORD_HASH", "").strip()
|
|
679
|
+
if not hashed:
|
|
680
|
+
import bcrypt
|
|
681
|
+
|
|
682
|
+
pir_password = os.environ.get("PIR_PASSWORD", "").strip()
|
|
683
|
+
if not pir_password:
|
|
684
|
+
raise RuntimeError(
|
|
685
|
+
"Migration 016 requires PIR_ADMIN_PASSWORD_HASH or PIR_PASSWORD env var "
|
|
686
|
+
"to seed the admin user. Set at least one in .env."
|
|
687
|
+
)
|
|
688
|
+
hashed = bcrypt.hashpw(pir_password.encode("utf-8"), bcrypt.gensalt()).decode()
|
|
689
|
+
|
|
690
|
+
admin_id = os.environ.get("MARVIS_ADMIN_USER_ID", "").strip() or "usr_admin"
|
|
691
|
+
admin_slug = os.environ.get("MARVIS_ADMIN_SLUG", "").strip() or "admin"
|
|
692
|
+
admin_name = os.environ.get("MARVIS_ADMIN_DISPLAY_NAME", "").strip() or "Admin"
|
|
693
|
+
|
|
694
|
+
cursor = conn.execute("SELECT COUNT(*) FROM users")
|
|
695
|
+
user_count = cursor.fetchone()[0]
|
|
696
|
+
if user_count == 0:
|
|
697
|
+
conn.execute(
|
|
698
|
+
"INSERT OR IGNORE INTO users "
|
|
699
|
+
"(id, slug, display_name, type, password_hash, system_role) "
|
|
700
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
701
|
+
(admin_id, admin_slug, admin_name, "human", hashed, "super_admin"),
|
|
702
|
+
)
|
|
703
|
+
conn.commit()
|
|
704
|
+
logger.info("Migration 016: seeded admin user '%s' (super_admin)", admin_slug)
|
|
705
|
+
|
|
706
|
+
# Data migration: owner_id may contain slug strings (e.g. "emilio") from before
|
|
707
|
+
# the users table existed. Resolve each slug to the corresponding users.id.
|
|
708
|
+
# Values without a matching slug are left unchanged (NULL FK, graceful fallback).
|
|
709
|
+
conn.execute("""
|
|
710
|
+
UPDATE tasks
|
|
711
|
+
SET owner_id = (SELECT id FROM users WHERE slug = tasks.owner_id)
|
|
712
|
+
WHERE owner_id IS NOT NULL
|
|
713
|
+
AND EXISTS (SELECT 1 FROM users WHERE slug = tasks.owner_id)
|
|
714
|
+
""")
|
|
715
|
+
conn.commit()
|
|
716
|
+
logger.info("Migration 016: owner_id slug→id data migration complete")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _seed_agents(conn: sqlite3.Connection) -> None:
|
|
720
|
+
"""Migration 018 post-hook: seed the deploy's configured system agents.
|
|
721
|
+
|
|
722
|
+
The agent slugs come from settings.static_agent_identities (deploy .env), so
|
|
723
|
+
OSS core hardcodes no tenant agent names. A fresh OSS install with no config
|
|
724
|
+
seeds nothing here (no internal agents). On prod this migration already ran;
|
|
725
|
+
rows persist independently and re-running is inert (INSERT OR IGNORE).
|
|
726
|
+
|
|
727
|
+
IDs follow the convention usr_{slug} / agt-{slug} (same as seed_agent_users.py
|
|
728
|
+
and migration 047). All rows are idempotent.
|
|
729
|
+
"""
|
|
730
|
+
for slug in settings.static_agent_identities:
|
|
731
|
+
usr_id = f"usr_{slug}"
|
|
732
|
+
agt_id = f"agt-{slug}"
|
|
733
|
+
conn.execute(
|
|
734
|
+
"INSERT OR IGNORE INTO users (id, slug, display_name, type, avatar_color, system_role, created_at, updated_at) "
|
|
735
|
+
"VALUES (?, ?, ?, 'agent', '#3B82F6', 'operator', datetime('now','utc'), datetime('now','utc'))",
|
|
736
|
+
(usr_id, slug, slug),
|
|
737
|
+
)
|
|
738
|
+
conn.execute(
|
|
739
|
+
"INSERT OR IGNORE INTO agents (id, user_id, scheduler_agent_id, agent_type, model, status, description, created_at, updated_at) "
|
|
740
|
+
"VALUES (?, ?, ?, 'system', 'sonnet', 'active', ?, datetime('now','utc'), datetime('now','utc'))",
|
|
741
|
+
(agt_id, usr_id, slug, f"{slug} agent"),
|
|
742
|
+
)
|
|
743
|
+
conn.commit()
|
|
744
|
+
logger.info(
|
|
745
|
+
"Migration 018: seeded %d configured system agent(s)",
|
|
746
|
+
len(settings.static_agent_identities),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _add_documents_columns(conn: sqlite3.Connection) -> None:
|
|
751
|
+
"""Migration 045 post-hook: add doc_type, doc_title, workspace_id to documents (idempotent).
|
|
752
|
+
|
|
753
|
+
This runs AFTER conn.executescript(sql) so we can safely ALTER TABLE and CREATE INDEX.
|
|
754
|
+
The SQL file only does the schema_versions INSERT to avoid index-on-missing-column errors.
|
|
755
|
+
"""
|
|
756
|
+
if not _column_exists(conn, "documents", "doc_type"):
|
|
757
|
+
conn.execute(
|
|
758
|
+
"ALTER TABLE documents ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'handoff'"
|
|
759
|
+
)
|
|
760
|
+
logger.info("Migration 045: added documents.doc_type")
|
|
761
|
+
if not _column_exists(conn, "documents", "doc_title"):
|
|
762
|
+
conn.execute("ALTER TABLE documents ADD COLUMN doc_title TEXT")
|
|
763
|
+
logger.info("Migration 045: added documents.doc_title")
|
|
764
|
+
if not _column_exists(conn, "documents", "workspace_id"):
|
|
765
|
+
conn.execute(
|
|
766
|
+
"ALTER TABLE documents ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'ws_default'"
|
|
767
|
+
)
|
|
768
|
+
logger.info("Migration 045: added documents.workspace_id")
|
|
769
|
+
# Index must be created after the column exists (can't be in SQL file)
|
|
770
|
+
conn.execute(
|
|
771
|
+
"CREATE INDEX IF NOT EXISTS idx_documents_doc_type ON documents(doc_type)"
|
|
772
|
+
)
|
|
773
|
+
conn.commit()
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _add_salience_columns(conn: sqlite3.Connection) -> None:
|
|
777
|
+
"""Migration 046 post-hook: add salience, archived, salience_updated_at to documents (idempotent).
|
|
778
|
+
|
|
779
|
+
Same pattern as migration 045 — SQL file only does schema_versions INSERT + boost_log table.
|
|
780
|
+
ALTER TABLE + partial indexes run here after columns exist.
|
|
781
|
+
"""
|
|
782
|
+
if not _column_exists(conn, "documents", "salience"):
|
|
783
|
+
conn.execute(
|
|
784
|
+
"ALTER TABLE documents ADD COLUMN salience REAL NOT NULL DEFAULT 0.5"
|
|
785
|
+
)
|
|
786
|
+
logger.info("Migration 046: added documents.salience")
|
|
787
|
+
if not _column_exists(conn, "documents", "archived"):
|
|
788
|
+
conn.execute(
|
|
789
|
+
"ALTER TABLE documents ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"
|
|
790
|
+
)
|
|
791
|
+
logger.info("Migration 046: added documents.archived")
|
|
792
|
+
if not _column_exists(conn, "documents", "salience_updated_at"):
|
|
793
|
+
conn.execute("ALTER TABLE documents ADD COLUMN salience_updated_at TEXT")
|
|
794
|
+
logger.info("Migration 046: added documents.salience_updated_at")
|
|
795
|
+
# Partial indexes for active (non-archived) documents
|
|
796
|
+
conn.execute(
|
|
797
|
+
"CREATE INDEX IF NOT EXISTS idx_documents_active ON documents(doc_type) WHERE archived = 0"
|
|
798
|
+
)
|
|
799
|
+
conn.execute(
|
|
800
|
+
"CREATE INDEX IF NOT EXISTS idx_documents_salience ON documents(salience DESC, doc_type) WHERE archived = 0"
|
|
801
|
+
)
|
|
802
|
+
conn.commit()
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
806
|
+
row = conn.execute(
|
|
807
|
+
"SELECT 1 FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?",
|
|
808
|
+
(table,),
|
|
809
|
+
).fetchone()
|
|
810
|
+
return row is not None
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _backfill_documents_fts(conn: sqlite3.Connection) -> None:
|
|
814
|
+
"""Migration 135 post-hook: backfill full-text bodies for documents_fts.
|
|
815
|
+
|
|
816
|
+
SQL migrations cannot read filesystem bodies. The SQL file creates the FTS5
|
|
817
|
+
table, trigger sync, and a file_path-only fallback. This hook replaces that
|
|
818
|
+
fallback with the full body for loadable files and row-backed document
|
|
819
|
+
sources, while staying idempotent through DELETE + INSERT by rowid.
|
|
820
|
+
"""
|
|
821
|
+
if not _table_exists(conn, "documents_fts") or not _table_exists(conn, "documents"):
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
columns = {row[1] for row in conn.execute("PRAGMA table_info(documents)").fetchall()}
|
|
825
|
+
title_expr = "doc_title" if "doc_title" in columns else "file_path AS doc_title"
|
|
826
|
+
salience_expr = "salience" if "salience" in columns else "0.5 AS salience"
|
|
827
|
+
archived_filter = "WHERE COALESCE(archived, 0) = 0" if "archived" in columns else ""
|
|
828
|
+
rows = conn.execute(
|
|
829
|
+
f"""SELECT id, file_path, project, {title_expr}, {salience_expr}
|
|
830
|
+
FROM documents
|
|
831
|
+
{archived_filter}"""
|
|
832
|
+
).fetchall()
|
|
833
|
+
|
|
834
|
+
for row in rows:
|
|
835
|
+
doc_id = int(row["id"])
|
|
836
|
+
title = row["doc_title"] or row["file_path"] or ""
|
|
837
|
+
content = _documents_fts_content(conn, row)
|
|
838
|
+
conn.execute("DELETE FROM documents_fts WHERE rowid = ?", (doc_id,))
|
|
839
|
+
conn.execute(
|
|
840
|
+
"INSERT INTO documents_fts(rowid, doc_id, title, content) VALUES (?, ?, ?, ?)",
|
|
841
|
+
(doc_id, doc_id, title, content),
|
|
842
|
+
)
|
|
843
|
+
conn.commit()
|
|
844
|
+
logger.info("Migration 135: backfilled documents_fts rows=%d", len(rows))
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _documents_fts_content(conn: sqlite3.Connection, row: sqlite3.Row) -> str:
|
|
848
|
+
file_path = row["file_path"] or ""
|
|
849
|
+
title = row["doc_title"] or file_path
|
|
850
|
+
project = row["project"] or ""
|
|
851
|
+
|
|
852
|
+
if file_path.startswith("task:"):
|
|
853
|
+
task_id = file_path.split(":", 1)[1]
|
|
854
|
+
task = _fetch_one_or_none(
|
|
855
|
+
conn,
|
|
856
|
+
"SELECT title, description, status, project, tags FROM tasks WHERE id = ?",
|
|
857
|
+
(task_id,),
|
|
858
|
+
)
|
|
859
|
+
if task is not None:
|
|
860
|
+
return "\n".join(
|
|
861
|
+
str(part)
|
|
862
|
+
for part in (
|
|
863
|
+
task["title"],
|
|
864
|
+
task["description"],
|
|
865
|
+
f"Status: {task['status']}",
|
|
866
|
+
f"Project: {task['project']}",
|
|
867
|
+
f"Tags: {task['tags']}",
|
|
868
|
+
)
|
|
869
|
+
if part
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
if file_path.startswith("learning:"):
|
|
873
|
+
learning_id = file_path.split(":", 1)[1]
|
|
874
|
+
learning = _fetch_one_or_none(
|
|
875
|
+
conn,
|
|
876
|
+
"SELECT title, description, prevention, category, severity, tags "
|
|
877
|
+
"FROM learnings WHERE id = ?",
|
|
878
|
+
(learning_id,),
|
|
879
|
+
)
|
|
880
|
+
if learning is not None:
|
|
881
|
+
return "\n".join(
|
|
882
|
+
str(part)
|
|
883
|
+
for part in (
|
|
884
|
+
learning["title"],
|
|
885
|
+
learning["description"],
|
|
886
|
+
f"Prevention: {learning['prevention']}",
|
|
887
|
+
f"Category: {learning['category']}",
|
|
888
|
+
f"Severity: {learning['severity']}",
|
|
889
|
+
f"Tags: {learning['tags']}",
|
|
890
|
+
)
|
|
891
|
+
if part
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if file_path.startswith("inbox_item:"):
|
|
895
|
+
inbox_id = file_path.split(":", 1)[1]
|
|
896
|
+
inbox = _fetch_one_or_none(
|
|
897
|
+
conn,
|
|
898
|
+
"SELECT title, content, tldr, source, status FROM inbox_items WHERE id = ?",
|
|
899
|
+
(inbox_id,),
|
|
900
|
+
)
|
|
901
|
+
if inbox is not None:
|
|
902
|
+
return "\n".join(
|
|
903
|
+
str(part)
|
|
904
|
+
for part in (
|
|
905
|
+
inbox["title"],
|
|
906
|
+
inbox["content"],
|
|
907
|
+
f"TLDR: {inbox['tldr']}",
|
|
908
|
+
f"Source: {inbox['source']}",
|
|
909
|
+
f"Status: {inbox['status']}",
|
|
910
|
+
)
|
|
911
|
+
if part
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if file_path.startswith("/") and _is_loadable_document_path(file_path):
|
|
915
|
+
try:
|
|
916
|
+
return Path(file_path).read_text(encoding="utf-8", errors="replace")
|
|
917
|
+
except OSError:
|
|
918
|
+
pass
|
|
919
|
+
|
|
920
|
+
return "\n".join(part for part in (str(title), str(project), str(file_path)) if part)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _fetch_one_or_none(
|
|
924
|
+
conn: sqlite3.Connection,
|
|
925
|
+
sql: str,
|
|
926
|
+
params: tuple[object, ...],
|
|
927
|
+
) -> sqlite3.Row | None:
|
|
928
|
+
try:
|
|
929
|
+
return conn.execute(sql, params).fetchone()
|
|
930
|
+
except sqlite3.OperationalError:
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _is_loadable_document_path(file_path: str) -> bool:
|
|
935
|
+
path = Path(file_path)
|
|
936
|
+
try:
|
|
937
|
+
return path.is_file() and not path.is_symlink() and path.stat().st_size <= 500_000
|
|
938
|
+
except OSError:
|
|
939
|
+
return False
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _add_task_completion_mode(conn: sqlite3.Connection) -> None:
|
|
943
|
+
"""Migration 063 post-hook: add tasks.completion_mode (idempotent).
|
|
944
|
+
|
|
945
|
+
Values: 'pr' (default, requires merged PR), 'doc' (research/brainstorm/plan),
|
|
946
|
+
'none' (verify/diagnose/free transition).
|
|
947
|
+
|
|
948
|
+
Backfill heuristic for existing in_progress tasks: scan title/tags for
|
|
949
|
+
research/brainstorm/plan/verify keywords and set completion_mode='doc'
|
|
950
|
+
so Fix 2 cleanup can close them via normal PATCH. All other existing rows
|
|
951
|
+
stay on default 'pr' (backward compat — code fixes keep the strict guard).
|
|
952
|
+
"""
|
|
953
|
+
if not _column_exists(conn, "tasks", "completion_mode"):
|
|
954
|
+
conn.execute(
|
|
955
|
+
"ALTER TABLE tasks ADD COLUMN completion_mode TEXT NOT NULL DEFAULT 'pr'"
|
|
956
|
+
)
|
|
957
|
+
logger.info("Migration 063: added tasks.completion_mode")
|
|
958
|
+
|
|
959
|
+
# Backfill existing in_progress tasks that look like research/planning work.
|
|
960
|
+
# This unblocks Fix 2 cleanup of the 32 orphan tasks without manual PATCH.
|
|
961
|
+
# Heuristic: title starts with research/brainstorm/plan/verify/diagnose/analyze/investigate
|
|
962
|
+
# OR any tag in research-y set. Conservative — only in_progress rows.
|
|
963
|
+
research_keywords = (
|
|
964
|
+
"research",
|
|
965
|
+
"brainstorm",
|
|
966
|
+
"plan",
|
|
967
|
+
"verify",
|
|
968
|
+
"verifi",
|
|
969
|
+
"diagnose",
|
|
970
|
+
"diagnost",
|
|
971
|
+
"analyze",
|
|
972
|
+
"analizza",
|
|
973
|
+
"investigate",
|
|
974
|
+
"indaga",
|
|
975
|
+
"indagar",
|
|
976
|
+
)
|
|
977
|
+
research_tags = {
|
|
978
|
+
"research",
|
|
979
|
+
"brainstorm",
|
|
980
|
+
"plan",
|
|
981
|
+
"planning",
|
|
982
|
+
"verification",
|
|
983
|
+
"verify",
|
|
984
|
+
"investigation",
|
|
985
|
+
"diagnostics",
|
|
986
|
+
"analysis",
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
cursor = conn.execute(
|
|
990
|
+
"SELECT id, title, tags FROM tasks WHERE status = 'in_progress'"
|
|
991
|
+
)
|
|
992
|
+
rows = cursor.fetchall()
|
|
993
|
+
backfilled = 0
|
|
994
|
+
for row in rows:
|
|
995
|
+
task_id = row["id"] if isinstance(row, sqlite3.Row) else row[0]
|
|
996
|
+
title = (row["title"] if isinstance(row, sqlite3.Row) else row[1]) or ""
|
|
997
|
+
tags_raw = row["tags"] if isinstance(row, sqlite3.Row) else row[2]
|
|
998
|
+
title_lc = title.lower()
|
|
999
|
+
try:
|
|
1000
|
+
tags_list = set(json.loads(tags_raw)) if tags_raw else set()
|
|
1001
|
+
except (json.JSONDecodeError, TypeError):
|
|
1002
|
+
tags_list = set()
|
|
1003
|
+
matches_title = any(
|
|
1004
|
+
title_lc.startswith(k) or f" {k}" in title_lc for k in research_keywords
|
|
1005
|
+
)
|
|
1006
|
+
matches_tags = bool(tags_list & research_tags)
|
|
1007
|
+
if matches_title or matches_tags:
|
|
1008
|
+
conn.execute(
|
|
1009
|
+
"UPDATE tasks SET completion_mode = 'doc' WHERE id = ?",
|
|
1010
|
+
(task_id,),
|
|
1011
|
+
)
|
|
1012
|
+
backfilled += 1
|
|
1013
|
+
conn.commit()
|
|
1014
|
+
logger.info(
|
|
1015
|
+
"Migration 063: backfilled %d in_progress tasks to completion_mode='doc'",
|
|
1016
|
+
backfilled,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _migration_070_digest_ranking_inputs_recovery(conn: sqlite3.Connection) -> None:
|
|
1021
|
+
"""Recovery migration for digest ranking inputs on DBs already past version 69."""
|
|
1022
|
+
if not _column_exists(conn, "inbox_items", "domain_key"):
|
|
1023
|
+
conn.execute("ALTER TABLE inbox_items ADD COLUMN domain_key TEXT")
|
|
1024
|
+
logger.info("Migration 070: added inbox_items.domain_key")
|
|
1025
|
+
if not _column_exists(conn, "inbox_items", "published_at"):
|
|
1026
|
+
conn.execute("ALTER TABLE inbox_items ADD COLUMN published_at TEXT")
|
|
1027
|
+
logger.info("Migration 070: added inbox_items.published_at")
|
|
1028
|
+
if not _column_exists(conn, "inbox_items", "freshness_at"):
|
|
1029
|
+
conn.execute("ALTER TABLE inbox_items ADD COLUMN freshness_at TEXT")
|
|
1030
|
+
logger.info("Migration 070: added inbox_items.freshness_at")
|
|
1031
|
+
conn.execute(
|
|
1032
|
+
"CREATE INDEX IF NOT EXISTS idx_inbox_items_workspace_domain_freshness "
|
|
1033
|
+
"ON inbox_items(workspace_id, domain_key, freshness_at DESC, created_at DESC)"
|
|
1034
|
+
)
|
|
1035
|
+
conn.commit()
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _migration_071_digest_selection_recovery(conn: sqlite3.Connection) -> None:
|
|
1039
|
+
"""Recovery migration for inbox_digest_selections on DBs already past version 69."""
|
|
1040
|
+
conn.execute(
|
|
1041
|
+
"CREATE TABLE IF NOT EXISTS inbox_digest_selections ("
|
|
1042
|
+
"id TEXT PRIMARY KEY, "
|
|
1043
|
+
"inbox_item_id TEXT NOT NULL, "
|
|
1044
|
+
"digest_cycle_key TEXT NOT NULL, "
|
|
1045
|
+
"state TEXT NOT NULL CHECK (state IN ('visible', 'overflow', 'expired')), "
|
|
1046
|
+
"domain_key TEXT NOT NULL, "
|
|
1047
|
+
"score REAL NOT NULL DEFAULT 0, "
|
|
1048
|
+
"rank_in_domain INTEGER, "
|
|
1049
|
+
"expires_at TEXT, "
|
|
1050
|
+
"workspace_id TEXT NOT NULL DEFAULT 'ws_default', "
|
|
1051
|
+
"created_at TEXT DEFAULT (datetime('now','utc')), "
|
|
1052
|
+
"updated_at TEXT DEFAULT (datetime('now','utc')), "
|
|
1053
|
+
"FOREIGN KEY (inbox_item_id) REFERENCES inbox_items(id) ON DELETE CASCADE"
|
|
1054
|
+
")"
|
|
1055
|
+
)
|
|
1056
|
+
conn.execute(
|
|
1057
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_selection_item_cycle "
|
|
1058
|
+
"ON inbox_digest_selections(workspace_id, inbox_item_id, digest_cycle_key)"
|
|
1059
|
+
)
|
|
1060
|
+
conn.execute(
|
|
1061
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_selection_active_item "
|
|
1062
|
+
"ON inbox_digest_selections(workspace_id, inbox_item_id) "
|
|
1063
|
+
"WHERE state IN ('visible', 'overflow')"
|
|
1064
|
+
)
|
|
1065
|
+
conn.execute(
|
|
1066
|
+
"CREATE INDEX IF NOT EXISTS idx_digest_selection_cycle_state_domain "
|
|
1067
|
+
"ON inbox_digest_selections(workspace_id, digest_cycle_key, state, domain_key, rank_in_domain)"
|
|
1068
|
+
)
|
|
1069
|
+
conn.commit()
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _migration_072_digest_app_settings_recovery(conn: sqlite3.Connection) -> None:
|
|
1073
|
+
"""Recovery migration for digest app_settings defaults on DBs already past version 69."""
|
|
1074
|
+
conn.execute(
|
|
1075
|
+
"INSERT OR IGNORE INTO app_settings (key, value) VALUES ('inbox_daily_digest_enabled', 'shadow')"
|
|
1076
|
+
)
|
|
1077
|
+
conn.execute(
|
|
1078
|
+
"INSERT OR IGNORE INTO app_settings (key, value) VALUES ('inbox_daily_digest_freeze_hour_utc', '6')"
|
|
1079
|
+
)
|
|
1080
|
+
conn.execute(
|
|
1081
|
+
"INSERT OR IGNORE INTO app_settings (key, value) VALUES ('inbox_daily_digest_admission_threshold', '1.0')"
|
|
1082
|
+
)
|
|
1083
|
+
conn.execute(
|
|
1084
|
+
"INSERT OR IGNORE INTO app_settings (key, value) VALUES ('inbox_daily_digest_overflow_ttl_days', '3')"
|
|
1085
|
+
)
|
|
1086
|
+
conn.execute(
|
|
1087
|
+
"INSERT OR IGNORE INTO app_settings (key, value) VALUES ('inbox_daily_digest_last_cycle_key', '')"
|
|
1088
|
+
)
|
|
1089
|
+
conn.commit()
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _seed_missing_agents(conn: sqlite3.Connection) -> None:
|
|
1093
|
+
"""Migration 047 post-hook: seed DevX, System Health, Reddit agents (idempotent).
|
|
1094
|
+
|
|
1095
|
+
Must be in Python hook (not SQL) because executescript() + PRAGMA foreign_keys=ON
|
|
1096
|
+
causes FK constraint errors when inserting users + agents in the same script.
|
|
1097
|
+
"""
|
|
1098
|
+
agents_base = settings.effective_agents_base
|
|
1099
|
+
# (usr_id, slug, display, color, agt_id, agt_type, model, desc, agent_dir)
|
|
1100
|
+
agents = [
|
|
1101
|
+
(
|
|
1102
|
+
"usr_devx",
|
|
1103
|
+
"devx",
|
|
1104
|
+
"DevX",
|
|
1105
|
+
"#EF4444",
|
|
1106
|
+
"agt-devx",
|
|
1107
|
+
"system",
|
|
1108
|
+
"sonnet",
|
|
1109
|
+
"DevX Session Monitor",
|
|
1110
|
+
"devx",
|
|
1111
|
+
),
|
|
1112
|
+
(
|
|
1113
|
+
"usr_system_health",
|
|
1114
|
+
"system-health",
|
|
1115
|
+
"System Health",
|
|
1116
|
+
"#10B981",
|
|
1117
|
+
"agt-system-health",
|
|
1118
|
+
"system",
|
|
1119
|
+
"haiku",
|
|
1120
|
+
"System Health Check",
|
|
1121
|
+
"system-monitor",
|
|
1122
|
+
),
|
|
1123
|
+
(
|
|
1124
|
+
"usr_reddit",
|
|
1125
|
+
"reddit",
|
|
1126
|
+
"Reddit",
|
|
1127
|
+
"#F97316",
|
|
1128
|
+
"agt-reddit",
|
|
1129
|
+
"system",
|
|
1130
|
+
"haiku",
|
|
1131
|
+
"Reddit Morning Digest",
|
|
1132
|
+
"reddit",
|
|
1133
|
+
),
|
|
1134
|
+
]
|
|
1135
|
+
for (
|
|
1136
|
+
usr_id,
|
|
1137
|
+
slug,
|
|
1138
|
+
display,
|
|
1139
|
+
color,
|
|
1140
|
+
agt_id,
|
|
1141
|
+
agt_type,
|
|
1142
|
+
model,
|
|
1143
|
+
desc,
|
|
1144
|
+
agent_dir,
|
|
1145
|
+
) in agents:
|
|
1146
|
+
soul_path = f"{agents_base}/{agent_dir}/SOUL.md"
|
|
1147
|
+
tools_path = f"{agents_base}/{agent_dir}/TOOLS.md"
|
|
1148
|
+
identity_path = f"{agents_base}/{agent_dir}/IDENTITY.md"
|
|
1149
|
+
conn.execute(
|
|
1150
|
+
"INSERT OR IGNORE INTO users (id, slug, display_name, type, avatar_color, system_role, created_at, updated_at) "
|
|
1151
|
+
"VALUES (?, ?, ?, 'agent', ?, 'operator', datetime('now','utc'), datetime('now','utc'))",
|
|
1152
|
+
[usr_id, slug, display, color],
|
|
1153
|
+
)
|
|
1154
|
+
conn.execute(
|
|
1155
|
+
"INSERT OR IGNORE INTO agents (id, user_id, scheduler_agent_id, agent_type, model, status, description, "
|
|
1156
|
+
"soul_path, tools_path, identity_path, created_at, updated_at) "
|
|
1157
|
+
"VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, datetime('now','utc'), datetime('now','utc'))",
|
|
1158
|
+
[
|
|
1159
|
+
agt_id,
|
|
1160
|
+
usr_id,
|
|
1161
|
+
slug,
|
|
1162
|
+
agt_type,
|
|
1163
|
+
model,
|
|
1164
|
+
desc,
|
|
1165
|
+
soul_path,
|
|
1166
|
+
tools_path,
|
|
1167
|
+
identity_path,
|
|
1168
|
+
],
|
|
1169
|
+
)
|
|
1170
|
+
conn.commit()
|
|
1171
|
+
logger.info("Migration 047: seeded DevX + System Health + Reddit agents")
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _fix_agent_paths_and_roles(conn: sqlite3.Connection) -> None:
|
|
1175
|
+
"""Migration 048 post-hook: fix soul_path/tools_path for devx, system-health, reddit, analyst + system_role."""
|
|
1176
|
+
agents_base = settings.effective_agents_base
|
|
1177
|
+
# Fix paths for devx, system-health, reddit (Bug 1: were NULL)
|
|
1178
|
+
path_fixes = [
|
|
1179
|
+
("agt-devx", "devx"),
|
|
1180
|
+
("agt-system-health", "system-monitor"),
|
|
1181
|
+
("agt-reddit", "reddit"),
|
|
1182
|
+
]
|
|
1183
|
+
for agt_id, agent_dir in path_fixes:
|
|
1184
|
+
conn.execute(
|
|
1185
|
+
"UPDATE agents SET soul_path = ?, tools_path = ?, identity_path = ?, updated_at = datetime('now','utc') WHERE id = ?",
|
|
1186
|
+
[
|
|
1187
|
+
f"{agents_base}/{agent_dir}/SOUL.md",
|
|
1188
|
+
f"{agents_base}/{agent_dir}/TOOLS.md",
|
|
1189
|
+
f"{agents_base}/{agent_dir}/IDENTITY.md",
|
|
1190
|
+
agt_id,
|
|
1191
|
+
],
|
|
1192
|
+
)
|
|
1193
|
+
# Fix analyst paths (Bug 2: pointed to .openclaw which is root-only)
|
|
1194
|
+
conn.execute(
|
|
1195
|
+
"UPDATE agents SET soul_path = ?, tools_path = ?, identity_path = ?, updated_at = datetime('now','utc') WHERE id = 'agt-analyst'",
|
|
1196
|
+
[
|
|
1197
|
+
f"{agents_base}/analyst/SOUL.md",
|
|
1198
|
+
f"{agents_base}/analyst/TOOLS.md",
|
|
1199
|
+
f"{agents_base}/analyst/IDENTITY.md",
|
|
1200
|
+
],
|
|
1201
|
+
)
|
|
1202
|
+
# Fix system_role from 'agent' to 'operator' for the three new agent users (Bug 4)
|
|
1203
|
+
conn.execute(
|
|
1204
|
+
"UPDATE users SET system_role = 'operator', updated_at = datetime('now','utc') "
|
|
1205
|
+
"WHERE id IN ('usr_devx', 'usr_system_health', 'usr_reddit') AND system_role = 'agent'"
|
|
1206
|
+
)
|
|
1207
|
+
conn.commit()
|
|
1208
|
+
logger.info("Migration 048: fixed agent paths + system_role")
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _migration_049_agent_role_and_learnings(conn: sqlite3.Connection) -> None:
|
|
1212
|
+
"""Migration 049: normalize agent roles + add learnings schema for REM consolidation."""
|
|
1213
|
+
# 1. Normalize agent roles to 'operator' (compatible with existing CHECK constraint).
|
|
1214
|
+
# Some agents may be 'admin' (from a prior hotfix) or 'agent' (from migration 018).
|
|
1215
|
+
# DB-driven (every type='agent' user) so no agent slugs are hardcoded in core.
|
|
1216
|
+
conn.execute(
|
|
1217
|
+
"UPDATE users SET system_role = 'operator', updated_at = datetime('now','utc') "
|
|
1218
|
+
"WHERE type = 'agent' AND system_role != 'operator'"
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
# 2. Seed the deploy's configured self-improvement / consolidation agents.
|
|
1222
|
+
# Slugs come from settings.self_improvement_agents (deploy .env); OSS core
|
|
1223
|
+
# hardcodes no internal agent names. Idempotent; inert on prod (already ran).
|
|
1224
|
+
for slug in settings.self_improvement_agents:
|
|
1225
|
+
conn.execute(
|
|
1226
|
+
"INSERT OR IGNORE INTO users (id, slug, display_name, type, system_role, "
|
|
1227
|
+
"avatar_color, created_at, updated_at) "
|
|
1228
|
+
"VALUES (?, ?, ?, 'agent', 'operator', '#8B5CF6', datetime('now','utc'), datetime('now','utc'))",
|
|
1229
|
+
(f"usr_{slug}", slug, slug),
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# 3. Add last_accessed_at to documents (spaced repetition tracking)
|
|
1233
|
+
if not _column_exists(conn, "documents", "last_accessed_at"):
|
|
1234
|
+
conn.execute("ALTER TABLE documents ADD COLUMN last_accessed_at TEXT")
|
|
1235
|
+
logger.info("Migration 049: added documents.last_accessed_at")
|
|
1236
|
+
|
|
1237
|
+
# 4. Add status + consolidated_from to learnings (draft lifecycle + anti-cycle)
|
|
1238
|
+
if not _column_exists(conn, "learnings", "status"):
|
|
1239
|
+
conn.execute(
|
|
1240
|
+
"ALTER TABLE learnings ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
|
|
1241
|
+
)
|
|
1242
|
+
logger.info("Migration 049: added learnings.status")
|
|
1243
|
+
if not _column_exists(conn, "learnings", "consolidated_from"):
|
|
1244
|
+
conn.execute("ALTER TABLE learnings ADD COLUMN consolidated_from TEXT")
|
|
1245
|
+
logger.info("Migration 049: added learnings.consolidated_from")
|
|
1246
|
+
|
|
1247
|
+
conn.commit()
|
|
1248
|
+
logger.info(
|
|
1249
|
+
"Migration 049: agent roles normalized + learnings schema + access tracking"
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _backfill_inbox_status_from_treatment(conn: sqlite3.Connection) -> None:
|
|
1254
|
+
"""Migration 058 post-hook: backfill inbox_items.status from treatment.
|
|
1255
|
+
|
|
1256
|
+
Idempotent: only updates rows still at 'received' status.
|
|
1257
|
+
"""
|
|
1258
|
+
cursor = conn.execute(
|
|
1259
|
+
"""
|
|
1260
|
+
UPDATE inbox_items SET status = CASE treatment
|
|
1261
|
+
WHEN 'read' THEN 'unread'
|
|
1262
|
+
WHEN 'save' THEN 'saved'
|
|
1263
|
+
WHEN 'read_save' THEN 'unread'
|
|
1264
|
+
WHEN 'ignore' THEN 'auto_ignored'
|
|
1265
|
+
ELSE 'unread'
|
|
1266
|
+
END
|
|
1267
|
+
WHERE status = 'received'
|
|
1268
|
+
"""
|
|
1269
|
+
)
|
|
1270
|
+
conn.commit()
|
|
1271
|
+
logger.info(
|
|
1272
|
+
"Migration 058: backfilled %d inbox_items status from treatment",
|
|
1273
|
+
cursor.rowcount,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _add_deep_research_column(conn: sqlite3.Connection) -> None:
|
|
1278
|
+
"""Migration 059 post-hook: add deep_research column to inbox_items if missing."""
|
|
1279
|
+
if not _column_exists(conn, "inbox_items", "deep_research"):
|
|
1280
|
+
conn.execute("ALTER TABLE inbox_items ADD COLUMN deep_research TEXT")
|
|
1281
|
+
conn.commit()
|
|
1282
|
+
logger.info("Migration 059: added inbox_items.deep_research column")
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _cleanup_generic_source_scores(conn: sqlite3.Connection) -> None:
|
|
1286
|
+
"""Remove generic source score entries (rss-marvisx, gmail-marvisx) that are no longer useful."""
|
|
1287
|
+
cursor = conn.execute(
|
|
1288
|
+
"DELETE FROM source_scores WHERE source_key IN ('rss-marvisx', 'gmail-marvisx')"
|
|
1289
|
+
)
|
|
1290
|
+
conn.commit()
|
|
1291
|
+
if cursor.rowcount > 0:
|
|
1292
|
+
logger.info("Cleaned up %d generic source_scores entries", cursor.rowcount)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _add_sent_in_newsletter_column(conn: sqlite3.Connection) -> None:
|
|
1296
|
+
"""Migration 060 post-hook: add sent_in_newsletter column to inbox_items if missing."""
|
|
1297
|
+
if not _column_exists(conn, "inbox_items", "sent_in_newsletter"):
|
|
1298
|
+
conn.execute("ALTER TABLE inbox_items ADD COLUMN sent_in_newsletter TEXT")
|
|
1299
|
+
conn.commit()
|
|
1300
|
+
logger.info("Migration 060: added inbox_items.sent_in_newsletter column")
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _migration_061_backfill_sources(conn: sqlite3.Connection) -> None:
|
|
1304
|
+
"""Migration 061 post-hook: backfill inbox_sources from distinct inbox_items.source.
|
|
1305
|
+
|
|
1306
|
+
Normalizes source_key the SAME way as _update_source_score in inbox_triage:
|
|
1307
|
+
- URLs -> parsed netloc with optional www. prefix removed
|
|
1308
|
+
- non-URLs -> lowercase trimmed raw string
|
|
1309
|
+
|
|
1310
|
+
Idempotent: INSERT OR IGNORE on the unique (workspace_id, source_key) index.
|
|
1311
|
+
Collisions (two raw sources that normalize to the same key) are logged but
|
|
1312
|
+
do not fail the migration.
|
|
1313
|
+
"""
|
|
1314
|
+
from urllib.parse import urlparse
|
|
1315
|
+
|
|
1316
|
+
if not _column_exists(conn, "inbox_items", "source"):
|
|
1317
|
+
logger.info("Migration 061: inbox_items.source missing, skipping backfill")
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
cursor = conn.execute(
|
|
1321
|
+
"SELECT DISTINCT source, COALESCE(workspace_id, 'ws_default') AS ws "
|
|
1322
|
+
"FROM inbox_items WHERE source IS NOT NULL AND source != ''"
|
|
1323
|
+
)
|
|
1324
|
+
rows = cursor.fetchall()
|
|
1325
|
+
|
|
1326
|
+
seen_keys: set[tuple[str, str]] = set()
|
|
1327
|
+
collisions = 0
|
|
1328
|
+
inserted = 0
|
|
1329
|
+
|
|
1330
|
+
for row in rows:
|
|
1331
|
+
raw_source = row["source"] if isinstance(row, sqlite3.Row) else row[0]
|
|
1332
|
+
ws = row["ws"] if isinstance(row, sqlite3.Row) else row[1]
|
|
1333
|
+
if not raw_source:
|
|
1334
|
+
continue
|
|
1335
|
+
|
|
1336
|
+
source_key = raw_source
|
|
1337
|
+
try:
|
|
1338
|
+
parsed = urlparse(raw_source)
|
|
1339
|
+
if parsed.netloc:
|
|
1340
|
+
source_key = parsed.netloc.removeprefix("www.").lower()
|
|
1341
|
+
else:
|
|
1342
|
+
source_key = raw_source.strip().lower()
|
|
1343
|
+
except Exception: # noqa: BLE001 - defensive, never fail migration
|
|
1344
|
+
source_key = raw_source.strip().lower()
|
|
1345
|
+
|
|
1346
|
+
if not source_key:
|
|
1347
|
+
continue
|
|
1348
|
+
|
|
1349
|
+
key_tuple = (ws, source_key)
|
|
1350
|
+
if key_tuple in seen_keys:
|
|
1351
|
+
collisions += 1
|
|
1352
|
+
continue
|
|
1353
|
+
seen_keys.add(key_tuple)
|
|
1354
|
+
|
|
1355
|
+
result = conn.execute(
|
|
1356
|
+
"INSERT OR IGNORE INTO inbox_sources "
|
|
1357
|
+
"(id, name, source_key, source_type, active, workspace_id) "
|
|
1358
|
+
"VALUES (?, ?, ?, 'legacy', 1, ?)",
|
|
1359
|
+
(str(uuid_mod.uuid4()), raw_source[:200], source_key, ws),
|
|
1360
|
+
)
|
|
1361
|
+
if result.rowcount > 0:
|
|
1362
|
+
inserted += 1
|
|
1363
|
+
|
|
1364
|
+
conn.commit()
|
|
1365
|
+
logger.info(
|
|
1366
|
+
"Migration 061: backfilled inbox_sources (inserted=%d, collisions=%d, total_distinct=%d)",
|
|
1367
|
+
inserted,
|
|
1368
|
+
collisions,
|
|
1369
|
+
len(rows),
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _migration_062_backfill_from_urls(conn: sqlite3.Connection) -> None:
|
|
1374
|
+
"""Migration 062 post-hook: re-backfill inbox_sources from URL domains.
|
|
1375
|
+
|
|
1376
|
+
Migration 061 populated inbox_sources from inbox_items.source, but in
|
|
1377
|
+
production that column holds generic strings ("rss-marvisx", "gmail", ...)
|
|
1378
|
+
while the real article domain lives in inbox_items.url. The Sources
|
|
1379
|
+
Dashboard joins inbox_sources against source_scores, and source_scores
|
|
1380
|
+
is keyed by URL domain (see _update_source_score in inbox_triage), so the
|
|
1381
|
+
061 entries never matched any score row and all metrics rendered as zero.
|
|
1382
|
+
|
|
1383
|
+
This hook extracts the real domain from DISTINCT inbox_items.url rows
|
|
1384
|
+
using the same urlparse + removeprefix("www.") + lowercase normalization
|
|
1385
|
+
that _update_source_score uses (modulo the explicit lowercase, which this
|
|
1386
|
+
hook applies defensively so case differences never break the JOIN).
|
|
1387
|
+
The legacy 061 rows are soft-deleted in the SQL portion of this migration
|
|
1388
|
+
(source_type='legacy', active=0) and left in place for audit history.
|
|
1389
|
+
|
|
1390
|
+
Idempotent via the UNIQUE (workspace_id, source_key) index on
|
|
1391
|
+
inbox_sources; re-runs are safe and only log zero insertions.
|
|
1392
|
+
"""
|
|
1393
|
+
from urllib.parse import urlparse
|
|
1394
|
+
|
|
1395
|
+
if not _column_exists(conn, "inbox_items", "url"):
|
|
1396
|
+
logger.info("Migration 062: inbox_items.url missing, skipping backfill")
|
|
1397
|
+
return
|
|
1398
|
+
|
|
1399
|
+
cursor = conn.execute(
|
|
1400
|
+
"SELECT DISTINCT url, COALESCE(workspace_id, 'ws_default') AS ws "
|
|
1401
|
+
"FROM inbox_items "
|
|
1402
|
+
"WHERE url IS NOT NULL AND url != ''"
|
|
1403
|
+
)
|
|
1404
|
+
rows = cursor.fetchall()
|
|
1405
|
+
|
|
1406
|
+
seen: set[tuple[str, str]] = set()
|
|
1407
|
+
inserted = 0
|
|
1408
|
+
skipped = 0
|
|
1409
|
+
|
|
1410
|
+
for row in rows:
|
|
1411
|
+
url = row["url"] if isinstance(row, sqlite3.Row) else row[0]
|
|
1412
|
+
ws = row["ws"] if isinstance(row, sqlite3.Row) else row[1]
|
|
1413
|
+
if not url:
|
|
1414
|
+
continue
|
|
1415
|
+
|
|
1416
|
+
try:
|
|
1417
|
+
parsed = urlparse(url)
|
|
1418
|
+
netloc = (parsed.netloc or "").removeprefix("www.").lower()
|
|
1419
|
+
except Exception: # noqa: BLE001 - defensive, never fail migration
|
|
1420
|
+
netloc = ""
|
|
1421
|
+
|
|
1422
|
+
if not netloc:
|
|
1423
|
+
skipped += 1
|
|
1424
|
+
continue
|
|
1425
|
+
|
|
1426
|
+
key = (ws, netloc)
|
|
1427
|
+
if key in seen:
|
|
1428
|
+
continue
|
|
1429
|
+
seen.add(key)
|
|
1430
|
+
|
|
1431
|
+
result = conn.execute(
|
|
1432
|
+
"INSERT OR IGNORE INTO inbox_sources "
|
|
1433
|
+
"(id, name, source_key, source_type, active, workspace_id) "
|
|
1434
|
+
"VALUES (?, ?, ?, 'rss', 1, ?)",
|
|
1435
|
+
(str(uuid_mod.uuid4()), netloc, netloc, ws),
|
|
1436
|
+
)
|
|
1437
|
+
if result.rowcount > 0:
|
|
1438
|
+
inserted += 1
|
|
1439
|
+
|
|
1440
|
+
conn.commit()
|
|
1441
|
+
logger.info(
|
|
1442
|
+
"Migration 062: backfilled inbox_sources from URL domains "
|
|
1443
|
+
"(inserted=%d, skipped_no_netloc=%d, distinct_urls=%d)",
|
|
1444
|
+
inserted,
|
|
1445
|
+
skipped,
|
|
1446
|
+
len(rows),
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _promote_llm_costs_columns(conn: sqlite3.Connection) -> None:
|
|
1451
|
+
"""Migration 102 post-hook: ALTER llm_costs to add tier_logical / fallback_used / litellm_request_id.
|
|
1452
|
+
|
|
1453
|
+
The SQL migration 102 only does CREATE TABLE IF NOT EXISTS (idempotent,
|
|
1454
|
+
fresh DBs get the full new schema directly). Production DBs already had
|
|
1455
|
+
the table lazy-created by inbox_llm_classifier with the old 8-column
|
|
1456
|
+
schema; this hook adds the 3 new columns guarded by _column_exists().
|
|
1457
|
+
|
|
1458
|
+
Why a hook instead of pure SQL: SQLite has no `ALTER TABLE ... ADD COLUMN
|
|
1459
|
+
IF NOT EXISTS`, and bare ALTER inside an executescript() raises
|
|
1460
|
+
"duplicate column name" on fresh DBs (where CREATE already provisioned
|
|
1461
|
+
them) which would abort the whole script and leave the migration in an
|
|
1462
|
+
inconsistent state. The hook runs Python-side after CREATE so we can
|
|
1463
|
+
branch safely.
|
|
1464
|
+
"""
|
|
1465
|
+
if not _column_exists(conn, "llm_costs", "tier_logical"):
|
|
1466
|
+
conn.execute("ALTER TABLE llm_costs ADD COLUMN tier_logical TEXT")
|
|
1467
|
+
logger.info("Migration 102: added llm_costs.tier_logical")
|
|
1468
|
+
if not _column_exists(conn, "llm_costs", "fallback_used"):
|
|
1469
|
+
conn.execute(
|
|
1470
|
+
"ALTER TABLE llm_costs ADD COLUMN fallback_used INTEGER NOT NULL DEFAULT 0"
|
|
1471
|
+
)
|
|
1472
|
+
logger.info("Migration 102: added llm_costs.fallback_used")
|
|
1473
|
+
if not _column_exists(conn, "llm_costs", "litellm_request_id"):
|
|
1474
|
+
conn.execute("ALTER TABLE llm_costs ADD COLUMN litellm_request_id TEXT")
|
|
1475
|
+
logger.info("Migration 102: added llm_costs.litellm_request_id")
|
|
1476
|
+
conn.commit()
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _migration_135_graph_edges_provider(conn: sqlite3.Connection) -> None:
|
|
1480
|
+
"""Add provider column/index for KG edges without retroactive backfill."""
|
|
1481
|
+
if not _column_exists(conn, "graph_edges", "provider"):
|
|
1482
|
+
conn.execute("ALTER TABLE graph_edges ADD COLUMN provider TEXT")
|
|
1483
|
+
logger.info("Migration 135: added graph_edges.provider")
|
|
1484
|
+
conn.execute(
|
|
1485
|
+
"CREATE INDEX IF NOT EXISTS idx_graph_edges_provider "
|
|
1486
|
+
"ON graph_edges(provider, relation)"
|
|
1487
|
+
)
|
|
1488
|
+
conn.commit()
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _add_session_theme_mode_column(conn: sqlite3.Connection) -> None:
|
|
1492
|
+
"""Ensure sessions_meta.theme_mode exists for existing databases."""
|
|
1493
|
+
if not _column_exists(conn, "sessions_meta", "theme_mode"):
|
|
1494
|
+
conn.execute(
|
|
1495
|
+
"ALTER TABLE sessions_meta ADD COLUMN theme_mode TEXT DEFAULT NULL"
|
|
1496
|
+
)
|
|
1497
|
+
logger.info("Added sessions_meta.theme_mode")
|
|
1498
|
+
conn.commit()
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
async def cleanup_expired_tickets(db: aiosqlite.Connection) -> int:
|
|
1502
|
+
"""Remove expired WS tickets. Returns count deleted."""
|
|
1503
|
+
cursor = await db.execute(
|
|
1504
|
+
"DELETE FROM ws_tickets WHERE expires_at < datetime('now')"
|
|
1505
|
+
)
|
|
1506
|
+
await db.commit()
|
|
1507
|
+
return cursor.rowcount
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
async def cleanup_expired_blacklist(db: aiosqlite.Connection) -> int:
|
|
1511
|
+
"""Remove expired blacklist entries. Returns count deleted."""
|
|
1512
|
+
cursor = await db.execute(
|
|
1513
|
+
"DELETE FROM token_blacklist WHERE expires_at < datetime('now')"
|
|
1514
|
+
)
|
|
1515
|
+
await db.commit()
|
|
1516
|
+
return cursor.rowcount
|