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,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)."""
|