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,1153 @@
1
+ # v1.0.0 - 2026-05-27 - S1 F1.8: graph use_cases extracted from router (Knowledge Graph)
2
+ """Knowledge-Graph use_cases — pure domain logic, transport-agnostic (no ``fastapi``).
3
+
4
+ Sibling of :mod:`core.api.routers.graph` in the S1 "collapse runtime" refactor.
5
+ Each query operation is a pure async function ``(ctx, db, *typed_args) -> <DTO>``;
6
+ the router is a thin adapter that resolves identity, enforces visibility, applies
7
+ rate limits, and maps :class:`ServiceError` -> ``HTTPException``.
8
+
9
+ Four router-specific decisions (deviations / specialisations of the template):
10
+
11
+ DECISION A — kg services that drag ``fastapi`` are kept out of this module.
12
+ ``graph_service`` / ``graph_ranker`` are fastapi-free → module-top imports.
13
+ But ``graph_cosmo_service`` (cosmo) and ``services.share_links``
14
+ (share_function URL) transitively import ``fastapi`` and live entirely in the
15
+ adapter. ``visibility.filter_visible_edges`` (overview macro RBAC) also imports
16
+ ``fastapi`` AND is test-pinned to the ``routers.graph`` namespace
17
+ (``patch("api.routers.graph.filter_visible_edges")`` in
18
+ test_overview_rbac_hides_edges) — so it is INJECTED into ``graph_overview`` as
19
+ a callable resolved at the adapter boundary (DECISION 1 style), never imported
20
+ here. To keep THIS module fastapi-free at import time (the import-linter
21
+ contract + the smoke test assert), no fastapi-importing module is referenced.
22
+
23
+ DECISION B — visibility ENFORCEMENT stays in the adapter (graph deviation from
24
+ the template's DECISION 1). ``get_visible_projects`` is resolved in the
25
+ router (DECISION 1 resolves at the boundary), but graph endpoints enforce
26
+ with DIFFERENT transport outcomes that are part of the API contract and are
27
+ test-pinned to a *plain-string* detail body:
28
+ * project-scoped reads (neighbors/hotspots/impact/context/pattern) →
29
+ ``403 "Project not accessible"`` (test_kg_security_h2_h3 asserts the
30
+ substring "not accessible").
31
+ * oracle-avoidance reads (resolve / overview module / orphans) →
32
+ ``404 "Not found"`` on a visibility miss.
33
+ Routing those through ``ServiceError`` + the structured ``to_http`` body
34
+ would change every response body, and the test patches
35
+ ``api.routers.graph.get_visible_projects`` (so the call must stay in the
36
+ router namespace). The check therefore lives where it fires today; the
37
+ use_case does the pure data work.
38
+
39
+ DECISION C — the deep/share transport layer stays in the adapter.
40
+ ``share_function`` couples a *workspace-share* side effect (signed URL via
41
+ ``share_links`` + ``enforce_workspace_share_role``, on the write pool) with a
42
+ pure KG-context bundle. URL generation + role enforcement are transport
43
+ concerns kept in the adapter; the pure pieces (include parsing, node lookup
44
+ error mapping, preview/hotspot/neighbors/context assembly) live here.
45
+
46
+ DECISION D — domain errors carry the LEGACY plain-string body.
47
+ Today every graph ``HTTPException`` uses a rich human-readable string detail
48
+ (documented in the endpoint docstrings, part of the agent-facing contract),
49
+ NOT the structured ``{code,message}`` shape. The use_case raises
50
+ :class:`ServiceError` whose ``message`` is byte-identical to the legacy
51
+ detail; the adapter re-raises it as ``HTTPException(status, message)`` (the
52
+ ``to_http_legacy`` helper) so bodies are unchanged.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import json as json_mod
57
+ import re
58
+ from datetime import datetime, timezone
59
+
60
+ import aiosqlite
61
+
62
+ from core.api.models.graph import GraphCapabilities, RankType
63
+ from core.api.models.graph_ux import (
64
+ HotspotItem,
65
+ LandingBundle,
66
+ OrphanFile,
67
+ OrphanSubCluster,
68
+ OrphansBundle,
69
+ OverviewBundle,
70
+ OverviewEdge,
71
+ OverviewNode,
72
+ PinOut,
73
+ RecentItem,
74
+ ResolveOut,
75
+ )
76
+ from core.api.services import graph_ranker, graph_service
77
+ from core.api.use_cases._context import CallerContext
78
+ from core.api.use_cases._errors import NotFoundError, ServiceError, ValidationError
79
+
80
+ class _PinWriteFailedError(ServiceError):
81
+ """500 — post-upsert fetch miss (should never happen). Plain-string body."""
82
+
83
+ http_status = 500
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Domain constants (pure — moved verbatim from the router)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ QUALIFIED_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-]+$")
91
+ MAX_QUALIFIED_NAME_LEN = 256
92
+
93
+ SHARE_FUNCTION_INCLUDES: frozenset[str] = frozenset(
94
+ {"preview", "neighbors", "context", "hotspot"}
95
+ )
96
+ DEFAULT_SHARE_FUNCTION_INCLUDE: tuple[str, ...] = (
97
+ "preview", "neighbors", "context", "hotspot"
98
+ )
99
+ PREVIEW_LINES: int = 20
100
+ NEIGHBORS_LIMIT: int = 10
101
+ SHARE_FUNCTION_DEFAULT_HOURS: int = 24
102
+
103
+ # Node ID regex for path params (same as PinIn field pattern)
104
+ _NODE_ID_RE = re.compile(r"^[a-z]+:[a-z]+:.+$")
105
+
106
+ # Orphan sub-cluster color map (deterministic by folder prefix)
107
+ _ORPHAN_COLORS: dict[str, str] = {
108
+ "docs": "#D4A017", # ocra
109
+ "memory": "#7B2D8B", # viola
110
+ "scripts": "#17A2B8", # ciano
111
+ "kb": "#556B2F", # oliva
112
+ "tests": "#FF69B4", # rosa
113
+ "output": "#808080", # grey
114
+ "data": "#20B2AA", # seafoam
115
+ }
116
+ _ORPHAN_COLOR_DEFAULT = "#A0A0A0" # neutral
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Pure helpers (moved verbatim from the router)
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def parse_include_csv(include: str | None) -> list[str]:
125
+ """Parse CSV `include` param into validated list of blocks.
126
+
127
+ Empty / None → default (all four blocks). Unknown tokens → ValueError.
128
+ """
129
+ if include is None or not include.strip():
130
+ return list(DEFAULT_SHARE_FUNCTION_INCLUDE)
131
+ tokens = [t.strip() for t in include.split(",") if t.strip()]
132
+ if not tokens:
133
+ return list(DEFAULT_SHARE_FUNCTION_INCLUDE)
134
+ unknown = [t for t in tokens if t not in SHARE_FUNCTION_INCLUDES]
135
+ if unknown:
136
+ raise ValueError(
137
+ f"Unknown include tokens: {unknown!r}. "
138
+ f"Allowed: {sorted(SHARE_FUNCTION_INCLUDES)}"
139
+ )
140
+ # Preserve order, dedupe.
141
+ seen: set[str] = set()
142
+ out: list[str] = []
143
+ for t in tokens:
144
+ if t not in seen:
145
+ seen.add(t)
146
+ out.append(t)
147
+ return out
148
+
149
+
150
+ def extract_hotspot(node_row: aiosqlite.Row) -> dict:
151
+ """Pull touch counters + top 3 authors from a graph_nodes row."""
152
+ authors_raw = node_row["touch_authors"] if "touch_authors" in node_row.keys() else None
153
+ try:
154
+ authors = json_mod.loads(authors_raw) if authors_raw else []
155
+ except (TypeError, ValueError):
156
+ authors = []
157
+ if not isinstance(authors, list):
158
+ authors = []
159
+ return {
160
+ "touch_count_total": node_row["touch_count_total"] if "touch_count_total" in node_row.keys() else 0,
161
+ "touch_count_30d": node_row["touch_count_30d"] if "touch_count_30d" in node_row.keys() else 0,
162
+ "touch_count_7d": node_row["touch_count_7d"] if "touch_count_7d" in node_row.keys() else 0,
163
+ "touch_last_at": node_row["touch_last_at"] if "touch_last_at" in node_row.keys() else None,
164
+ "top_authors": authors[:3],
165
+ }
166
+
167
+
168
+ def parse_db_datetime(value: str | None) -> datetime:
169
+ """Parse a SQLite datetime string to UTC-aware datetime.
170
+
171
+ SQLite stores timestamps as TEXT in ISO8601 format (various flavors).
172
+ This helper normalises them to UTC-aware Python datetime objects.
173
+ """
174
+ if not value:
175
+ return datetime.now(timezone.utc)
176
+ try:
177
+ # Handle both 'Z' suffix and offset-naive (assumed UTC)
178
+ s = value.replace("Z", "+00:00")
179
+ dt = datetime.fromisoformat(s)
180
+ if dt.tzinfo is None:
181
+ dt = dt.replace(tzinfo=timezone.utc)
182
+ return dt
183
+ except (ValueError, AttributeError):
184
+ return datetime.now(timezone.utc)
185
+
186
+
187
+ def orphan_color(folder: str) -> str:
188
+ """Return deterministic color for a folder based on its prefix."""
189
+ for prefix, color in _ORPHAN_COLORS.items():
190
+ if folder == prefix or folder.startswith(prefix + "/"):
191
+ return color
192
+ return _ORPHAN_COLOR_DEFAULT
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # neighbors / hotspots / impact / context / pattern (read queries)
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ async def graph_neighbors(
201
+ ctx: CallerContext,
202
+ db: aiosqlite.Connection,
203
+ *,
204
+ node_id: str,
205
+ relation: str | None = None,
206
+ edge_types: list[str] | None = None,
207
+ project: str | None = None,
208
+ direction: str = "both",
209
+ limit: int = 50,
210
+ rank: RankType = "none",
211
+ as_of: str | None = None,
212
+ ) -> dict:
213
+ """Return neighbours of a graph node, optionally ranked / time-travelled / cross-project.
214
+
215
+ Visibility (project) is enforced by the adapter (DECISION B). 422 (ValidationError)
216
+ on malformed inputs; 404 (NotFoundError) if the node does not exist at ``as_of``.
217
+ """
218
+ try:
219
+ raw = await graph_service.get_neighbors_with_metadata(
220
+ db,
221
+ node_id=node_id,
222
+ relation=relation,
223
+ direction=direction,
224
+ limit=limit,
225
+ as_of=as_of,
226
+ project=project,
227
+ edge_types=edge_types,
228
+ )
229
+ except ValueError as e:
230
+ raise ValidationError(code="invalid_request", message=str(e))
231
+
232
+ if not raw:
233
+ # Distinguish "node missing" from "node has no matching neighbours".
234
+ # Use temporal-aware existence check so a node that was deprecated
235
+ # pre-`as_of` is still reachable via time-travel.
236
+ try:
237
+ exists = await graph_service.node_exists_at(db, node_id, as_of=as_of)
238
+ except ValueError as e:
239
+ raise ValidationError(code="invalid_request", message=str(e))
240
+ if not exists:
241
+ raise NotFoundError(
242
+ code="node_not_found",
243
+ message=(
244
+ f"Node not found in the knowledge graph: {node_id!r}"
245
+ + (f" (at as_of={as_of!r})" if as_of else "")
246
+ + ". Reason: no row in graph_nodes with this id (and, if as_of was given, "
247
+ "not in the historical state at that timestamp). "
248
+ "Fix: (1) verify node_id format: '{lang}:{type}:{qualified_name}'; "
249
+ "(2) use mcp__pir__search to find similar ids; "
250
+ "(3) if the code was recently added, run scripts/populate_knowledge_graph.py to index it."
251
+ ),
252
+ )
253
+
254
+ response: dict = {
255
+ "node_id": node_id,
256
+ "neighbors": raw,
257
+ "count": len(raw),
258
+ }
259
+ if as_of is not None:
260
+ response["as_of"] = as_of
261
+
262
+ if rank == "none":
263
+ return response
264
+
265
+ ranker_fn = graph_ranker.RANKERS.get(rank)
266
+ if ranker_fn is None:
267
+ # FastAPI's Literal typing already rejects unknown values with a 422,
268
+ # but guard defensively for typos between router/registry.
269
+ raise ValidationError(
270
+ code="unknown_rank",
271
+ message=(
272
+ f"Unknown rank: {rank!r}. "
273
+ f"Allowed values: {sorted(graph_ranker.RANKERS.keys())}. "
274
+ "Fix: pick a registered ranker, or rank='none' to skip ranking."
275
+ ),
276
+ )
277
+
278
+ ranked = await ranker_fn(db, raw)
279
+ response["neighbors"] = ranked
280
+ response["count"] = len(ranked)
281
+ response["rank"] = rank
282
+ return response
283
+
284
+
285
+ async def graph_hotspots(
286
+ ctx: CallerContext,
287
+ db: aiosqlite.Connection,
288
+ *,
289
+ window: str = "30d",
290
+ limit: int = 20,
291
+ type_filter: str = "file",
292
+ project: str | None = None,
293
+ ) -> dict:
294
+ """Top N hotspot nodes ordered by touch count in the chosen window.
295
+
296
+ Visibility enforced by the adapter (DECISION B). 422 on malformed inputs.
297
+ """
298
+ try:
299
+ graph_service.validate_project(project)
300
+ hotspots = await graph_service.get_hotspots(
301
+ db,
302
+ window=window,
303
+ limit=limit,
304
+ type_filter=type_filter,
305
+ project=project,
306
+ )
307
+ except ValueError as e:
308
+ raise ValidationError(code="invalid_request", message=str(e))
309
+
310
+ return {
311
+ "window": window,
312
+ "type_filter": type_filter,
313
+ "project": project,
314
+ "hotspots": hotspots,
315
+ "count": len(hotspots),
316
+ }
317
+
318
+
319
+ async def graph_impact(
320
+ ctx: CallerContext,
321
+ db: aiosqlite.Connection,
322
+ *,
323
+ node_id: str,
324
+ depth: int = 2,
325
+ limit: int = 50,
326
+ edge_types: list[str] | None = None,
327
+ project: str | None = None,
328
+ ) -> dict:
329
+ """Reverse impact analysis for a graph node.
330
+
331
+ Visibility enforced by the adapter (DECISION B). 422 on malformed inputs,
332
+ 404 if the node does not exist.
333
+ """
334
+ try:
335
+ graph_service.validate_node_id(node_id)
336
+ graph_service.validate_project(project)
337
+ graph_service.validate_edge_types(edge_types)
338
+ except ValueError as e:
339
+ raise ValidationError(code="invalid_request", message=str(e))
340
+
341
+ if not await graph_service.node_exists_at(db, node_id):
342
+ raise NotFoundError(
343
+ code="node_not_found",
344
+ message=(
345
+ f"Node not found in the knowledge graph: {node_id!r}. "
346
+ "Reason: no row in graph_nodes with this id. "
347
+ "Fix: (1) verify format '{lang}:{type}:{qualified_name}' "
348
+ "(e.g. 'py:function:api.db.get_db'); "
349
+ "(2) use mcp__pir__search(q=<substring>) to find similar ids; "
350
+ "(3) run scripts/populate_knowledge_graph.py if the code was recently added."
351
+ ),
352
+ )
353
+
354
+ try:
355
+ return await graph_service.graph_impact(
356
+ db, node_id=node_id, depth=depth, limit=limit,
357
+ project=project, edge_types=edge_types,
358
+ )
359
+ except ValueError as e:
360
+ raise ValidationError(code="invalid_request", message=str(e))
361
+
362
+
363
+ async def graph_context(
364
+ ctx: CallerContext,
365
+ db: aiosqlite.Connection,
366
+ *,
367
+ node_id: str,
368
+ per_category_limit: int = 5,
369
+ project: str | None = None,
370
+ ) -> dict:
371
+ """Trace the rationale chain for a node.
372
+
373
+ Visibility enforced by the adapter (DECISION B). 422 on malformed inputs,
374
+ 404 if the node does not exist.
375
+ """
376
+ try:
377
+ graph_service.validate_node_id(node_id)
378
+ graph_service.validate_project(project)
379
+ except ValueError as e:
380
+ raise ValidationError(code="invalid_request", message=str(e))
381
+
382
+ if not await graph_service.node_exists_at(db, node_id):
383
+ raise NotFoundError(
384
+ code="node_not_found",
385
+ message=(
386
+ f"Node not found in the knowledge graph: {node_id!r}. "
387
+ "Reason: no row in graph_nodes with this id. "
388
+ "Fix: (1) verify format '{lang}:{type}:{qualified_name}' "
389
+ "(e.g. 'py:function:api.db.get_db'); "
390
+ "(2) use mcp__pir__search(q=<substring>) to find similar ids; "
391
+ "(3) run scripts/populate_knowledge_graph.py if the code was recently added."
392
+ ),
393
+ )
394
+
395
+ try:
396
+ return await graph_service.graph_context(
397
+ db, node_id=node_id, per_category_limit=per_category_limit,
398
+ project=project,
399
+ )
400
+ except ValueError as e:
401
+ raise ValidationError(code="invalid_request", message=str(e))
402
+
403
+
404
+ async def graph_pattern(
405
+ ctx: CallerContext,
406
+ db: aiosqlite.Connection,
407
+ *,
408
+ scope: str,
409
+ limit: int = 20,
410
+ project: str | None = None,
411
+ ) -> dict:
412
+ """Learnings applicable to a scope (file, module, or specific function).
413
+
414
+ Visibility enforced by the adapter (DECISION B). 422 on malformed inputs.
415
+ """
416
+ try:
417
+ graph_service.validate_project(project)
418
+ return await graph_service.graph_pattern(
419
+ db, scope=scope, limit=limit, project=project,
420
+ )
421
+ except ValueError as e:
422
+ raise ValidationError(code="invalid_request", message=str(e))
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # capabilities — KG schema metadata
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ async def get_capabilities(
431
+ ctx: CallerContext,
432
+ db: aiosqlite.Connection,
433
+ ) -> GraphCapabilities:
434
+ """Returns valid edge_types, node_kinds, node_prefixes + schema_version.
435
+
436
+ Security: mcp_version NOT exposed (no fingerprinting).
437
+ """
438
+ # graph_service is fastapi-free; EDGE_TYPES/NODE_KINDS/NODE_PREFIXES are
439
+ # module constants — imported at module top via ``graph_service``.
440
+ cursor = await db.execute("SELECT MAX(version) FROM schema_versions")
441
+ row = await cursor.fetchone()
442
+ schema_version = row[0] if row and row[0] is not None else None
443
+ return GraphCapabilities(
444
+ edge_types=list(graph_service.EDGE_TYPES),
445
+ node_kinds=list(graph_service.NODE_KINDS),
446
+ node_prefixes=list(graph_service.NODE_PREFIXES),
447
+ schema_version=schema_version,
448
+ )
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # share_function — pure pieces (DECISION C)
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def validate_qualified_name(qualified_name: str) -> str:
457
+ """Validate the share_function qualified_name. Raises ValidationError (422)."""
458
+ if not qualified_name or len(qualified_name) > MAX_QUALIFIED_NAME_LEN:
459
+ raise ValidationError(
460
+ code="invalid_qualified_name",
461
+ message=(
462
+ f"qualified_name too long or empty (got length {len(qualified_name)}, "
463
+ f"max {MAX_QUALIFIED_NAME_LEN}). "
464
+ "Expected: dotted-lowercase Python path like 'api.db.get_write_db'. "
465
+ "Fix: pass a non-empty name under the limit; check for accidentally concatenated values."
466
+ ),
467
+ )
468
+ if not QUALIFIED_NAME_PATTERN.match(qualified_name):
469
+ raise ValidationError(
470
+ code="invalid_qualified_name",
471
+ message=(
472
+ f"Invalid qualified_name: {qualified_name!r}. "
473
+ f"Expected pattern: {QUALIFIED_NAME_PATTERN.pattern} "
474
+ "(dotted-lowercase tokens, e.g. 'api.db.get_db' or 'api.services.graph_service.node_exists'). "
475
+ "Fix: use the Python import path, not a file path — no slashes, no uppercase, no trailing '()'."
476
+ ),
477
+ )
478
+ return qualified_name
479
+
480
+
481
+ async def lookup_function_node(
482
+ db: aiosqlite.Connection,
483
+ *,
484
+ qualified_name: str,
485
+ ) -> aiosqlite.Row:
486
+ """Look up the live ``type='function'`` node row for a qualified_name.
487
+
488
+ Raises NotFoundError (404) when no live node matches or when the matched
489
+ node has no recorded file_path.
490
+ """
491
+ db.row_factory = aiosqlite.Row
492
+ cur = await db.execute(
493
+ "SELECT id, type, name, qualified_name, file_path, line_number, "
494
+ "touch_count_total, touch_count_7d, touch_count_30d, "
495
+ "touch_authors, touch_last_at "
496
+ "FROM graph_nodes "
497
+ "WHERE qualified_name = ? AND type = 'function' "
498
+ "AND deprecated_at IS NULL "
499
+ "LIMIT 1",
500
+ (qualified_name,),
501
+ )
502
+ node_row = await cur.fetchone()
503
+ if node_row is None:
504
+ raise NotFoundError(
505
+ code="function_not_found",
506
+ message=(
507
+ f"Function not found in graph: {qualified_name!r}. "
508
+ "Reason: no graph_nodes row with type='function' + this qualified_name (live view excludes deprecated nodes). "
509
+ "Fix: (1) check for typos in the dotted path (e.g. 'api.db.get_db' not 'api.db.get_DB'); "
510
+ "(2) search alternates with mcp__pir__search(q=<name substring>); "
511
+ "(3) re-index with scripts/populate_knowledge_graph.py if the function is freshly added; "
512
+ "(4) query with as_of=<past timestamp> if the function was recently deprecated."
513
+ ),
514
+ )
515
+ if not node_row["file_path"]:
516
+ raise NotFoundError(
517
+ code="function_no_file_path",
518
+ message=(
519
+ f"Function {qualified_name!r} has no file_path recorded in graph_nodes. "
520
+ "Reason: the node was indexed without a source file (legacy/partial import). "
521
+ "Fix: re-run scripts/populate_knowledge_graph.py with the ast_parser to fill in file_path, "
522
+ "or use mcp__pir__share_file if you already know the file manually."
523
+ ),
524
+ )
525
+ return node_row
526
+
527
+
528
+ async def build_share_function_blocks(
529
+ db: aiosqlite.Connection,
530
+ *,
531
+ node_id: str,
532
+ node_row: aiosqlite.Row,
533
+ include_list: list[str],
534
+ ) -> dict:
535
+ """Assemble the optional KG-context blocks (neighbors/context/hotspot).
536
+
537
+ Pure data assembly (preview is added by the adapter, which owns the disk read
538
+ via ``share_links.validate_repo_path``). 422 on internal graph inconsistency.
539
+ """
540
+ blocks: dict = {}
541
+
542
+ if "neighbors" in include_list:
543
+ try:
544
+ raw = await graph_service.get_neighbors_with_metadata(
545
+ db,
546
+ node_id=node_id,
547
+ direction="incoming",
548
+ limit=NEIGHBORS_LIMIT,
549
+ )
550
+ ranker_fn = graph_ranker.RANKERS.get("suspect_write")
551
+ if ranker_fn is not None:
552
+ ranked = await ranker_fn(db, raw)
553
+ else:
554
+ ranked = raw
555
+ blocks["neighbors"] = {
556
+ "count": len(ranked),
557
+ "rank": "suspect_write",
558
+ "items": ranked,
559
+ }
560
+ except ValueError as e:
561
+ # Internal consistency error — surface rather than swallow.
562
+ raise ValidationError(code="invalid_request", message=str(e))
563
+
564
+ if "context" in include_list:
565
+ try:
566
+ blocks["context"] = await graph_service.graph_context(
567
+ db, node_id=node_id, per_category_limit=5,
568
+ )
569
+ except ValueError as e:
570
+ raise ValidationError(code="invalid_request", message=str(e))
571
+
572
+ if "hotspot" in include_list:
573
+ blocks["hotspot"] = extract_hotspot(node_row)
574
+
575
+ return blocks
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # P2 UX — landing / list_pins / resolve / overview / orphans (read queries)
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ async def graph_landing(
584
+ ctx: CallerContext,
585
+ db: aiosqlite.Connection,
586
+ *,
587
+ workspace_id: str,
588
+ user_id: str,
589
+ hotspots_cached: list[HotspotItem] | None = None,
590
+ recent_cached: list[RecentItem] | None = None,
591
+ pins_cached: list[PinOut] | None = None,
592
+ ) -> tuple[LandingBundle, list[HotspotItem], list[RecentItem], list[PinOut]]:
593
+ """Aggregated landing bundle: top-10 hotspots (30d) + last-20 recent + saved pins.
594
+
595
+ The TTLCache lives in the adapter (transport). The adapter passes any cached
596
+ slices in; this function computes the misses and returns the bundle PLUS the
597
+ (possibly freshly-computed) slices so the adapter can repopulate its cache.
598
+ """
599
+ db.row_factory = aiosqlite.Row
600
+
601
+ # --- Hotspots slice ---
602
+ hotspots = hotspots_cached
603
+ if hotspots is None:
604
+ cur = await db.execute(
605
+ """
606
+ SELECT id, type, name, qualified_name,
607
+ touch_count_30d, touch_authors
608
+ FROM graph_nodes
609
+ WHERE deprecated_at IS NULL
610
+ ORDER BY touch_count_30d DESC, touch_last_at DESC
611
+ LIMIT 10
612
+ """
613
+ )
614
+ rows = await cur.fetchall()
615
+ hotspots = []
616
+ for r in rows:
617
+ try:
618
+ authors = json_mod.loads(r["touch_authors"]) if r["touch_authors"] else []
619
+ except (TypeError, ValueError):
620
+ authors = []
621
+ hotspots.append(HotspotItem(
622
+ node_id=r["id"],
623
+ label=r["qualified_name"] or r["name"],
624
+ kind=r["type"],
625
+ touch_count=r["touch_count_30d"] or 0,
626
+ authors=authors[:3],
627
+ ))
628
+
629
+ # --- Recent artifacts slice ---
630
+ recent = recent_cached
631
+ if recent is None:
632
+ cur = await db.execute(
633
+ """
634
+ SELECT id, type, name, created_at
635
+ FROM graph_nodes
636
+ WHERE type IN ('commit', 'pr', 'task', 'handoff')
637
+ AND deprecated_at IS NULL
638
+ ORDER BY created_at DESC
639
+ LIMIT 20
640
+ """
641
+ )
642
+ rows = await cur.fetchall()
643
+ recent = []
644
+ for r in rows:
645
+ kind_val = r["type"]
646
+ if kind_val not in ("commit", "pr", "task", "handoff"):
647
+ continue
648
+ recent.append(RecentItem(
649
+ kind=kind_val, # type: ignore[arg-type]
650
+ node_id=r["id"],
651
+ label=r["name"],
652
+ at=parse_db_datetime(r["created_at"]),
653
+ ))
654
+
655
+ # --- Pins slice (user-scoped) ---
656
+ saved = pins_cached
657
+ if saved is None:
658
+ cur = await db.execute(
659
+ """
660
+ SELECT p.node_id, p.pinned_at, p.note,
661
+ n.deprecated_at AS node_deprecated_at
662
+ FROM kg_pins p
663
+ JOIN graph_nodes n ON n.id = p.node_id
664
+ WHERE p.user_id = ?
665
+ AND n.deprecated_at IS NULL
666
+ ORDER BY p.pinned_at DESC
667
+ """,
668
+ (user_id,),
669
+ )
670
+ rows = await cur.fetchall()
671
+ saved = [
672
+ PinOut(
673
+ node_id=r["node_id"],
674
+ pinned_at=parse_db_datetime(r["pinned_at"]),
675
+ note=r["note"],
676
+ is_stale=False,
677
+ )
678
+ for r in rows
679
+ ]
680
+
681
+ bundle = LandingBundle(hotspots=hotspots, recent=recent, saved_nodes=saved)
682
+ return bundle, hotspots, recent, saved
683
+
684
+
685
+ async def list_graph_pins(
686
+ ctx: CallerContext,
687
+ db: aiosqlite.Connection,
688
+ *,
689
+ user_id: str,
690
+ ) -> list[PinOut]:
691
+ """List all pins for the current user, ordered by pinned_at DESC.
692
+
693
+ Pins on soft-deleted nodes are excluded (JOIN graph_nodes WHERE deprecated_at IS NULL).
694
+ """
695
+ db.row_factory = aiosqlite.Row
696
+ cur = await db.execute(
697
+ """
698
+ SELECT p.node_id, p.pinned_at, p.note
699
+ FROM kg_pins p
700
+ JOIN graph_nodes n ON n.id = p.node_id
701
+ WHERE p.user_id = ?
702
+ AND n.deprecated_at IS NULL
703
+ ORDER BY p.pinned_at DESC
704
+ """,
705
+ (user_id,),
706
+ )
707
+ rows = await cur.fetchall()
708
+ return [
709
+ PinOut(
710
+ node_id=r["node_id"],
711
+ pinned_at=parse_db_datetime(r["pinned_at"]),
712
+ note=r["note"],
713
+ is_stale=False,
714
+ )
715
+ for r in rows
716
+ ]
717
+
718
+
719
+ async def create_graph_pin(
720
+ ctx: CallerContext,
721
+ db: aiosqlite.Connection,
722
+ *,
723
+ workspace_id: str,
724
+ user_id: str,
725
+ node_id: str,
726
+ note: str | None,
727
+ ) -> PinOut:
728
+ """Upsert a node pin for the current user.
729
+
730
+ Idempotent: POST twice with the same node_id → single row, updates `note`.
731
+ Commits the write (the write pool documents "caller must commit"). The
732
+ adapter is responsible for cache invalidation (transport).
733
+
734
+ 404 (NotFoundError) when the node does not exist / is soft-deleted.
735
+ """
736
+ # Verify the node exists (not soft-deleted)
737
+ db.row_factory = aiosqlite.Row
738
+ cur = await db.execute(
739
+ "SELECT id FROM graph_nodes WHERE id = ? AND deprecated_at IS NULL LIMIT 1",
740
+ (node_id,),
741
+ )
742
+ node_row = await cur.fetchone()
743
+ if node_row is None:
744
+ raise NotFoundError(
745
+ code="node_not_found",
746
+ message=(
747
+ f"Node not found: {node_id!r}. "
748
+ "Either the node does not exist or has been soft-deleted. "
749
+ "Fix: verify node_id format and that the node is in the live graph."
750
+ ),
751
+ )
752
+
753
+ # UPSERT — ON CONFLICT updates note and pinned_at
754
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%f") + "Z"
755
+ await db.execute(
756
+ """
757
+ INSERT INTO kg_pins (workspace_id, user_id, node_id, pinned_at, note)
758
+ VALUES (?, ?, ?, ?, ?)
759
+ ON CONFLICT(workspace_id, user_id, node_id) DO UPDATE SET
760
+ note = excluded.note,
761
+ pinned_at = excluded.pinned_at
762
+ """,
763
+ (workspace_id, user_id, node_id, now, note),
764
+ )
765
+ # get_write_db() documents "caller must commit" — flush WAL immediately so
766
+ # the write is visible to the read-pool without waiting for periodic flush.
767
+ await db.commit()
768
+
769
+ # Fetch back the stored row
770
+ db.row_factory = aiosqlite.Row
771
+ cur = await db.execute(
772
+ "SELECT node_id, pinned_at, note FROM kg_pins WHERE user_id = ? AND node_id = ? LIMIT 1",
773
+ (user_id, node_id),
774
+ )
775
+ row = await cur.fetchone()
776
+ if row is None:
777
+ # Should not happen after a successful upsert. Legacy router raised
778
+ # HTTPException(500, "Pin write succeeded but fetch failed") — preserve
779
+ # the status (500) and plain-string body via a ServiceError subclass.
780
+ raise _PinWriteFailedError(
781
+ code="pin_write_failed",
782
+ message="Pin write succeeded but fetch failed",
783
+ )
784
+
785
+ return PinOut(
786
+ node_id=row["node_id"],
787
+ pinned_at=parse_db_datetime(row["pinned_at"]),
788
+ note=row["note"],
789
+ is_stale=False,
790
+ )
791
+
792
+
793
+ async def delete_graph_pin(
794
+ ctx: CallerContext,
795
+ db: aiosqlite.Connection,
796
+ *,
797
+ user_id: str,
798
+ node_id: str,
799
+ ) -> dict:
800
+ """Remove a pin for the current user.
801
+
802
+ 422 (ValidationError) on malformed node_id; 404 (NotFoundError) if the pin
803
+ does not exist for this user. Commits the delete. The adapter invalidates
804
+ the pins cache (transport).
805
+ """
806
+ # Validate node_id format
807
+ if not _NODE_ID_RE.match(node_id):
808
+ raise ValidationError(
809
+ code="invalid_node_id",
810
+ message=f"Invalid node_id format: {node_id!r}. Expected pattern: prefix:kind:slug.",
811
+ )
812
+
813
+ # Check existence first (to return proper 404)
814
+ db.row_factory = aiosqlite.Row
815
+ cur = await db.execute(
816
+ "SELECT id FROM kg_pins WHERE user_id = ? AND node_id = ? LIMIT 1",
817
+ (user_id, node_id),
818
+ )
819
+ existing = await cur.fetchone()
820
+ if existing is None:
821
+ raise NotFoundError(
822
+ code="pin_not_found",
823
+ message=f"Pin not found for node {node_id!r} by this user.",
824
+ )
825
+
826
+ await db.execute(
827
+ "DELETE FROM kg_pins WHERE user_id = ? AND node_id = ?",
828
+ (user_id, node_id),
829
+ )
830
+ # get_write_db() documents "caller must commit" — flush WAL immediately so
831
+ # the deletion is visible to the read-pool without waiting for periodic flush.
832
+ await db.commit()
833
+
834
+ return {"deleted": True, "node_id": node_id}
835
+
836
+
837
+ async def graph_resolve(
838
+ ctx: CallerContext,
839
+ db: aiosqlite.Connection,
840
+ *,
841
+ path: str,
842
+ visible_projects: set[str] | None,
843
+ ) -> ResolveOut:
844
+ """Resolve a file path to its graph_nodes id.
845
+
846
+ Security (P0):
847
+ - Rejects null bytes, absolute paths, and `..` path components with 404
848
+ (never 403 — avoid oracle).
849
+ - Returns 404 (not 403) on visibility miss (no oracle).
850
+
851
+ ``visible_projects`` is resolved at the adapter boundary (DECISION 1):
852
+ ``None`` means unrestricted (local/agent-bypass). All misses → 404 NotFoundError.
853
+ """
854
+ # --- Path security validation (mirrors finder._validate_path) ---
855
+ if "\x00" in path:
856
+ raise NotFoundError(code="not_found", message="Not found")
857
+ if path.startswith("/"):
858
+ raise NotFoundError(code="not_found", message="Not found")
859
+ parts = path.replace("\\", "/").split("/")
860
+ if ".." in parts:
861
+ raise NotFoundError(code="not_found", message="Not found")
862
+
863
+ # --- Lookup via file_path column (AST parser writes path here, not metadata.path) ---
864
+ db.row_factory = aiosqlite.Row
865
+ cur = await db.execute(
866
+ """
867
+ SELECT id, type, project_id
868
+ FROM graph_nodes
869
+ WHERE type = 'file'
870
+ AND file_path = ?
871
+ AND deprecated_at IS NULL
872
+ LIMIT 1
873
+ """,
874
+ (path,),
875
+ )
876
+ row = await cur.fetchone()
877
+ if row is None:
878
+ raise NotFoundError(code="not_found", message="Not found")
879
+
880
+ # --- RBAC visibility check (404, not 403) ---
881
+ project_id = row["project_id"]
882
+ if project_id and visible_projects is not None and project_id not in visible_projects:
883
+ raise NotFoundError(code="not_found", message="Not found")
884
+
885
+ return ResolveOut(node_id=row["id"], kind=row["type"])
886
+
887
+
888
+ async def graph_overview(
889
+ ctx: CallerContext,
890
+ db: aiosqlite.Connection,
891
+ *,
892
+ level: str,
893
+ scope: str | None,
894
+ cross_project: bool,
895
+ limit: int,
896
+ user,
897
+ visible_projects: set[str] | None,
898
+ filter_visible_edges,
899
+ ) -> OverviewBundle:
900
+ """Return graph overview at macro or module level of detail.
901
+
902
+ ``user`` is the raw UserInfo (passed through for ``filter_visible_edges``,
903
+ which needs ``UserInfo.teams``). ``filter_visible_edges`` is the RBAC edge
904
+ filter callable, INJECTED by the adapter (DECISION A): it imports fastapi and
905
+ is test-pinned to the ``routers.graph`` namespace, so the use_case never
906
+ imports it. ``visible_projects`` is resolved at the boundary for the
907
+ module-level RBAC check (DECISION 1). 422 on malformed inputs; 404 on a
908
+ module visibility miss (oracle-avoidance).
909
+ """
910
+ if level == "module" and not scope:
911
+ raise ValidationError(
912
+ code="scope_required",
913
+ message="scope is required for level=module. Format: project:artifact:<slug>",
914
+ )
915
+
916
+ db.row_factory = aiosqlite.Row
917
+ hidden_count = 0
918
+
919
+ if level == "macro":
920
+ # Fetch project hub nodes (top-K by degree)
921
+ cur = await db.execute(
922
+ """
923
+ SELECT id, type, name, qualified_name, metadata, degree
924
+ FROM graph_nodes
925
+ WHERE type = 'project'
926
+ AND deprecated_at IS NULL
927
+ ORDER BY degree DESC
928
+ LIMIT ?
929
+ """,
930
+ (limit,),
931
+ )
932
+ node_rows = await cur.fetchall()
933
+
934
+ nodes: list[OverviewNode] = []
935
+ node_ids: set[str] = set()
936
+ for r in node_rows:
937
+ try:
938
+ meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
939
+ except (TypeError, ValueError):
940
+ meta = {}
941
+ nodes.append(OverviewNode(
942
+ id=r["id"],
943
+ type=r["type"],
944
+ label=r["qualified_name"] or r["name"],
945
+ sub_nodes=None,
946
+ metadata=meta,
947
+ ))
948
+ node_ids.add(r["id"])
949
+
950
+ # Fetch edges between these project nodes
951
+ if node_ids:
952
+ placeholders = ",".join("?" * len(node_ids))
953
+ id_list = list(node_ids)
954
+ cur = await db.execute(
955
+ f"""
956
+ SELECT e.source_id, e.target_id, e.relation, COUNT(*) AS weight,
957
+ sn.project_id AS source_project, tn.project_id AS target_project
958
+ FROM graph_edges e
959
+ JOIN graph_nodes sn ON sn.id = e.source_id
960
+ JOIN graph_nodes tn ON tn.id = e.target_id
961
+ WHERE e.source_id IN ({placeholders})
962
+ AND e.target_id IN ({placeholders})
963
+ GROUP BY e.source_id, e.target_id, e.relation
964
+ """,
965
+ id_list + id_list,
966
+ )
967
+ raw_edges = [dict(r) for r in await cur.fetchall()]
968
+ else:
969
+ raw_edges = []
970
+
971
+ # DECISION A: filter_visible_edges (api.visibility) imports fastapi and is
972
+ # test-pinned to the routers.graph namespace, so it is injected by the
973
+ # adapter — this module never imports it.
974
+ # Apply RBAC filter
975
+ if cross_project:
976
+ filtered_edges, hidden_count = await filter_visible_edges(db, user, raw_edges)
977
+ else:
978
+ # Filter to only intra-project edges (same project_id source/target)
979
+ filtered_edges = [
980
+ e for e in raw_edges
981
+ if e.get("source_project") == e.get("target_project")
982
+ ]
983
+ # RBAC on top
984
+ filtered_edges, hidden_count = await filter_visible_edges(db, user, filtered_edges)
985
+
986
+ edges = [
987
+ OverviewEdge(
988
+ source=e["source_id"],
989
+ target=e["target_id"],
990
+ relation=e["relation"],
991
+ weight=e.get("weight", 1),
992
+ )
993
+ for e in filtered_edges
994
+ ]
995
+
996
+ return OverviewBundle(
997
+ level="macro",
998
+ scope=None,
999
+ nodes=nodes,
1000
+ edges=edges,
1001
+ hidden_cross_project_count=hidden_count,
1002
+ )
1003
+
1004
+ else: # level == "module"
1005
+ # Extract project slug from scope (project:artifact:<slug>)
1006
+ parts = scope.split(":", 2) if scope else []
1007
+ if len(parts) < 3 or parts[0] != "project":
1008
+ raise ValidationError(
1009
+ code="invalid_scope",
1010
+ message=f"Invalid scope format: {scope!r}. Expected: project:artifact:<slug>",
1011
+ )
1012
+ project_slug = parts[2]
1013
+
1014
+ # RBAC check for this project (404 oracle-avoidance)
1015
+ if visible_projects is not None and project_slug not in visible_projects:
1016
+ raise NotFoundError(code="not_found", message="Not found")
1017
+
1018
+ # Fetch file nodes for this project, group by first path segment
1019
+ cur = await db.execute(
1020
+ """
1021
+ SELECT id, type, name, qualified_name, metadata
1022
+ FROM graph_nodes
1023
+ WHERE type = 'file'
1024
+ AND project_id = ?
1025
+ AND deprecated_at IS NULL
1026
+ ORDER BY degree DESC
1027
+ LIMIT ?
1028
+ """,
1029
+ (project_slug, limit),
1030
+ )
1031
+ file_rows = await cur.fetchall()
1032
+
1033
+ # Build module nodes by grouping on first path segment
1034
+ module_map: dict[str, list] = {}
1035
+ for r in file_rows:
1036
+ try:
1037
+ meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
1038
+ except (TypeError, ValueError):
1039
+ meta = {}
1040
+ path_val = meta.get("path", r["name"] or "")
1041
+ first_seg = path_val.split("/")[0] if "/" in path_val else path_val
1042
+ if first_seg not in module_map:
1043
+ module_map[first_seg] = []
1044
+ module_map[first_seg].append(r)
1045
+
1046
+ nodes = []
1047
+ for folder, file_list in module_map.items():
1048
+ node_id = f"module:artifact:{project_slug}/{folder}"
1049
+ nodes.append(OverviewNode(
1050
+ id=node_id,
1051
+ type="module",
1052
+ label=folder,
1053
+ sub_nodes=len(file_list),
1054
+ metadata={"folder": folder, "project": project_slug},
1055
+ ))
1056
+
1057
+ return OverviewBundle(
1058
+ level="module",
1059
+ scope=scope,
1060
+ nodes=nodes,
1061
+ edges=[], # module-level edges are computed on demand (P3/P4)
1062
+ hidden_cross_project_count=0,
1063
+ )
1064
+
1065
+
1066
+ async def graph_orphans(
1067
+ ctx: CallerContext,
1068
+ db: aiosqlite.Connection,
1069
+ *,
1070
+ scope: str,
1071
+ visible_projects: set[str] | None,
1072
+ ) -> OrphansBundle:
1073
+ """Return file nodes with no edges (orphans) within a scope.
1074
+
1075
+ Uses a LEFT JOIN anti-join so the planner can use the source/target indexes.
1076
+ Files grouped by first path segment, capped at 30 per sub-cluster.
1077
+
1078
+ ``visible_projects`` resolved at the boundary (DECISION 1); 404 on miss
1079
+ (oracle-avoidance). 422 on malformed scope (defence-in-depth; the router's
1080
+ Query pattern also gates it).
1081
+ """
1082
+ # Extract project_id from scope
1083
+ parts = scope.split(":", 2)
1084
+ if len(parts) < 3:
1085
+ raise ValidationError(code="invalid_scope", message=f"Invalid scope: {scope!r}")
1086
+
1087
+ project_slug = parts[2].split("/")[0] if "/" in parts[2] else parts[2]
1088
+
1089
+ # RBAC: check project visibility (404 oracle-avoidance)
1090
+ if visible_projects is not None and project_slug not in visible_projects:
1091
+ raise NotFoundError(code="not_found", message="Not found")
1092
+
1093
+ db.row_factory = aiosqlite.Row
1094
+
1095
+ # LEFT JOIN anti-join query — must use indexed columns source_id / target_id
1096
+ cur = await db.execute(
1097
+ """
1098
+ SELECT n.id, n.name, n.qualified_name, n.metadata, n.updated_at
1099
+ FROM graph_nodes n
1100
+ LEFT JOIN graph_edges e1 ON e1.source_id = n.id
1101
+ LEFT JOIN graph_edges e2 ON e2.target_id = n.id
1102
+ WHERE n.type = 'file'
1103
+ AND e1.source_id IS NULL
1104
+ AND e2.target_id IS NULL
1105
+ AND n.deprecated_at IS NULL
1106
+ AND n.project_id = ?
1107
+ ORDER BY n.name
1108
+ """,
1109
+ (project_slug,),
1110
+ )
1111
+ rows = await cur.fetchall()
1112
+
1113
+ # Group by first path segment
1114
+ cluster_map: dict[str, list[dict]] = {}
1115
+ for r in rows:
1116
+ try:
1117
+ meta = json_mod.loads(r["metadata"]) if r["metadata"] else {}
1118
+ except (TypeError, ValueError):
1119
+ meta = {}
1120
+ path_val = meta.get("path", r["name"] or "")
1121
+ folder = path_val.split("/")[0] if "/" in path_val else "(root)"
1122
+ if folder not in cluster_map:
1123
+ cluster_map[folder] = []
1124
+ cluster_map[folder].append({
1125
+ "node_id": r["id"],
1126
+ "label": r["qualified_name"] or r["name"],
1127
+ "path": path_val,
1128
+ "last_modified": r["updated_at"],
1129
+ })
1130
+
1131
+ # Build sub-clusters with 30-cap
1132
+ sub_clusters: list[OrphanSubCluster] = []
1133
+ for folder, file_list in sorted(cluster_map.items()):
1134
+ total = len(file_list)
1135
+ capped = file_list[:30]
1136
+ overflow = total - len(capped)
1137
+ sub_clusters.append(OrphanSubCluster(
1138
+ folder=folder,
1139
+ color=orphan_color(folder),
1140
+ count=total,
1141
+ files=[
1142
+ OrphanFile(
1143
+ node_id=f["node_id"],
1144
+ label=f["label"],
1145
+ path=f["path"],
1146
+ last_modified=parse_db_datetime(f["last_modified"]) if f["last_modified"] else None,
1147
+ )
1148
+ for f in capped
1149
+ ],
1150
+ overflow_count=overflow,
1151
+ ))
1152
+
1153
+ return OrphansBundle(scope=scope, sub_clusters=sub_clusters)