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
@@ -0,0 +1,836 @@
1
+ # v2.0.0 - 2026-05-27 - S1 F1.8: thin adapter over use_cases.graph (visibility + rate-limit + share kept at adapter)
2
+ # v1.8.0 - 2026-04-17 - P2: 5 new UX endpoints (landing/pins/resolve/overview/orphans) + slowapi + filter_visible_edges
3
+ # v1.7.0 - 2026-04-16 - H2: graph ACL — get_visible_projects check on ?project=X endpoints
4
+ # v1.5.0 - 2026-04-14 - KG Fase 2: cross-project (project filter + edge_types + BFS cycle detection)
5
+ # v1.4.0 - 2026-04-14 - KG Fase 1f: impact + context + pattern endpoints
6
+ """HTTP adapter for the Knowledge Graph domain (S1 collapse-runtime).
7
+
8
+ This router is a thin transport adapter. All query orchestration + validation +
9
+ error mapping lives in :mod:`core.api.use_cases.graph` (pure, fastapi-free). Each
10
+ handler resolves identity into a :class:`CallerContext`, calls the use_case inside
11
+ ``try/except`` -> ``to_http_legacy``, and owns the transport concerns.
12
+
13
+ Endpoints:
14
+ GET /api/v1/graph/neighbors/{node_id} — live view (Fase 1a)
15
+ GET /api/v1/graph/neighbors/{node_id}?rank=X — ranked (Fase 1b)
16
+ GET /api/v1/graph/neighbors/{node_id}?as_of=<iso> — time-travel (Fase 1d)
17
+ GET /api/v1/graph/neighbors/{node_id}?project=X&edge_types=Y&edge_types=Z — cross-project (Fase 2)
18
+ GET /api/v1/graph/hotspots?window=30d&limit=20 — churn hotspots (Fase 1e)
19
+ GET /api/v1/graph/impact/{node_id}?depth=2&limit=50 — reverse impact (Fase 1f)
20
+ GET /api/v1/graph/impact/{id}?project=X&edge_types=mentions — multi-hop cross-project (Fase 2)
21
+ GET /api/v1/graph/context/{node_id} — rationale chain (Fase 1f)
22
+ GET /api/v1/graph/pattern?scope=<path|module> — applicable learnings (Fase 1f)
23
+ GET /api/v1/graph/function-share/{qualified_name} — share + KG context bundle (vision audit P1)
24
+ --- P2 UX endpoints ---
25
+ GET /api/v1/graph/landing — hotspots + recent + pins bundle
26
+ GET /api/v1/graph/pins — list user pins (user-scoped)
27
+ POST /api/v1/graph/pins — upsert pin (Bearer required, CSRF via Bearer)
28
+ DELETE /api/v1/graph/pins/{node_id} — delete pin
29
+ GET /api/v1/graph/resolve?path=... — resolve file path to node_id
30
+ GET /api/v1/graph/overview?level=macro|module — LOD overview graph
31
+ GET /api/v1/graph/orphans?scope=... — orphan files (LEFT JOIN anti-join)
32
+ GET /api/v1/graph/cosmo — Cosmo canvas dataset
33
+
34
+ Read-only — uses get_db (read-only pool). Auth via the standard dual
35
+ cookie+Bearer dependency `get_current_user_or_agent`.
36
+
37
+ CSRF on pin writes: POST/DELETE /graph/pins require Authorization: Bearer <token>.
38
+ The cookie-based session is not accepted for mutations (Bearer-required approach).
39
+
40
+ STAYS IN THE ADAPTER (transport concerns):
41
+ * VISIBILITY enforcement (use_cases.graph DECISION B): ``get_visible_projects``
42
+ is resolved here AND the gate fires here, because the per-endpoint outcome is
43
+ test-pinned to a plain-string body — project-scoped reads → 403
44
+ "Project not accessible" (test_kg_security_h2_h3), oracle reads → 404
45
+ "Not found". For resolve/overview/orphans the resolved ``visible_projects`` is
46
+ PASSED INTO the use_case (DECISION 1), which raises NotFoundError on a miss.
47
+ ``filter_visible_edges`` (overview macro RBAC) is imported here and INJECTED
48
+ into the use_case: it imports fastapi and the overview test pins it to THIS
49
+ namespace (``patch("api.routers.graph.filter_visible_edges")``).
50
+ * RATE LIMITS (@limiter.limit) — slowapi decorators stay on the handlers.
51
+ * SHARE-URL side effect for ``function-share`` (use_cases.graph DECISION C):
52
+ role enforcement + signed URL via ``share_links`` (imports fastapi) + the
53
+ on-disk preview read stay here; the pure KG-context pieces live in the use_case.
54
+ * PIN-WRITE TTLCache invalidation (the caches are module-level transport state).
55
+
56
+ ERROR BOUNDARY: graph's HTTPException detail bodies are rich plain strings (part
57
+ of the agent-facing contract, NOT the structured ``{code,message}`` shape). The
58
+ use_case raises :class:`ServiceError` whose ``message`` is byte-identical to the
59
+ legacy detail; ``to_http_legacy`` re-raises ``HTTPException(status, message)`` so
60
+ bodies are unchanged.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import logging
65
+ import re
66
+ from typing import Literal
67
+
68
+ import aiosqlite
69
+ from cachetools import TTLCache
70
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
71
+
72
+ from core.api.db import get_db, get_write_db
73
+ from core.api.models import UserInfo
74
+ from core.api.models.graph import GraphCapabilities, RankType
75
+ from core.api.models.graph_ux import (
76
+ LandingBundle,
77
+ OrphansBundle,
78
+ OverviewBundle,
79
+ PinIn,
80
+ PinOut,
81
+ ResolveOut,
82
+ )
83
+ from core.api.models.graph_cosmo import GraphCosmoOut
84
+ from core.api.rate_limit import limiter
85
+ from core.api.security import get_current_user_or_agent
86
+ from core.api.services import graph_cosmo_service
87
+ from core.api.use_cases import graph as uc
88
+ from core.api.use_cases._context import CallerContext
89
+ from core.api.use_cases._errors import ServiceError
90
+ from core.api.visibility import filter_visible_edges, get_visible_projects
91
+ from core.api.services.share_links import (
92
+ create_shared_link_record,
93
+ enforce_workspace_share_role,
94
+ public_repo_path,
95
+ stored_repo_path,
96
+ validate_repo_path,
97
+ )
98
+
99
+ logger = logging.getLogger(__name__)
100
+
101
+
102
+ def to_http_legacy(err: ServiceError) -> HTTPException:
103
+ """Map a domain ``ServiceError`` to an ``HTTPException`` with the LEGACY body.
104
+
105
+ Graph endpoints have always returned a plain-string ``detail`` (the rich,
106
+ agent-facing message). The structured ``{code,message}`` body produced by
107
+ ``routers/_adapter.to_http`` would change every response and break the
108
+ string-substring assertions (test_kg_security_h2_h3, test_kg_share_function).
109
+ So we preserve the plain-string body here.
110
+ """
111
+ return HTTPException(status_code=err.http_status, detail=err.message)
112
+
113
+
114
+ # Relation types backed by the latest graph_edges CHECK constraint (mig 132).
115
+ # Kept here (not imported from graph_service) because FastAPI Literal typing
116
+ # requires a literal value set for static introspection. Keep synchronized
117
+ # with graph_service.EDGE_TYPES + mcp-pir edgeTypeEnum + docs kg-fetcher.ts +
118
+ # kg-schema-snapshot.json + latest migration CHECK (see scripts/_drift_check.py
119
+ # check B for the 6-source sync mandate).
120
+ EdgeType = Literal[
121
+ "calls", "imports", "defines",
122
+ "produces", "contains",
123
+ "describes", "documents", "cites", "applies_to",
124
+ "depends_on", "mentions", "refers_to", "shares_tag", "similar_to",
125
+ "resolves_to", # Phase 7.2: module stub -> file canonical bridge
126
+ "modifies", # KG PR-Impact (mig 132): pr_artifact -> function_artifact
127
+ ]
128
+
129
+ PROJECT_SLUG_QUERY_PATTERN = r"^[a-z0-9][a-z0-9&\-]+$"
130
+
131
+ router = APIRouter(prefix="/api/v1/graph", tags=["graph"])
132
+
133
+
134
+ @router.get("/neighbors/{node_id}")
135
+ async def graph_neighbors(
136
+ node_id: str,
137
+ relation: str | None = Query(
138
+ None, description="Deprecated (Fase 1). Prefer `edge_types`."
139
+ ),
140
+ edge_types: list[EdgeType] | None = Query(
141
+ None,
142
+ description="Fase 2: repeatable filter on edge relations. "
143
+ "Usage: ?edge_types=calls&edge_types=depends_on — union semantics. "
144
+ "Overrides `relation` when both are set.",
145
+ ),
146
+ project: str | None = Query(
147
+ None,
148
+ max_length=50,
149
+ pattern=PROJECT_SLUG_QUERY_PATTERN,
150
+ description="Fase 2: filter neighbours by project_id (ARCH-01 "
151
+ "project_scope=source). Pass slug like 'marvisx' or 'c&i-normativa'.",
152
+ ),
153
+ direction: Literal["incoming", "outgoing", "both"] = Query(
154
+ "both",
155
+ description="incoming = who points to this; outgoing = what this points to",
156
+ ),
157
+ limit: int = Query(50, ge=1, le=200),
158
+ rank: RankType = Query(
159
+ "none",
160
+ description="Ranker strategy: none (raw) or suspect_write (Fase 1b)",
161
+ ),
162
+ as_of: str | None = Query(
163
+ None,
164
+ description="ISO timestamp for time-travel query (YYYY-MM-DD[ HH:MM:SS]). "
165
+ "When set, returns the graph state as it was at that moment. "
166
+ "When omitted, returns the live view (excludes deprecated nodes/edges).",
167
+ max_length=32,
168
+ pattern=r"^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$",
169
+ ),
170
+ user: UserInfo = Depends(get_current_user_or_agent),
171
+ db: aiosqlite.Connection = Depends(get_db),
172
+ ) -> dict:
173
+ """Return neighbours of a graph node, optionally ranked / time-travelled / cross-project.
174
+
175
+ 422 on malformed `node_id`, `relation`, `edge_types`, `project`, `rank`, or `as_of`.
176
+ 404 if the node does not exist in the graph (at `as_of` if specified).
177
+ """
178
+ # H2: visibility check — only when caller scopes to a specific project
179
+ if project:
180
+ visible = await get_visible_projects(db, user)
181
+ if visible is not None and project not in visible:
182
+ raise HTTPException(status_code=403, detail="Project not accessible")
183
+
184
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
185
+ try:
186
+ return await uc.graph_neighbors(
187
+ ctx, db,
188
+ node_id=node_id, relation=relation, edge_types=edge_types,
189
+ project=project, direction=direction, limit=limit, rank=rank, as_of=as_of,
190
+ )
191
+ except ServiceError as e:
192
+ raise to_http_legacy(e)
193
+
194
+
195
+ @router.get("/hotspots")
196
+ async def graph_hotspots(
197
+ window: Literal["7d", "30d", "total"] = Query(
198
+ "30d",
199
+ description="Rolling window: 7d/30d for recent churn, total for all-time.",
200
+ ),
201
+ limit: int = Query(20, ge=1, le=100),
202
+ type_filter: Literal["function", "file", "all"] = Query(
203
+ "file",
204
+ description="Filter by node type (file = default, all = function+file).",
205
+ ),
206
+ project: str | None = Query(
207
+ None,
208
+ max_length=50,
209
+ pattern=PROJECT_SLUG_QUERY_PATTERN,
210
+ description="Fase 2: filter hotspots to this project (default: all projects).",
211
+ ),
212
+ user: UserInfo = Depends(get_current_user_or_agent),
213
+ db: aiosqlite.Connection = Depends(get_db),
214
+ ) -> dict:
215
+ """Top N hotspot nodes ordered by touch count in the chosen window.
216
+
217
+ 422 on malformed `window`, `type_filter`, or `project`.
218
+ """
219
+ # H2: visibility check — only when caller scopes to a specific project
220
+ if project:
221
+ visible = await get_visible_projects(db, user)
222
+ if visible is not None and project not in visible:
223
+ raise HTTPException(status_code=403, detail="Project not accessible")
224
+
225
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
226
+ try:
227
+ return await uc.graph_hotspots(
228
+ ctx, db,
229
+ window=window, limit=limit, type_filter=type_filter, project=project,
230
+ )
231
+ except ServiceError as e:
232
+ raise to_http_legacy(e)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Fase 1f — graph_impact / graph_context / graph_pattern
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ @router.get("/impact/{node_id}")
241
+ async def graph_impact_endpoint(
242
+ node_id: str,
243
+ depth: int = Query(
244
+ 2, ge=1, le=5,
245
+ description="BFS hops for transitive caller discovery (1 = direct only). "
246
+ "Fase 2 caps at min(requested, 4) for multi-hop safety.",
247
+ ),
248
+ limit: int = Query(
249
+ 50, ge=1, le=200,
250
+ description="Max transitive callers returned (hard-capped at 200).",
251
+ ),
252
+ edge_types: list[EdgeType] | None = Query(
253
+ None,
254
+ description="Fase 2: repeatable filter on edge relations. "
255
+ "Default (None) walks along `calls` edges. Pass e.g. "
256
+ "?edge_types=calls&edge_types=depends_on&edge_types=mentions for "
257
+ "cross-project multi-hop impact.",
258
+ ),
259
+ project: str | None = Query(
260
+ None,
261
+ max_length=50,
262
+ pattern=PROJECT_SLUG_QUERY_PATTERN,
263
+ description="Fase 2: scope walk to source nodes belonging to this project.",
264
+ ),
265
+ user: UserInfo = Depends(get_current_user_or_agent),
266
+ db: aiosqlite.Connection = Depends(get_db),
267
+ ) -> dict:
268
+ """Reverse impact analysis for a graph node.
269
+
270
+ 422 on malformed `node_id`, `edge_types`, or `project`.
271
+ 404 if the node does not exist.
272
+ """
273
+ # H2: visibility check — only when caller scopes to a specific project
274
+ if project:
275
+ visible = await get_visible_projects(db, user)
276
+ if visible is not None and project not in visible:
277
+ raise HTTPException(status_code=403, detail="Project not accessible")
278
+
279
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
280
+ try:
281
+ return await uc.graph_impact(
282
+ ctx, db,
283
+ node_id=node_id, depth=depth, limit=limit,
284
+ edge_types=edge_types, project=project,
285
+ )
286
+ except ServiceError as e:
287
+ raise to_http_legacy(e)
288
+
289
+
290
+ @router.get("/context/{node_id}")
291
+ async def graph_context_endpoint(
292
+ node_id: str,
293
+ per_category_limit: int = Query(
294
+ 5, ge=1, le=20,
295
+ description="Max items per category (commits/prs/tasks/handoffs/learnings).",
296
+ ),
297
+ project: str | None = Query(
298
+ None,
299
+ max_length=50,
300
+ pattern=PROJECT_SLUG_QUERY_PATTERN,
301
+ description="Fase 2: scope source node to this project (ARCH-01). "
302
+ "The chain nodes can still cross projects for multi-hop discovery.",
303
+ ),
304
+ user: UserInfo = Depends(get_current_user_or_agent),
305
+ db: aiosqlite.Connection = Depends(get_db),
306
+ ) -> dict:
307
+ """Trace the rationale chain for a node.
308
+
309
+ 422 on malformed `node_id` or `project`.
310
+ 404 if the node does not exist.
311
+ """
312
+ # H2: visibility check — only when caller scopes to a specific project
313
+ if project:
314
+ visible = await get_visible_projects(db, user)
315
+ if visible is not None and project not in visible:
316
+ raise HTTPException(status_code=403, detail="Project not accessible")
317
+
318
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
319
+ try:
320
+ return await uc.graph_context(
321
+ ctx, db,
322
+ node_id=node_id, per_category_limit=per_category_limit, project=project,
323
+ )
324
+ except ServiceError as e:
325
+ raise to_http_legacy(e)
326
+
327
+
328
+ @router.get("/pattern")
329
+ async def graph_pattern_endpoint(
330
+ scope: str = Query(
331
+ ..., max_length=256,
332
+ description="File path (api/db.py), module dotted name (api.db), or full node id.",
333
+ ),
334
+ limit: int = Query(
335
+ 20, ge=1, le=100,
336
+ description="Max learnings returned.",
337
+ ),
338
+ project: str | None = Query(
339
+ None,
340
+ max_length=50,
341
+ pattern=PROJECT_SLUG_QUERY_PATTERN,
342
+ description="Fase 2: filter learnings to those whose `applies_to` target "
343
+ "lives in this project.",
344
+ ),
345
+ user: UserInfo = Depends(get_current_user_or_agent),
346
+ db: aiosqlite.Connection = Depends(get_db),
347
+ ) -> dict:
348
+ """Learnings applicable to a scope (file, module, or specific function).
349
+
350
+ 422 on malformed `scope` or `project`.
351
+ """
352
+ # H2: visibility check — only when caller scopes to a specific project
353
+ if project:
354
+ visible = await get_visible_projects(db, user)
355
+ if visible is not None and project not in visible:
356
+ raise HTTPException(status_code=403, detail="Project not accessible")
357
+
358
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
359
+ try:
360
+ return await uc.graph_pattern(
361
+ ctx, db, scope=scope, limit=limit, project=project,
362
+ )
363
+ except ServiceError as e:
364
+ raise to_http_legacy(e)
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # capabilities — KG schema metadata for agent discovery (Phase 7.x Pilastro 5)
369
+ # ---------------------------------------------------------------------------
370
+
371
+
372
+ @router.get(
373
+ "/capabilities",
374
+ response_model=GraphCapabilities,
375
+ summary="KG schema metadata for agent discovery",
376
+ )
377
+ async def get_capabilities(
378
+ user: UserInfo = Depends(get_current_user_or_agent),
379
+ db: aiosqlite.Connection = Depends(get_db),
380
+ ) -> GraphCapabilities:
381
+ """Returns valid edge_types, node_kinds, node_prefixes + schema_version.
382
+
383
+ Agents should call this on cold-start (before building graph_* queries)
384
+ to discover the current taxonomy instead of hardcoding enums.
385
+
386
+ Security (deepen section 9): mcp_version NOT exposed (no fingerprinting).
387
+ """
388
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
389
+ try:
390
+ return await uc.get_capabilities(ctx, db)
391
+ except ServiceError as e:
392
+ raise to_http_legacy(e)
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # share_function — daily-QoL tool combining share_file + KG context bundle
397
+ # ---------------------------------------------------------------------------
398
+
399
+ SHARE_FUNCTION_DEFAULT_HOURS: int = uc.SHARE_FUNCTION_DEFAULT_HOURS
400
+ PREVIEW_LINES: int = uc.PREVIEW_LINES
401
+
402
+
403
+ def _read_file_preview(
404
+ file_path: str, line_number: int | None, n_lines: int = PREVIEW_LINES
405
+ ) -> dict:
406
+ """Read `n_lines` from `file_path` starting at `line_number` (1-based).
407
+
408
+ Transport concern: ``validate_repo_path`` (share_links) imports fastapi, and
409
+ reading off disk is an I/O side effect — kept in the adapter. Returns
410
+ `{start_line, end_line, lines: [str]}` or `{error: ...}` so the rest of the
411
+ bundle survives.
412
+ """
413
+ try:
414
+ target = validate_repo_path(file_path)
415
+ except HTTPException as exc:
416
+ return {"error": f"path validation failed: {exc.detail}"}
417
+ if not target.is_file():
418
+ return {"error": "file not found on disk"}
419
+ try:
420
+ content = target.read_text(encoding="utf-8", errors="replace")
421
+ except OSError as exc:
422
+ return {"error": f"read failed: {exc}"}
423
+
424
+ all_lines = content.splitlines()
425
+ total = len(all_lines)
426
+ start = max(1, int(line_number or 1))
427
+ # Clamp start to file length to avoid empty preview on stale line_number.
428
+ if start > total:
429
+ start = max(1, total - n_lines + 1)
430
+ end = min(total, start + n_lines - 1)
431
+ # Slice uses 0-based indexing.
432
+ lines = all_lines[start - 1 : end]
433
+ return {
434
+ "start_line": start,
435
+ "end_line": end,
436
+ "total_lines": total,
437
+ "lines": lines,
438
+ }
439
+
440
+
441
+ @router.get("/function-share/{qualified_name:path}")
442
+ async def share_function_endpoint(
443
+ qualified_name: str,
444
+ include: str | None = Query(
445
+ None,
446
+ max_length=128,
447
+ description="CSV list of blocks to include. Allowed: "
448
+ "preview,neighbors,context,hotspot. Omit or leave empty for all four.",
449
+ ),
450
+ hours: int = Query(
451
+ SHARE_FUNCTION_DEFAULT_HOURS,
452
+ ge=1, le=720,
453
+ description="Hours until the share URL expires (1-720, default 24).",
454
+ ),
455
+ current_user: UserInfo = Depends(get_current_user_or_agent),
456
+ db: aiosqlite.Connection = Depends(get_write_db),
457
+ ) -> dict:
458
+ """Hand off a function with full KG context in a single call.
459
+
460
+ Given a Python `qualified_name` (e.g. `api.db.get_db`), returns share_url +
461
+ preview + neighbors + context + hotspot. See use_cases.graph DECISION C for
462
+ the transport/pure split.
463
+
464
+ 422 on malformed `qualified_name` or unknown `include` tokens.
465
+ 404 if no `type='function'` node matches `qualified_name` in the live graph.
466
+ """
467
+ ctx = CallerContext.from_user_info(current_user, is_human_session=False)
468
+
469
+ # 1. Validate qualified_name + includes (pure → use_case, 422 via ServiceError).
470
+ try:
471
+ uc.validate_qualified_name(qualified_name)
472
+ include_list = uc.parse_include_csv(include)
473
+ except ValueError as e:
474
+ # parse_include_csv raises ValueError (pure helper) — map to 422.
475
+ raise HTTPException(status_code=422, detail=str(e))
476
+ except ServiceError as e:
477
+ raise to_http_legacy(e)
478
+
479
+ # 2. Enforce workspace-share role (transport — share_links imports fastapi).
480
+ enforce_workspace_share_role(current_user)
481
+
482
+ # 3. Lookup the function node (pure → use_case, 404 via ServiceError).
483
+ try:
484
+ node_row = await uc.lookup_function_node(db, qualified_name=qualified_name)
485
+ except ServiceError as e:
486
+ raise to_http_legacy(e)
487
+
488
+ node_id = node_row["id"]
489
+ file_path = node_row["file_path"]
490
+ line_number = node_row["line_number"]
491
+
492
+ # 4. Generate the share URL (transport side effect — primary pull-factor).
493
+ # Pattern mirrors finder._create_share_link_impl workspace branch.
494
+ try:
495
+ target = validate_repo_path(file_path)
496
+ except HTTPException as exc:
497
+ raise HTTPException(
498
+ status_code=404,
499
+ detail=(
500
+ f"File path for {qualified_name!r} rejected by validate_repo_path: {exc.detail}. "
501
+ f"Recorded file_path in graph_nodes: {file_path!r}. "
502
+ "Reason: the indexed path resolves outside the allowed share root, or contains traversal tokens. "
503
+ "Fix: re-run the indexer with the correct repo_share_root, or fix the graph_nodes row manually."
504
+ ),
505
+ ) from exc
506
+ if not target.is_file():
507
+ raise HTTPException(
508
+ status_code=404,
509
+ detail=(
510
+ f"File {file_path!r} no longer exists on disk. "
511
+ "Reason: the graph_nodes row is stale — the file was moved or deleted since indexing. "
512
+ "Fix: re-run scripts/populate_knowledge_graph.py to refresh, "
513
+ "or locate the new path with `git log --diff-filter=D --name-only` (deleted) or mcp__pir__search."
514
+ ),
515
+ )
516
+
517
+ share_record = await create_shared_link_record(
518
+ stored_path=stored_repo_path(file_path),
519
+ public_path=public_repo_path(file_path),
520
+ current_user=current_user,
521
+ db=db,
522
+ hours=hours,
523
+ )
524
+
525
+ response: dict = {
526
+ "qualified_name": qualified_name,
527
+ "node_id": node_id,
528
+ "file_path": file_path,
529
+ "line_number": line_number,
530
+ "share_url": share_record["url"],
531
+ "share_token": share_record["token"],
532
+ "share_expires_at": share_record["expires_at"],
533
+ "includes": include_list,
534
+ }
535
+
536
+ # 5. Preview block (transport — disk read kept in the adapter).
537
+ if "preview" in include_list:
538
+ response["preview"] = _read_file_preview(
539
+ file_path=file_path, line_number=line_number,
540
+ )
541
+
542
+ # 6. Pure KG-context blocks (neighbors / context / hotspot → use_case).
543
+ try:
544
+ response.update(
545
+ await uc.build_share_function_blocks(
546
+ db, node_id=node_id, node_row=node_row, include_list=include_list,
547
+ )
548
+ )
549
+ except ServiceError as e:
550
+ raise to_http_legacy(e)
551
+
552
+ return response
553
+
554
+
555
+ # ---------------------------------------------------------------------------
556
+ # P2 UX endpoints — landing / pins / resolve / overview / orphans
557
+ # ---------------------------------------------------------------------------
558
+
559
+ # Split TTL caches (transport state — kept in the adapter):
560
+ # hotspots_cache[workspace_id] — 60s TTL, expensive query
561
+ # recent_cache[workspace_id] — 60s TTL, medium query
562
+ # pins_cache[user_id] — 60s TTL, cheap query, invalidated on write
563
+ _hotspots_cache: TTLCache = TTLCache(maxsize=128, ttl=60)
564
+ _recent_cache: TTLCache = TTLCache(maxsize=128, ttl=60)
565
+ _pins_cache: TTLCache = TTLCache(maxsize=512, ttl=60)
566
+
567
+
568
+ # --- Landing ---
569
+
570
+ @router.get("/landing", response_model=LandingBundle, summary="KG landing bundle")
571
+ @limiter.limit("60/minute")
572
+ async def graph_landing(
573
+ request: Request,
574
+ user: UserInfo = Depends(get_current_user_or_agent),
575
+ db: aiosqlite.Connection = Depends(get_db),
576
+ ) -> LandingBundle:
577
+ """Aggregated landing bundle: top-10 hotspots (30d) + last-20 recent artifacts + saved pins.
578
+
579
+ Uses split TTLCache (60s) keyed by workspace_id for hotspots/recent, and
580
+ user_id for pins. All three slices are merged at request time.
581
+
582
+ Pins on soft-deleted nodes are excluded (JOIN with graph_nodes WHERE deprecated_at IS NULL).
583
+
584
+ Rate limit: 60/minute/user.
585
+ """
586
+ ws_id = getattr(user, "workspace_id", "ws_default") or "ws_default"
587
+ user_id = getattr(user, "user_id", "unknown")
588
+
589
+ hotspots_key = f"hotspots:{ws_id}"
590
+ recent_key = f"recent:{ws_id}"
591
+ pins_key = f"pins:{user_id}"
592
+
593
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
594
+ try:
595
+ bundle, hotspots, recent, saved = await uc.graph_landing(
596
+ ctx, db,
597
+ workspace_id=ws_id, user_id=user_id,
598
+ hotspots_cached=_hotspots_cache.get(hotspots_key),
599
+ recent_cached=_recent_cache.get(recent_key),
600
+ pins_cached=_pins_cache.get(pins_key),
601
+ )
602
+ except ServiceError as e:
603
+ raise to_http_legacy(e)
604
+
605
+ # Repopulate caches with the (possibly freshly-computed) slices.
606
+ _hotspots_cache[hotspots_key] = hotspots
607
+ _recent_cache[recent_key] = recent
608
+ _pins_cache[pins_key] = saved
609
+ return bundle
610
+
611
+
612
+ # --- List pins ---
613
+
614
+ @router.get("/pins", response_model=list[PinOut], summary="List user KG pins")
615
+ @limiter.limit("120/minute")
616
+ async def list_graph_pins(
617
+ request: Request,
618
+ user: UserInfo = Depends(get_current_user_or_agent),
619
+ db: aiosqlite.Connection = Depends(get_db),
620
+ ) -> list[PinOut]:
621
+ """List all pins for the current user, ordered by pinned_at DESC.
622
+
623
+ Pins on soft-deleted nodes are excluded (JOIN graph_nodes WHERE deprecated_at IS NULL).
624
+ Rate limit: 120/minute/user.
625
+ """
626
+ user_id = getattr(user, "user_id", "unknown")
627
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
628
+ try:
629
+ return await uc.list_graph_pins(ctx, db, user_id=user_id)
630
+ except ServiceError as e:
631
+ raise to_http_legacy(e)
632
+
633
+
634
+ # --- Create/upsert pin ---
635
+ # CSRF mitigation: Bearer-required (get_current_user_or_agent accepts Bearer).
636
+ # POST with Authorization header triggers CORS preflight for cross-origin requests,
637
+ # blocking CSRF. Cookie-only sessions are still accepted for same-origin Console.
638
+
639
+ @router.post("/pins", response_model=PinOut, summary="Pin a KG node (upsert)")
640
+ @limiter.limit("30/minute")
641
+ async def create_graph_pin(
642
+ body: PinIn,
643
+ request: Request,
644
+ user: UserInfo = Depends(get_current_user_or_agent),
645
+ db: aiosqlite.Connection = Depends(get_write_db),
646
+ ) -> PinOut:
647
+ """Upsert a node pin for the current user.
648
+
649
+ Idempotent: POST twice with the same node_id → 200, single row.
650
+ Updates `note` on conflict. Returns 200 (not 201) to signal idempotency.
651
+
652
+ CSRF: requires Authorization: Bearer header (or valid session cookie for same-origin).
653
+ Rate limit: 30/minute/user.
654
+
655
+ Invalidates pins_cache[user_id] for landing bundle consistency.
656
+ """
657
+ user_id = getattr(user, "user_id", "unknown")
658
+ ws_id = getattr(user, "workspace_id", "ws_default") or "ws_default"
659
+
660
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
661
+ try:
662
+ result = await uc.create_graph_pin(
663
+ ctx, db,
664
+ workspace_id=ws_id, user_id=user_id, node_id=body.node_id, note=body.note,
665
+ )
666
+ except ServiceError as e:
667
+ raise to_http_legacy(e)
668
+
669
+ # Invalidate pins_cache for this user (transport).
670
+ _pins_cache.pop(f"pins:{user_id}", None)
671
+ return result
672
+
673
+
674
+ # --- Delete pin ---
675
+
676
+ @router.delete("/pins/{node_id}", response_model=dict, summary="Unpin a KG node")
677
+ @limiter.limit("30/minute")
678
+ async def delete_graph_pin(
679
+ node_id: str,
680
+ request: Request,
681
+ user: UserInfo = Depends(get_current_user_or_agent),
682
+ db: aiosqlite.Connection = Depends(get_write_db),
683
+ ) -> dict:
684
+ """Remove a pin for the current user.
685
+
686
+ Returns 200 on success, 404 if the pin does not exist for this user.
687
+ CSRF: same policy as POST /graph/pins (Bearer-required mitigates CSRF).
688
+ Rate limit: 30/minute/user.
689
+
690
+ Invalidates pins_cache[user_id] for landing bundle consistency.
691
+ """
692
+ user_id = getattr(user, "user_id", "unknown")
693
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
694
+ try:
695
+ result = await uc.delete_graph_pin(ctx, db, user_id=user_id, node_id=node_id)
696
+ except ServiceError as e:
697
+ raise to_http_legacy(e)
698
+
699
+ # Invalidate pins_cache for this user (transport).
700
+ _pins_cache.pop(f"pins:{user_id}", None)
701
+ return result
702
+
703
+
704
+ # --- Resolve file path → node_id ---
705
+
706
+ @router.get("/resolve", response_model=ResolveOut, summary="Resolve file path to KG node")
707
+ @limiter.limit("120/minute")
708
+ async def graph_resolve(
709
+ request: Request,
710
+ path: str = Query(..., max_length=1024, description="Relative file path (no .. or absolute)"),
711
+ user: UserInfo = Depends(get_current_user_or_agent),
712
+ db: aiosqlite.Connection = Depends(get_db),
713
+ ) -> ResolveOut:
714
+ """Resolve a file path to its graph_nodes id.
715
+
716
+ Security (P0): rejects null bytes / absolute / `..` with 404 (never 403 —
717
+ no oracle). Returns 404 (not 403) on a visibility miss.
718
+
719
+ Rate limit: 120/minute/user.
720
+ """
721
+ # DECISION 1: resolve visibility at the boundary; the use_case enforces (404).
722
+ visible = await get_visible_projects(db, user)
723
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
724
+ try:
725
+ return await uc.graph_resolve(ctx, db, path=path, visible_projects=visible)
726
+ except ServiceError as e:
727
+ raise to_http_legacy(e)
728
+
729
+
730
+ # --- Overview (macro / module LOD) ---
731
+
732
+ @router.get("/overview", response_model=OverviewBundle, summary="KG overview (macro/module LOD)")
733
+ @limiter.limit("6/minute")
734
+ async def graph_overview(
735
+ request: Request,
736
+ level: Literal["macro", "module"] = Query(
737
+ ..., description="Detail level: macro = project nodes, module = module nodes"
738
+ ),
739
+ scope: str | None = Query(
740
+ None,
741
+ max_length=256,
742
+ description="Required for level=module. Format: project:artifact:<slug>",
743
+ ),
744
+ cross_project: bool = Query(
745
+ True,
746
+ description="Include cross-project edges (default: True, D14)",
747
+ ),
748
+ limit: int = Query(300, ge=1, le=300),
749
+ user: UserInfo = Depends(get_current_user_or_agent),
750
+ db: aiosqlite.Connection = Depends(get_db),
751
+ ) -> OverviewBundle:
752
+ """Return graph overview at macro or module level of detail.
753
+
754
+ macro: project:artifact:* nodes + aggregated cross-project edges, capped by degree.
755
+ module: module nodes for a given project scope.
756
+
757
+ RBAC: filter_visible_edges applied (macro) — hidden_cross_project_count returned;
758
+ module-level visibility miss → 404.
759
+ Rate limit: 6/minute/user (expensive).
760
+ """
761
+ # DECISION 1: resolve visibility at the boundary for the module-level RBAC
762
+ # check; the macro path applies filter_visible_edges inside the use_case
763
+ # (needs the full UserInfo, passed through). filter_visible_edges is INJECTED
764
+ # so the overview test can patch it at this namespace (DECISION A).
765
+ visible = await get_visible_projects(db, user)
766
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
767
+ try:
768
+ return await uc.graph_overview(
769
+ ctx, db,
770
+ level=level, scope=scope, cross_project=cross_project, limit=limit,
771
+ user=user, visible_projects=visible,
772
+ filter_visible_edges=filter_visible_edges,
773
+ )
774
+ except ServiceError as e:
775
+ raise to_http_legacy(e)
776
+
777
+
778
+ # --- Orphans ---
779
+
780
+ _ORPHAN_SCOPE_PATTERN = r"^(project|module):artifact:.+$"
781
+
782
+ @router.get("/orphans", response_model=OrphansBundle, summary="KG orphan files (unlinked)")
783
+ @limiter.limit("30/minute")
784
+ async def graph_orphans(
785
+ request: Request,
786
+ scope: str = Query(
787
+ ...,
788
+ max_length=256,
789
+ pattern=_ORPHAN_SCOPE_PATTERN,
790
+ description="Scope to search for orphans: project:artifact:<slug> or module:artifact:<project>/<folder>",
791
+ ),
792
+ user: UserInfo = Depends(get_current_user_or_agent),
793
+ db: aiosqlite.Connection = Depends(get_db),
794
+ ) -> OrphansBundle:
795
+ """Return file nodes with no edges (orphans) within a scope.
796
+
797
+ Uses a LEFT JOIN anti-join — NOT a NOT IN subquery — so the query planner
798
+ can use idx_graph_edges_source_id and idx_graph_edges_target_id.
799
+
800
+ Files grouped by first path segment, capped at 30 per sub-cluster.
801
+ Rate limit: 30/minute/user.
802
+ """
803
+ # DECISION 1: resolve visibility at the boundary; the use_case enforces (404).
804
+ visible = await get_visible_projects(db, user)
805
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
806
+ try:
807
+ return await uc.graph_orphans(ctx, db, scope=scope, visible_projects=visible)
808
+ except ServiceError as e:
809
+ raise to_http_legacy(e)
810
+
811
+
812
+ # --- Cosmo canvas adapter (PR #3) ---
813
+
814
+
815
+ @router.get(
816
+ "/cosmo",
817
+ response_model=GraphCosmoOut,
818
+ summary="Cosmo canvas dataset: project super-nodes + aggregated cross-project edges",
819
+ )
820
+ @limiter.limit("30/minute")
821
+ async def graph_cosmo(
822
+ request: Request,
823
+ user: UserInfo = Depends(get_current_user_or_agent),
824
+ db: aiosqlite.Connection = Depends(get_db),
825
+ ) -> GraphCosmoOut:
826
+ """Bundle per il canvas `/graph`: project super-nodi + aggregated edges.
827
+
828
+ Ogni project ha: degree (count edges uscenti cross-project), satellites
829
+ top-8 per recency (kind derivato dal prefix node_id). Edges scartati se
830
+ uno degli endpoint non e' visibile all'utente. Rate limit 30/min.
831
+
832
+ ``graph_cosmo_service.fetch_cosmo_graph`` is fully self-contained (it does its
833
+ own get_visible_projects internally and takes the raw UserInfo), so this
834
+ handler stays a thin pass-through.
835
+ """
836
+ return await graph_cosmo_service.fetch_cosmo_graph(db, user)