attocode-code-intel 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 (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,111 @@
1
+ """Utility helpers for ARQ job implementations and enqueuing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _recent_enqueues: dict[str, float] = {}
16
+ _DEDUP_WINDOW_S = 60
17
+
18
+
19
+ async def enqueue_embedding_job(repo_id: str, branch_name: str = "main") -> bool:
20
+ """Enqueue a generate_embeddings job via ARQ.
21
+
22
+ Returns True if enqueued, False if skipped (dedup or no Redis).
23
+ Silently returns False if Redis is not configured — graceful degradation.
24
+ """
25
+ from attocode_intel.config import CodeIntelConfig
26
+
27
+ config = CodeIntelConfig.from_env()
28
+ if not config.redis_url:
29
+ logger.debug("Redis not configured, skipping embedding job enqueue")
30
+ return False
31
+
32
+ # Dedup: skip if same repo+branch was enqueued in last 60s
33
+ key = f"{repo_id}:{branch_name}"
34
+ now = time.monotonic()
35
+ if key in _recent_enqueues and now - _recent_enqueues[key] < _DEDUP_WINDOW_S:
36
+ logger.debug("Skipping duplicate embedding enqueue for %s", key)
37
+ return False
38
+
39
+ try:
40
+ from arq import create_pool
41
+ from arq.connections import RedisSettings
42
+
43
+ pool = await create_pool(RedisSettings.from_dsn(config.redis_url))
44
+ await pool.enqueue_job("generate_embeddings", repo_id, branch_name)
45
+ await pool.close()
46
+ _recent_enqueues[key] = now
47
+ logger.info("Enqueued embedding job for repo %s branch %s", repo_id, branch_name)
48
+ return True
49
+ except Exception:
50
+ logger.warning("Failed to enqueue embedding job", exc_info=True)
51
+ return False
52
+
53
+
54
+ async def get_repo_or_error(
55
+ repo_id: str, session: AsyncSession,
56
+ ):
57
+ """Look up a Repository by ID or return an error dict.
58
+
59
+ Returns (repo, None) on success, or (None, {"error": ...}) on failure.
60
+ """
61
+ from sqlalchemy import select
62
+
63
+ from attocode_intel.db.models import Repository
64
+
65
+ result = await session.execute(
66
+ select(Repository).where(Repository.id == uuid.UUID(repo_id))
67
+ )
68
+ repo = result.scalar_one_or_none()
69
+ if repo is None:
70
+ return None, {"error": f"Repository {repo_id} not found"}
71
+ return repo, None
72
+
73
+
74
+ async def get_branch_or_error(
75
+ repo_id: uuid.UUID, branch_name: str, session: AsyncSession,
76
+ ):
77
+ """Look up a Branch by repo ID and name, or return an error dict.
78
+
79
+ Returns (branch, None) on success, or (None, {"error": ...}) on failure.
80
+ """
81
+ from sqlalchemy import select
82
+
83
+ from attocode_intel.db.models import Branch
84
+
85
+ result = await session.execute(
86
+ select(Branch).where(Branch.repo_id == repo_id, Branch.name == branch_name)
87
+ )
88
+ branch = result.scalar_one_or_none()
89
+ if branch is None:
90
+ return None, {"error": f"Branch {branch_name} not found"}
91
+ return branch, None
92
+
93
+
94
+ async def resolve_repo_credential(repo_id, session: AsyncSession):
95
+ """Fetch and decrypt RepoCredential for a repo.
96
+
97
+ Returns a Credential instance or None if no credential is configured.
98
+ """
99
+ from sqlalchemy import select
100
+
101
+ from attocode_intel.crypto import decrypt_credential
102
+ from attocode_intel.db.models import RepoCredential
103
+ from attocode_intel.git.credentials import Credential
104
+
105
+ result = await session.execute(
106
+ select(RepoCredential).where(RepoCredential.repo_id == repo_id).limit(1)
107
+ )
108
+ repo_cred = result.scalar_one_or_none()
109
+ if repo_cred is None:
110
+ return None
111
+ return Credential(cred_type=repo_cred.cred_type, value=decrypt_credential(repo_cred.encrypted_value))
@@ -0,0 +1,573 @@
1
+ """ARQ job implementations for background processing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime, timezone
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def index_repository(ctx: dict, repo_id: str, branch_name: str = "main") -> dict:
12
+ """Full index of a repository branch.
13
+
14
+ Timeout: 30min, Retries: 2, Priority: medium
15
+ """
16
+ from attocode_intel.db.engine import get_session
17
+ from attocode_intel.db.models import Branch, IndexingJob
18
+ from attocode_intel.pubsub import publish_event
19
+
20
+ logger.info("Starting full index for repo %s branch %s", repo_id, branch_name)
21
+ await publish_event(repo_id, "index.started", {"branch": branch_name})
22
+
23
+ async for session in get_session():
24
+ try:
25
+ from sqlalchemy import select
26
+
27
+ from attocode_intel.workers.job_utils import get_repo_or_error
28
+
29
+ repo, err = await get_repo_or_error(repo_id, session)
30
+ if err:
31
+ return err
32
+
33
+ # Create/update job record
34
+ job = IndexingJob(
35
+ repo_id=repo.id,
36
+ job_type="full_index",
37
+ status="running",
38
+ branch_name=branch_name,
39
+ started_at=datetime.now(timezone.utc),
40
+ )
41
+ session.add(job)
42
+ await session.flush()
43
+
44
+ # Get or create branch
45
+ branch_result = await session.execute(
46
+ select(Branch).where(Branch.repo_id == repo.id, Branch.name == branch_name)
47
+ )
48
+ branch = branch_result.scalar_one_or_none()
49
+ if branch is None:
50
+ branch = Branch(
51
+ repo_id=repo.id,
52
+ name=branch_name,
53
+ is_default=(branch_name == repo.default_branch),
54
+ )
55
+ session.add(branch)
56
+ await session.flush()
57
+
58
+ # Progress callback
59
+ async def on_progress(data: dict) -> None:
60
+ await publish_event(repo_id, "index.progress", data)
61
+
62
+ # Clone if needed, then index
63
+ from attocode_intel.api.deps import get_config
64
+ from attocode_intel.git.manager import GitRepoManager
65
+ from attocode_intel.indexing.full_indexer import FullIndexer
66
+
67
+ config = get_config()
68
+ git_mgr = GitRepoManager(config.git_clone_dir, config.git_ssh_key_path)
69
+
70
+ # Clone the repo if it has a clone_url and hasn't been cloned yet
71
+ if repo.clone_url and not repo.clone_path:
72
+ import asyncio
73
+
74
+ from attocode_intel.workers.job_utils import resolve_repo_credential
75
+
76
+ credential = await resolve_repo_credential(repo.id, session)
77
+ loop = asyncio.get_running_loop()
78
+ clone_path = await loop.run_in_executor(
79
+ None, git_mgr.clone, repo.clone_url, repo_id, credential,
80
+ )
81
+ repo.clone_path = clone_path
82
+ await session.flush()
83
+ logger.info("Cloned %s to %s", repo.clone_url, clone_path)
84
+
85
+ # Detect default branch from cloned repo
86
+ branches = git_mgr.list_branches(repo_id)
87
+ default = next((b for b in branches if b.is_default), None)
88
+ if default and default.name != repo.default_branch:
89
+ repo.default_branch = default.name
90
+ if branch_name == "main" and default.name != "main":
91
+ branch_name = default.name
92
+ branch.name = default.name
93
+ branch.is_default = True
94
+ await session.flush()
95
+
96
+ # Determine the working path for indexing
97
+ work_path = repo.clone_path or repo.local_path
98
+ if not work_path:
99
+ return {"error": "No clone_path or local_path for repo"}
100
+
101
+ # Guard: if the path doesn't exist on this machine, skip
102
+ # (local_path is a client-side path that the server can't access)
103
+ import os
104
+
105
+ if not os.path.exists(work_path):
106
+ logger.warning(
107
+ "Skipping full index for repo %s: path %s not accessible (remote client path?)",
108
+ repo_id, work_path,
109
+ )
110
+ repo.index_status = "pending_client"
111
+ await session.commit()
112
+ return {"skipped": True, "reason": f"Path {work_path} not accessible from server"}
113
+
114
+ # Register path override so git operations use the correct directory
115
+ git_mgr.register_path(repo_id, work_path)
116
+
117
+ # Advisory lock: prevent concurrent indexing of the same branch
118
+ from attocode_intel.indexing.incremental import IncrementalPipeline
119
+
120
+ await IncrementalPipeline.acquire_branch_lock(session, branch.id)
121
+
122
+ indexer = FullIndexer(session, git_mgr, progress_callback=lambda d: None)
123
+
124
+ stats = await indexer.index(repo_id, branch.id, ref=branch_name)
125
+
126
+ # Update records
127
+ branch.last_indexed_at = datetime.now(timezone.utc)
128
+ repo.index_status = "indexed"
129
+ repo.last_indexed_at = datetime.now(timezone.utc)
130
+ job.status = "completed"
131
+ job.completed_at = datetime.now(timezone.utc)
132
+ job.result = stats
133
+
134
+ await session.commit()
135
+ await publish_event(repo_id, "index.completed", stats)
136
+
137
+ # Auto-trigger embedding generation after successful index
138
+ from attocode_intel.workers.job_utils import enqueue_embedding_job
139
+ await enqueue_embedding_job(repo_id, branch_name)
140
+
141
+ return stats
142
+
143
+ except Exception as e:
144
+ logger.exception("Error indexing repo %s", repo_id)
145
+ try:
146
+ repo.index_status = "failed"
147
+ job.status = "failed"
148
+ job.completed_at = datetime.now(timezone.utc)
149
+ job.error = str(e)
150
+ job.result = {"error": str(e)}
151
+ await session.commit()
152
+ except Exception:
153
+ logger.warning("Failed to persist error state for repo %s", repo_id)
154
+ await publish_event(repo_id, "index.failed", {"error": str(e)})
155
+ return {"error": str(e)}
156
+
157
+ return {"error": "No session available"}
158
+
159
+
160
+ async def index_branch_delta(
161
+ ctx: dict,
162
+ repo_id: str,
163
+ branch_name: str,
164
+ from_ref: str,
165
+ to_ref: str,
166
+ ) -> dict:
167
+ """Delta index of changed files between two refs.
168
+
169
+ Timeout: 10min, Retries: 3, Priority: medium
170
+ """
171
+ from attocode_intel.db.engine import get_session
172
+ from attocode_intel.db.models import IndexingJob
173
+ from attocode_intel.pubsub import publish_event
174
+
175
+ logger.info("Starting delta index for repo %s: %s..%s", repo_id, from_ref, to_ref)
176
+ await publish_event(repo_id, "index.started", {"branch": branch_name, "type": "delta"})
177
+
178
+ async for session in get_session():
179
+ try:
180
+ from attocode_intel.workers.job_utils import get_branch_or_error, get_repo_or_error
181
+
182
+ repo, err = await get_repo_or_error(repo_id, session)
183
+ if err:
184
+ return err
185
+
186
+ branch, err = await get_branch_or_error(repo.id, branch_name, session)
187
+ if err:
188
+ return err
189
+
190
+ job = IndexingJob(
191
+ repo_id=repo.id,
192
+ job_type="delta_index",
193
+ status="running",
194
+ branch_name=branch_name,
195
+ started_at=datetime.now(timezone.utc),
196
+ )
197
+ session.add(job)
198
+ await session.flush()
199
+
200
+ from attocode_intel.api.deps import get_config
201
+ from attocode_intel.git.manager import GitRepoManager
202
+ from attocode_intel.indexing.delta_indexer import DeltaIndexer
203
+
204
+ config = get_config()
205
+ git_mgr = GitRepoManager(config.git_clone_dir, config.git_ssh_key_path)
206
+
207
+ # Fetch latest commits for bare clones
208
+ if repo.clone_url and repo.clone_path:
209
+ import asyncio
210
+
211
+ from attocode_intel.workers.job_utils import resolve_repo_credential
212
+
213
+ credential = await resolve_repo_credential(repo.id, session)
214
+ loop = asyncio.get_running_loop()
215
+ await loop.run_in_executor(None, git_mgr.fetch, str(repo.id), credential)
216
+
217
+ # Advisory lock: prevent concurrent indexing of the same branch
218
+ from attocode_intel.indexing.incremental import IncrementalPipeline
219
+
220
+ await IncrementalPipeline.acquire_branch_lock(session, branch.id)
221
+
222
+ indexer = DeltaIndexer(session, git_mgr)
223
+
224
+ stats = await indexer.index(repo_id, branch.id, from_ref, to_ref)
225
+
226
+ branch.head_commit = to_ref
227
+ branch.last_indexed_at = datetime.now(timezone.utc)
228
+ job.status = "completed"
229
+ job.completed_at = datetime.now(timezone.utc)
230
+ job.result = stats
231
+
232
+ await session.commit()
233
+ await publish_event(repo_id, "index.completed", stats)
234
+ return stats
235
+
236
+ except Exception as e:
237
+ logger.exception("Error delta-indexing repo %s", repo_id)
238
+ try:
239
+ job.status = "failed"
240
+ job.completed_at = datetime.now(timezone.utc)
241
+ job.error = str(e)
242
+ job.result = {"error": str(e)}
243
+ await session.commit()
244
+ except Exception:
245
+ logger.warning("Failed to persist error state for delta-index repo %s", repo_id)
246
+ await publish_event(repo_id, "index.failed", {"error": str(e)})
247
+ return {"error": str(e)}
248
+
249
+ return {"error": "No session available"}
250
+
251
+
252
+ async def generate_embeddings(ctx: dict, repo_id: str, branch_name: str = "main") -> dict:
253
+ """Generate embeddings for all files in a branch.
254
+
255
+ Timeout: 20min, Retries: 2, Priority: low
256
+
257
+ Pipeline:
258
+ 1. Resolve branch manifest (all content_shas)
259
+ 2. Check which shas already have embeddings
260
+ 3. For each missing sha: read content, embed, store with vector
261
+ 4. Batch 32 files at a time for memory efficiency
262
+ """
263
+ from attocode_intel.db.engine import get_session
264
+ from attocode_intel.db.models import FileContent
265
+ from attocode_intel.pubsub import publish_event
266
+
267
+ logger.info("Starting embedding generation for repo %s branch %s", repo_id, branch_name)
268
+ await publish_event(repo_id, "embeddings.started", {"branch": branch_name})
269
+
270
+ async for session in get_session():
271
+ try:
272
+ from sqlalchemy import select
273
+
274
+ from attocode_intel.workers.job_utils import get_branch_or_error, get_repo_or_error
275
+
276
+ # 1. Get repo + branch
277
+ repo, err = await get_repo_or_error(repo_id, session)
278
+ if err:
279
+ return err
280
+
281
+ branch, err = await get_branch_or_error(repo.id, branch_name, session)
282
+ if err:
283
+ return err
284
+
285
+ # 2. Create embedding provider
286
+ from attocode_intel.api.deps import get_config
287
+ from attocode_intel._internal.integrations.context.embeddings import create_embedding_provider
288
+
289
+ config = get_config()
290
+ try:
291
+ provider = create_embedding_provider(config.embedding_model)
292
+ except (ImportError, RuntimeError) as exc:
293
+ return {"error": str(exc)}
294
+ if provider.name == "none":
295
+ return {"error": "No embedding provider available", "hint": "Install sentence-transformers or set OPENAI_API_KEY"}
296
+
297
+ dim = provider.dimension()
298
+
299
+ # 3. Ensure vector column exists with the right dimension
300
+ from attocode_intel.storage.embedding_store import (
301
+ EmbeddingStore,
302
+ ensure_vector_columns,
303
+ )
304
+
305
+ await ensure_vector_columns(session, primary_dim=dim)
306
+
307
+ # 4. Resolve branch manifest
308
+ from attocode_intel.storage.branch_overlay import BranchOverlay
309
+
310
+ overlay = BranchOverlay(session)
311
+ manifest = await overlay.resolve_manifest(branch.id)
312
+ all_shas = set(manifest.values())
313
+
314
+ if not all_shas:
315
+ return {"embedded": 0, "skipped": 0, "total": 0, "model": provider.name, "dimension": dim}
316
+
317
+ # 5. Find which SHAs need embedding
318
+ store = EmbeddingStore(session)
319
+ existing_shas = await store.batch_has_embeddings(all_shas, provider.name)
320
+ missing_shas = all_shas - existing_shas
321
+
322
+ embedded_count = 0
323
+ skipped_count = len(existing_shas)
324
+ batch_size = 32
325
+
326
+ # 6. Process in batches
327
+ sha_list = list(missing_shas)
328
+ for i in range(0, len(sha_list), batch_size):
329
+ batch_shas = sha_list[i:i + batch_size]
330
+
331
+ # Read content for this batch
332
+ content_result = await session.execute(
333
+ select(FileContent).where(FileContent.sha256.in_(batch_shas))
334
+ )
335
+ contents = {fc.sha256: fc for fc in content_result.scalars()}
336
+
337
+ texts = []
338
+ shas_in_batch = []
339
+ for sha in batch_shas:
340
+ fc = contents.get(sha)
341
+ if fc is None:
342
+ continue
343
+ try:
344
+ text_content = fc.content.decode("utf-8", errors="replace")
345
+ text_content = text_content.replace('\x00', '')
346
+ except Exception:
347
+ continue
348
+ # Truncate very large files to avoid OOM in embedding model
349
+ if len(text_content) > 100_000:
350
+ text_content = text_content[:100_000]
351
+ texts.append(text_content)
352
+ shas_in_batch.append(sha)
353
+
354
+ if not texts:
355
+ continue
356
+
357
+ # Embed the batch (CPU-bound, run in executor to avoid blocking event loop)
358
+ try:
359
+ import asyncio
360
+ loop = asyncio.get_running_loop()
361
+ vectors = await loop.run_in_executor(None, provider.embed, texts)
362
+ except Exception:
363
+ logger.exception("Embedding failed for batch starting at index %d", i)
364
+ continue
365
+
366
+ # Store embeddings with vectors
367
+ for sha, text_content, vector in zip(shas_in_batch, texts, vectors):
368
+ await store.upsert_embeddings(sha, [{
369
+ "embedding_model": provider.name,
370
+ "chunk_text": text_content[:500], # Store truncated preview
371
+ "chunk_type": "file",
372
+ "vector": vector,
373
+ }])
374
+ embedded_count += 1
375
+
376
+ # Publish progress
377
+ await publish_event(repo_id, "embeddings.progress", {
378
+ "branch": branch_name,
379
+ "embedded": embedded_count,
380
+ "total": len(missing_shas),
381
+ })
382
+
383
+ await session.commit()
384
+
385
+ stats = {
386
+ "embedded": embedded_count,
387
+ "skipped": skipped_count,
388
+ "total": len(all_shas),
389
+ "model": provider.name,
390
+ "dimension": dim,
391
+ }
392
+ await publish_event(repo_id, "embeddings.completed", stats)
393
+ logger.info("Embedding generation complete for repo %s: %s", repo_id, stats)
394
+ return stats
395
+
396
+ except Exception as e:
397
+ logger.exception("Error generating embeddings for repo %s", repo_id)
398
+ await publish_event(repo_id, "embeddings.failed", {"error": str(e)})
399
+ return {"error": str(e)}
400
+
401
+ return {"error": "No session available"}
402
+
403
+
404
+ async def cleanup_stale_branches(ctx: dict) -> dict:
405
+ """Cron job: remove branch overlays for branches that no longer exist.
406
+
407
+ Runs every 6 hours. For local repos: compares DB branches against git.
408
+ For remote repos: cleans up merged branches (merged_at > 7 days ago)
409
+ and inactive branches (no overlay changes and no commits in 30 days).
410
+ """
411
+ from datetime import timedelta
412
+
413
+ from sqlalchemy import func as sa_func
414
+ from sqlalchemy import select
415
+
416
+ from attocode_intel.api.deps import get_config
417
+ from attocode_intel.db.engine import get_session
418
+ from attocode_intel.db.models import Branch, BranchFile, Commit, Repository
419
+ from attocode_intel.git.manager import GitRepoManager
420
+
421
+ logger.info("Running stale branch cleanup")
422
+ cleaned = 0
423
+
424
+ async for session in get_session():
425
+ config = get_config()
426
+ git_mgr = GitRepoManager(config.git_clone_dir, config.git_ssh_key_path)
427
+ now = datetime.now(timezone.utc)
428
+
429
+ result = await session.execute(select(Repository))
430
+ repos = result.scalars().all()
431
+
432
+ for repo in repos:
433
+ if repo.clone_path:
434
+ # Local/cloned repos: compare against git branches
435
+ try:
436
+ git_branches = {b.name for b in git_mgr.list_branches(str(repo.id))}
437
+ except (FileNotFoundError, ValueError):
438
+ continue
439
+
440
+ branch_result = await session.execute(
441
+ select(Branch).where(Branch.repo_id == repo.id)
442
+ )
443
+ for db_branch in branch_result.scalars():
444
+ if db_branch.name not in git_branches and not db_branch.is_default:
445
+ logger.info(
446
+ "Removing stale branch %s from repo %s",
447
+ db_branch.name, repo.id,
448
+ )
449
+ await session.delete(db_branch)
450
+ cleaned += 1
451
+ else:
452
+ # Remote repos: clean up merged + inactive branches
453
+ branch_result = await session.execute(
454
+ select(Branch).where(Branch.repo_id == repo.id)
455
+ )
456
+ for db_branch in branch_result.scalars():
457
+ if db_branch.is_default:
458
+ continue
459
+
460
+ # Clean up merged branches older than retention threshold
461
+ if db_branch.merged_at and (now - db_branch.merged_at) > timedelta(days=config.gc_merged_branch_retention_days):
462
+ logger.info(
463
+ "Removing merged branch %s from repo %s (merged %s)",
464
+ db_branch.name, repo.id, db_branch.merged_at,
465
+ )
466
+ await session.delete(db_branch)
467
+ cleaned += 1
468
+ continue
469
+
470
+ # Clean up inactive branches: no overlay changes AND no commits in retention period
471
+ if not db_branch.merged_at:
472
+ overlay_count_result = await session.execute(
473
+ select(sa_func.count()).select_from(
474
+ select(BranchFile.path)
475
+ .where(BranchFile.branch_id == db_branch.id)
476
+ .subquery()
477
+ )
478
+ )
479
+ overlay_count = overlay_count_result.scalar() or 0
480
+
481
+ if overlay_count == 0:
482
+ cutoff = now - timedelta(days=config.gc_inactive_branch_retention_days)
483
+ recent_commit_result = await session.execute(
484
+ select(sa_func.count()).select_from(
485
+ select(Commit.id)
486
+ .where(
487
+ Commit.repo_id == repo.id,
488
+ Commit.branch_name == db_branch.name,
489
+ Commit.created_at > cutoff,
490
+ )
491
+ .subquery()
492
+ )
493
+ )
494
+ recent_commits = recent_commit_result.scalar() or 0
495
+ if recent_commits == 0:
496
+ logger.info(
497
+ "Removing inactive branch %s from repo %s",
498
+ db_branch.name, repo.id,
499
+ )
500
+ await session.delete(db_branch)
501
+ cleaned += 1
502
+
503
+ await session.commit()
504
+
505
+ logger.info("Stale branch cleanup complete: removed %d branches", cleaned)
506
+ return {"cleaned": cleaned}
507
+
508
+
509
+ async def prune_expired_revocations(ctx: dict) -> dict:
510
+ """Cron job: prune expired token revocations.
511
+
512
+ Runs every 24 hours. Removes entries whose expires_at has passed
513
+ (tokens that are already expired don't need blocklist entries).
514
+ """
515
+ from sqlalchemy import delete
516
+
517
+ from attocode_intel.db.engine import get_session
518
+ from attocode_intel.db.models import RevokedToken
519
+
520
+ logger.info("Running token revocation cleanup")
521
+
522
+ async for session in get_session():
523
+ result = await session.execute(
524
+ delete(RevokedToken).where(
525
+ RevokedToken.expires_at < datetime.now(timezone.utc)
526
+ )
527
+ )
528
+ pruned = result.rowcount
529
+ await session.commit()
530
+ logger.info("Pruned %d expired token revocations", pruned)
531
+ return {"pruned": pruned}
532
+
533
+ return {"pruned": 0}
534
+
535
+
536
+ async def gc_unreferenced_content(ctx: dict) -> dict:
537
+ """Cron job: garbage collect unreferenced file_contents.
538
+
539
+ Runs every 24 hours.
540
+ """
541
+ from attocode_intel.api.deps import get_config
542
+ from attocode_intel.db.engine import get_session
543
+ from attocode_intel.storage.content_store import ContentStore
544
+
545
+ logger.info("Running content GC")
546
+ config = get_config()
547
+ async for session in get_session():
548
+ store = ContentStore(session)
549
+ count = await store.gc_unreferenced(min_age_minutes=config.gc_content_min_age_minutes)
550
+ await session.commit()
551
+ return {"removed": count}
552
+ return {"removed": 0}
553
+
554
+
555
+ async def gc_orphaned_embeddings(ctx: dict) -> dict:
556
+ """Cron job: garbage collect orphaned embeddings.
557
+
558
+ Deletes embeddings whose content_sha is not referenced by any branch manifest.
559
+ Runs daily at 3:30am.
560
+ """
561
+ from attocode_intel.api.deps import get_config
562
+ from attocode_intel.db.engine import get_session
563
+ from attocode_intel.storage.embedding_store import EmbeddingStore
564
+
565
+ logger.info("Running orphaned embeddings GC")
566
+ config = get_config()
567
+ async for session in get_session():
568
+ store = EmbeddingStore(session)
569
+ count = await store.gc_orphaned(min_age_minutes=config.gc_content_min_age_minutes)
570
+ await session.commit()
571
+ logger.info("Orphaned embeddings GC complete: removed %d embeddings", count)
572
+ return {"removed": count}
573
+ return {"removed": 0}