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,624 @@
1
+ """Snapshot / restore MCP tools — portable code-intel state bundles.
2
+
3
+ Phase 2a ships the local snapshot surface. A snapshot is a ``.tar.gz``
4
+ under ``.attocode/snapshots/`` containing:
5
+
6
+ - ``manifest.json`` at the archive root (see :data:`MANIFEST_SCHEMA_VERSION`
7
+ below) listing every component + its SHA-256.
8
+ - ``stores/<name>`` — live-consistent copies of every local DB
9
+ (SQLite backup API) plus the trigram binary files.
10
+ - ``cache_manifest.json`` — the project's current cache manifest.
11
+
12
+ Restore uses an atomic ``.attocode.staging/`` → ``rename`` dance so a
13
+ failed extraction cannot corrupt the live store.
14
+
15
+ This is the **local-only** form — it does not yet push to an OCI
16
+ registry or the HTTP server. Phase 3 will add those paths on top of the
17
+ same manifest format, so the snapshot file produced here is forward
18
+ compatible.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import shutil
27
+ import sqlite3
28
+ import tarfile
29
+ import tempfile
30
+ import time
31
+ from dataclasses import dataclass
32
+ from typing import Any
33
+
34
+ from attocode_intel._shared import _get_project_dir, mcp
35
+ from attocode_intel.artifacts.hashing import sha256_file
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ MANIFEST_SCHEMA_VERSION = 1
40
+ SNAPSHOT_SUFFIX = ".atsnap.tar.gz"
41
+ SNAPSHOT_DIR_NAME = "snapshots"
42
+ # Local snapshot operations append audit entries to a per-project
43
+ # JSONL log so create/restore/delete events are traceable after the
44
+ # fact. The server side already writes to ``audit_events`` via
45
+ # ``log_event``; this is the equivalent for the local stdio MCP.
46
+ SNAPSHOT_AUDIT_LOG = os.path.join("cache", "snapshot_events.jsonl")
47
+
48
+ # Everything under .attocode/ that's worth capturing. Each tuple is
49
+ # (source relative path, archive relative path under "stores/").
50
+ _SQLITE_STORES: tuple[tuple[str, str], ...] = (
51
+ ("index/symbols.db", "symbols.db"),
52
+ ("vectors/embeddings.db", "embeddings.db"),
53
+ ("index/kw_index.db", "kw_index.db"),
54
+ ("cache/memory.db", "memory.db"),
55
+ ("adrs.db", "adrs.db"),
56
+ ("frecency/frecency.db", "frecency.db"),
57
+ ("query_history/query_history.db", "query_history.db"),
58
+ )
59
+
60
+ # Binary files that we copy as-is (no SQLite backup dance).
61
+ _BINARY_FILES: tuple[tuple[str, str], ...] = (
62
+ ("index/trigrams.lookup", "trigrams.lookup"),
63
+ ("index/trigrams.postings", "trigrams.postings"),
64
+ ("index/trigrams.db", "trigrams.db"),
65
+ )
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+
74
+ def _snapshots_dir(project_dir: str) -> str:
75
+ return os.path.join(project_dir, ".attocode", SNAPSHOT_DIR_NAME)
76
+
77
+
78
+ def _append_audit_event(project_dir: str, event_type: str, detail: dict[str, Any]) -> None:
79
+ """Append one JSONL entry to the local snapshot audit log.
80
+
81
+ Best-effort: any write failure is logged and swallowed so a broken
82
+ audit path never blocks a successful snapshot operation.
83
+ """
84
+ log_path = os.path.join(project_dir, ".attocode", SNAPSHOT_AUDIT_LOG)
85
+ try:
86
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
87
+ entry = {
88
+ "event_type": event_type,
89
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
90
+ "detail": detail,
91
+ }
92
+ with open(log_path, "a", encoding="utf-8") as f:
93
+ f.write(json.dumps(entry, sort_keys=True))
94
+ f.write("\n")
95
+ except OSError as exc:
96
+ logger.debug("snapshot audit log append failed: %s", exc)
97
+
98
+
99
+ def _attocode_dir(project_dir: str) -> str:
100
+ return os.path.join(project_dir, ".attocode")
101
+
102
+
103
+ def _backup_sqlite(source: str, dest: str) -> None:
104
+ """Use the SQLite backup API for a consistent live copy.
105
+
106
+ Copying a SQLite database file while another connection has it open
107
+ can produce a corrupt snapshot. The backup API serializes reads on
108
+ the source and writes a consistent image to the destination even if
109
+ the source is actively being mutated.
110
+ """
111
+ src_conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
112
+ try:
113
+ dest_conn = sqlite3.connect(dest)
114
+ try:
115
+ src_conn.backup(dest_conn)
116
+ finally:
117
+ dest_conn.close()
118
+ finally:
119
+ src_conn.close()
120
+
121
+
122
+ def _fmt_bytes(n: int) -> str:
123
+ for unit in ("B", "KB", "MB", "GB"):
124
+ if n < 1024:
125
+ return f"{n}{unit}" if unit == "B" else f"{n:.1f}{unit}"
126
+ n //= 1024
127
+ return f"{n}TB"
128
+
129
+
130
+ def _list_snapshot_files(project_dir: str) -> list[str]:
131
+ sdir = _snapshots_dir(project_dir)
132
+ if not os.path.isdir(sdir):
133
+ return []
134
+ return sorted(
135
+ os.path.join(sdir, f)
136
+ for f in os.listdir(sdir)
137
+ if f.endswith(SNAPSHOT_SUFFIX)
138
+ )
139
+
140
+
141
+ def _resolve_snapshot(project_dir: str, name: str) -> str | None:
142
+ """Resolve ``name`` to a snapshot path. Accepts bare names, base names,
143
+ and full paths."""
144
+ if os.path.isabs(name) and os.path.exists(name):
145
+ return name
146
+ sdir = _snapshots_dir(project_dir)
147
+ candidates = [
148
+ os.path.join(sdir, name),
149
+ os.path.join(sdir, name + SNAPSHOT_SUFFIX),
150
+ ]
151
+ # Also allow matching any existing snapshot whose basename starts with name.
152
+ for f in _list_snapshot_files(project_dir):
153
+ if os.path.basename(f).startswith(name):
154
+ candidates.append(f)
155
+ for c in candidates:
156
+ if os.path.exists(c):
157
+ return c
158
+ return None
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class _Component:
163
+ name: str
164
+ archive_path: str # relative path inside the tar
165
+ source_path: str
166
+ size_bytes: int
167
+ digest: str # sha256 of the staged copy (not the live file)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Build manifest + staging
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _stage_snapshot(
176
+ project_dir: str,
177
+ staging_dir: str,
178
+ include_patterns: set[str] | None,
179
+ ) -> list[_Component]:
180
+ """Copy every store (SQLite via backup, binaries via shutil) into
181
+ ``staging_dir/stores/`` and return a list of :class:`_Component`.
182
+
183
+ ``include_patterns`` is a set of component logical names (e.g.
184
+ ``{"symbols", "embeddings"}``). Empty / None means "include everything".
185
+ """
186
+ attocode = _attocode_dir(project_dir)
187
+ stores_dir = os.path.join(staging_dir, "stores")
188
+ os.makedirs(stores_dir, exist_ok=True)
189
+ components: list[_Component] = []
190
+
191
+ def _include(name: str) -> bool:
192
+ if not include_patterns:
193
+ return True
194
+ return name in include_patterns
195
+
196
+ # SQLite stores.
197
+ for src_rel, out_name in _SQLITE_STORES:
198
+ logical = out_name.split(".")[0] # "symbols.db" → "symbols"
199
+ if not _include(logical):
200
+ continue
201
+ src = os.path.join(attocode, src_rel)
202
+ if not os.path.exists(src):
203
+ continue
204
+ dest = os.path.join(stores_dir, out_name)
205
+ try:
206
+ _backup_sqlite(src, dest)
207
+ except sqlite3.Error as exc:
208
+ logger.warning(
209
+ "snapshot: SQLite backup of %s failed (%s); falling back to file copy",
210
+ src, exc,
211
+ )
212
+ shutil.copy2(src, dest)
213
+ size = os.path.getsize(dest)
214
+ components.append(_Component(
215
+ name=logical,
216
+ archive_path=f"stores/{out_name}",
217
+ source_path=src_rel,
218
+ size_bytes=size,
219
+ digest=sha256_file(dest),
220
+ ))
221
+
222
+ # Binary / non-SQLite files (trigrams).
223
+ if _include("trigrams"):
224
+ for src_rel, out_name in _BINARY_FILES:
225
+ src = os.path.join(attocode, src_rel)
226
+ if not os.path.exists(src):
227
+ continue
228
+ dest = os.path.join(stores_dir, out_name)
229
+ shutil.copy2(src, dest)
230
+ components.append(_Component(
231
+ name="trigrams",
232
+ archive_path=f"stores/{out_name}",
233
+ source_path=src_rel,
234
+ size_bytes=os.path.getsize(dest),
235
+ digest=sha256_file(dest),
236
+ ))
237
+
238
+ # cache_manifest.json (top level of the archive)
239
+ mpath = os.path.join(attocode, "cache_manifest.json")
240
+ if os.path.exists(mpath):
241
+ dest = os.path.join(staging_dir, "cache_manifest.json")
242
+ shutil.copy2(mpath, dest)
243
+ components.append(_Component(
244
+ name="cache_manifest",
245
+ archive_path="cache_manifest.json",
246
+ source_path="cache_manifest.json",
247
+ size_bytes=os.path.getsize(dest),
248
+ digest=sha256_file(dest),
249
+ ))
250
+
251
+ return components
252
+
253
+
254
+ def _write_manifest(
255
+ staging_dir: str,
256
+ *,
257
+ project_dir: str,
258
+ name: str,
259
+ components: list[_Component],
260
+ ) -> dict[str, Any]:
261
+ # Embed only the basename of project_dir in the manifest to avoid
262
+ # leaking absolute filesystem paths into shareable snapshots. The
263
+ # absolute path is kept in a private ``_producer_abs_path`` field
264
+ # for same-host restore convenience (restore path ignores it anyway).
265
+ manifest = {
266
+ "schema_version": MANIFEST_SCHEMA_VERSION,
267
+ "schema": "atto.snapshot.v1",
268
+ "snapshot_name": name,
269
+ "project_name": os.path.basename(os.path.abspath(project_dir)) or "project",
270
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
271
+ "producer": {
272
+ "service": "attocode-local",
273
+ "version": "phase-2a",
274
+ },
275
+ "components": [
276
+ {
277
+ "name": c.name,
278
+ "archive_path": c.archive_path,
279
+ "source_path": c.source_path,
280
+ "size_bytes": c.size_bytes,
281
+ "digest": f"sha256:{c.digest}",
282
+ "media_type": _media_type_for(c.name, c.archive_path),
283
+ }
284
+ for c in components
285
+ ],
286
+ "total_size_bytes": sum(c.size_bytes for c in components),
287
+ }
288
+ with open(os.path.join(staging_dir, "manifest.json"), "w", encoding="utf-8") as f:
289
+ json.dump(manifest, f, indent=2, sort_keys=True)
290
+ return manifest
291
+
292
+
293
+ def _media_type_for(name: str, archive_path: str) -> str:
294
+ if archive_path.endswith(".db"):
295
+ return f"application/vnd.attocode.sqlite.{name}.v1+sqlite"
296
+ if name == "trigrams":
297
+ return "application/vnd.attocode.trigram.v1+bin"
298
+ if archive_path == "cache_manifest.json":
299
+ return "application/vnd.attocode.cache_manifest.v1+json"
300
+ return "application/vnd.attocode.blob.v1+bin"
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # MCP tools
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ @mcp.tool()
309
+ def snapshot_create(name: str = "", include: str = "") -> str:
310
+ """Create a portable snapshot of the current code-intel state.
311
+
312
+ Bundles SQLite stores (via the live SQLite backup API — safe while
313
+ the stores are open) + trigram files + cache_manifest.json into a
314
+ single ``.atsnap.tar.gz`` file under ``.attocode/snapshots/``.
315
+
316
+ Args:
317
+ name: Human-friendly name. If empty, uses a timestamp.
318
+ include: Comma-separated list of component logical names to
319
+ include (e.g. ``"symbols,embeddings,learnings"``). Empty
320
+ means "include everything".
321
+
322
+ Returns a summary of the produced snapshot file.
323
+ """
324
+ project_dir = _get_project_dir()
325
+ sdir = _snapshots_dir(project_dir)
326
+ os.makedirs(sdir, exist_ok=True)
327
+
328
+ timestamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
329
+ base_name = (name or f"snapshot_{timestamp}").replace("/", "_").replace(" ", "_")
330
+ if not base_name.endswith("_" + timestamp) and not name:
331
+ base_name = f"{base_name}"
332
+ out_file = os.path.join(sdir, f"{base_name}{SNAPSHOT_SUFFIX}")
333
+
334
+ include_set = {s.strip() for s in include.split(",") if s.strip()} if include else None
335
+
336
+ with tempfile.TemporaryDirectory(prefix="atto-snap-", dir=sdir) as staging:
337
+ components = _stage_snapshot(project_dir, staging, include_set)
338
+ if not components:
339
+ return (
340
+ f"snapshot_create: nothing to snapshot "
341
+ f"(no components matched include={include!r})"
342
+ )
343
+ manifest = _write_manifest(
344
+ staging, project_dir=project_dir, name=base_name, components=components,
345
+ )
346
+
347
+ # Build the tar.
348
+ tmp_tar = out_file + ".tmp"
349
+ with tarfile.open(tmp_tar, "w:gz") as tar:
350
+ # manifest.json first so it's cheap to read without extracting.
351
+ tar.add(os.path.join(staging, "manifest.json"), arcname="manifest.json")
352
+ if os.path.exists(os.path.join(staging, "cache_manifest.json")):
353
+ tar.add(
354
+ os.path.join(staging, "cache_manifest.json"),
355
+ arcname="cache_manifest.json",
356
+ )
357
+ stores_dir = os.path.join(staging, "stores")
358
+ if os.path.isdir(stores_dir):
359
+ tar.add(stores_dir, arcname="stores")
360
+ os.replace(tmp_tar, out_file)
361
+
362
+ size = os.path.getsize(out_file)
363
+ _append_audit_event(project_dir, "snapshot.created", {
364
+ "snapshot_name": base_name,
365
+ "archive_path": out_file,
366
+ "components": len(manifest["components"]),
367
+ "archive_size_bytes": size,
368
+ "total_size_uncompressed": manifest["total_size_bytes"],
369
+ })
370
+ return (
371
+ f"snapshot_create: {out_file}\n"
372
+ f" components: {len(manifest['components'])}\n"
373
+ f" total_size_uncompressed: {_fmt_bytes(manifest['total_size_bytes'])}\n"
374
+ f" archive_size: {_fmt_bytes(size)}"
375
+ )
376
+
377
+
378
+ @mcp.tool()
379
+ def snapshot_list() -> str:
380
+ """List all snapshots under ``.attocode/snapshots/``."""
381
+ project_dir = _get_project_dir()
382
+ snaps = _list_snapshot_files(project_dir)
383
+ if not snaps:
384
+ return "snapshot_list: no snapshots in .attocode/snapshots/"
385
+ lines = [f"snapshot_list: {len(snaps)} snapshot(s):"]
386
+ for path in snaps:
387
+ size = os.path.getsize(path)
388
+ mtime = time.strftime(
389
+ "%Y-%m-%d %H:%M:%S",
390
+ time.localtime(os.path.getmtime(path)),
391
+ )
392
+ lines.append(f" {os.path.basename(path):45s} {_fmt_bytes(size):>10s} {mtime}")
393
+ return "\n".join(lines)
394
+
395
+
396
+ @mcp.tool()
397
+ def snapshot_delete(name: str, confirm: bool = False) -> str:
398
+ """Delete a snapshot.
399
+
400
+ Args:
401
+ name: Snapshot filename or bare prefix (see ``snapshot_list``).
402
+ confirm: Must be True to actually delete.
403
+ """
404
+ project_dir = _get_project_dir()
405
+ path = _resolve_snapshot(project_dir, name)
406
+ if path is None:
407
+ return f"snapshot_delete: no snapshot matching {name!r}"
408
+ size = os.path.getsize(path)
409
+ if not confirm:
410
+ return (
411
+ f"snapshot_delete: DRY RUN — would delete {path} ({_fmt_bytes(size)}). "
412
+ f"Re-run with confirm=True."
413
+ )
414
+ os.unlink(path)
415
+ _append_audit_event(project_dir, "snapshot.deleted", {
416
+ "snapshot_path": path,
417
+ "archive_size_bytes": size,
418
+ })
419
+ return f"snapshot_delete: removed {path} ({_fmt_bytes(size)})"
420
+
421
+
422
+ @mcp.tool()
423
+ def snapshot_restore(name: str, confirm: bool = False) -> str:
424
+ """Restore a snapshot into ``.attocode/``.
425
+
426
+ Uses a staging directory + atomic rename so a partial extraction
427
+ can't corrupt the live state. On failure the live state is preserved.
428
+
429
+ **Caution:** this overwrites the current ``.attocode/`` store files.
430
+ Pre-existing snapshots, the ``snapshots/`` directory itself, and any
431
+ files not covered by the snapshot are preserved.
432
+
433
+ Args:
434
+ name: Snapshot filename or bare prefix.
435
+ confirm: Must be True to actually restore.
436
+ """
437
+ project_dir = _get_project_dir()
438
+ path = _resolve_snapshot(project_dir, name)
439
+ if path is None:
440
+ return f"snapshot_restore: no snapshot matching {name!r}"
441
+
442
+ # Preview — read manifest without extracting the whole archive.
443
+ try:
444
+ with tarfile.open(path, "r:gz") as tar:
445
+ mfile = tar.extractfile("manifest.json")
446
+ if mfile is None:
447
+ return "snapshot_restore: malformed snapshot (no manifest.json)"
448
+ manifest = json.loads(mfile.read().decode("utf-8"))
449
+ except (tarfile.TarError, json.JSONDecodeError, KeyError) as exc:
450
+ return f"snapshot_restore: could not read snapshot manifest: {exc}"
451
+
452
+ components = manifest.get("components", [])
453
+ if not confirm:
454
+ lines = [
455
+ "snapshot_restore: DRY RUN — nothing written.",
456
+ f" source: {path}",
457
+ f" created: {manifest.get('created_at', '?')}",
458
+ f" components: {len(components)}",
459
+ ]
460
+ for c in components:
461
+ lines.append(
462
+ f" - {c.get('name', '?'):15s} "
463
+ f"{_fmt_bytes(c.get('size_bytes', 0)):>10s} {c.get('archive_path', '?')}"
464
+ )
465
+ lines.append(" Re-run with confirm=True to apply.")
466
+ return "\n".join(lines)
467
+
468
+ # Apply: extract to a staging directory, then atomic move of each
469
+ # individual store file. We do NOT atomic-rename the whole .attocode
470
+ # directory because that would wipe files that aren't in the snapshot.
471
+ attocode = _attocode_dir(project_dir)
472
+ with tempfile.TemporaryDirectory(prefix="atto-restore-", dir=attocode) as staging:
473
+ try:
474
+ with tarfile.open(path, "r:gz") as tar:
475
+ # ``filter="data"`` (Python 3.12+) rejects absolute paths,
476
+ # path traversal, and symlinks escaping the staging dir —
477
+ # safer than the legacy default and future-proof against
478
+ # Python 3.14's stricter behavior.
479
+ tar.extractall(staging, filter="data")
480
+ except tarfile.TarError as exc:
481
+ return f"snapshot_restore: extraction failed: {exc}"
482
+
483
+ # Verify component digests.
484
+ bad: list[str] = []
485
+ for c in components:
486
+ ap = c.get("archive_path", "")
487
+ staged_path = os.path.join(staging, ap)
488
+ if not os.path.exists(staged_path):
489
+ bad.append(f"missing: {ap}")
490
+ continue
491
+ expected = c.get("digest", "")
492
+ if expected.startswith("sha256:"):
493
+ actual = "sha256:" + sha256_file(staged_path)
494
+ if actual != expected:
495
+ bad.append(f"digest mismatch: {ap}")
496
+ if bad:
497
+ return "snapshot_restore: integrity check failed:\n " + "\n ".join(bad)
498
+
499
+ # Move each component into place. Atomic per-file; the whole
500
+ # operation is not atomic but any single file's replacement is.
501
+ restored: list[str] = []
502
+ failed: list[str] = []
503
+ for c in components:
504
+ ap = c.get("archive_path", "")
505
+ src = os.path.join(staging, ap)
506
+ if ap == "cache_manifest.json":
507
+ dest = os.path.join(attocode, "cache_manifest.json")
508
+ elif ap.startswith("stores/"):
509
+ # Map back to the original .attocode-relative path.
510
+ out_name = ap.split("/", 1)[1]
511
+ dest_rel = _dest_for_store(out_name)
512
+ if dest_rel is None:
513
+ failed.append(f"{ap}: unknown store name")
514
+ continue
515
+ dest = os.path.join(attocode, dest_rel)
516
+ else:
517
+ failed.append(f"{ap}: unknown archive layout")
518
+ continue
519
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
520
+ try:
521
+ os.replace(src, dest)
522
+ restored.append(ap)
523
+ except OSError as exc:
524
+ failed.append(f"{ap}: {exc}")
525
+
526
+ _append_audit_event(project_dir, "snapshot.restored", {
527
+ "snapshot_path": path,
528
+ "restored_components": len(restored),
529
+ "failed_components": len(failed),
530
+ })
531
+ lines = [
532
+ f"snapshot_restore: restored from {path}",
533
+ f" restored: {len(restored)} component(s)",
534
+ ]
535
+ if failed:
536
+ lines.append(f" failed: {len(failed)}")
537
+ for f in failed:
538
+ lines.append(f" - {f}")
539
+ return "\n".join(lines)
540
+
541
+
542
+ def _dest_for_store(out_name: str) -> str | None:
543
+ """Map a snapshot's ``stores/<out_name>`` back to a .attocode-relative path."""
544
+ for src_rel, candidate in _SQLITE_STORES:
545
+ if candidate == out_name:
546
+ return src_rel
547
+ for src_rel, candidate in _BINARY_FILES:
548
+ if candidate == out_name:
549
+ return src_rel
550
+ return None
551
+
552
+
553
+ @mcp.tool()
554
+ def snapshot_diff(a: str, b: str) -> str:
555
+ """Diff two snapshots at the component-digest level.
556
+
557
+ Args:
558
+ a: First snapshot name or path.
559
+ b: Second snapshot name or path.
560
+
561
+ Returns a summary of which components changed, were added, or removed.
562
+ """
563
+ project_dir = _get_project_dir()
564
+ path_a = _resolve_snapshot(project_dir, a)
565
+ path_b = _resolve_snapshot(project_dir, b)
566
+ if path_a is None:
567
+ return f"snapshot_diff: no snapshot matching {a!r}"
568
+ if path_b is None:
569
+ return f"snapshot_diff: no snapshot matching {b!r}"
570
+
571
+ def _manifest_of(path: str) -> dict[str, Any]:
572
+ with tarfile.open(path, "r:gz") as tar:
573
+ mfile = tar.extractfile("manifest.json")
574
+ assert mfile is not None
575
+ return json.loads(mfile.read().decode("utf-8"))
576
+
577
+ try:
578
+ ma = _manifest_of(path_a)
579
+ mb = _manifest_of(path_b)
580
+ except (tarfile.TarError, json.JSONDecodeError, KeyError, AssertionError) as exc:
581
+ return f"snapshot_diff: manifest read failed: {exc}"
582
+
583
+ def _digest_map(m: dict[str, Any]) -> dict[str, str]:
584
+ """The local snapshot format emits three separate components all
585
+ named ``trigrams`` (one per on-disk file). Key on
586
+ ``(name, archive_path)`` so the three trigram files show up as
587
+ three separate diff entries instead of collapsing.
588
+ """
589
+ out: dict[str, str] = {}
590
+ for c in m.get("components", []):
591
+ name = c.get("name", "?")
592
+ archive_path = c.get("archive_path", "")
593
+ key = f"{name}[{archive_path}]" if archive_path else name
594
+ out[key] = c.get("digest", "")
595
+ return out
596
+
597
+ da = _digest_map(ma)
598
+ db = _digest_map(mb)
599
+
600
+ added = [k for k in db if k not in da]
601
+ removed = [k for k in da if k not in db]
602
+ changed = [k for k in da if k in db and da[k] != db[k]]
603
+ same = [k for k in da if k in db and da[k] == db[k]]
604
+
605
+ lines = [
606
+ f"snapshot_diff: {os.path.basename(path_a)} → {os.path.basename(path_b)}",
607
+ f" same: {len(same)}",
608
+ f" changed: {len(changed)}",
609
+ f" added: {len(added)}",
610
+ f" removed: {len(removed)}",
611
+ ]
612
+ if changed:
613
+ lines.append(" changed components:")
614
+ for n in sorted(changed):
615
+ lines.append(f" - {n}: {da[n][:16]}… → {db[n][:16]}…")
616
+ if added:
617
+ lines.append(" added components:")
618
+ for n in sorted(added):
619
+ lines.append(f" + {n}")
620
+ if removed:
621
+ lines.append(" removed components:")
622
+ for n in sorted(removed):
623
+ lines.append(f" - {n}")
624
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ """Background workers for async job processing (ARQ-based)."""