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.
Files changed (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. 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