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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Explicitly copy selected knowledge to a team; never send source files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def share(argv):
|
|
12
|
+
parser = argparse.ArgumentParser(prog="attocode-code-intel share")
|
|
13
|
+
parser.add_argument("--project", default=".")
|
|
14
|
+
parser.add_argument("--learning", type=int, required=True, help="Local learning ID to copy")
|
|
15
|
+
parser.add_argument("--server", required=True)
|
|
16
|
+
parser.add_argument("--repo", required=True)
|
|
17
|
+
parser.add_argument("--token-env", default="ATTOCODE_API_KEY")
|
|
18
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
from attocode_intel._internal.integrations.context.memory_store import MemoryStore
|
|
21
|
+
|
|
22
|
+
root = Path(args.project).resolve()
|
|
23
|
+
with MemoryStore(str(root)) as store:
|
|
24
|
+
row = next((row for row in store.list_all() if row["id"] == args.learning), None)
|
|
25
|
+
if row is None:
|
|
26
|
+
parser.error("Active learning not found")
|
|
27
|
+
payload = {
|
|
28
|
+
key: row[key]
|
|
29
|
+
for key in ("type", "description", "details", "scope", "confidence", "anchor_blob_oid")
|
|
30
|
+
}
|
|
31
|
+
payload["workspace"] = args.repo
|
|
32
|
+
if args.dry_run:
|
|
33
|
+
print(json.dumps({"operation": "record_learning", "arguments": payload}, indent=2))
|
|
34
|
+
return
|
|
35
|
+
from urllib.parse import urlsplit
|
|
36
|
+
|
|
37
|
+
parsed = urlsplit(args.server)
|
|
38
|
+
if (
|
|
39
|
+
parsed.scheme not in {"http", "https"}
|
|
40
|
+
or not parsed.netloc
|
|
41
|
+
or parsed.username
|
|
42
|
+
or parsed.password
|
|
43
|
+
):
|
|
44
|
+
parser.error("Server must be an HTTP(S) URL without embedded credentials")
|
|
45
|
+
token = os.environ.get(args.token_env)
|
|
46
|
+
if not token:
|
|
47
|
+
parser.error(f"Set {args.token_env} to a team API key with intelligence:write scope")
|
|
48
|
+
import httpx
|
|
49
|
+
|
|
50
|
+
response = httpx.post(
|
|
51
|
+
args.server.rstrip("/") + "/api/v2/operations/record_learning",
|
|
52
|
+
json=payload,
|
|
53
|
+
headers={"Authorization": "Bearer " + token},
|
|
54
|
+
timeout=30,
|
|
55
|
+
)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
print(json.dumps(response.json(), indent=2))
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Branch overlay model — multi-branch indexing with parent chain resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BranchOverlay:
|
|
16
|
+
"""Manages branch-level file overlays on top of a default branch.
|
|
17
|
+
|
|
18
|
+
Resolution algorithm:
|
|
19
|
+
1. Load default branch manifest (all file paths → content SHAs)
|
|
20
|
+
2. Walk from target branch → parent → default via parent_branch_id chain
|
|
21
|
+
3. Apply overlays in order: default → intermediate → target
|
|
22
|
+
4. Additions/modifications replace entries; deletions remove them
|
|
23
|
+
5. Result: complete dict[str, str] (path → content_sha)
|
|
24
|
+
|
|
25
|
+
Branch version is incremented on every write for consistency tracking.
|
|
26
|
+
Clients can pass If-Match headers with branch version for strict consistency.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
30
|
+
self._session = session
|
|
31
|
+
|
|
32
|
+
async def _bump_version(self, branch_id: uuid.UUID) -> int:
|
|
33
|
+
"""Increment branch version counter. Returns new version.
|
|
34
|
+
|
|
35
|
+
Raises ValueError if ``branch_id`` doesn't exist.
|
|
36
|
+
"""
|
|
37
|
+
from sqlalchemy import update
|
|
38
|
+
|
|
39
|
+
from attocode_intel.db.models import Branch
|
|
40
|
+
|
|
41
|
+
result = await self._session.execute(
|
|
42
|
+
update(Branch)
|
|
43
|
+
.where(Branch.id == branch_id)
|
|
44
|
+
.values(version=Branch.version + 1)
|
|
45
|
+
.returning(Branch.version)
|
|
46
|
+
)
|
|
47
|
+
new_version = result.scalar_one_or_none()
|
|
48
|
+
if new_version is None:
|
|
49
|
+
raise ValueError(f"Branch not found: {branch_id}")
|
|
50
|
+
return new_version
|
|
51
|
+
|
|
52
|
+
async def get_version(self, branch_id: uuid.UUID) -> int:
|
|
53
|
+
"""Get current branch version for consistency checks."""
|
|
54
|
+
from sqlalchemy import select
|
|
55
|
+
|
|
56
|
+
from attocode_intel.db.models import Branch
|
|
57
|
+
|
|
58
|
+
result = await self._session.execute(
|
|
59
|
+
select(Branch.version).where(Branch.id == branch_id)
|
|
60
|
+
)
|
|
61
|
+
return result.scalar_one_or_none() or 0
|
|
62
|
+
|
|
63
|
+
async def check_version(self, branch_id: uuid.UUID, expected: int) -> None:
|
|
64
|
+
"""Verify branch version matches expected. Raises ValueError on mismatch.
|
|
65
|
+
|
|
66
|
+
Used for If-Match optimistic concurrency control.
|
|
67
|
+
"""
|
|
68
|
+
current = await self.get_version(branch_id)
|
|
69
|
+
if current != expected:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Branch version mismatch: expected {expected}, current {current}. "
|
|
72
|
+
"Another update occurred — retry with the latest version."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def set_file(
|
|
76
|
+
self,
|
|
77
|
+
branch_id: uuid.UUID,
|
|
78
|
+
path: str,
|
|
79
|
+
content_sha: str,
|
|
80
|
+
status: str = "modified",
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Set a file in the branch overlay. Bumps branch version."""
|
|
83
|
+
from sqlalchemy import select
|
|
84
|
+
|
|
85
|
+
from attocode_intel.db.models import BranchFile
|
|
86
|
+
|
|
87
|
+
result = await self._session.execute(
|
|
88
|
+
select(BranchFile).where(
|
|
89
|
+
BranchFile.branch_id == branch_id,
|
|
90
|
+
BranchFile.path == path,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
existing = result.scalar_one_or_none()
|
|
94
|
+
if existing:
|
|
95
|
+
existing.content_sha = content_sha
|
|
96
|
+
existing.status = status
|
|
97
|
+
else:
|
|
98
|
+
bf = BranchFile(
|
|
99
|
+
branch_id=branch_id,
|
|
100
|
+
path=path,
|
|
101
|
+
content_sha=content_sha,
|
|
102
|
+
status=status,
|
|
103
|
+
)
|
|
104
|
+
self._session.add(bf)
|
|
105
|
+
|
|
106
|
+
await self._bump_version(branch_id)
|
|
107
|
+
await self._session.flush()
|
|
108
|
+
|
|
109
|
+
async def set_files_batch(
|
|
110
|
+
self,
|
|
111
|
+
branch_id: uuid.UUID,
|
|
112
|
+
files: list[tuple[str, str, str]],
|
|
113
|
+
expected_version: int | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Set multiple files in one operation. Each tuple: (path, content_sha, status).
|
|
116
|
+
|
|
117
|
+
Single version bump for the batch.
|
|
118
|
+
"""
|
|
119
|
+
from sqlalchemy import select
|
|
120
|
+
|
|
121
|
+
from attocode_intel.db.models import BranchFile
|
|
122
|
+
|
|
123
|
+
if not files:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if expected_version is not None:
|
|
127
|
+
await self.check_version(branch_id, expected_version)
|
|
128
|
+
|
|
129
|
+
# Load existing entries for these paths in one query
|
|
130
|
+
paths = [f[0] for f in files]
|
|
131
|
+
result = await self._session.execute(
|
|
132
|
+
select(BranchFile).where(
|
|
133
|
+
BranchFile.branch_id == branch_id,
|
|
134
|
+
BranchFile.path.in_(paths),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
existing_map = {bf.path: bf for bf in result.scalars()}
|
|
138
|
+
|
|
139
|
+
for path, content_sha, status in files:
|
|
140
|
+
if path in existing_map:
|
|
141
|
+
existing_map[path].content_sha = content_sha
|
|
142
|
+
existing_map[path].status = status
|
|
143
|
+
else:
|
|
144
|
+
bf = BranchFile(
|
|
145
|
+
branch_id=branch_id,
|
|
146
|
+
path=path,
|
|
147
|
+
content_sha=content_sha,
|
|
148
|
+
status=status,
|
|
149
|
+
)
|
|
150
|
+
self._session.add(bf)
|
|
151
|
+
|
|
152
|
+
await self._bump_version(branch_id)
|
|
153
|
+
await self._session.flush()
|
|
154
|
+
|
|
155
|
+
async def delete_file(self, branch_id: uuid.UUID, path: str) -> None:
|
|
156
|
+
"""Mark a file as deleted in the branch overlay. Bumps branch version."""
|
|
157
|
+
from sqlalchemy import select
|
|
158
|
+
|
|
159
|
+
from attocode_intel.db.models import BranchFile
|
|
160
|
+
|
|
161
|
+
result = await self._session.execute(
|
|
162
|
+
select(BranchFile).where(
|
|
163
|
+
BranchFile.branch_id == branch_id,
|
|
164
|
+
BranchFile.path == path,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
existing = result.scalar_one_or_none()
|
|
168
|
+
if existing:
|
|
169
|
+
existing.content_sha = None
|
|
170
|
+
existing.status = "deleted"
|
|
171
|
+
else:
|
|
172
|
+
bf = BranchFile(
|
|
173
|
+
branch_id=branch_id,
|
|
174
|
+
path=path,
|
|
175
|
+
content_sha=None,
|
|
176
|
+
status="deleted",
|
|
177
|
+
)
|
|
178
|
+
self._session.add(bf)
|
|
179
|
+
|
|
180
|
+
await self._bump_version(branch_id)
|
|
181
|
+
await self._session.flush()
|
|
182
|
+
|
|
183
|
+
async def resolve_file(
|
|
184
|
+
self,
|
|
185
|
+
branch_id: uuid.UUID,
|
|
186
|
+
path: str,
|
|
187
|
+
) -> str | None:
|
|
188
|
+
"""Resolve a single file through the overlay chain.
|
|
189
|
+
|
|
190
|
+
Returns the content_sha or None if file doesn't exist.
|
|
191
|
+
"""
|
|
192
|
+
manifest = await self.resolve_manifest(branch_id)
|
|
193
|
+
return manifest.get(path)
|
|
194
|
+
|
|
195
|
+
async def resolve_manifest(
|
|
196
|
+
self,
|
|
197
|
+
branch_id: uuid.UUID,
|
|
198
|
+
) -> dict[str, str]:
|
|
199
|
+
"""Resolve the complete file manifest for a branch.
|
|
200
|
+
|
|
201
|
+
Walks the overlay chain from default → intermediate → target branch.
|
|
202
|
+
C3 fix: Uses visited set to detect and break cyclic parent_branch_id chains.
|
|
203
|
+
"""
|
|
204
|
+
from sqlalchemy import select
|
|
205
|
+
|
|
206
|
+
from attocode_intel.db.models import Branch, BranchFile
|
|
207
|
+
|
|
208
|
+
# Build the chain from target to default
|
|
209
|
+
chain: list[uuid.UUID] = []
|
|
210
|
+
visited: set[uuid.UUID] = set()
|
|
211
|
+
current_id: uuid.UUID | None = branch_id
|
|
212
|
+
|
|
213
|
+
while current_id is not None:
|
|
214
|
+
if current_id in visited:
|
|
215
|
+
logger.warning(
|
|
216
|
+
"Cyclic parent_branch_id detected at %s in chain %s — breaking",
|
|
217
|
+
current_id, [str(b) for b in chain],
|
|
218
|
+
)
|
|
219
|
+
break
|
|
220
|
+
visited.add(current_id)
|
|
221
|
+
chain.append(current_id)
|
|
222
|
+
result = await self._session.execute(
|
|
223
|
+
select(Branch.parent_branch_id).where(Branch.id == current_id)
|
|
224
|
+
)
|
|
225
|
+
parent_id = result.scalar_one_or_none()
|
|
226
|
+
current_id = parent_id
|
|
227
|
+
|
|
228
|
+
# Reverse: default first, target last
|
|
229
|
+
chain.reverse()
|
|
230
|
+
|
|
231
|
+
# Build manifest by applying overlays in order
|
|
232
|
+
manifest: dict[str, str] = {}
|
|
233
|
+
|
|
234
|
+
for bid in chain:
|
|
235
|
+
result = await self._session.execute(
|
|
236
|
+
select(BranchFile).where(BranchFile.branch_id == bid)
|
|
237
|
+
)
|
|
238
|
+
for bf in result.scalars():
|
|
239
|
+
if bf.status == "deleted":
|
|
240
|
+
manifest.pop(bf.path, None)
|
|
241
|
+
elif bf.content_sha:
|
|
242
|
+
manifest[bf.path] = bf.content_sha
|
|
243
|
+
|
|
244
|
+
return manifest
|
|
245
|
+
|
|
246
|
+
async def diff_branches(
|
|
247
|
+
self,
|
|
248
|
+
branch_a_id: uuid.UUID,
|
|
249
|
+
branch_b_id: uuid.UUID,
|
|
250
|
+
) -> dict[str, str]:
|
|
251
|
+
"""Compare two branches. Returns dict of path → change_type.
|
|
252
|
+
|
|
253
|
+
change_type: added|modified|deleted
|
|
254
|
+
"""
|
|
255
|
+
manifest_a = await self.resolve_manifest(branch_a_id)
|
|
256
|
+
manifest_b = await self.resolve_manifest(branch_b_id)
|
|
257
|
+
|
|
258
|
+
diff: dict[str, str] = {}
|
|
259
|
+
all_paths = set(manifest_a.keys()) | set(manifest_b.keys())
|
|
260
|
+
|
|
261
|
+
for path in all_paths:
|
|
262
|
+
sha_a = manifest_a.get(path)
|
|
263
|
+
sha_b = manifest_b.get(path)
|
|
264
|
+
|
|
265
|
+
if sha_a is None and sha_b is not None:
|
|
266
|
+
diff[path] = "added"
|
|
267
|
+
elif sha_a is not None and sha_b is None:
|
|
268
|
+
diff[path] = "deleted"
|
|
269
|
+
elif sha_a != sha_b:
|
|
270
|
+
diff[path] = "modified"
|
|
271
|
+
|
|
272
|
+
return diff
|
|
273
|
+
|
|
274
|
+
async def merge_branch(
|
|
275
|
+
self,
|
|
276
|
+
source_id: uuid.UUID,
|
|
277
|
+
target_id: uuid.UUID,
|
|
278
|
+
*,
|
|
279
|
+
delete_source: bool = False,
|
|
280
|
+
) -> dict:
|
|
281
|
+
"""Merge source branch overlay into target branch.
|
|
282
|
+
|
|
283
|
+
Resolves both manifests, computes the diff, and applies source changes
|
|
284
|
+
to the target overlay. Optionally deletes the source branch after merge.
|
|
285
|
+
|
|
286
|
+
Returns merge statistics.
|
|
287
|
+
"""
|
|
288
|
+
from datetime import datetime, timezone
|
|
289
|
+
|
|
290
|
+
from sqlalchemy import select
|
|
291
|
+
|
|
292
|
+
from attocode_intel.db.models import Branch
|
|
293
|
+
|
|
294
|
+
diff = await self.diff_branches(target_id, source_id)
|
|
295
|
+
|
|
296
|
+
# Apply source changes to target overlay
|
|
297
|
+
files_to_set: list[tuple[str, str, str]] = []
|
|
298
|
+
source_manifest = await self.resolve_manifest(source_id)
|
|
299
|
+
deleted = 0
|
|
300
|
+
|
|
301
|
+
for path, change_type in diff.items():
|
|
302
|
+
if change_type == "added":
|
|
303
|
+
sha = source_manifest.get(path)
|
|
304
|
+
if sha:
|
|
305
|
+
files_to_set.append((path, sha, "added"))
|
|
306
|
+
elif change_type == "modified":
|
|
307
|
+
sha = source_manifest.get(path)
|
|
308
|
+
if sha:
|
|
309
|
+
files_to_set.append((path, sha, "modified"))
|
|
310
|
+
elif change_type == "deleted":
|
|
311
|
+
await self.delete_file(target_id, path)
|
|
312
|
+
deleted += 1
|
|
313
|
+
|
|
314
|
+
if files_to_set:
|
|
315
|
+
await self.set_files_batch(target_id, files_to_set)
|
|
316
|
+
|
|
317
|
+
stats = {
|
|
318
|
+
"added": sum(1 for _, _, s in files_to_set if s == "added"),
|
|
319
|
+
"modified": sum(1 for _, _, s in files_to_set if s == "modified"),
|
|
320
|
+
"deleted": deleted,
|
|
321
|
+
"total": len(diff),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if delete_source:
|
|
325
|
+
# Mark source as merged and delete
|
|
326
|
+
result = await self._session.execute(
|
|
327
|
+
select(Branch).where(Branch.id == source_id)
|
|
328
|
+
)
|
|
329
|
+
source_branch = result.scalar_one_or_none()
|
|
330
|
+
if source_branch:
|
|
331
|
+
source_branch.merged_at = datetime.now(timezone.utc)
|
|
332
|
+
await self._session.delete(source_branch)
|
|
333
|
+
await self._session.flush()
|
|
334
|
+
|
|
335
|
+
return stats
|
|
336
|
+
|
|
337
|
+
async def get_overlay_stats(self, branch_id: uuid.UUID) -> dict:
|
|
338
|
+
"""Get statistics about a branch's overlay (files added/modified/deleted)."""
|
|
339
|
+
from sqlalchemy import func, select
|
|
340
|
+
|
|
341
|
+
from attocode_intel.db.models import BranchFile
|
|
342
|
+
|
|
343
|
+
result = await self._session.execute(
|
|
344
|
+
select(BranchFile.status, func.count())
|
|
345
|
+
.where(BranchFile.branch_id == branch_id)
|
|
346
|
+
.group_by(BranchFile.status)
|
|
347
|
+
)
|
|
348
|
+
stats = {"added": 0, "modified": 0, "deleted": 0, "total": 0}
|
|
349
|
+
for status, count in result:
|
|
350
|
+
stats[status] = count
|
|
351
|
+
stats["total"] += count
|
|
352
|
+
return stats
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Content-addressed file storage — SHA-256 keyed deduplication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContentStore:
|
|
18
|
+
"""SHA-256 keyed content storage with deduplication.
|
|
19
|
+
|
|
20
|
+
Files are stored once by content hash. Multiple branches can reference
|
|
21
|
+
the same content without duplication.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
25
|
+
self._session = session
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def hash_content(content: bytes) -> str:
|
|
29
|
+
"""Compute SHA-256 hash of content."""
|
|
30
|
+
return hashlib.sha256(content).hexdigest()
|
|
31
|
+
|
|
32
|
+
async def store(
|
|
33
|
+
self,
|
|
34
|
+
content: bytes,
|
|
35
|
+
language: str | None = None,
|
|
36
|
+
content_type: str = "source",
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Store content and return its SHA-256 hash. Idempotent (INSERT ON CONFLICT DO NOTHING)."""
|
|
39
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
40
|
+
|
|
41
|
+
from attocode_intel.db.models import FileContent
|
|
42
|
+
|
|
43
|
+
sha = self.hash_content(content)
|
|
44
|
+
|
|
45
|
+
# C4 fix: atomic INSERT ON CONFLICT DO NOTHING — eliminates TOCTOU race
|
|
46
|
+
stmt = insert(FileContent).values(
|
|
47
|
+
sha256=sha,
|
|
48
|
+
content=content,
|
|
49
|
+
size_bytes=len(content),
|
|
50
|
+
language=language,
|
|
51
|
+
content_type=content_type,
|
|
52
|
+
).on_conflict_do_nothing(index_elements=["sha256"])
|
|
53
|
+
await self._session.execute(stmt)
|
|
54
|
+
await self._session.flush()
|
|
55
|
+
return sha
|
|
56
|
+
|
|
57
|
+
async def store_batch(
|
|
58
|
+
self,
|
|
59
|
+
items: list[tuple[bytes, str | None]],
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""Store multiple items efficiently. Returns list of SHA-256 hashes.
|
|
62
|
+
|
|
63
|
+
Deduplicates within batch and uses INSERT ON CONFLICT DO NOTHING.
|
|
64
|
+
"""
|
|
65
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
66
|
+
|
|
67
|
+
from attocode_intel.db.models import FileContent
|
|
68
|
+
|
|
69
|
+
# Pre-compute all hashes
|
|
70
|
+
entries = [(self.hash_content(content), content, lang) for content, lang in items]
|
|
71
|
+
|
|
72
|
+
# Deduplicate within the batch by SHA.
|
|
73
|
+
seen: dict[str, tuple[str, bytes, str | None]] = {}
|
|
74
|
+
hashes = []
|
|
75
|
+
for sha, content, language in entries:
|
|
76
|
+
hashes.append(sha)
|
|
77
|
+
if sha not in seen:
|
|
78
|
+
seen[sha] = (sha, content, language)
|
|
79
|
+
|
|
80
|
+
# Bulk existence check on deduplicated set
|
|
81
|
+
unique_shas = list(seen.keys())
|
|
82
|
+
existing = await self.batch_exists(unique_shas)
|
|
83
|
+
|
|
84
|
+
# Only insert new content using ON CONFLICT DO NOTHING
|
|
85
|
+
for sha, content, language in seen.values():
|
|
86
|
+
if sha in existing:
|
|
87
|
+
continue
|
|
88
|
+
stmt = insert(FileContent).values(
|
|
89
|
+
sha256=sha,
|
|
90
|
+
content=content,
|
|
91
|
+
size_bytes=len(content),
|
|
92
|
+
language=language,
|
|
93
|
+
).on_conflict_do_nothing(index_elements=["sha256"])
|
|
94
|
+
await self._session.execute(stmt)
|
|
95
|
+
|
|
96
|
+
await self._session.flush()
|
|
97
|
+
return hashes
|
|
98
|
+
|
|
99
|
+
async def get(self, sha: str) -> bytes | None:
|
|
100
|
+
"""Retrieve content by SHA-256 hash."""
|
|
101
|
+
from sqlalchemy import select
|
|
102
|
+
|
|
103
|
+
from attocode_intel.db.models import FileContent
|
|
104
|
+
|
|
105
|
+
result = await self._session.execute(
|
|
106
|
+
select(FileContent.content).where(FileContent.sha256 == sha)
|
|
107
|
+
)
|
|
108
|
+
row = result.scalar_one_or_none()
|
|
109
|
+
return row if row is not None else None
|
|
110
|
+
|
|
111
|
+
async def exists(self, sha: str) -> bool:
|
|
112
|
+
"""Check if content exists by hash."""
|
|
113
|
+
from sqlalchemy import select
|
|
114
|
+
|
|
115
|
+
from attocode_intel.db.models import FileContent
|
|
116
|
+
|
|
117
|
+
result = await self._session.execute(
|
|
118
|
+
select(FileContent.sha256).where(FileContent.sha256 == sha)
|
|
119
|
+
)
|
|
120
|
+
return result.scalar_one_or_none() is not None
|
|
121
|
+
|
|
122
|
+
async def batch_exists(self, shas: list[str]) -> set[str]:
|
|
123
|
+
"""Check existence of multiple hashes in one query. Returns set of existing SHAs."""
|
|
124
|
+
if not shas:
|
|
125
|
+
return set()
|
|
126
|
+
|
|
127
|
+
from sqlalchemy import select
|
|
128
|
+
|
|
129
|
+
from attocode_intel.db.models import FileContent
|
|
130
|
+
|
|
131
|
+
result = await self._session.execute(
|
|
132
|
+
select(FileContent.sha256).where(FileContent.sha256.in_(shas))
|
|
133
|
+
)
|
|
134
|
+
return {row[0] for row in result}
|
|
135
|
+
|
|
136
|
+
async def gc_unreferenced(
|
|
137
|
+
self,
|
|
138
|
+
min_age_minutes: int = 5,
|
|
139
|
+
*,
|
|
140
|
+
repo_id: uuid.UUID | None = None,
|
|
141
|
+
) -> int:
|
|
142
|
+
"""Delete file_contents not referenced by any branch_files, symbols, dependencies, or embeddings.
|
|
143
|
+
|
|
144
|
+
Only deletes content older than min_age_minutes to avoid racing
|
|
145
|
+
with concurrent indexers still writing references.
|
|
146
|
+
|
|
147
|
+
When ``repo_id`` is provided, scopes the delete to content
|
|
148
|
+
referenced *only* from branches of that repo. A blob still
|
|
149
|
+
referenced from a different repo's branches is never deleted.
|
|
150
|
+
``repo_id=None`` runs a global sweep (background worker).
|
|
151
|
+
|
|
152
|
+
Returns count of deleted rows.
|
|
153
|
+
"""
|
|
154
|
+
from sqlalchemy import text
|
|
155
|
+
|
|
156
|
+
if repo_id is None:
|
|
157
|
+
result = await self._session.execute(
|
|
158
|
+
text("""
|
|
159
|
+
DELETE FROM file_contents
|
|
160
|
+
WHERE created_at < NOW() - make_interval(mins => :age)
|
|
161
|
+
AND sha256 NOT IN (
|
|
162
|
+
SELECT DISTINCT content_sha FROM branch_files WHERE content_sha IS NOT NULL
|
|
163
|
+
UNION
|
|
164
|
+
SELECT DISTINCT content_sha FROM symbols
|
|
165
|
+
UNION
|
|
166
|
+
SELECT DISTINCT source_sha FROM dependencies
|
|
167
|
+
UNION
|
|
168
|
+
SELECT DISTINCT target_sha FROM dependencies
|
|
169
|
+
UNION
|
|
170
|
+
SELECT DISTINCT content_sha FROM embeddings
|
|
171
|
+
)
|
|
172
|
+
""").bindparams(age=min_age_minutes)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
# Scoped GC: delete content whose *only* live references live in
|
|
176
|
+
# branch_files of the target repo, AND which is no longer in any
|
|
177
|
+
# of the target repo's branches. We still require the content to
|
|
178
|
+
# be globally unreferenced from symbols/deps/embeddings because
|
|
179
|
+
# those are not repo-scoped; otherwise we'd break indexing
|
|
180
|
+
# caches for other repos that happened to share the same blob.
|
|
181
|
+
result = await self._session.execute(
|
|
182
|
+
text("""
|
|
183
|
+
DELETE FROM file_contents
|
|
184
|
+
WHERE created_at < NOW() - make_interval(mins => :age)
|
|
185
|
+
AND sha256 IN (
|
|
186
|
+
-- blobs currently or previously associated with this repo
|
|
187
|
+
SELECT DISTINCT bf.content_sha
|
|
188
|
+
FROM branch_files bf
|
|
189
|
+
JOIN branches b ON b.id = bf.branch_id
|
|
190
|
+
WHERE b.repo_id = :repo_id AND bf.content_sha IS NOT NULL
|
|
191
|
+
)
|
|
192
|
+
AND sha256 NOT IN (
|
|
193
|
+
-- still live in any branch (any repo)
|
|
194
|
+
SELECT DISTINCT bf.content_sha
|
|
195
|
+
FROM branch_files bf
|
|
196
|
+
WHERE bf.content_sha IS NOT NULL
|
|
197
|
+
UNION
|
|
198
|
+
SELECT DISTINCT content_sha FROM symbols
|
|
199
|
+
UNION
|
|
200
|
+
SELECT DISTINCT source_sha FROM dependencies
|
|
201
|
+
UNION
|
|
202
|
+
SELECT DISTINCT target_sha FROM dependencies
|
|
203
|
+
UNION
|
|
204
|
+
SELECT DISTINCT content_sha FROM embeddings
|
|
205
|
+
)
|
|
206
|
+
""").bindparams(age=min_age_minutes, repo_id=str(repo_id))
|
|
207
|
+
)
|
|
208
|
+
count = result.rowcount
|
|
209
|
+
if count:
|
|
210
|
+
if repo_id is None:
|
|
211
|
+
logger.info(
|
|
212
|
+
"GC: removed %d unreferenced file contents (older than %dm, global)",
|
|
213
|
+
count, min_age_minutes,
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
logger.info(
|
|
217
|
+
"GC: removed %d unreferenced file contents (older than %dm, repo=%s)",
|
|
218
|
+
count, min_age_minutes, repo_id,
|
|
219
|
+
)
|
|
220
|
+
return count
|