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/main.py ADDED
@@ -0,0 +1,1641 @@
1
+ # v1.18.0 - 2026-05-22 - P1.5.E1: remove PaddleOCR local (broken warm-up since 30-apr); OCR live via Mac Gateway tier-ocr
2
+ # v1.17.0 - 2026-04-30 - P1.5.E0: add PaddleOCR async warm + /health/ocr backend status
3
+ # v1.16.0 - 2026-04-17 - P2: add slowapi rate limiting middleware for /graph endpoints
4
+ # v1.15.0 - 2026-03-15 - Add push delivery + Voyage init in lifespan + orphan proxy cleanup
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from contextlib import asynccontextmanager
11
+ from datetime import datetime, timezone
12
+
13
+ import aiosqlite
14
+ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
15
+ from fastapi.encoders import jsonable_encoder
16
+ from fastapi.exceptions import RequestValidationError
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.responses import JSONResponse
19
+ from slowapi import _rate_limit_exceeded_handler
20
+ from slowapi.errors import RateLimitExceeded
21
+ from starlette.exceptions import HTTPException as StarletteHTTPException
22
+
23
+ from core.api.config import settings
24
+ from core.api.db import cleanup_expired_blacklist, cleanup_expired_tickets, run_migrations
25
+ from core.api.use_cases._errors import ServiceError
26
+ from core.api.routers import auth, sessions
27
+ from core.api.routers.comments import router as comments_router
28
+ from core.api.routers.costs import router as costs_router
29
+ from core.api.routers.files import router as files_router
30
+ from core.api.routers.projects import router as projects_router
31
+ from core.api.routers.admin_settings import router as admin_settings_router
32
+ from core.api.routers.settings import router as settings_router
33
+ from core.api.routers.status_updates import router as status_updates_router
34
+ from core.api.routers.tasks import router as tasks_router
35
+ from core.api.services import claude_metrics, codex_metrics, opencode_sessions, tmux
36
+ from core.api.services.metrics_providers import get_metrics_provider
37
+ from core.api.services.providers import get_provider
38
+ from core.api.services.session_metrics_service import (
39
+ compute_cost_session_extended,
40
+ on_conversation_id_changed,
41
+ )
42
+ from core.api.routers.agent import router as agent_router
43
+ from core.api.routers.monitoring import router as monitoring_router
44
+ from core.api.routers.tags import router as tags_router
45
+ from core.api.routers.finder import router as finder_router
46
+ from core.api.routers.finder import share_router as share_manage_router
47
+ from core.api.routers.finder import shared_router as shared_file_router
48
+ from core.api.routers.kg import router as kg_router
49
+ from core.api.routers.share_repo import router as repo_share_router
50
+ from core.api.routers.share_repo import shared_repo_router
51
+ from core.api.routers.pull_requests import router as pull_requests_router
52
+ from core.api.routers.users import router as users_router
53
+ from core.api.routers.raci import router as raci_router
54
+ from core.api.routers.audit import router as audit_router
55
+ from core.api.routers.webhooks import router as webhooks_router
56
+ from core.api.routers.admin_pr_impact import router as admin_pr_impact_router
57
+ from core.api.routers.pr_impact import router as pr_impact_router
58
+ from core.api.routers.agent_tokens import router as agent_tokens_router
59
+ from core.api.routers.handoffs import router as handoffs_router
60
+ from core.api.routers.teams import router as teams_router
61
+ from core.api.routers.graph import router as graph_router
62
+ from core.api.routers.learnings import router as learnings_router
63
+ try:
64
+ from core.api.routers.automations import router as automations_router
65
+ except ImportError: # SaaS-only module, absent in OSS mirror
66
+ automations_router = None
67
+ from core.api.routers.notifications import router as notifications_router
68
+ from core.api.routers.push import router as push_router
69
+ from core.api.routers.ci_checks import router as ci_checks_router
70
+ from core.api.routers.search import router as search_router
71
+ from core.api.routers.documents import router as documents_router
72
+ from core.api.routers.docs_coverage import router as docs_coverage_router
73
+ from core.api.routers.docs_governance import router as docs_governance_router
74
+ from core.api.routers.judge import router as judge_router
75
+ from core.api.routers.inbox import router as inbox_router
76
+ from core.api.routers.ingest_triage import router as ingest_triage_router
77
+ from core.api.routers.ingest_api_keys import router as ingest_api_keys_router
78
+ from core.api.routers.llm_config import router as llm_config_router
79
+ try:
80
+ from core.api.routers.newsletter import router as newsletter_router
81
+ except ImportError: # SaaS-only module, absent in OSS mirror
82
+ newsletter_router = None
83
+ from core.api.routers.bench import router as bench_router
84
+ from core.api.routers.app_settings import router as app_settings_router
85
+ try:
86
+ from core.api.routers.reddit import router as reddit_router
87
+ except ImportError: # SaaS-only module, absent in OSS mirror
88
+ reddit_router = None
89
+ from core.api.routers.terminal import router as terminal_metrics_router
90
+ from core.api.routers.brain import router as brain_router
91
+ from core.api.routers.brain_directions import router as brain_directions_router
92
+ from core.api.services.metrics_collector import metrics_collector
93
+ from core.api.services.security_collector import security_collector
94
+ from core.api.terminal import router as terminal_router
95
+
96
+ logging.basicConfig(
97
+ level=logging.INFO,
98
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
99
+ )
100
+ logger = logging.getLogger(__name__)
101
+
102
+ _cleanup_task: asyncio.Task | None = None
103
+ _session_maintenance_task: asyncio.Task | None = None
104
+ _metrics_task: asyncio.Task | None = None
105
+ _security_task: asyncio.Task | None = None
106
+ _maintenance_task: asyncio.Task | None = None
107
+ _reminder_task: asyncio.Task | None = None
108
+ _n8n_dispatch_task: asyncio.Task | None = None
109
+ _push_delivery_task: asyncio.Task | None = None
110
+ _notifications_sync_task: asyncio.Task | None = None
111
+ _inbox_digest_task: asyncio.Task | None = None
112
+ _brain_jobs_task: asyncio.Task | None = None
113
+ _wal_checkpoint_task: asyncio.Task | None = None
114
+ _terminal_metrics_dump_task: asyncio.Task | None = None
115
+ _pr_impact_sweep_task: asyncio.Task | None = None
116
+ _pr_impact_gc_task: asyncio.Task | None = None
117
+
118
+
119
+ async def _session_has_active_cli_for_provider(
120
+ name: str,
121
+ provider: str | None,
122
+ status: str | None,
123
+ cache: dict[tuple[str, str], bool] | None = None,
124
+ ) -> bool:
125
+ """Return true when tmux foreground or child process matches the provider CLI."""
126
+ from core.api.services.providers import ALL_KNOWN_PROCESS_NAMES, get_provider
127
+
128
+ if status in ALL_KNOWN_PROCESS_NAMES:
129
+ return True
130
+
131
+ provider_name = provider or "claude"
132
+ cache_key = (name, provider_name)
133
+ if cache is not None and cache_key in cache:
134
+ return cache[cache_key]
135
+
136
+ try:
137
+ process_names = get_provider(provider_name).process_names
138
+ except ValueError:
139
+ process_names = ALL_KNOWN_PROCESS_NAMES
140
+
141
+ active = await tmux.get_cli_pid(name, process_names) is not None
142
+ if cache is not None:
143
+ cache[cache_key] = active
144
+ return active
145
+
146
+
147
+ async def _periodic_cleanup() -> None:
148
+ """Background task: clean expired tickets and blacklist entries every hour."""
149
+ while True:
150
+ try:
151
+ await asyncio.sleep(3600)
152
+ from core.api.db import write_db
153
+
154
+ from core.api.services.ingest.ingress import cleanup_ingest_ephemeral
155
+
156
+ async with write_db() as db:
157
+ tickets = await cleanup_expired_tickets(db)
158
+ blacklist = await cleanup_expired_blacklist(db)
159
+ idem, rate, quota = await cleanup_ingest_ephemeral(db)
160
+ if tickets or blacklist or idem or rate or quota:
161
+ logger.info(
162
+ "Cleanup: %d tickets, %d blacklist, %d idempotency, "
163
+ "%d rate, %d quota rows pruned",
164
+ tickets,
165
+ blacklist,
166
+ idem,
167
+ rate,
168
+ quota,
169
+ )
170
+ except asyncio.CancelledError:
171
+ break
172
+ except Exception:
173
+ logger.exception("Periodic cleanup error")
174
+
175
+
176
+ async def _periodic_session_maintenance() -> None:
177
+ """Background task: auto-hibernate idle sessions + persist costs. Every 2 minutes.
178
+
179
+ Structured as gather-then-write: all external I/O (tmux, metrics parsing,
180
+ file checks) happens OUTSIDE the DB write lock. SQL operations are collected
181
+ as (sql, params) tuples and executed in a single write_db() batch at the end.
182
+ Target: write lock held <100ms (was 4-12s before refactor).
183
+ """
184
+ io_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="cost-io")
185
+ loop = asyncio.get_running_loop()
186
+
187
+ try:
188
+ while True:
189
+ try:
190
+ await asyncio.sleep(120) # Check every 2 minutes
191
+
192
+ from core.api.db import acquire_db, write_db
193
+ import time as _time
194
+
195
+ # Pending SQL operations: list of (sql, params) tuples
196
+ pending_ops: list[tuple[str, tuple]] = []
197
+ active_cli_cache: dict[tuple[str, str], bool] = {}
198
+
199
+ try:
200
+ reconciled = await sessions.reconcile_sessions_metadata()
201
+ if reconciled:
202
+ logger.info(
203
+ "Session maintenance: reconciled %d session metadata rows",
204
+ reconciled,
205
+ )
206
+ except Exception:
207
+ logger.exception("Session metadata reconciliation failed")
208
+
209
+ # ============================================================
210
+ # PHASE 1: READ — fetch all needed data from DB (read pool)
211
+ # ============================================================
212
+ async with acquire_db() as rdb:
213
+ cursor = await rdb.execute(
214
+ """SELECT name, auto_hibernate_minutes, conversation_id, project_slug, provider
215
+ FROM sessions_meta
216
+ WHERE hibernated = 0
217
+ AND (auto_hibernate_minutes > 0 OR auto_hibernate_minutes IS NULL)"""
218
+ )
219
+ hibernate_rows = [dict(r) for r in await cursor.fetchall()]
220
+ if not settings.auto_hibernate_enabled:
221
+ hibernate_rows = []
222
+
223
+ detect_cursor = await rdb.execute(
224
+ """SELECT name, project_slug, provider FROM sessions_meta
225
+ WHERE hibernated = 0 AND conversation_id IS NULL"""
226
+ )
227
+ detect_rows = [dict(r) for r in await detect_cursor.fetchall()]
228
+
229
+ cost_cursor = await rdb.execute(
230
+ "SELECT name, conversation_id, project_slug, provider FROM sessions_meta "
231
+ "WHERE conversation_id IS NOT NULL"
232
+ )
233
+ cost_rows = [dict(r) for r in await cost_cursor.fetchall()]
234
+
235
+ metrics_cursor = await rdb.execute(
236
+ """SELECT name, conversation_id, provider FROM sessions_meta
237
+ WHERE hibernated = 0 AND conversation_id IS NOT NULL"""
238
+ )
239
+ metrics_rows = [dict(r) for r in await metrics_cursor.fetchall()]
240
+
241
+ # PR3: Step 3 (pane-scraped working_seconds accumulator) removed.
242
+ # `working_seconds_msg` (migration 087) is the single source of
243
+ # truth — populated by parse_session via provider-specific
244
+ # message-gap analysis (claude_metrics, opencode_metrics).
245
+
246
+ # ============================================================
247
+ # PHASE 2: GATHER — all external I/O outside any DB context
248
+ # ============================================================
249
+
250
+ # Get tmux statuses once (reused by all steps)
251
+ statuses = (
252
+ await tmux.get_all_session_statuses()
253
+ if (
254
+ hibernate_rows
255
+ or detect_rows
256
+ or cost_rows
257
+ or metrics_rows
258
+ )
259
+ else {}
260
+ )
261
+
262
+ # Cache metrics parsed during hibernate check for reuse in cost upsert
263
+ parsed_metrics: dict[str, claude_metrics.SessionMetrics] = {}
264
+
265
+ # --- Step 1: Auto-hibernate gather ---
266
+ for row in hibernate_rows:
267
+ name = row["name"]
268
+ max_idle_min = row["auto_hibernate_minutes"] or 240
269
+ status = statuses.get(name)
270
+ row_provider = row["provider"] if row["provider"] else "claude"
271
+
272
+ if not await _session_has_active_cli_for_provider(
273
+ name, row_provider, status, active_cli_cache
274
+ ):
275
+ continue
276
+ mp = get_metrics_provider(row_provider)
277
+ if mp is None:
278
+ continue
279
+ # Auto-hibernate detection currently has Claude-only fallbacks
280
+ # (pane_start → JSONL timestamp). Non-Claude providers need a
281
+ # stored conversation_id to be auto-hibernatable. PR2 wires
282
+ # OpenCode detection through opencode_sessions.
283
+ conv_id = row["conversation_id"]
284
+ if not conv_id and row_provider == "claude":
285
+ pane_start = await tmux.get_pane_start_time(name)
286
+ if pane_start:
287
+ conv_id = claude_metrics.detect_conversation_for_session(
288
+ pane_start
289
+ )
290
+
291
+ if not conv_id:
292
+ continue
293
+
294
+ metrics = await loop.run_in_executor(
295
+ io_pool, mp.parse_session, conv_id, None
296
+ )
297
+ if metrics:
298
+ parsed_metrics[conv_id] = metrics
299
+
300
+ if not metrics or not metrics.last_timestamp:
301
+ continue
302
+
303
+ last_activity = datetime.fromisoformat(
304
+ metrics.last_timestamp.replace("Z", "+00:00")
305
+ )
306
+ idle_minutes = (
307
+ datetime.now(timezone.utc) - last_activity
308
+ ).total_seconds() / 60
309
+
310
+ if idle_minutes >= max_idle_min:
311
+ # Guard: check JSONL mtime
312
+ _jsonl_stale = True
313
+ try:
314
+ for _pdir in claude_metrics.CLAUDE_PROJECTS_DIR.iterdir():
315
+ _jsonl_path = _pdir / f"{conv_id}.jsonl"
316
+ if _jsonl_path.exists():
317
+ _file_age_min = (
318
+ _time.time() - _jsonl_path.stat().st_mtime
319
+ ) / 60
320
+ _jsonl_stale = _file_age_min >= max_idle_min
321
+ break
322
+ except Exception:
323
+ pass
324
+ if not _jsonl_stale:
325
+ logger.debug(
326
+ "Session %s: last assistant %.0f min ago but JSONL recently written, skipping hibernate",
327
+ name,
328
+ idle_minutes,
329
+ )
330
+ continue
331
+
332
+ logger.info(
333
+ "Auto-hibernating session %s (idle %.0f min, threshold %d min)",
334
+ name,
335
+ idle_minutes,
336
+ max_idle_min,
337
+ )
338
+ # Send tmux keys to hibernate (slow I/O, outside DB lock)
339
+ await tmux.send_keys_raw(name, "C-c")
340
+ await asyncio.sleep(1.0)
341
+ await tmux.send_keys_raw(name, "C-u")
342
+ await asyncio.sleep(0.3)
343
+ await tmux.send_keys_raw(name, "/exit")
344
+ await asyncio.sleep(0.5)
345
+ await tmux.send_keys_raw(name, "Escape")
346
+ await asyncio.sleep(0.3)
347
+ await tmux.send_keys_raw(name, "Enter")
348
+ await asyncio.sleep(2.0)
349
+
350
+ now = datetime.now(timezone.utc).isoformat()
351
+ # PR3: drop last_context_pct from hibernate write.
352
+ # Real/scaled columns (migration 087) are populated by
353
+ # Step 2.5 a few lines below; last_context_pct_legacy
354
+ # stays for forensic reads only.
355
+ pending_ops.append(
356
+ (
357
+ """UPDATE sessions_meta SET
358
+ hibernated = 1, hibernated_at = ?,
359
+ conversation_id = ?, model = ?,
360
+ last_cost_usd = ?,
361
+ last_message_count = ?
362
+ WHERE name = ?""",
363
+ (
364
+ now,
365
+ conv_id,
366
+ metrics.model,
367
+ metrics.cost_usd,
368
+ metrics.message_count,
369
+ name,
370
+ ),
371
+ )
372
+ )
373
+
374
+ # --- Step 1.5: Detect conversation_id gather ---
375
+ newly_detected: dict[str, set[str]] = {}
376
+ for row in detect_rows:
377
+ name = row["name"]
378
+ status = statuses.get(name)
379
+ row_provider = row["provider"] if row["provider"] else "claude"
380
+ if not await _session_has_active_cli_for_provider(
381
+ name, row_provider, status, active_cli_cache
382
+ ):
383
+ continue
384
+
385
+ conv_id: str | None = None
386
+
387
+ if row_provider == "claude":
388
+ pane_id = await tmux.get_pane_id(name)
389
+ if pane_id:
390
+ conv_id = await loop.run_in_executor(
391
+ io_pool, claude_metrics.read_pane_session_id, pane_id
392
+ )
393
+
394
+ if not conv_id:
395
+ claude_pid = await tmux.get_claude_pid(name)
396
+ if claude_pid:
397
+ conv_id = claude_metrics.detect_conversation_by_pid(
398
+ claude_pid
399
+ )
400
+
401
+ if not conv_id:
402
+ pane_start = await tmux.get_pane_start_time(name)
403
+ if pane_start:
404
+ conv_id = await loop.run_in_executor(
405
+ io_pool,
406
+ claude_metrics.detect_conversation_for_session,
407
+ pane_start,
408
+ )
409
+ elif row_provider == "opencode":
410
+ # Detect by pane cwd + time_created window. Needed for
411
+ # sessions that launched OpenCode manually (bypassing
412
+ # the Console "New Session" modal flow, which wires
413
+ # conversation_id via wait_for_new_session_id).
414
+ pane_cwd = await tmux.get_pane_cwd(name)
415
+ if pane_cwd:
416
+ pane_start = await tmux.get_pane_start_time(name)
417
+ pane_start_ms = (
418
+ int(pane_start * 1000) if pane_start else None
419
+ )
420
+ # Exclude ids already bound to OTHER tmux sessions
421
+ # so we don't steal another pane's conversation
422
+ # when cwds happen to coincide.
423
+ already_linked = [
424
+ r["conversation_id"]
425
+ for r in cost_rows
426
+ if r["name"] != name
427
+ and r["conversation_id"]
428
+ and (r["provider"] or "claude") == "opencode"
429
+ ]
430
+ conv_id = await loop.run_in_executor(
431
+ io_pool,
432
+ opencode_sessions.detect_opencode_for_session,
433
+ pane_cwd,
434
+ pane_start_ms,
435
+ already_linked,
436
+ )
437
+ elif row_provider == "codex":
438
+ already_linked = [
439
+ r["conversation_id"]
440
+ for r in cost_rows
441
+ if r["name"] != name
442
+ and r["conversation_id"]
443
+ and (r["provider"] or "claude") == "codex"
444
+ ]
445
+ already_linked.extend(newly_detected.get("codex", set()))
446
+ codex_pid = await tmux.get_cli_pid(
447
+ name, get_provider("codex").process_names
448
+ )
449
+ if codex_pid:
450
+ conv_id = await loop.run_in_executor(
451
+ io_pool,
452
+ codex_metrics.detect_codex_for_process,
453
+ codex_pid,
454
+ already_linked,
455
+ )
456
+ if not conv_id:
457
+ pane_start = await tmux.get_pane_start_time(name)
458
+ pane_cwd = await tmux.get_pane_cwd(name)
459
+ conv_id = await loop.run_in_executor(
460
+ io_pool,
461
+ codex_metrics.detect_codex_for_session,
462
+ pane_start,
463
+ pane_cwd,
464
+ already_linked,
465
+ )
466
+
467
+ if conv_id:
468
+ newly_detected.setdefault(row_provider, set()).add(conv_id)
469
+ if row_provider == "codex":
470
+ # Codex footer fields come from sessions_meta; refresh
471
+ # the newly linked JSONL in this maintenance cycle
472
+ # instead of waiting for the next 2-minute pass.
473
+ linked_row = {
474
+ "name": name,
475
+ "conversation_id": conv_id,
476
+ "project_slug": row["project_slug"],
477
+ "provider": row_provider,
478
+ }
479
+ cost_rows.append(linked_row)
480
+ metrics_rows.append(linked_row)
481
+ pending_ops.append(
482
+ (
483
+ "UPDATE sessions_meta SET conversation_id = ? WHERE name = ?",
484
+ (conv_id, name),
485
+ )
486
+ )
487
+ # PR2: track into resume chain. INSERT OR IGNORE so
488
+ # repeated detections of the same conv_id are no-ops.
489
+ pending_ops.append(
490
+ (
491
+ "INSERT OR IGNORE INTO session_conversations "
492
+ "(session_name, conversation_id, ord, created_at) "
493
+ "VALUES (?, ?, "
494
+ "COALESCE((SELECT MAX(ord)+1 FROM session_conversations WHERE session_name=?), 0), "
495
+ "?)",
496
+ (
497
+ name,
498
+ conv_id,
499
+ name,
500
+ datetime.now(timezone.utc).isoformat(),
501
+ ),
502
+ )
503
+ )
504
+ logger.info(
505
+ "Detected conversation_id for %s session %s: %s",
506
+ row_provider,
507
+ name,
508
+ conv_id,
509
+ )
510
+
511
+ # --- Step 2: Cost persistence gather ---
512
+ upserted = 0
513
+ for row in cost_rows:
514
+ try:
515
+ conv_id = row["conversation_id"]
516
+ row_provider = row["provider"] if row["provider"] else "claude"
517
+ mp = get_metrics_provider(row_provider)
518
+ if mp is None:
519
+ continue
520
+ metrics = parsed_metrics.get(conv_id)
521
+ if not metrics:
522
+ metrics = await loop.run_in_executor(
523
+ io_pool,
524
+ mp.parse_session,
525
+ conv_id,
526
+ None,
527
+ )
528
+ if not metrics:
529
+ continue
530
+
531
+ pending_ops.append(
532
+ (
533
+ """
534
+ INSERT INTO session_costs (conversation_id, session_name, project_slug, model,
535
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
536
+ cost_usd, message_count, updated_at)
537
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
538
+ ON CONFLICT(conversation_id) DO UPDATE SET
539
+ session_name = excluded.session_name,
540
+ project_slug = excluded.project_slug,
541
+ model = excluded.model,
542
+ input_tokens = excluded.input_tokens,
543
+ output_tokens = excluded.output_tokens,
544
+ cache_read_tokens = excluded.cache_read_tokens,
545
+ cache_write_tokens = excluded.cache_write_tokens,
546
+ cost_usd = excluded.cost_usd,
547
+ message_count = excluded.message_count,
548
+ updated_at = excluded.updated_at
549
+ """,
550
+ (
551
+ conv_id,
552
+ row["name"],
553
+ row["project_slug"],
554
+ metrics.model,
555
+ metrics.input_tokens,
556
+ metrics.output_tokens,
557
+ metrics.cache_read_tokens,
558
+ metrics.cache_write_tokens,
559
+ metrics.cost_usd,
560
+ metrics.message_count,
561
+ ),
562
+ )
563
+ )
564
+ # PR2: dual cost (conversation vs session-cumulative).
565
+ # PR4: plus shadow-cost (equivalent) aggregated across
566
+ # the same resume chain. Session cost aggregates across
567
+ # `session_conversations`; equivalent remains None when
568
+ # no conversation in chain had known pricing (skip).
569
+ cost_session_total: float = metrics.cost_conversation_usd or metrics.cost_usd or 0.0
570
+ cost_session_equivalent: float | None = (
571
+ metrics.cost_session_equivalent_usd
572
+ if metrics.cost_session_equivalent_usd is not None
573
+ else metrics.cost_conversation_equivalent_usd
574
+ )
575
+ equivalent_version = metrics.cost_equivalent_pricing_version
576
+ cost_session_incomplete = False
577
+ try:
578
+ async with acquire_db() as _rdb:
579
+ (
580
+ cost_session_total,
581
+ equiv_total,
582
+ equiv_version_db,
583
+ cost_session_complete,
584
+ ) = await compute_cost_session_extended(
585
+ _rdb, row["name"], row_provider
586
+ )
587
+ cost_session_incomplete = not cost_session_complete
588
+ if equiv_total is not None:
589
+ cost_session_equivalent = equiv_total
590
+ if equiv_version_db:
591
+ equivalent_version = equiv_version_db
592
+ except Exception:
593
+ logger.debug(
594
+ "cost_session aggregation failed for %s (falling back to conversation-only)",
595
+ row["name"],
596
+ exc_info=True,
597
+ )
598
+ cost_session_total = metrics.cost_conversation_usd or metrics.cost_usd or 0.0
599
+ cost_session_incomplete = True
600
+
601
+ pending_ops.append(
602
+ (
603
+ """UPDATE sessions_meta SET
604
+ last_cost_usd = ?,
605
+ last_cost_conversation_usd = ?,
606
+ last_cost_session_usd = ?,
607
+ last_cost_session_incomplete = ?,
608
+ last_cost_conversation_equivalent_usd = ?,
609
+ last_cost_session_equivalent_usd = ?,
610
+ last_cost_equivalent_pricing_version = ?,
611
+ last_context_pct_real = ?,
612
+ last_context_pct_scaled = ?,
613
+ last_input_tokens = ?,
614
+ last_output_tokens = ?,
615
+ last_reasoning_tokens = ?,
616
+ working_seconds_msg = ?,
617
+ metrics_refreshed_at = datetime('now'),
618
+ pricing_version = ?,
619
+ last_message_count = ?,
620
+ model = COALESCE(?, model)
621
+ WHERE name = ?""",
622
+ (
623
+ metrics.cost_usd, # legacy alias (= conversation)
624
+ metrics.cost_conversation_usd or metrics.cost_usd,
625
+ cost_session_total,
626
+ 1 if cost_session_incomplete else 0,
627
+ metrics.cost_conversation_equivalent_usd,
628
+ cost_session_equivalent,
629
+ equivalent_version,
630
+ metrics.context_pct_real,
631
+ metrics.context_pct_scaled,
632
+ metrics.input_tokens,
633
+ metrics.output_tokens,
634
+ metrics.reasoning_tokens,
635
+ metrics.working_seconds_msg,
636
+ metrics.pricing_version,
637
+ metrics.message_count,
638
+ metrics.model,
639
+ row["name"],
640
+ ),
641
+ )
642
+ )
643
+ upserted += 1
644
+ except Exception:
645
+ logger.exception("Cost gather failed for %s", row["name"])
646
+
647
+ # --- Step 2.5: Refresh context_pct gather ---
648
+ metrics_updated = 0
649
+ for row in metrics_rows:
650
+ name = row["name"]
651
+ conv_id = row["conversation_id"]
652
+ status = statuses.get(name)
653
+ row_provider = row["provider"] if row["provider"] else "claude"
654
+ if not await _session_has_active_cli_for_provider(
655
+ name, row_provider, status, active_cli_cache
656
+ ):
657
+ continue
658
+ mp = get_metrics_provider(row_provider)
659
+ if mp is None:
660
+ continue
661
+
662
+ # Claude-only fast path: statusline.sh writes pane-metrics on
663
+ # every tick; avoids a JSONL tail read when available.
664
+ # PR3: write the real ratio, not the /84 auto-compact fudge.
665
+ # context_pct_scaled is populated by the parser (PR2) —
666
+ # don't duplicate it here.
667
+ if row_provider == "claude":
668
+ pane_id_for_metrics = await tmux.get_pane_id(name)
669
+ if pane_id_for_metrics:
670
+ pm = await loop.run_in_executor(
671
+ io_pool,
672
+ claude_metrics.read_pane_metrics,
673
+ pane_id_for_metrics,
674
+ )
675
+ if pm and pm.session_id == conv_id:
676
+ real_pct = min(round(pm.used_pct, 1), 100.0)
677
+ pending_ops.append(
678
+ (
679
+ "UPDATE sessions_meta SET last_context_pct_real = ? WHERE name = ?",
680
+ (real_pct, name),
681
+ )
682
+ )
683
+ metrics_updated += 1
684
+ continue
685
+
686
+ context_pct = await loop.run_in_executor(
687
+ io_pool, mp.get_last_context_pct, conv_id, None
688
+ )
689
+ if context_pct is not None:
690
+ pending_ops.append(
691
+ (
692
+ "UPDATE sessions_meta SET last_context_pct_real = ? WHERE name = ?",
693
+ (context_pct, name),
694
+ )
695
+ )
696
+ metrics_updated += 1
697
+
698
+ # PR3: Step 3 removed — see note in PHASE 1 query block above.
699
+
700
+ # ============================================================
701
+ # PHASE 3: WRITE — single batch, lock held <100ms
702
+ # ============================================================
703
+ if pending_ops:
704
+ async with write_db() as db:
705
+ for sql, params in pending_ops:
706
+ await db.execute(sql, params)
707
+ # write_db() auto-commits on exit
708
+
709
+ if upserted > 0:
710
+ logger.info(
711
+ "Session maintenance: upserted %d cost records", upserted
712
+ )
713
+ if metrics_updated > 0:
714
+ logger.info(
715
+ "Session maintenance: refreshed context_pct for %d sessions",
716
+ metrics_updated,
717
+ )
718
+
719
+ except asyncio.CancelledError:
720
+ raise
721
+ except Exception:
722
+ logger.exception("Session maintenance error")
723
+ except asyncio.CancelledError:
724
+ pass
725
+ finally:
726
+ io_pool.shutdown(wait=False)
727
+
728
+
729
+ async def _periodic_metrics_collection() -> None:
730
+ """Background task: collect server metrics.
731
+
732
+ System metrics (CPU/RAM/disk/network) every monitoring_metrics_interval (10s).
733
+ Docker stats every monitoring_docker_interval (60s) — separate to avoid
734
+ blocking the event loop with N JSON parses every 10s.
735
+ """
736
+ docker_counter = 0
737
+ docker_every_n = max(
738
+ 1, settings.monitoring_docker_interval // settings.monitoring_metrics_interval
739
+ )
740
+ while True:
741
+ try:
742
+ await asyncio.sleep(settings.monitoring_metrics_interval)
743
+ docker_counter += 1
744
+ include_docker = docker_counter % docker_every_n == 0
745
+ snapshot = await metrics_collector.collect_all(
746
+ include_docker=include_docker
747
+ )
748
+ await metrics_collector.save_to_db(snapshot)
749
+ except asyncio.CancelledError:
750
+ break
751
+ except Exception:
752
+ logger.exception("Metrics collection error")
753
+
754
+
755
+ async def _periodic_security_collection() -> None:
756
+ """Background task: collect security events every 60 seconds."""
757
+ while True:
758
+ try:
759
+ await asyncio.sleep(settings.monitoring_security_interval)
760
+ events = await asyncio.to_thread(security_collector.read_new_ssh_events)
761
+ if events:
762
+ await security_collector.save_events_to_db(events)
763
+ except asyncio.CancelledError:
764
+ break
765
+ except Exception:
766
+ logger.exception("Security collection error")
767
+
768
+
769
+ async def _periodic_metrics_maintenance() -> None:
770
+ """Background task: aggregate candles and cleanup old data. Runs hourly."""
771
+ while True:
772
+ try:
773
+ await asyncio.sleep(3600)
774
+ # Aggregate BEFORE cleanup to avoid data loss
775
+ candles = await metrics_collector.aggregate_to_candles()
776
+ raw = await metrics_collector.cleanup_old_raw(
777
+ hours=settings.monitoring_retention_raw_hours
778
+ )
779
+ old_candles = await metrics_collector.cleanup_old_candles(
780
+ days=settings.monitoring_retention_candles_days
781
+ )
782
+ old_events = await security_collector.cleanup_old_events(
783
+ days=settings.monitoring_retention_events_days
784
+ )
785
+ if candles or raw or old_candles or old_events:
786
+ logger.info(
787
+ "Monitoring maintenance: %d candles, cleaned %d raw, %d candles, %d events",
788
+ candles,
789
+ raw,
790
+ old_candles,
791
+ old_events,
792
+ )
793
+
794
+ except asyncio.CancelledError:
795
+ break
796
+ except Exception:
797
+ logger.exception("Metrics maintenance error")
798
+
799
+
800
+ async def _periodic_n8n_dispatch() -> None:
801
+ """Background task: dispatch pending events to n8n webhook."""
802
+ from core.api.services.event_dispatcher import dispatch_pending_events
803
+
804
+ while True:
805
+ try:
806
+ await asyncio.sleep(settings.n8n_dispatch_interval)
807
+ await dispatch_pending_events()
808
+ except asyncio.CancelledError:
809
+ logger.info("n8n dispatcher shutting down, final drain...")
810
+ try:
811
+ await asyncio.wait_for(dispatch_pending_events(), timeout=30.0)
812
+ except (asyncio.TimeoutError, Exception):
813
+ pass
814
+ break
815
+ except Exception:
816
+ logger.exception("n8n dispatch error")
817
+
818
+
819
+ async def _periodic_reminder_check() -> None:
820
+ """Background task: check and send task due-date reminders every 60 minutes."""
821
+ while True:
822
+ try:
823
+ await asyncio.sleep(3600)
824
+ from core.api.services.reminder_service import check_and_send_reminders
825
+
826
+ sent = await check_and_send_reminders()
827
+ if sent:
828
+ logger.info("Reminder check: sent %d reminder(s)", sent)
829
+ except asyncio.CancelledError:
830
+ break
831
+ except Exception:
832
+ logger.exception("Reminder check error")
833
+
834
+
835
+ async def _periodic_push_delivery() -> None:
836
+ """Background task: drain unpushed notifications and send web push every 5s."""
837
+ from core.api.services.push_service import periodic_push_delivery
838
+
839
+ while True:
840
+ try:
841
+ await asyncio.sleep(5)
842
+ await periodic_push_delivery()
843
+ except asyncio.CancelledError:
844
+ # Final drain before shutdown
845
+ try:
846
+ await asyncio.wait_for(periodic_push_delivery(), timeout=10.0)
847
+ except (asyncio.TimeoutError, Exception):
848
+ pass
849
+ break
850
+ except Exception:
851
+ logger.exception("Push delivery error")
852
+
853
+
854
+ async def _notifications_acted_at_sync_loop() -> None:
855
+ """Background task: auto-mark task_pending notifications as acted/read when
856
+ corresponding task is no longer pending. Runs every 60s to avoid inline
857
+ UPDATE on the GET /notifications endpoint (which caused SQLITE_BUSY).
858
+
859
+ Uses write_db() for single-writer pattern (serialized background writes).
860
+
861
+ Sleeps FIRST to avoid grabbing _write_lock at startup when the writer is
862
+ still settling after init_pool(). Grabbing the lock in the first tick
863
+ caused a startup deadlock that froze all writes (login hangs, 524 gateway).
864
+ """
865
+ while True:
866
+ try:
867
+ await asyncio.sleep(60)
868
+ from core.api.db import write_db
869
+
870
+ async with write_db() as db:
871
+ now = datetime.now(timezone.utc).isoformat()
872
+ await db.execute(
873
+ """UPDATE notifications
874
+ SET acted_at = ?, read_at = COALESCE(read_at, ?)
875
+ WHERE acted_at IS NULL AND type = 'task_pending'
876
+ AND target_type = 'task' AND target_id IN (
877
+ SELECT id FROM tasks WHERE status != 'pending'
878
+ )""",
879
+ (now, now),
880
+ )
881
+ except asyncio.CancelledError:
882
+ break
883
+ except Exception as exc:
884
+ logger.warning("notifications_acted_at_sync_loop error: %s", exc)
885
+
886
+
887
+ async def _periodic_wal_checkpoint() -> None:
888
+ """Background task: PRAGMA wal_checkpoint(TRUNCATE) every 30 minutes.
889
+
890
+ Prevents WAL from growing unbounded (incident 2026-04-10: 68MB WAL → 960ms queries).
891
+ Sleep-first pattern per single-writer learning 2026-04-15.
892
+ """
893
+ while True:
894
+ try:
895
+ await asyncio.sleep(1800)
896
+ from core.api.db import wal_checkpoint
897
+
898
+ busy, log, checkpointed = await wal_checkpoint()
899
+ if log > 0:
900
+ logger.info(
901
+ "WAL checkpoint: busy=%d log=%d checkpointed=%d",
902
+ busy,
903
+ log,
904
+ checkpointed,
905
+ )
906
+ except asyncio.CancelledError:
907
+ break
908
+ except Exception:
909
+ logger.exception("WAL checkpoint error")
910
+
911
+
912
+ async def _periodic_inbox_digest() -> None:
913
+ """Background task: run inbox digest expiry/freeze checks periodically."""
914
+ from core.api.services.inbox_digest_jobs import run_digest_jobs_if_due
915
+
916
+ while True:
917
+ try:
918
+ await asyncio.sleep(settings.inbox_digest_scheduler_interval_seconds)
919
+ result = await run_digest_jobs_if_due()
920
+ if result.get("status") == "ok":
921
+ logger.info(
922
+ "Inbox digest cycle %s: %d visible, %d overflow, %d expired",
923
+ result.get("cycle_key"),
924
+ result.get("visible", 0),
925
+ result.get("overflow", 0),
926
+ result.get("expired", 0),
927
+ )
928
+ except asyncio.CancelledError:
929
+ break
930
+ except Exception:
931
+ logger.exception("Inbox digest scheduler error")
932
+
933
+
934
+ async def _periodic_brain_jobs() -> None:
935
+ """Background task: run Brain digest/journal cycle once per cycle_key.
936
+
937
+ Sleep BEFORE the work (production incident 5c06a53 — sleeping after the
938
+ lock froze login startup). The job itself is idempotent per cycle_key via
939
+ `brain_last_cycle_key` short-circuit + lease.
940
+ """
941
+ from core.api.services.brain import run_brain_jobs_if_due
942
+
943
+ while True:
944
+ try:
945
+ await asyncio.sleep(settings.brain_scheduler_interval_seconds)
946
+ result = await run_brain_jobs_if_due()
947
+ status = result.get("status")
948
+ if status in ("ok", "partial"):
949
+ logger.info(
950
+ "Brain cycle %s %s: %d events, %d journals",
951
+ result.get("cycle_key"),
952
+ status,
953
+ result.get("event_count", 0),
954
+ result.get("journal_count", 0),
955
+ )
956
+ except asyncio.CancelledError:
957
+ break
958
+ except Exception:
959
+ logger.exception("Brain scheduler error")
960
+
961
+
962
+ @asynccontextmanager
963
+ async def lifespan(app: FastAPI):
964
+ """Startup and shutdown events."""
965
+ global \
966
+ _cleanup_task, \
967
+ _session_maintenance_task, \
968
+ _metrics_task, \
969
+ _security_task, \
970
+ _maintenance_task, \
971
+ _reminder_task, \
972
+ _n8n_dispatch_task, \
973
+ _push_delivery_task, \
974
+ _notifications_sync_task, \
975
+ _inbox_digest_task, \
976
+ _brain_jobs_task, \
977
+ _wal_checkpoint_task, \
978
+ _terminal_metrics_dump_task, \
979
+ _pr_impact_sweep_task, \
980
+ _pr_impact_gc_task
981
+
982
+ # Startup
983
+ logger.info("Starting Console PiR API (env=%s)", settings.pir_env)
984
+ run_migrations()
985
+
986
+ # Phase 1.5 D14: Phoenix client-side OTel SDK (feature flag — default OFF).
987
+ # Set TRACING_ENABLED=true env var to enable end-to-end trace chain through
988
+ # Mac Queue Gateway. Idempotent + safe: missing deps → log warning + skip.
989
+ try:
990
+ from core.api.observability.tracing import init_tracing
991
+ init_tracing(app)
992
+ except Exception:
993
+ logger.exception("init_tracing failed — continuing without Phoenix")
994
+
995
+ # Connection pool (bounded, pre-configured PRAGMAs)
996
+ from core.api.db import init_pool
997
+
998
+ await init_pool(size=settings.db_pool_size)
999
+
1000
+ # Kill orphaned tmux-proxy processes from previous API instance
1001
+ try:
1002
+ from core.api.terminal import kill_orphan_proxies
1003
+
1004
+ await kill_orphan_proxies()
1005
+ except Exception:
1006
+ logger.warning("Failed to kill orphan tmux-proxy processes", exc_info=True)
1007
+
1008
+ try:
1009
+ await tmux.configure_history_limits()
1010
+ except Exception:
1011
+ logger.warning("Failed to configure tmux history limits", exc_info=True)
1012
+
1013
+ # Register SIGCHLD handler to automatically reap zombie children.
1014
+ # os.fork() children (PTY proxies) are not tracked by uvloop, so without
1015
+ # this handler SIGCHLD is unhandled and zombie processes accumulate indefinitely.
1016
+ import signal as _signal
1017
+ from core.api.terminal import reap_zombie_children
1018
+
1019
+ try:
1020
+ loop = asyncio.get_event_loop()
1021
+ loop.add_signal_handler(_signal.SIGCHLD, reap_zombie_children)
1022
+ logger.info("SIGCHLD handler registered (zombie reaping active)")
1023
+ except Exception:
1024
+ logger.warning("Failed to register SIGCHLD handler", exc_info=True)
1025
+
1026
+ from core.api.services.terminal_metrics import TerminalMetricsCollector
1027
+ from core.api.services.terminal_metrics_dump import terminal_metrics_background_loop
1028
+
1029
+ app.state.terminal_metrics = TerminalMetricsCollector()
1030
+ _terminal_metrics_dump_task = asyncio.create_task(
1031
+ terminal_metrics_background_loop(app.state.terminal_metrics)
1032
+ )
1033
+
1034
+ # Load project directories from DB settings
1035
+ import json
1036
+ import sqlite3
1037
+ from pathlib import Path as _Path
1038
+
1039
+ try:
1040
+ conn = sqlite3.connect(settings.db_path)
1041
+ conn.row_factory = sqlite3.Row
1042
+ row = conn.execute(
1043
+ "SELECT value FROM settings WHERE key = 'project_dirs'"
1044
+ ).fetchone()
1045
+ conn.close()
1046
+ if row:
1047
+ from core.api.routers.projects import _set_project_dirs
1048
+
1049
+ dirs = [_Path(d).expanduser() for d in json.loads(row["value"])]
1050
+ _set_project_dirs(dirs)
1051
+ logger.info("Project dirs loaded from DB: %s", dirs)
1052
+ except Exception:
1053
+ logger.exception("Failed to load project dirs from DB, using defaults")
1054
+
1055
+ canary_mode = settings.pir_instance == "canary"
1056
+
1057
+ _cleanup_task = asyncio.create_task(_periodic_cleanup())
1058
+ _session_maintenance_task = asyncio.create_task(_periodic_session_maintenance())
1059
+ if canary_mode:
1060
+ logger.info(
1061
+ "[canary] skipping reminder, notification sync, inbox digest, brain jobs"
1062
+ )
1063
+ else:
1064
+ _reminder_task = asyncio.create_task(_periodic_reminder_check())
1065
+ _notifications_sync_task = asyncio.create_task(
1066
+ _notifications_acted_at_sync_loop()
1067
+ )
1068
+ _inbox_digest_task = asyncio.create_task(_periodic_inbox_digest())
1069
+ # Brain v1.0.1: register the five canonical source collectors
1070
+ # BEFORE spawning _periodic_brain_jobs so the first tick already
1071
+ # sees them. Idempotent — safe across lifespan restarts.
1072
+ from core.api.services.brain.sources import register_all_collectors
1073
+
1074
+ register_all_collectors()
1075
+ _brain_jobs_task = asyncio.create_task(_periodic_brain_jobs())
1076
+ _wal_checkpoint_task = asyncio.create_task(_periodic_wal_checkpoint())
1077
+
1078
+ # n8n integration
1079
+ if canary_mode and settings.n8n_webhook_url:
1080
+ logger.info("[canary] skipping n8n event dispatcher")
1081
+ elif settings.n8n_webhook_url:
1082
+ from core.api.services.event_dispatcher import init_session
1083
+
1084
+ await init_session()
1085
+ _n8n_dispatch_task = asyncio.create_task(_periodic_n8n_dispatch())
1086
+ logger.info(
1087
+ "n8n event dispatcher started (interval=%ds)",
1088
+ settings.n8n_dispatch_interval,
1089
+ )
1090
+ if settings.n8n_api_url:
1091
+ from core.api.services.n8n_client import init_api_session
1092
+
1093
+ await init_api_session()
1094
+ logger.info("n8n API client initialized")
1095
+
1096
+ # Web Push delivery (outbox pattern)
1097
+ if canary_mode and settings.vapid_private_key:
1098
+ logger.info("[canary] skipping web push delivery")
1099
+ elif settings.vapid_private_key:
1100
+ from core.api.services.push_service import init_push_session
1101
+
1102
+ await init_push_session()
1103
+ _push_delivery_task = asyncio.create_task(_periodic_push_delivery())
1104
+ logger.info("Push delivery started (interval=5s)")
1105
+
1106
+ # Voyage AI client for embeddings
1107
+ from core.api.services.embedding_service import init_voyage_client
1108
+
1109
+ if settings.pir_voyage_disabled:
1110
+ logger.info("[canary] skipping Voyage client initialization")
1111
+ else:
1112
+ init_voyage_client()
1113
+
1114
+ # Monitoring collectors
1115
+ await metrics_collector.start()
1116
+ if canary_mode:
1117
+ logger.info("[canary] skipping security collector")
1118
+ else:
1119
+ await security_collector.start()
1120
+ _metrics_task = asyncio.create_task(_periodic_metrics_collection())
1121
+ if not canary_mode:
1122
+ _security_task = asyncio.create_task(_periodic_security_collection())
1123
+ _maintenance_task = asyncio.create_task(_periodic_metrics_maintenance())
1124
+ logger.info("Monitoring collectors started")
1125
+
1126
+ # KG PR-Impact sub-01 D4 + D8: dispatcher sweep + webhook GC.
1127
+ # Both honored only when the feature flag is not 'off' so we don't run
1128
+ # background work for an inert pipeline.
1129
+ pr_impact_enabled = getattr(settings, "pr_impact_enabled", "shadow")
1130
+ if pr_impact_enabled != "off" and not canary_mode:
1131
+ from core.api.services.pr_impact_pipeline.dispatcher import (
1132
+ periodic_pr_impact_sweep,
1133
+ restart_replay,
1134
+ )
1135
+ from core.api.services.pr_impact_pipeline.gc import periodic_webhook_gc
1136
+
1137
+ try:
1138
+ await restart_replay(settings.db_path)
1139
+ except Exception:
1140
+ logger.exception("pr_impact restart_replay failed — continuing")
1141
+ _pr_impact_sweep_task = asyncio.create_task(
1142
+ periodic_pr_impact_sweep(settings.db_path)
1143
+ )
1144
+ _pr_impact_gc_task = asyncio.create_task(
1145
+ periodic_webhook_gc(settings.db_path)
1146
+ )
1147
+ logger.info(
1148
+ "pr_impact background tasks started (mode=%s)", pr_impact_enabled
1149
+ )
1150
+
1151
+ logger.info("API ready")
1152
+
1153
+ yield
1154
+
1155
+ # Shutdown
1156
+ logger.info("Shutting down...")
1157
+ if _cleanup_task:
1158
+ _cleanup_task.cancel()
1159
+ try:
1160
+ await _cleanup_task
1161
+ except asyncio.CancelledError:
1162
+ pass
1163
+ if _session_maintenance_task:
1164
+ _session_maintenance_task.cancel()
1165
+ try:
1166
+ await _session_maintenance_task
1167
+ except asyncio.CancelledError:
1168
+ pass
1169
+ if _reminder_task:
1170
+ _reminder_task.cancel()
1171
+ try:
1172
+ await _reminder_task
1173
+ except asyncio.CancelledError:
1174
+ pass
1175
+ if _notifications_sync_task:
1176
+ _notifications_sync_task.cancel()
1177
+ try:
1178
+ await _notifications_sync_task
1179
+ except asyncio.CancelledError:
1180
+ pass
1181
+ if _inbox_digest_task:
1182
+ _inbox_digest_task.cancel()
1183
+ try:
1184
+ await _inbox_digest_task
1185
+ except asyncio.CancelledError:
1186
+ pass
1187
+ if _brain_jobs_task:
1188
+ _brain_jobs_task.cancel()
1189
+ try:
1190
+ await _brain_jobs_task
1191
+ except asyncio.CancelledError:
1192
+ pass
1193
+ if _wal_checkpoint_task:
1194
+ _wal_checkpoint_task.cancel()
1195
+ try:
1196
+ await _wal_checkpoint_task
1197
+ except asyncio.CancelledError:
1198
+ pass
1199
+ if _terminal_metrics_dump_task:
1200
+ _terminal_metrics_dump_task.cancel()
1201
+ try:
1202
+ await _terminal_metrics_dump_task
1203
+ except asyncio.CancelledError:
1204
+ pass
1205
+
1206
+ # Stop push delivery
1207
+ if _push_delivery_task:
1208
+ _push_delivery_task.cancel()
1209
+ try:
1210
+ await _push_delivery_task
1211
+ except asyncio.CancelledError:
1212
+ pass
1213
+ from core.api.services.push_service import close_push_session
1214
+
1215
+ await close_push_session()
1216
+
1217
+ # Stop n8n dispatcher + close API client
1218
+ if _n8n_dispatch_task:
1219
+ _n8n_dispatch_task.cancel()
1220
+ try:
1221
+ await _n8n_dispatch_task
1222
+ except asyncio.CancelledError:
1223
+ pass
1224
+ from core.api.services.event_dispatcher import close_session
1225
+
1226
+ await close_session()
1227
+ if settings.n8n_api_url:
1228
+ from core.api.services.n8n_client import close_api_session
1229
+
1230
+ await close_api_session()
1231
+
1232
+ # Stop monitoring collectors
1233
+ for task in (_metrics_task, _security_task, _maintenance_task):
1234
+ if task:
1235
+ task.cancel()
1236
+ try:
1237
+ await task
1238
+ except asyncio.CancelledError:
1239
+ pass
1240
+ await metrics_collector.stop()
1241
+ await security_collector.stop()
1242
+
1243
+ # Close connection pool
1244
+ from core.api.db import close_pool
1245
+
1246
+ await close_pool()
1247
+
1248
+ # Cleanup terminal sessions if terminal module loaded
1249
+ try:
1250
+ from core.api.terminal import session_manager
1251
+
1252
+ await session_manager.cleanup_all()
1253
+ except ImportError:
1254
+ pass
1255
+
1256
+ logger.info("Shutdown complete")
1257
+
1258
+
1259
+ # Shared limiter instance (defined in api.rate_limit to avoid circular imports)
1260
+ from core.api.rate_limit import limiter # noqa: E402
1261
+
1262
+ app = FastAPI(
1263
+ title="Console PiR API",
1264
+ version="1.0.0",
1265
+ docs_url="/docs" if settings.expose_openapi else None,
1266
+ redoc_url="/redoc" if settings.expose_openapi else None,
1267
+ openapi_url="/openapi.json" if settings.expose_openapi else None,
1268
+ lifespan=lifespan,
1269
+ )
1270
+
1271
+ # slowapi: attach limiter to app state + register 429 handler
1272
+ app.state.limiter = limiter
1273
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
1274
+
1275
+ # CORS
1276
+ app.add_middleware(
1277
+ CORSMiddleware,
1278
+ allow_origins=settings.cors_origins,
1279
+ allow_credentials=True,
1280
+ allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE"],
1281
+ allow_headers=["*"],
1282
+ )
1283
+
1284
+ # Phase 6.5: audit_log row per MCP tool call (allowlist in middleware).
1285
+ from core.api.middleware.tool_call_audit import MCPToolCallAuditMiddleware # noqa: E402
1286
+ app.add_middleware(MCPToolCallAuditMiddleware)
1287
+
1288
+
1289
+ @app.middleware("http")
1290
+ async def security_headers(request: Request, call_next) -> Response:
1291
+ """Add security headers to all responses."""
1292
+ response = await call_next(request)
1293
+ response.headers["X-Content-Type-Options"] = "nosniff"
1294
+ response.headers["X-Frame-Options"] = "DENY"
1295
+ response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
1296
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
1297
+ if settings.is_production:
1298
+ response.headers["Strict-Transport-Security"] = "max-age=31536000"
1299
+ return response
1300
+
1301
+
1302
+ # Routers
1303
+ app.include_router(auth.router, prefix="/api/v1")
1304
+ app.include_router(sessions.router, prefix="/api/v1")
1305
+ app.include_router(tasks_router)
1306
+ app.include_router(projects_router)
1307
+ app.include_router(costs_router)
1308
+ app.include_router(comments_router)
1309
+ app.include_router(status_updates_router)
1310
+ app.include_router(settings_router)
1311
+ app.include_router(admin_settings_router)
1312
+ app.include_router(files_router)
1313
+ app.include_router(terminal_metrics_router)
1314
+ app.include_router(terminal_router)
1315
+ app.include_router(monitoring_router)
1316
+ app.include_router(kg_router)
1317
+ app.include_router(agent_router)
1318
+ app.include_router(tags_router)
1319
+ app.include_router(finder_router)
1320
+ app.include_router(share_manage_router)
1321
+ app.include_router(shared_file_router)
1322
+ app.include_router(repo_share_router)
1323
+ app.include_router(shared_repo_router)
1324
+ app.include_router(pull_requests_router)
1325
+ app.include_router(users_router)
1326
+ app.include_router(raci_router)
1327
+ app.include_router(audit_router)
1328
+ app.include_router(webhooks_router)
1329
+ app.include_router(admin_pr_impact_router)
1330
+ app.include_router(pr_impact_router)
1331
+ app.include_router(agent_tokens_router)
1332
+ app.include_router(handoffs_router)
1333
+ app.include_router(teams_router)
1334
+ app.include_router(learnings_router)
1335
+ app.include_router(graph_router)
1336
+ if automations_router is not None:
1337
+ app.include_router(automations_router)
1338
+ else:
1339
+ logger.info("automations router absent (SaaS-only module) — skipping")
1340
+ app.include_router(notifications_router)
1341
+ app.include_router(push_router)
1342
+ app.include_router(ci_checks_router)
1343
+ app.include_router(search_router)
1344
+ app.include_router(documents_router)
1345
+ app.include_router(docs_coverage_router)
1346
+ app.include_router(docs_governance_router)
1347
+ app.include_router(judge_router)
1348
+ app.include_router(inbox_router)
1349
+ app.include_router(ingest_triage_router)
1350
+ app.include_router(ingest_api_keys_router)
1351
+ app.include_router(llm_config_router)
1352
+ if newsletter_router is not None:
1353
+ app.include_router(newsletter_router)
1354
+ else:
1355
+ logger.info("newsletter router absent (SaaS-only module) — skipping")
1356
+ app.include_router(bench_router)
1357
+ app.include_router(app_settings_router)
1358
+ if reddit_router is not None:
1359
+ app.include_router(reddit_router)
1360
+ else:
1361
+ logger.info("reddit router absent (SaaS-only module) — skipping")
1362
+ app.include_router(brain_router)
1363
+ app.include_router(brain_directions_router)
1364
+
1365
+
1366
+ def _cors_headers_for(request: Request) -> dict[str, str]:
1367
+ """Replicate CORSMiddleware logic for error responses.
1368
+
1369
+ Starlette CORSMiddleware does NOT add headers to responses generated
1370
+ from unhandled exceptions (encode/starlette#1175). Without these, the
1371
+ browser reports a misleading 'CORS error' for every 500/422/404.
1372
+ """
1373
+ origin = request.headers.get("origin", "")
1374
+ allowed = settings.cors_origins or []
1375
+ if "*" in allowed or origin in allowed:
1376
+ return {
1377
+ "Access-Control-Allow-Origin": origin or "*",
1378
+ "Access-Control-Allow-Credentials": "true",
1379
+ "Vary": "Origin",
1380
+ }
1381
+ return {}
1382
+
1383
+
1384
+ @app.exception_handler(StarletteHTTPException)
1385
+ async def http_exception_handler_cors(request: Request, exc: StarletteHTTPException):
1386
+ return JSONResponse(
1387
+ status_code=exc.status_code,
1388
+ content={"detail": exc.detail},
1389
+ headers=_cors_headers_for(request),
1390
+ )
1391
+
1392
+
1393
+ @app.exception_handler(RequestValidationError)
1394
+ async def validation_exception_handler_cors(
1395
+ request: Request, exc: RequestValidationError
1396
+ ):
1397
+ # jsonable_encoder handles non-JSON-serializable items inside exc.errors()
1398
+ # (e.g. ValueError/ctx objects from Pydantic custom validators).
1399
+ return JSONResponse(
1400
+ status_code=422,
1401
+ content={"detail": jsonable_encoder(exc.errors())},
1402
+ headers=_cors_headers_for(request),
1403
+ )
1404
+
1405
+
1406
+ @app.exception_handler(Exception)
1407
+ async def global_exception_handler_cors(request: Request, exc: Exception):
1408
+ logger.exception(
1409
+ "Unhandled exception on %s %s: %s",
1410
+ request.method,
1411
+ request.url.path,
1412
+ exc,
1413
+ )
1414
+ return JSONResponse(
1415
+ status_code=500,
1416
+ content={"detail": "Internal server error", "type": type(exc).__name__},
1417
+ headers=_cors_headers_for(request),
1418
+ )
1419
+
1420
+
1421
+ @app.exception_handler(ServiceError)
1422
+ async def service_error_handler(request: Request, exc: ServiceError):
1423
+ """Map any domain ServiceError escaping a router to the right HTTP status.
1424
+
1425
+ Makes the incremental use_cases extraction (S1) regression-proof: even a
1426
+ not-yet-refactored router that surfaces a ServiceError gets the correct
1427
+ status + {code, message} body via this single handler.
1428
+ """
1429
+ return JSONResponse(
1430
+ status_code=exc.http_status,
1431
+ content={"code": exc.code, "message": exc.message},
1432
+ headers=_cors_headers_for(request),
1433
+ )
1434
+
1435
+
1436
+ @app.get("/health", tags=["health"])
1437
+ async def health_check() -> dict:
1438
+ """Liveness probe — returns service status and version."""
1439
+ return {"status": "ok", "version": app.version}
1440
+
1441
+
1442
+ # Brain v1 — WebSocket endpoint (sub-05 S5, §4.16).
1443
+ # Emits `marvisx:brain_cycle_changed` payloads to subscribed clients after
1444
+ # each cycle phase. Visibility is enforced at emit time, per-subscriber.
1445
+ async def _resolve_ws_user(ws: WebSocket):
1446
+ """Authenticate a /ws/brain client via cookie OR Bearer token."""
1447
+ from core.api.db import acquire_db
1448
+ from core.api.security import _lookup_agent_token, _resolve_agent_userinfo
1449
+ from core.api.security import verify_session_jwt
1450
+
1451
+ # 1. Bearer (agents) — header OR `?token=` query (browsers can't set WS headers)
1452
+ auth_header = ws.headers.get("authorization", "")
1453
+ bearer_token: str | None = None
1454
+ if auth_header.startswith("Bearer "):
1455
+ bearer_token = auth_header[7:]
1456
+ else:
1457
+ token_qp = ws.query_params.get("token")
1458
+ if token_qp:
1459
+ bearer_token = token_qp
1460
+
1461
+ if bearer_token:
1462
+ async with acquire_db() as db:
1463
+ db.row_factory = aiosqlite.Row
1464
+ resolved = await _lookup_agent_token(bearer_token, db)
1465
+ if resolved is not None:
1466
+ agent_name, scopes, ws_id = resolved
1467
+ return await _resolve_agent_userinfo(agent_name, db, scopes, ws_id)
1468
+ if settings.tasks_api_token and bearer_token == settings.tasks_api_token:
1469
+ return await _resolve_agent_userinfo(
1470
+ "agent", db, workspace_id="ws_default"
1471
+ )
1472
+ return None
1473
+
1474
+ # 2. Cookie (Console PiR web)
1475
+ cookie_token = ws.cookies.get("pir_session")
1476
+ if not cookie_token:
1477
+ return None
1478
+ try:
1479
+ payload = verify_session_jwt(cookie_token)
1480
+ except Exception:
1481
+ return None
1482
+ slug = payload.get("sub")
1483
+ if not slug:
1484
+ return None
1485
+ async with acquire_db() as db:
1486
+ db.row_factory = aiosqlite.Row
1487
+ async with db.execute(
1488
+ "SELECT id, system_role, display_name, workspace_id FROM users "
1489
+ "WHERE slug = ? AND deleted_at IS NULL",
1490
+ (slug,),
1491
+ ) as cur:
1492
+ row = await cur.fetchone()
1493
+ if not row:
1494
+ return None
1495
+ from core.api.models import UserInfo
1496
+
1497
+ return UserInfo(
1498
+ username=slug,
1499
+ user_id=row["id"],
1500
+ system_role=row["system_role"],
1501
+ display_name=row["display_name"],
1502
+ workspace_id=(
1503
+ payload.get("workspace_id")
1504
+ or (row["workspace_id"] if "workspace_id" in row.keys() else None)
1505
+ or "ws_default"
1506
+ ),
1507
+ )
1508
+
1509
+
1510
+ @app.websocket("/ws/brain")
1511
+ async def brain_websocket(ws: WebSocket) -> None:
1512
+ """Brain v1 — real-time cycle phase events.
1513
+
1514
+ Auth via cookie OR Bearer (header / `?token=`). Visibility is applied at
1515
+ emit time per-subscriber — admins/super_admins bypass. Heartbeat ping
1516
+ every 30s; reconnect is client-side (no server replay buffer in v1).
1517
+ """
1518
+ from core.api.services.brain.ws_emitter import Subscriber, _serialize, get_hub
1519
+
1520
+ await ws.accept()
1521
+ user = await _resolve_ws_user(ws)
1522
+ if user is None:
1523
+ await ws.send_text(
1524
+ _serialize({"type": "error", "code": "unauthorized"})
1525
+ )
1526
+ await ws.close(code=4401)
1527
+ return
1528
+
1529
+ is_unrestricted = user.system_role in ("admin", "super_admin")
1530
+ visible_projects: set[str] = set()
1531
+ if not is_unrestricted:
1532
+ try:
1533
+ from core.api.db import acquire_db
1534
+ from core.api.visibility import get_visible_projects
1535
+
1536
+ async with acquire_db() as db:
1537
+ db.row_factory = aiosqlite.Row
1538
+ visible_projects = set(
1539
+ await get_visible_projects(
1540
+ db,
1541
+ user,
1542
+ user.workspace_id or "ws_default",
1543
+ )
1544
+ )
1545
+ except Exception:
1546
+ logger.exception("brain ws: visible_projects resolution failed")
1547
+
1548
+ subscriber = Subscriber(
1549
+ ws=ws,
1550
+ user_id=user.user_id or user.username or "",
1551
+ system_role=user.system_role,
1552
+ workspace_id=user.workspace_id or "ws_default",
1553
+ visible_projects=visible_projects,
1554
+ is_unrestricted=is_unrestricted,
1555
+ )
1556
+ hub = get_hub()
1557
+ await hub.register(subscriber)
1558
+
1559
+ try:
1560
+ await ws.send_text(
1561
+ _serialize({
1562
+ "type": "marvisx:brain_subscribed",
1563
+ "user_id": subscriber.user_id,
1564
+ "role": subscriber.system_role,
1565
+ })
1566
+ )
1567
+
1568
+ async def _send_loop() -> None:
1569
+ while True:
1570
+ payload = await subscriber.queue.get()
1571
+ await ws.send_text(_serialize(payload))
1572
+
1573
+ async def _heartbeat_loop() -> None:
1574
+ while True:
1575
+ await asyncio.sleep(30)
1576
+ try:
1577
+ await ws.send_text(_serialize({"type": "ping"}))
1578
+ except Exception:
1579
+ return
1580
+
1581
+ async def _recv_loop() -> None:
1582
+ while True:
1583
+ # The client may send pongs / explicit close frames. Discard
1584
+ # text frames; on disconnect WebSocketDisconnect bubbles up.
1585
+ await ws.receive_text()
1586
+
1587
+ send_task = asyncio.create_task(_send_loop())
1588
+ heartbeat_task = asyncio.create_task(_heartbeat_loop())
1589
+ recv_task = asyncio.create_task(_recv_loop())
1590
+
1591
+ done, pending = await asyncio.wait(
1592
+ {send_task, heartbeat_task, recv_task},
1593
+ return_when=asyncio.FIRST_COMPLETED,
1594
+ )
1595
+ for task in pending:
1596
+ task.cancel()
1597
+ for task in done:
1598
+ exc = task.exception()
1599
+ if exc and not isinstance(exc, (WebSocketDisconnect, asyncio.CancelledError)):
1600
+ logger.exception(
1601
+ "brain ws: task ended with error", exc_info=exc
1602
+ )
1603
+ except WebSocketDisconnect:
1604
+ pass
1605
+ except Exception:
1606
+ logger.exception("brain ws: unhandled error")
1607
+ finally:
1608
+ await hub.unregister(subscriber)
1609
+ try:
1610
+ await ws.close()
1611
+ except Exception:
1612
+ pass
1613
+
1614
+
1615
+ @app.get("/health/ocr", tags=["health"])
1616
+ async def health_ocr() -> dict:
1617
+ """OCR backend health (P1.5.E1).
1618
+
1619
+ Reports whether the tesseract system binary is callable with the Italian
1620
+ language pack installed. PaddleOCR-VL lives upstream on the Mac Gateway
1621
+ (tier-ocr/tier-docparse) and is monitored separately. Used as post-deploy
1622
+ gate to verify the local OCR pipeline never silently regresses to
1623
+ ``parser_used=null``.
1624
+ """
1625
+ import subprocess
1626
+
1627
+ result = {"tesseract": False, "italian_lang": False}
1628
+ try:
1629
+ completed = await asyncio.to_thread(
1630
+ subprocess.run,
1631
+ ["tesseract", "--list-langs"],
1632
+ capture_output=True,
1633
+ timeout=5,
1634
+ )
1635
+ result["tesseract"] = completed.returncode == 0
1636
+ result["italian_lang"] = b"ita" in (completed.stdout or b"")
1637
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1638
+ pass
1639
+ except Exception:
1640
+ logger.exception("Unexpected error probing tesseract")
1641
+ return result