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,793 @@
1
+ """Approve-and-insert saga for phase-1 file ingestion."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ from core.api.db import acquire_write_db
17
+ from core.api.paths import repo_root
18
+ from core.api.services.ingest.embedding_router import embed_and_index
19
+ from core.api.services.ingest.events import broadcast_ingest_changed
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ PROJECTS_ROOT = Path("/data/projects")
24
+ REPO_ROOT = repo_root(__file__)
25
+ SUBPROCESS_TIMEOUT_SEC = 60
26
+
27
+ # Phase 1.5 E6: keep-ref so fire-and-forget background tasks don't get GC'd
28
+ # mid-flight (asyncio.create_task warning). Tasks self-discard on completion.
29
+ _PENDING_ENRICHMENT_TASKS: set[asyncio.Task[None]] = set()
30
+ _NODE_TYPE_ALLOWLIST = {
31
+ "handoff",
32
+ "solution",
33
+ "learning",
34
+ "audit",
35
+ "analysis",
36
+ "research",
37
+ "guide",
38
+ "plan",
39
+ "brainstorm",
40
+ "file",
41
+ # Phase 1.5 E5-fix9: business document taxonomy.
42
+ "policy",
43
+ "contract",
44
+ "transcript",
45
+ "record",
46
+ "report",
47
+ }
48
+ _SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9_.:-]+")
49
+ _COMMIT_SUBJECT_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
50
+ _COMMIT_SUBJECT_MAX_FRAGMENT = 120
51
+ _TEXT_TARGET_SUFFIXES = {".md", ".txt"}
52
+ ProjectType = Literal["work", "code", "system"]
53
+
54
+
55
+ def _project_root(slug: str) -> Path:
56
+ return (PROJECTS_ROOT / slug).resolve()
57
+
58
+
59
+ def _load_project_entry(slug: str) -> tuple[ProjectType, Path | None]:
60
+ """Resolve project type and repo path from project.yaml via the project index.
61
+
62
+ On metadata drift we fail closed as ``work``: no git commit and no external
63
+ embedding unless the separate DB policy says otherwise.
64
+ """
65
+ yaml_path = _project_root(slug) / "project.yaml"
66
+ if yaml_path.exists():
67
+ try:
68
+ import yaml
69
+ from core.api.config import ALLOWED_REPO_PARENTS
70
+
71
+ data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
72
+ if not isinstance(data, dict):
73
+ logger.warning("project.yaml is not a mapping for slug=%s", slug)
74
+ return "work", None
75
+ raw_type = data.get("type", "work")
76
+ project_type: ProjectType = (
77
+ raw_type if raw_type in ("work", "code", "system") else "work"
78
+ )
79
+ raw_repo = data.get("repo_path")
80
+ repo_path = Path(raw_repo).resolve() if raw_repo else None
81
+ if repo_path and not any(
82
+ repo_path.is_relative_to(parent) for parent in ALLOWED_REPO_PARENTS
83
+ ):
84
+ logger.warning("repo_path containment violation for slug=%s: %s", slug, repo_path)
85
+ repo_path = None
86
+ return project_type, repo_path
87
+ except Exception:
88
+ logger.warning(
89
+ "project.yaml parse failed for slug=%s; defaulting to work",
90
+ slug,
91
+ exc_info=True,
92
+ )
93
+ return "work", None
94
+
95
+ try:
96
+ from core.api.routers.projects import _find_project_entry
97
+
98
+ entry = _find_project_entry(slug)
99
+ except Exception:
100
+ logger.warning("project index lookup failed for slug=%s", slug, exc_info=True)
101
+ return "work", None
102
+
103
+ if entry is None:
104
+ logger.warning("project index missing for slug=%s, defaulting to work", slug)
105
+ return "work", None
106
+ if entry.project_type not in ("work", "code", "system"):
107
+ logger.warning(
108
+ "project type unknown for slug=%s type=%r, defaulting to work",
109
+ slug,
110
+ entry.project_type,
111
+ )
112
+ return "work", None
113
+ return entry.project_type, entry.repo_path
114
+
115
+
116
+ def _safe_relative_path(value: str) -> Path:
117
+ rel = Path(value)
118
+ if rel.is_absolute() or ".." in rel.parts:
119
+ raise ValueError(f"Unsafe target path: {value}")
120
+ return rel
121
+
122
+
123
+ def _unique_target_path(project_root: Path, target_folder: str, target_filename: str, sha: str) -> Path:
124
+ folder = _safe_relative_path(target_folder)
125
+ filename = _safe_relative_path(target_filename)
126
+ if len(filename.parts) != 1:
127
+ raise ValueError("target_filename must be a basename")
128
+ target_dir = (project_root / folder).resolve()
129
+ if not target_dir.is_relative_to(project_root):
130
+ raise ValueError("target_folder escapes project root")
131
+ target_dir.mkdir(parents=True, exist_ok=True)
132
+
133
+ candidate = (target_dir / filename.name).resolve()
134
+ if not candidate.is_relative_to(project_root):
135
+ raise ValueError("target path escapes project root")
136
+ if not candidate.exists():
137
+ return candidate
138
+
139
+ stem = candidate.stem
140
+ suffix = candidate.suffix or ".md"
141
+ return target_dir / f"{stem}-{sha[:8]}{suffix}"
142
+
143
+
144
+ def _sidecar_path(path: Path) -> Path:
145
+ return path.with_suffix(".metadata.json")
146
+
147
+
148
+ def _move_sidecar_if_present(source_path: Path, target_path: Path, sha: str) -> None:
149
+ sidecar = _sidecar_path(source_path)
150
+ if not sidecar.exists():
151
+ return
152
+
153
+ target_sidecar = _sidecar_path(target_path)
154
+ if target_sidecar.exists():
155
+ target_sidecar = target_path.with_name(
156
+ f"{target_path.stem}-{sha[:8]}.metadata.json"
157
+ )
158
+ sidecar.replace(target_sidecar)
159
+
160
+
161
+ def _frontmatter_value(value: Any) -> str:
162
+ if isinstance(value, list):
163
+ items = [str(item).strip() for item in value if str(item).strip()]
164
+ return json.dumps(items, ensure_ascii=False)
165
+ return json.dumps(str(value), ensure_ascii=False)
166
+
167
+
168
+ def _classification_frontmatter(classification: dict[str, Any]) -> str:
169
+ lines: list[str] = []
170
+ for key in ("type", "title", "tags"):
171
+ value = classification.get(key)
172
+ if value in (None, "", []):
173
+ continue
174
+ lines.append(f"{key}: {_frontmatter_value(value)}")
175
+ if not lines:
176
+ return ""
177
+ return "---\n" + "\n".join(lines) + "\n---\n\n"
178
+
179
+
180
+ def _looks_like_frontmatter(text: str) -> bool:
181
+ return text.startswith("---\n") or text.startswith("---\r\n")
182
+
183
+
184
+ def _text_artifact_body(extracted_text: str, classification: dict[str, Any]) -> str:
185
+ body = extracted_text.lstrip("\ufeff")
186
+ if _looks_like_frontmatter(body):
187
+ return body
188
+ return f"{_classification_frontmatter(classification)}{body}"
189
+
190
+
191
+ def _should_materialize_extracted_text(
192
+ *,
193
+ source_path: Path,
194
+ target_path: Path,
195
+ extracted_text: str,
196
+ ) -> bool:
197
+ if not extracted_text.strip():
198
+ return False
199
+ if target_path.suffix.lower() not in _TEXT_TARGET_SUFFIXES:
200
+ return False
201
+ return source_path.suffix.lower() != target_path.suffix.lower()
202
+
203
+
204
+ def _write_text_atomic(target_path: Path, text: str) -> None:
205
+ tmp_path = target_path.with_name(f".{target_path.name}.{os.getpid()}.tmp")
206
+ try:
207
+ tmp_path.write_text(text, encoding="utf-8")
208
+ tmp_path.replace(target_path)
209
+ finally:
210
+ tmp_path.unlink(missing_ok=True)
211
+
212
+
213
+ def _materialize_target_file(
214
+ *,
215
+ source_path: Path,
216
+ target_path: Path,
217
+ row,
218
+ classification: dict[str, Any],
219
+ ) -> None:
220
+ """Create the target artifact without confusing source bytes and parser output."""
221
+ extracted_text = str(row["extracted_text"] or "")
222
+ if _should_materialize_extracted_text(
223
+ source_path=source_path,
224
+ target_path=target_path,
225
+ extracted_text=extracted_text,
226
+ ):
227
+ _write_text_atomic(
228
+ target_path,
229
+ _text_artifact_body(extracted_text, classification),
230
+ )
231
+ _move_sidecar_if_present(source_path, target_path, row["sha256"])
232
+ source_path.unlink()
233
+ return
234
+
235
+ source_path.replace(target_path)
236
+ _move_sidecar_if_present(source_path, target_path, row["sha256"])
237
+
238
+
239
+ async def _run_populator(module: str, target_path: Path) -> tuple[bool, str]:
240
+ cmd = [sys.executable, "-m", module, "--incremental", str(target_path)]
241
+ env = {**os.environ, "KG_HOOK_DISABLED": "1"}
242
+ try:
243
+ proc = await asyncio.create_subprocess_exec(
244
+ *cmd,
245
+ cwd=str(REPO_ROOT),
246
+ env=env,
247
+ stdout=asyncio.subprocess.PIPE,
248
+ stderr=asyncio.subprocess.PIPE,
249
+ )
250
+ except OSError as exc:
251
+ return False, str(exc)
252
+
253
+ try:
254
+ stdout_b, stderr_b = await asyncio.wait_for(
255
+ proc.communicate(), timeout=SUBPROCESS_TIMEOUT_SEC
256
+ )
257
+ except asyncio.TimeoutError:
258
+ proc.kill()
259
+ try:
260
+ await asyncio.wait_for(proc.wait(), timeout=5)
261
+ except asyncio.TimeoutError:
262
+ pass
263
+ return False, f"{module} timed out"
264
+
265
+ stdout = stdout_b.decode("utf-8", errors="replace") if stdout_b else ""
266
+ stderr = stderr_b.decode("utf-8", errors="replace") if stderr_b else ""
267
+ if proc.returncode != 0:
268
+ return False, stderr[:500] or stdout[:500] or f"{module} exited {proc.returncode}"
269
+ return True, stdout[:500]
270
+
271
+
272
+ def _commit_subject_fragment(value: str, fallback: str) -> str:
273
+ cleaned = _COMMIT_SUBJECT_CONTROL_RE.sub(" ", str(value))
274
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
275
+ if not cleaned:
276
+ return fallback
277
+ return cleaned[:_COMMIT_SUBJECT_MAX_FRAGMENT]
278
+
279
+
280
+ def _commit_ingested_file(
281
+ *,
282
+ slug: str,
283
+ repo_path: Path | None,
284
+ target_path: Path,
285
+ target_filename: str,
286
+ ) -> bool:
287
+ if repo_path is None:
288
+ logger.info("ingest git commit skipped: project=%s has no repo_path", slug)
289
+ return False
290
+ repo = repo_path.resolve()
291
+ if not (repo / ".git").exists():
292
+ logger.info("ingest git commit skipped: repo_path is not a git repo: %s", repo)
293
+ return False
294
+ if not target_path.is_relative_to(repo):
295
+ logger.info("ingest git commit skipped: target outside repo: %s", target_path)
296
+ return False
297
+
298
+ rel_target = target_path.relative_to(repo)
299
+ env = {
300
+ **os.environ,
301
+ "GIT_AUTHOR_NAME": "ingest-bot",
302
+ "GIT_AUTHOR_EMAIL": "ingest@marvisx",
303
+ "GIT_COMMITTER_NAME": "ingest-bot",
304
+ "GIT_COMMITTER_EMAIL": "ingest@marvisx",
305
+ }
306
+ subprocess.run(
307
+ ["git", "add", "--", str(rel_target)],
308
+ cwd=str(repo),
309
+ env=env,
310
+ check=True,
311
+ timeout=10,
312
+ )
313
+ result = subprocess.run(
314
+ [
315
+ "git",
316
+ "commit",
317
+ "--only",
318
+ "-m",
319
+ "feat(ingest): "
320
+ f"{_commit_subject_fragment(slug, 'project')} "
321
+ f"{_commit_subject_fragment(target_filename, 'file')}",
322
+ "--",
323
+ str(rel_target),
324
+ ],
325
+ cwd=str(repo),
326
+ env=env,
327
+ capture_output=True,
328
+ text=True,
329
+ timeout=30,
330
+ check=False,
331
+ )
332
+ if result.returncode != 0:
333
+ raise RuntimeError(result.stderr.strip()[:500] or "git commit failed")
334
+ logger.info("ingest git commit created: project=%s target=%s", slug, rel_target)
335
+ return True
336
+
337
+
338
+ async def _set_saga_error(ingest_id: str, project_slug: str, message: str) -> None:
339
+ async with acquire_write_db() as db:
340
+ await db.execute(
341
+ """
342
+ UPDATE ingest_pending
343
+ SET status = 'parse_error',
344
+ error_message = ?,
345
+ updated_at = datetime('now')
346
+ WHERE id = ?
347
+ """,
348
+ (message[:1000], ingest_id),
349
+ )
350
+ await db.commit()
351
+ await broadcast_ingest_changed(
352
+ "saga_error",
353
+ ingest_id=ingest_id,
354
+ project_slug=project_slug,
355
+ status="parse_error",
356
+ )
357
+
358
+
359
+ async def compensate_step(ingest_id: str, step_failed: str) -> None:
360
+ """Best-effort compensation for partially written ingest side effects."""
361
+ pattern = f'%"ingest_id":"{ingest_id}"%'
362
+ spaced_pattern = f'%"ingest_id": "{ingest_id}"%'
363
+ async with acquire_write_db() as db:
364
+ if step_failed in {"populate", "kg_edges", "ensure_kg_edge"}:
365
+ await db.execute(
366
+ "DELETE FROM kg_edge_activity WHERE metadata_json LIKE ? OR metadata_json LIKE ?",
367
+ (pattern, spaced_pattern),
368
+ )
369
+ await db.execute(
370
+ "DELETE FROM graph_edges WHERE metadata LIKE ? OR metadata LIKE ?",
371
+ (pattern, spaced_pattern),
372
+ )
373
+ await db.execute(
374
+ "DELETE FROM graph_nodes WHERE metadata LIKE ? OR metadata LIKE ?",
375
+ (pattern, spaced_pattern),
376
+ )
377
+ elif step_failed == "voyage":
378
+ from core.api.services.ingest.retry_voyage import enqueue_voyage_removal
379
+
380
+ await enqueue_voyage_removal(ingest_id)
381
+ await db.execute(
382
+ """
383
+ UPDATE ingest_pending
384
+ SET status = 'parse_error',
385
+ error_message = ?,
386
+ updated_at = datetime('now')
387
+ WHERE id = ?
388
+ """,
389
+ (f"compensated failed ingest step: {step_failed}", ingest_id),
390
+ )
391
+ await db.commit()
392
+
393
+
394
+ def _fallback_node_id(document_type: str, slug: str, target_path: Path) -> str:
395
+ digest = hashlib.sha1(str(target_path).encode("utf-8")).hexdigest()[:12]
396
+ safe_slug = _SAFE_ID_RE.sub("-", slug)
397
+ return f"{document_type}:artifact:{safe_slug}-{digest}"
398
+
399
+
400
+ async def _ensure_kg_edge(
401
+ *,
402
+ ingest_id: str,
403
+ project_slug: str,
404
+ target_path: Path,
405
+ document_type: str,
406
+ classification: dict[str, Any],
407
+ populator_warnings: list[str],
408
+ ) -> str | None:
409
+ """Returns the artifact node id (for downstream Phase 1.5 E6 KG enrichment trigger)."""
410
+ now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
411
+ project_node_id = f"project:artifact:{project_slug}"
412
+ metadata_json = json.dumps(
413
+ {
414
+ "ingest_id": ingest_id,
415
+ "target_path": str(target_path),
416
+ "classification": classification,
417
+ "populator_warnings": populator_warnings,
418
+ },
419
+ ensure_ascii=False,
420
+ )
421
+ candidate_paths = [
422
+ str(target_path),
423
+ str(target_path.relative_to(PROJECTS_ROOT)),
424
+ ]
425
+ if project_slug == "marvisx":
426
+ candidate_paths.append(f"projects/{target_path.relative_to(PROJECTS_ROOT)}")
427
+
428
+ async with acquire_write_db() as db:
429
+ await db.execute(
430
+ """
431
+ INSERT OR IGNORE INTO graph_nodes
432
+ (id, type, name, qualified_name, file_path, metadata, project_id,
433
+ created_at, updated_at, last_seen_at)
434
+ VALUES (?, 'project', ?, ?, NULL, '{}', ?, datetime('now'), datetime('now'), ?)
435
+ """,
436
+ (project_node_id, project_slug, f"project.{project_slug}", project_slug, now_iso),
437
+ )
438
+
439
+ placeholders = ",".join("?" for _ in candidate_paths)
440
+ async with db.execute(
441
+ f"""
442
+ SELECT id, type
443
+ FROM graph_nodes
444
+ WHERE project_id = ?
445
+ AND file_path IN ({placeholders})
446
+ AND deprecated_at IS NULL
447
+ ORDER BY
448
+ CASE
449
+ WHEN id LIKE 'xlsx:artifact:%' THEN 0
450
+ WHEN type != 'file' THEN 1
451
+ ELSE 2
452
+ END
453
+ LIMIT 1
454
+ """,
455
+ (project_slug, *candidate_paths),
456
+ ) as cursor:
457
+ node = await cursor.fetchone()
458
+
459
+ if node is None:
460
+ node_type = document_type if document_type in _NODE_TYPE_ALLOWLIST else "file"
461
+ artifact_node_id = _fallback_node_id(node_type, project_slug, target_path)
462
+ await db.execute(
463
+ """
464
+ INSERT OR IGNORE INTO graph_nodes
465
+ (id, type, name, qualified_name, file_path, metadata, project_id,
466
+ created_at, updated_at, last_seen_at)
467
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), ?)
468
+ """,
469
+ (
470
+ artifact_node_id,
471
+ node_type,
472
+ target_path.name,
473
+ f"{node_type}.{target_path.stem}",
474
+ str(target_path),
475
+ metadata_json,
476
+ project_slug,
477
+ now_iso,
478
+ ),
479
+ )
480
+ else:
481
+ artifact_node_id = node["id"]
482
+
483
+ await db.execute(
484
+ """
485
+ INSERT INTO graph_edges
486
+ (source_id, target_id, relation, confidence, source, metadata,
487
+ source_file, created_at, first_seen_at, last_seen_at, project_id,
488
+ weight, last_touched_at)
489
+ VALUES (?, ?, 'contains', 1.0, 'db', ?, ?, datetime('now'), ?, ?, ?, 1.0, ?)
490
+ ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
491
+ metadata = excluded.metadata,
492
+ last_seen_at = excluded.last_seen_at,
493
+ weight = 1.0,
494
+ last_touched_at = excluded.last_touched_at
495
+ """,
496
+ (
497
+ project_node_id,
498
+ artifact_node_id,
499
+ metadata_json,
500
+ str(target_path),
501
+ now_iso,
502
+ now_iso,
503
+ project_slug,
504
+ now_iso,
505
+ ),
506
+ )
507
+ await db.execute(
508
+ """
509
+ INSERT INTO kg_edge_activity
510
+ (edge_source_id, edge_target_id, edge_relation, event_type,
511
+ source_agent, metadata_json)
512
+ VALUES (?, ?, 'contains', 'ingest_insert', 'pir-ingest', ?)
513
+ """,
514
+ (project_node_id, artifact_node_id, metadata_json),
515
+ )
516
+ await db.commit()
517
+ return artifact_node_id
518
+
519
+
520
+ async def _load_ingest_row(ingest_id: str):
521
+ async with acquire_write_db() as db:
522
+ async with db.execute(
523
+ "SELECT * FROM ingest_pending WHERE id = ?", (ingest_id,)
524
+ ) as cursor:
525
+ return await cursor.fetchone()
526
+
527
+
528
+ def _target_path_from_inserted_row(row) -> Path:
529
+ project_root = _project_root(row["project_slug"])
530
+ classification = json.loads(row["classification_json"] or "{}")
531
+ target_folder = row["target_folder"] or classification.get("target_folder")
532
+ target_filename = row["target_filename"] or classification.get("target_filename")
533
+ if not target_folder or not target_filename:
534
+ raise ValueError("missing target_folder/target_filename")
535
+ folder = _safe_relative_path(str(target_folder))
536
+ filename = _safe_relative_path(str(target_filename))
537
+ if len(filename.parts) != 1:
538
+ raise ValueError("target_filename must be a basename")
539
+ target_path = (project_root / folder / filename.name).resolve()
540
+ if not target_path.is_relative_to(project_root):
541
+ raise ValueError("target path escapes project root")
542
+ return target_path
543
+
544
+
545
+ async def _finish_inserted_row(row) -> None:
546
+ project_slug = row["project_slug"]
547
+ target_path = _target_path_from_inserted_row(row)
548
+ if not target_path.exists():
549
+ raise FileNotFoundError(str(target_path))
550
+
551
+ project_type, _repo_path = _load_project_entry(project_slug)
552
+ classification = json.loads(row["classification_json"] or "{}")
553
+ populator_warnings: list[str] = []
554
+ for module in ("core.scripts.populate_artifacts", "core.scripts.populate_cross_project"):
555
+ ok, message = await _run_populator(module, target_path)
556
+ if not ok:
557
+ populator_warnings.append(f"{module}: {message}")
558
+
559
+ await _ensure_kg_edge(
560
+ ingest_id=row["id"],
561
+ project_slug=project_slug,
562
+ target_path=target_path,
563
+ document_type=str(classification.get("type") or "file"),
564
+ classification=classification,
565
+ populator_warnings=populator_warnings,
566
+ )
567
+ embed_status = await embed_and_index(
568
+ ingest_id=row["id"],
569
+ slug=project_slug,
570
+ target_path=target_path,
571
+ extracted_text=row["extracted_text"],
572
+ document_type=str(classification.get("type") or "file"),
573
+ title=str(classification.get("title") or target_path.stem),
574
+ project_type=project_type,
575
+ )
576
+ logger.info("saga recovery embed: ingest_id=%s status=%s", row["id"], embed_status)
577
+
578
+ error_message = "; ".join(populator_warnings)[:1000] if populator_warnings else None
579
+ async with acquire_write_db() as db:
580
+ await db.execute(
581
+ """
582
+ UPDATE ingest_pending
583
+ SET status = 'done',
584
+ error_message = ?,
585
+ updated_at = datetime('now')
586
+ WHERE id = ?
587
+ AND status = 'inserted'
588
+ """,
589
+ (error_message, row["id"]),
590
+ )
591
+ await db.commit()
592
+ await broadcast_ingest_changed(
593
+ "done",
594
+ ingest_id=row["id"],
595
+ project_slug=project_slug,
596
+ status="done",
597
+ )
598
+
599
+
600
+ async def resume_saga_from_status(
601
+ ingest_id: str,
602
+ prev_status: str,
603
+ timeout: int | None = None,
604
+ ) -> None:
605
+ """Idempotently resume a stale ingest saga row from the live schema states."""
606
+ step_timeout = timeout or int(os.environ.get("INGEST_STEP_TIMEOUT_SECONDS", "60"))
607
+ if prev_status == "approved":
608
+ await asyncio.wait_for(execute_saga(ingest_id), timeout=step_timeout)
609
+ return
610
+ if prev_status == "inserted":
611
+ row = await _load_ingest_row(ingest_id)
612
+ if row is None or row["status"] != "inserted":
613
+ return
614
+ await asyncio.wait_for(_finish_inserted_row(row), timeout=step_timeout)
615
+ return
616
+ raise ValueError(f"unsupported ingest recovery status: {prev_status}")
617
+
618
+
619
+ async def execute_saga(ingest_id: str) -> None:
620
+ row = None
621
+ async with acquire_write_db() as db:
622
+ async with db.execute(
623
+ "SELECT * FROM ingest_pending WHERE id = ?", (ingest_id,)
624
+ ) as cursor:
625
+ row = await cursor.fetchone()
626
+ if row is None or row["status"] != "approved":
627
+ return
628
+ # U3 defense-in-depth: an api_ingress row must never AUTO-insert (no human
629
+ # in the loop) unless its key policy is 'trusted'. This backstop guards
630
+ # AUTO-insert only — explicit human triage approval (basis "approve:<user>")
631
+ # is an insertion authorization and is always honored, so the primary
632
+ # 'open' flow (triage -> human approve -> inserted) works. The parse-time
633
+ # policy gate already enforces this for the auto path; default-deny here.
634
+ basis = row["triage_decision_id"] or ""
635
+ if (
636
+ row["source_kind"] == "api_ingress"
637
+ and row["ingest_policy"] != "trusted"
638
+ and not basis.startswith("approve:")
639
+ ):
640
+ logger.error(
641
+ "saga refused api_ingress auto-insert for row %s: policy=%r is not "
642
+ "'trusted' (basis=%r); open/unknown keys auto-insert only via human triage",
643
+ ingest_id,
644
+ row["ingest_policy"],
645
+ basis,
646
+ )
647
+ return
648
+
649
+ project_slug = row["project_slug"]
650
+ try:
651
+ project_root = _project_root(project_slug)
652
+ project_type, repo_path = _load_project_entry(project_slug)
653
+ logger.info(
654
+ "saga branching: ingest_id=%s project=%s project_type=%s",
655
+ ingest_id,
656
+ project_slug,
657
+ project_type,
658
+ )
659
+ source_path = Path(row["file_path"]).resolve()
660
+ if not source_path.exists():
661
+ raise FileNotFoundError(str(source_path))
662
+ if not source_path.is_relative_to(project_root):
663
+ raise ValueError("source path escapes project root")
664
+
665
+ classification = json.loads(row["classification_json"] or "{}")
666
+ target_folder = row["target_folder"] or classification.get("target_folder")
667
+ target_filename = row["target_filename"] or classification.get("target_filename")
668
+ if not target_folder or not target_filename:
669
+ raise ValueError("missing target_folder/target_filename")
670
+
671
+ target_path = _unique_target_path(
672
+ project_root,
673
+ str(target_folder),
674
+ str(target_filename),
675
+ row["sha256"],
676
+ )
677
+ _materialize_target_file(
678
+ source_path=source_path,
679
+ target_path=target_path,
680
+ row=row,
681
+ classification=classification,
682
+ )
683
+
684
+ commit_created = False
685
+ if project_type in ("code", "system"):
686
+ try:
687
+ commit_created = _commit_ingested_file(
688
+ slug=project_slug,
689
+ repo_path=repo_path,
690
+ target_path=target_path,
691
+ target_filename=target_path.name,
692
+ )
693
+ except Exception as exc:
694
+ logger.warning("ingest git commit blocked: %s", exc)
695
+ await _set_saga_error(ingest_id, project_slug, f"workspace guard: {exc}")
696
+ return
697
+
698
+ async with acquire_write_db() as db:
699
+ await db.execute(
700
+ """
701
+ UPDATE ingest_pending
702
+ SET status = 'inserted',
703
+ target_folder = ?,
704
+ target_filename = ?,
705
+ updated_at = datetime('now')
706
+ WHERE id = ?
707
+ """,
708
+ (
709
+ str(target_path.parent.relative_to(project_root)),
710
+ target_path.name,
711
+ ingest_id,
712
+ ),
713
+ )
714
+ await db.commit()
715
+
716
+ populator_warnings: list[str] = []
717
+ if commit_created:
718
+ await asyncio.sleep(1.5)
719
+ else:
720
+ for module in ("core.scripts.populate_artifacts", "core.scripts.populate_cross_project"):
721
+ ok, message = await _run_populator(module, target_path)
722
+ if not ok:
723
+ populator_warnings.append(f"{module}: {message}")
724
+
725
+ artifact_node_id = await _ensure_kg_edge(
726
+ ingest_id=ingest_id,
727
+ project_slug=project_slug,
728
+ target_path=target_path,
729
+ document_type=str(classification.get("type") or "file"),
730
+ classification=classification,
731
+ populator_warnings=populator_warnings,
732
+ )
733
+ embed_status = await embed_and_index(
734
+ ingest_id=ingest_id,
735
+ slug=project_slug,
736
+ target_path=target_path,
737
+ extracted_text=row["extracted_text"],
738
+ document_type=str(classification.get("type") or "file"),
739
+ title=str(classification.get("title") or target_path.stem),
740
+ project_type=project_type,
741
+ )
742
+ logger.info("saga embed: ingest_id=%s status=%s", ingest_id, embed_status)
743
+
744
+ error_message = "; ".join(populator_warnings)[:1000] if populator_warnings else None
745
+ async with acquire_write_db() as db:
746
+ await db.execute(
747
+ """
748
+ UPDATE ingest_pending
749
+ SET status = 'done',
750
+ error_message = ?,
751
+ updated_at = datetime('now')
752
+ WHERE id = ?
753
+ """,
754
+ (error_message, ingest_id),
755
+ )
756
+ await db.commit()
757
+ await broadcast_ingest_changed(
758
+ "done",
759
+ ingest_id=ingest_id,
760
+ project_slug=project_slug,
761
+ status="done",
762
+ )
763
+ # Phase 1.5 E6: KG edge enrichment background (LLM #2). Fire-and-forget,
764
+ # never raises. Guarded by LLM_KG_ENRICHER_ENABLED env. Cron Phase 2 future
765
+ # re-triggers nodes with kg_enriched_at IS NULL.
766
+ if artifact_node_id and (
767
+ (os.environ.get("LLM_KG_ENRICHER_ENABLED", "false") or "").strip().lower() == "true"
768
+ ):
769
+ from core.api.services.ingest.llm.kg_enricher import enrich_kg_for_node
770
+
771
+ task = asyncio.create_task(enrich_kg_for_node(artifact_node_id))
772
+ _PENDING_ENRICHMENT_TASKS.add(task)
773
+ task.add_done_callback(_PENDING_ENRICHMENT_TASKS.discard)
774
+ except Exception as exc:
775
+ logger.exception("ingest saga failed: id=%s", ingest_id)
776
+ async with acquire_write_db() as db:
777
+ await db.execute(
778
+ """
779
+ UPDATE ingest_pending
780
+ SET status = 'parse_error',
781
+ error_message = ?,
782
+ updated_at = datetime('now')
783
+ WHERE id = ?
784
+ """,
785
+ (str(exc)[:1000], ingest_id),
786
+ )
787
+ await db.commit()
788
+ await broadcast_ingest_changed(
789
+ "saga_error",
790
+ ingest_id=ingest_id,
791
+ project_slug=project_slug,
792
+ status="parse_error",
793
+ )