clm-kernel 0.0.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.
- clm/__init__.py +230 -0
- clm/__main__.py +25 -0
- clm/cross_cutting/__init__.py +50 -0
- clm/cross_cutting/_test_runner.py +36 -0
- clm/cross_cutting/async_utils.py +25 -0
- clm/cross_cutting/cel_builder.py +123 -0
- clm/cross_cutting/cel_converter.py +697 -0
- clm/cross_cutting/cel_engine.py +252 -0
- clm/cross_cutting/cel_ops.py +72 -0
- clm/cross_cutting/cel_ops_db.py +157 -0
- clm/cross_cutting/cel_ops_files.py +99 -0
- clm/cross_cutting/cel_ops_state.py +71 -0
- clm/cross_cutting/cel_ops_strings.py +64 -0
- clm/cross_cutting/cel_resolver.py +63 -0
- clm/cross_cutting/cel_slices.py +56 -0
- clm/cross_cutting/cel_store.py +330 -0
- clm/cross_cutting/clm_logger.py +140 -0
- clm/cross_cutting/config/__init__.py +155 -0
- clm/cross_cutting/config/config_constants.py +392 -0
- clm/cross_cutting/config/env_parameters.py +90 -0
- clm/cross_cutting/config/logging.py +378 -0
- clm/cross_cutting/config/settings.py +504 -0
- clm/cross_cutting/context.py +5 -0
- clm/cross_cutting/domain_models.py +55 -0
- clm/cross_cutting/domain_types.py +61 -0
- clm/cross_cutting/effects.py +67 -0
- clm/cross_cutting/errors.py +153 -0
- clm/cross_cutting/governance.py +331 -0
- clm/cross_cutting/guards.py +41 -0
- clm/cross_cutting/mcard.py +321 -0
- clm/cross_cutting/native.py +185 -0
- clm/cross_cutting/observability.py +289 -0
- clm/cross_cutting/pcard.py +434 -0
- clm/cross_cutting/registry.py +71 -0
- clm/cross_cutting/resources.py +84 -0
- clm/cross_cutting/secrets.py +44 -0
- clm/cross_cutting/subprocess_utils.py +118 -0
- clm/cross_cutting/telemetry.py +164 -0
- clm/cross_cutting/testing/__init__.py +8 -0
- clm/cross_cutting/testing/comparator.py +187 -0
- clm/cross_cutting/testing/datasets.py +87 -0
- clm/cross_cutting/timeutil.py +20 -0
- clm/cross_cutting/types/new_type_check.py +0 -0
- clm/cross_cutting/utils/__init__.py +1 -0
- clm/cross_cutting/utils/url_safety.py +50 -0
- clm/cross_cutting/vcard.py +560 -0
- clm/cross_cutting/writer.py +118 -0
- clm/layer0/README.md +50 -0
- clm/layer0/__init__.py +223 -0
- clm/layer0/algebra.py +60 -0
- clm/layer0/algebra_baldwin.py +133 -0
- clm/layer0/algebra_baldwin_generators.py +36 -0
- clm/layer0/algebra_equivalence.py +262 -0
- clm/layer0/algebra_ingest.py +306 -0
- clm/layer0/algebra_manifest_io.py +156 -0
- clm/layer0/assembler.py +481 -0
- clm/layer0/cel/__init__.py +23 -0
- clm/layer0/cel_eval.py +491 -0
- clm/layer0/codec.py +106 -0
- clm/layer0/context.py +122 -0
- clm/layer0/db.py +160 -0
- clm/layer0/exceptions.py +73 -0
- clm/layer0/fibration.py +106 -0
- clm/layer0/gates.py +50 -0
- clm/layer0/hash.py +80 -0
- clm/layer0/loader.py +826 -0
- clm/layer0/math_utils.py +163 -0
- clm/layer0/mcard.py +96 -0
- clm/layer0/model/__init__.py +0 -0
- clm/layer0/model/action.py +223 -0
- clm/layer0/model/card.py +280 -0
- clm/layer0/model/card_triad.py +427 -0
- clm/layer0/model/compatibility.py +60 -0
- clm/layer0/model/dictionary.py +46 -0
- clm/layer0/model/dots.py +331 -0
- clm/layer0/model/event_producer.py +88 -0
- clm/layer0/model/g_time.py +89 -0
- clm/layer0/model/handle.py +163 -0
- clm/layer0/model/hash/__init__.py +6 -0
- clm/layer0/model/hash/algorithms/__init__.py +3 -0
- clm/layer0/model/hash/algorithms/custom_hash.py +7 -0
- clm/layer0/model/hash/algorithms/local_sha256.py +178 -0
- clm/layer0/model/hash/constants.py +53 -0
- clm/layer0/model/hash/enums.py +60 -0
- clm/layer0/model/hash/validator.py +307 -0
- clm/layer0/model/interpreter.py +275 -0
- clm/layer0/model/pagination.py +45 -0
- clm/layer0/model/pcard.py +716 -0
- clm/layer0/model/schema.py +44 -0
- clm/layer0/model/utils/__init__.py +1 -0
- clm/layer0/model/utils/content_analyzer.py +152 -0
- clm/layer0/model/validators/__init__.py +1 -0
- clm/layer0/model/validators/base_validator.py +44 -0
- clm/layer0/model/validators/binary_validator.py +66 -0
- clm/layer0/model/validators/text_validator.py +82 -0
- clm/layer0/model/validators/validation_registry.py +49 -0
- clm/layer0/model/vcard.py +823 -0
- clm/layer0/model/vcard_ext/__init__.py +39 -0
- clm/layer0/model/vcard_ext/core.py +60 -0
- clm/layer0/model/vcard_ext/network.py +23 -0
- clm/layer0/model/vcard_ext/observability.py +63 -0
- clm/layer0/model/vcard_ext/storage.py +60 -0
- clm/layer0/model/vcard_ext/vendors.py +211 -0
- clm/layer0/model/vcard_sandwich.py +119 -0
- clm/layer0/model/vcard_vocabulary.py +463 -0
- clm/layer0/model/workflow.py +274 -0
- clm/layer0/narrative.py +431 -0
- clm/layer0/ontology/collection.py +125 -0
- clm/layer0/parser.py +429 -0
- clm/layer0/relations.py +73 -0
- clm/layer0/rule_evaluator.py +251 -0
- clm/layer0/schema/__init__.py +38 -0
- clm/layer0/schema/ddl.py +97 -0
- clm/layer0/templates/abstract.yaml +99 -0
- clm/layer0/templates/balanced.yaml +253 -0
- clm/layer0/templates/concrete.yaml +172 -0
- clm/layer0/type_registry.py +223 -0
- clm/layer0/types.py +204 -0
- clm/layer0/utils.py +886 -0
- clm/layer0/verifier_core.py +262 -0
- clm/layer1/__init__.py +31 -0
- clm/layer1/action_dispatcher.py +275 -0
- clm/layer1/adapter_registry.py +233 -0
- clm/layer1/algebra_baldwin_generators.py +192 -0
- clm/layer1/arrow_compiler.py +525 -0
- clm/layer1/batch_processor.py +152 -0
- clm/layer1/cel/__init__.py +27 -0
- clm/layer1/cel/builder.py +173 -0
- clm/layer1/engine.py +759 -0
- clm/layer1/engine_async.py +89 -0
- clm/layer1/eval_callback.py +49 -0
- clm/layer1/gate_composer.py +229 -0
- clm/layer1/hoare_sandwich.py +298 -0
- clm/layer1/identity_guard.py +348 -0
- clm/layer1/matrix.py +138 -0
- clm/layer1/neural_runtime.py +221 -0
- clm/layer1/operations/__init__.py +123 -0
- clm/layer1/operations/augment.py +156 -0
- clm/layer1/operations/base.py +109 -0
- clm/layer1/operations/builtins.py +338 -0
- clm/layer1/operations/dispatch.py +144 -0
- clm/layer1/operations/exclude.py +107 -0
- clm/layer1/operations/handle.py +197 -0
- clm/layer1/operations/identity.py +139 -0
- clm/layer1/operations/invert.py +66 -0
- clm/layer1/operations/loader.py +116 -0
- clm/layer1/operations/manipulators/__init__.py +23 -0
- clm/layer1/operations/manipulators/document.py +211 -0
- clm/layer1/operations/manipulators/media.py +134 -0
- clm/layer1/operations/manipulators/structured.py +159 -0
- clm/layer1/operations/manipulators/tabular.py +334 -0
- clm/layer1/operations/port.py +65 -0
- clm/layer1/operations/services.py +529 -0
- clm/layer1/operations/split.py +117 -0
- clm/layer1/operations/substitute.py +75 -0
- clm/layer1/petri.py +1052 -0
- clm/layer1/pocketflow_orchestrator.py +408 -0
- clm/layer1/pocketflow_scheduler.py +519 -0
- clm/layer1/protocols.py +197 -0
- clm/layer1/runtime_adapter.py +718 -0
- clm/layer1/runtimes/__init__.py +71 -0
- clm/layer1/runtimes/_generated_assertions.py +125 -0
- clm/layer1/runtimes/_generated_types.py +42 -0
- clm/layer1/runtimes/base.py +265 -0
- clm/layer1/runtimes/binary.py +101 -0
- clm/layer1/runtimes/factory.py +203 -0
- clm/layer1/runtimes/javascript.py +321 -0
- clm/layer1/runtimes/javascript_runtime.js +33 -0
- clm/layer1/runtimes/js_scripts/isomorphism.mjs +22 -0
- clm/layer1/runtimes/js_scripts/loader.mjs +52 -0
- clm/layer1/runtimes/lambda_calc.py +126 -0
- clm/layer1/runtimes/python.py +288 -0
- clm/layer1/runtimes/python_runtime.py +41 -0
- clm/layer1/runtimes/script.py +114 -0
- clm/layer1/sandbox.py +334 -0
- clm/layer1/savepoint_guard.py +136 -0
- clm/layer1/sparse_net.py +122 -0
- clm/layer1/spec_tester.py +165 -0
- clm/layer1/store.py +295 -0
- clm/layer1/test_runner.py +498 -0
- clm/layer1/type_inspector.py +166 -0
- clm/layer1/verifier.py +772 -0
- clm/layer1/vm.py +91 -0
- clm/layer2/__init__.py +78 -0
- clm/layer2/card_collection.py +678 -0
- clm/layer2/classifier.py +479 -0
- clm/layer2/engine/__init__.py +1 -0
- clm/layer2/engine/abstract_sql_engine.py +320 -0
- clm/layer2/engine/base.py +193 -0
- clm/layer2/engine/duckdb_engine.py +576 -0
- clm/layer2/engine/sqlite_engine.py +518 -0
- clm/layer2/fiber/__init__.py +31 -0
- clm/layer2/fiber/context.py +89 -0
- clm/layer2/fiber/disposable.py +163 -0
- clm/layer2/fiber/fiber.py +125 -0
- clm/layer2/fiber/hoare.py +191 -0
- clm/layer2/file_io.py +376 -0
- clm/layer2/file_registrar.py +77 -0
- clm/layer2/ingest.py +202 -0
- clm/layer2/lineage.py +322 -0
- clm/layer2/mcard_fs.py +262 -0
- clm/layer2/merkle.py +11 -0
- clm/layer2/mime.py +244 -0
- clm/layer2/mime_detector.py +245 -0
- clm/layer2/private_collection.py +118 -0
- clm/layer2/shared_store.py +11 -0
- clm/layer2/storage/__init__.py +190 -0
- clm/layer2/storage/db_bitemporal.py +141 -0
- clm/layer2/storage/db_driver.py +331 -0
- clm/layer2/storage/db_hyperlinks.py +214 -0
- clm/layer2/storage/db_pool.py +139 -0
- clm/layer2/storage/db_repository.py +229 -0
- clm/layer2/storage/savepoint.py +78 -0
- clm/layer2/storage/sqlite_wal.py +74 -0
- clm/layer2/tridb.py +245 -0
- clm/layer2/vcard_receipt.py +71 -0
- clm/layer2/vfs.py +17 -0
- clm/layer3/__init__.py +141 -0
- clm/layer3/agency/__init__.py +19 -0
- clm/layer3/agency/config.py +220 -0
- clm/layer3/agency/providers/__init__.py +15 -0
- clm/layer3/agency/providers/base.py +91 -0
- clm/layer3/agency/providers/mlc_llm.py +169 -0
- clm/layer3/agency/providers/ollama.py +203 -0
- clm/layer3/agency/router.py +71 -0
- clm/layer3/agency/runtime.py +524 -0
- clm/layer3/gateway.py +353 -0
- clm/layer3/open_interpreter_adapter.py +159 -0
- clm/layer3/rag/__init__.py +70 -0
- clm/layer3/rag/cli.py +228 -0
- clm/layer3/rag/config.py +153 -0
- clm/layer3/rag/embeddings/__init__.py +16 -0
- clm/layer3/rag/embeddings/base.py +79 -0
- clm/layer3/rag/embeddings/ollama.py +211 -0
- clm/layer3/rag/embeddings/vision.py +290 -0
- clm/layer3/rag/engine.py +276 -0
- clm/layer3/rag/graph/__init__.py +22 -0
- clm/layer3/rag/graph/community.py +198 -0
- clm/layer3/rag/graph/engine.py +433 -0
- clm/layer3/rag/graph/extractor.py +255 -0
- clm/layer3/rag/graph/schema.py +115 -0
- clm/layer3/rag/graph/store.py +641 -0
- clm/layer3/rag/indexer.py +333 -0
- clm/layer3/rag/llm_providers.json +52 -0
- clm/layer3/rag/semantic_versioning.py +313 -0
- clm/layer3/rag/vector/__init__.py +28 -0
- clm/layer3/rag/vector/handle_vector_store.py +749 -0
- clm/layer3/rag/vector/schema.py +151 -0
- clm/layer3/rag/vector/store.py +632 -0
- clm/layer3/reticulum/__init__.py +55 -0
- clm/layer3/reticulum/bridge.py +74 -0
- clm/layer3/reticulum/daemon.py +88 -0
- clm/layer3/reticulum/discovery.py +164 -0
- clm/layer3/reticulum/identity.py +107 -0
- clm/layer3/reticulum/media.py +148 -0
- clm/layer3/reticulum/transport.py +133 -0
- clm/layer3/satori/__init__.py +209 -0
- clm/layer3/satori/alpha_conversion.py +168 -0
- clm/layer3/satori/beta_reduction.py +359 -0
- clm/layer3/satori/continuation.py +38 -0
- clm/layer3/satori/eta_conversion.py +205 -0
- clm/layer3/satori/free_variables.py +163 -0
- clm/layer3/satori/io_effects.py +370 -0
- clm/layer3/satori/lambda_runtime.py +726 -0
- clm/layer3/satori/lambda_term.py +230 -0
- clm/layer3/satori/sheaf_audit.py +261 -0
- clm/layer3/satori/speech_act.py +279 -0
- clm/layer4/README.md +9 -0
- clm/layer4/__init__.py +67 -0
- clm/layer4/_sovereign_executor.py +580 -0
- clm/layer4/action_commands.py +719 -0
- clm/layer4/api.py +236 -0
- clm/layer4/bootstrap/__init__.py +13 -0
- clm/layer4/bootstrap/bootstrapper.py +118 -0
- clm/layer4/bootstrap/cleanup.py +44 -0
- clm/layer4/bootstrap/clm_hooks.py +49 -0
- clm/layer4/bootstrap/genesis.py +77 -0
- clm/layer4/bootstrap/injection.py +125 -0
- clm/layer4/cli.py +549 -0
- clm/layer4/collection_manager.py +553 -0
- clm/layer4/commands/__init__.py +81 -0
- clm/layer4/commands/action_cmd.py +42 -0
- clm/layer4/commands/check_cmd.py +82 -0
- clm/layer4/commands/envelope.py +73 -0
- clm/layer4/commands/evaluate_cmd.py +224 -0
- clm/layer4/commands/mcard_cmd.py +24 -0
- clm/layer4/commands/petri_cmd.py +92 -0
- clm/layer4/commands/tridb_cmd.py +319 -0
- clm/layer4/commands/version_cmd.py +27 -0
- clm/layer4/diff_commands.py +167 -0
- clm/layer4/eoa_sponsor.py +232 -0
- clm/layer4/events.py +69 -0
- clm/layer4/flux_gateway.py +133 -0
- clm/layer4/gateway.py +91 -0
- clm/layer4/loader.py +307 -0
- clm/layer4/mcard_commands.py +166 -0
- clm/layer4/membrane.py +273 -0
- clm/layer4/navigation.py +50 -0
- clm/layer4/protocol/protocol_entry_python.py +108 -0
- clm/layer4/protocol/python_sync.py +126 -0
- clm/layer4/runner.py +150 -0
- clm/layer4/satori.py +262 -0
- clm/layer4/server/python_sync_client.py +150 -0
- clm/layer4/server/python_sync_server.py +139 -0
- clm/layer4/server/service_api.py +315 -0
- clm/layer4/server/start_servers.py +107 -0
- clm/layer4/server/stop_servers.py +24 -0
- clm/layer4/simulated_optimization_fix.py +0 -0
- clm/layer4/storage.py +463 -0
- clm/layer4/tridb_admin.py +268 -0
- clm/layer4/websocket/test_ws_client.py +104 -0
- clm/layer4/websocket/test_ws_server.py +138 -0
- clm/layer4/websocket/ws_server_local.py +160 -0
- clm/layer5/__init__.py +51 -0
- clm/layer5/async_batch_writer.py +133 -0
- clm/layer5/learning.py +276 -0
- clm/layer5/telemetry_hook.py +107 -0
- clm/layer5/type_lattice.py +238 -0
- clm/py.typed +0 -0
- clm/schemas/__init__.py +652 -0
- clm/schemas/abstract_face_schema.yaml +77 -0
- clm/schemas/cel_schema.yaml +416 -0
- clm/schemas/lifecycle/__init__.py +45 -0
- clm/schemas/lifecycle/models.py +123 -0
- clm/schemas/lifecycle/service.py +364 -0
- clm/schemas/mcard_schema.sql +192 -0
- clm/schemas/mcard_vector_schema.sql +283 -0
- clm/schemas/mlp-context-v1.json +91 -0
- clm_kernel-0.0.0.dist-info/METADATA +121 -0
- clm_kernel-0.0.0.dist-info/RECORD +333 -0
- clm_kernel-0.0.0.dist-info/WHEEL +4 -0
- clm_kernel-0.0.0.dist-info/entry_points.txt +3 -0
- clm_kernel-0.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Hyperlink and graph-traversal SQL mixin relocated to Layer 2 Storage (Sprint 387).
|
|
2
|
+
|
|
3
|
+
Part of the CLMRepository MRO.
|
|
4
|
+
Extracted from Layer 0. Provides graph traversal and hyperlink resolution
|
|
5
|
+
over content-addressed MCards and semantic handles.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from clm.cross_cutting.clm_logger import get_logger
|
|
13
|
+
from clm.layer0.exceptions import DatabaseError
|
|
14
|
+
|
|
15
|
+
_logger = get_logger("clm.db")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HyperlinkMixin:
|
|
19
|
+
"""Graph traversal and hyperlink resolution methods for CLMRepository.
|
|
20
|
+
|
|
21
|
+
Implements:
|
|
22
|
+
- ``extract_links(content)``: parses [[wikilinks]] and mcard:// URIs.
|
|
23
|
+
- ``get_outgoing_links(hash_val, pillar)``: outgoing references from a card.
|
|
24
|
+
- ``get_backlinks(target_hash_or_handle, pillar)``: cards pointing to a target.
|
|
25
|
+
- ``traverse_graph(start_hash, max_depth, pillar)``: BFS graph traversal.
|
|
26
|
+
- ``find_citing_stages(doc_handle, pillar)``: stage cards citing an arch doc.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def extract_links(content: str) -> list[tuple[str, str]]:
|
|
31
|
+
"""Extract typed hyperlinks from MCard content.
|
|
32
|
+
|
|
33
|
+
Supports:
|
|
34
|
+
- Wikilinks: ``[[target]]`` or ``[[target|label]]``
|
|
35
|
+
- URIs: ``mcard://handle/name``, ``mcard://hash/hex``,
|
|
36
|
+
``mcard://eval/expr``
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
list of ``(link_type, target)`` tuples where link_type is one of
|
|
40
|
+
``'wikilink'``, ``'handle'``, ``'hash'``, or ``'eval'``.
|
|
41
|
+
"""
|
|
42
|
+
if not content:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
links: list[tuple[str, str]] = []
|
|
46
|
+
|
|
47
|
+
# 1. Wikilinks: [[target]] or [[target|label]]
|
|
48
|
+
for match in re.finditer(r"\[\[([^\|\]]+)(?:\|[^\]]+)?\]\]", content):
|
|
49
|
+
target = match.group(1).strip()
|
|
50
|
+
if target:
|
|
51
|
+
links.append(("wikilink", target))
|
|
52
|
+
|
|
53
|
+
# 2. MCard URIs
|
|
54
|
+
for match in re.finditer(r"mcard://(handle|hash|eval)/([^\s\'\"\)\>]+)", content):
|
|
55
|
+
scheme_type = match.group(1)
|
|
56
|
+
target = match.group(2).strip()
|
|
57
|
+
if target:
|
|
58
|
+
links.append((scheme_type, target))
|
|
59
|
+
|
|
60
|
+
return links
|
|
61
|
+
|
|
62
|
+
def get_outgoing_links(self, hash_val: str, pillar: str = "content_memory") -> list[tuple[str, str]]:
|
|
63
|
+
"""Return all outgoing hyperlinks extracted from a card's content.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
list of ``(link_type, target)`` tuples.
|
|
67
|
+
"""
|
|
68
|
+
content = self.get_card_content(hash_val, pillar)
|
|
69
|
+
if not content:
|
|
70
|
+
return []
|
|
71
|
+
return self.extract_links(content)
|
|
72
|
+
|
|
73
|
+
def get_backlinks(self, target: str, pillar: str = "content_memory") -> list[str]:
|
|
74
|
+
"""Find all card hashes that link to the given target (hash or handle).
|
|
75
|
+
|
|
76
|
+
Scans the card table using SQLite LIKE for candidates, then confirms
|
|
77
|
+
via ``extract_links`` to prevent substring false positives.
|
|
78
|
+
"""
|
|
79
|
+
sql = "SELECT hash, content FROM card WHERE content LIKE ?;"
|
|
80
|
+
like_pattern = f"%{target}%"
|
|
81
|
+
try:
|
|
82
|
+
res = self.db.execute(pillar, sql, [like_pattern])
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise DatabaseError(f"Failed to query backlinks for {target}: {e}") from e
|
|
85
|
+
|
|
86
|
+
if not res or not res.strip():
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
referring_hashes: list[str] = []
|
|
90
|
+
for line in res.strip().split("\n"):
|
|
91
|
+
line = line.strip()
|
|
92
|
+
if not line:
|
|
93
|
+
continue
|
|
94
|
+
pipe_idx = line.find("|")
|
|
95
|
+
if pipe_idx == -1:
|
|
96
|
+
continue
|
|
97
|
+
h = line[:pipe_idx].strip()
|
|
98
|
+
content = self.get_card_content(h, pillar) or ""
|
|
99
|
+
links = self.extract_links(content)
|
|
100
|
+
if any(t == target for _, t in links):
|
|
101
|
+
referring_hashes.append(h)
|
|
102
|
+
|
|
103
|
+
return referring_hashes
|
|
104
|
+
|
|
105
|
+
def traverse_graph(
|
|
106
|
+
self,
|
|
107
|
+
start_hash: str,
|
|
108
|
+
max_depth: int = 3,
|
|
109
|
+
pillar: str = "content_memory",
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Breadth-first traversal of the hyperlink graph starting from a card.
|
|
112
|
+
|
|
113
|
+
Returns a dictionary representing the traversed subgraph:
|
|
114
|
+
- ``nodes``: set of visited card hashes
|
|
115
|
+
- ``edges``: list of ``(source_hash, link_type, target)``
|
|
116
|
+
"""
|
|
117
|
+
visited: set[str] = set()
|
|
118
|
+
edges: list[tuple[str, str, str]] = []
|
|
119
|
+
queue: list[tuple[str, int]] = [(start_hash, 0)]
|
|
120
|
+
|
|
121
|
+
while queue:
|
|
122
|
+
current_hash, depth = queue.pop(0)
|
|
123
|
+
if current_hash in visited or depth > max_depth:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
visited.add(current_hash)
|
|
127
|
+
links = self.get_outgoing_links(current_hash, pillar)
|
|
128
|
+
|
|
129
|
+
for link_type, target in links:
|
|
130
|
+
edges.append((current_hash, link_type, target))
|
|
131
|
+
if depth + 1 <= max_depth:
|
|
132
|
+
next_hash: str | None = None
|
|
133
|
+
if link_type == "hash":
|
|
134
|
+
next_hash = target
|
|
135
|
+
elif link_type in ("handle", "wikilink"):
|
|
136
|
+
resolved = self.resolve_handle_hash(target, pillar)
|
|
137
|
+
if resolved:
|
|
138
|
+
next_hash = resolved
|
|
139
|
+
|
|
140
|
+
if next_hash and next_hash not in visited:
|
|
141
|
+
queue.append((next_hash, depth + 1))
|
|
142
|
+
|
|
143
|
+
return {"nodes": list(visited), "edges": edges}
|
|
144
|
+
|
|
145
|
+
def find_citing_stages(self, doc_handle: str, pillar: str = "content_memory") -> list[str]:
|
|
146
|
+
"""Return Abstract-face card hashes whose ``external_references`` cite ``doc_handle``.
|
|
147
|
+
|
|
148
|
+
Implements the contravariant Yoneda functor Hom(-, A) specialised to
|
|
149
|
+
the Sprint 134 provenance graph: given an architecture doc handle A,
|
|
150
|
+
return the hashes of every Abstract-face card whose
|
|
151
|
+
``context.external_references`` block cites it.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
doc_handle: Architecture document handle name, e.g.
|
|
155
|
+
``"doc:arch:134-provenance-hyperlinks"``.
|
|
156
|
+
pillar: Database pillar to scan (default ``"content_memory"``).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A deduplicated list of card hashes that cite ``doc_handle``.
|
|
160
|
+
Empty list if no card cites it or the handle is unknown.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
DatabaseError: if the underlying scan query fails.
|
|
164
|
+
"""
|
|
165
|
+
if not doc_handle:
|
|
166
|
+
return []
|
|
167
|
+
# Reuse the existing LIKE-pattern backlink scan: any card whose
|
|
168
|
+
# serialized content mentions the handle (as an mcard:// URI or a
|
|
169
|
+
# [[wikilink]]) is a candidate. The filter below confirms the
|
|
170
|
+
# citation via extract_links so partial-string false positives
|
|
171
|
+
# (e.g. a hash that happens to contain the handle as a substring)
|
|
172
|
+
# are rejected.
|
|
173
|
+
sql = "SELECT hash, content FROM card WHERE content LIKE ?;"
|
|
174
|
+
like_pattern = f"%{doc_handle}%"
|
|
175
|
+
try:
|
|
176
|
+
res = self.db.execute(pillar, sql, [like_pattern])
|
|
177
|
+
except Exception as e:
|
|
178
|
+
raise DatabaseError(
|
|
179
|
+
f"Failed to find citing stages for handle {doc_handle}: {e}"
|
|
180
|
+
) from e
|
|
181
|
+
if not res or not res.strip():
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
citing: list[str] = []
|
|
185
|
+
seen: set[str] = set()
|
|
186
|
+
# ``db.execute`` returns pipe-separated rows "hash|content" — but the
|
|
187
|
+
# content column itself may contain newlines, so we re-fetch per
|
|
188
|
+
# card via get_card_content to be safe. First pass extracts the
|
|
189
|
+
# candidate hashes only.
|
|
190
|
+
candidate_hashes: list[str] = []
|
|
191
|
+
for line in res.strip().split("\n"):
|
|
192
|
+
line = line.strip()
|
|
193
|
+
if not line:
|
|
194
|
+
continue
|
|
195
|
+
# The first pipe-delimited field is the hash; the rest is the
|
|
196
|
+
# content (which may contain additional pipes).
|
|
197
|
+
pipe_idx = line.find("|")
|
|
198
|
+
if pipe_idx == -1:
|
|
199
|
+
continue
|
|
200
|
+
h = line[:pipe_idx].strip()
|
|
201
|
+
if h and h not in seen:
|
|
202
|
+
seen.add(h)
|
|
203
|
+
candidate_hashes.append(h)
|
|
204
|
+
|
|
205
|
+
# Second pass: confirm via extract_links that the candidate actually
|
|
206
|
+
# cites the handle as a hyperlink (not as incidental prose).
|
|
207
|
+
for h in candidate_hashes:
|
|
208
|
+
content = self.get_card_content(h, pillar) or ""
|
|
209
|
+
links = self.extract_links(content)
|
|
210
|
+
for link_type, target in links:
|
|
211
|
+
if link_type == "handle" and target == doc_handle:
|
|
212
|
+
citing.append(h)
|
|
213
|
+
break
|
|
214
|
+
return citing
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""High-Concurrency SQLite WAL Connection Pool (Sprint 177 / Sprint 387).
|
|
2
|
+
|
|
3
|
+
Thread-safe SQLite connection pool separating Read and Write channels:
|
|
4
|
+
- Single Writer: Serialized writes using BEGIN IMMEDIATE to avoid busy locks.
|
|
5
|
+
- Multi-Reader Pool: Fixed-size queue of read-only WAL connections.
|
|
6
|
+
- In-Memory Support: Detects ':memory:' and uses shared-cache URI mode.
|
|
7
|
+
- Directory Assurance: Automatically creates parent directories for disk DBs.
|
|
8
|
+
|
|
9
|
+
Canonical Path: clm.layer2.storage.db_pool
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
import threading
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from queue import Empty, Queue
|
|
19
|
+
from typing import Generator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClmDbPool:
|
|
23
|
+
"""Thread-safe SQLite connection pool with dedicated writer and reader pool."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, db_path: str, max_readers: int = 8, timeout: float = 5.0) -> None:
|
|
26
|
+
self.raw_path = db_path
|
|
27
|
+
self.is_memory = db_path == ":memory:" or "mode=memory" in db_path
|
|
28
|
+
self.db_path = (
|
|
29
|
+
"file:clm_memdb?mode=memory&cache=shared"
|
|
30
|
+
if db_path == ":memory:"
|
|
31
|
+
else db_path
|
|
32
|
+
)
|
|
33
|
+
self.uri = self.is_memory or db_path.startswith("file:")
|
|
34
|
+
self.max_readers = max_readers
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._write_lock = threading.Lock()
|
|
37
|
+
self._writer_conn: sqlite3.Connection | None = None
|
|
38
|
+
self._reader_queue: Queue[sqlite3.Connection] = Queue(maxsize=max_readers)
|
|
39
|
+
self._closed = False
|
|
40
|
+
self._ensure_dir()
|
|
41
|
+
self._init_db()
|
|
42
|
+
|
|
43
|
+
def _ensure_dir(self) -> None:
|
|
44
|
+
"""Create parent directory if database is a disk file."""
|
|
45
|
+
if not self.is_memory and not self.db_path.startswith("file:"):
|
|
46
|
+
dir_path = os.path.dirname(os.path.abspath(self.db_path))
|
|
47
|
+
if dir_path:
|
|
48
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def _connect(self) -> sqlite3.Connection:
|
|
51
|
+
"""Open a raw connection with timeout and URI configuration."""
|
|
52
|
+
return sqlite3.connect(
|
|
53
|
+
self.db_path,
|
|
54
|
+
timeout=self.timeout,
|
|
55
|
+
uri=self.uri,
|
|
56
|
+
check_same_thread=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _init_db(self) -> None:
|
|
60
|
+
"""Initialize WAL mode and pragmatic performance settings."""
|
|
61
|
+
conn = self._connect()
|
|
62
|
+
try:
|
|
63
|
+
if not self.is_memory:
|
|
64
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
65
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
66
|
+
conn.execute("PRAGMA busy_timeout=5000;")
|
|
67
|
+
conn.execute("PRAGMA cache_size=-64000;") # 64MB cache
|
|
68
|
+
conn.commit()
|
|
69
|
+
finally:
|
|
70
|
+
conn.close()
|
|
71
|
+
|
|
72
|
+
@contextmanager
|
|
73
|
+
def read_connection(self) -> Generator[sqlite3.Connection, None, None]:
|
|
74
|
+
"""Borrow a read connection from the pool, recycling cleanly on return."""
|
|
75
|
+
conn = None
|
|
76
|
+
try:
|
|
77
|
+
conn = self._reader_queue.get_nowait()
|
|
78
|
+
except Empty:
|
|
79
|
+
conn = self._connect()
|
|
80
|
+
if not self.is_memory:
|
|
81
|
+
conn.execute("PRAGMA query_only=ON;")
|
|
82
|
+
conn.execute("PRAGMA busy_timeout=5000;")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
yield conn
|
|
86
|
+
except Exception:
|
|
87
|
+
if conn:
|
|
88
|
+
try:
|
|
89
|
+
conn.rollback()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
conn.close()
|
|
93
|
+
conn = None
|
|
94
|
+
raise
|
|
95
|
+
finally:
|
|
96
|
+
if conn is not None and not self._closed and self._reader_queue.qsize() < self.max_readers:
|
|
97
|
+
self._reader_queue.put_nowait(conn)
|
|
98
|
+
elif conn is not None:
|
|
99
|
+
conn.close()
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def write_transaction(self) -> Generator[sqlite3.Connection, None, None]:
|
|
103
|
+
"""Acquire exclusive writer connection with automatic rollback and reconnect on error."""
|
|
104
|
+
with self._write_lock:
|
|
105
|
+
if self._writer_conn is None:
|
|
106
|
+
self._writer_conn = self._connect()
|
|
107
|
+
self._writer_conn.execute("PRAGMA busy_timeout=5000;")
|
|
108
|
+
try:
|
|
109
|
+
self._writer_conn.execute("BEGIN IMMEDIATE;")
|
|
110
|
+
yield self._writer_conn
|
|
111
|
+
self._writer_conn.commit()
|
|
112
|
+
except Exception:
|
|
113
|
+
try:
|
|
114
|
+
self._writer_conn.rollback()
|
|
115
|
+
except sqlite3.OperationalError:
|
|
116
|
+
try:
|
|
117
|
+
self._writer_conn.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
self._writer_conn = None
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
"""Close all pooled connections."""
|
|
125
|
+
self._closed = True
|
|
126
|
+
with self._write_lock:
|
|
127
|
+
if self._writer_conn:
|
|
128
|
+
try:
|
|
129
|
+
self._writer_conn.close()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
self._writer_conn = None
|
|
133
|
+
|
|
134
|
+
while not self._reader_queue.empty():
|
|
135
|
+
try:
|
|
136
|
+
conn = self._reader_queue.get_nowait()
|
|
137
|
+
conn.close()
|
|
138
|
+
except Empty:
|
|
139
|
+
break
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""RepositoryBase core CRUD relocated to Layer 2 Storage (Sprint 387).
|
|
2
|
+
|
|
3
|
+
Extracted from Layer 0. The foundational Create/Retrieve/Delete operations
|
|
4
|
+
on content-addressed MCards, semantic handles, and handle history.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from clm.cross_cutting.clm_logger import get_logger
|
|
11
|
+
from clm.layer0.exceptions import DatabaseError
|
|
12
|
+
from clm.layer0.utils import clm_hash_string
|
|
13
|
+
|
|
14
|
+
_logger = get_logger("clm.db")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RepositoryBase:
|
|
18
|
+
"""Core CRUD operations for the MCard three-table schema.
|
|
19
|
+
|
|
20
|
+
Implements CRD-only operations (Create, Retrieve, Delete — no Update) on
|
|
21
|
+
content-addressed MCards, semantic handles, and the append-only handle
|
|
22
|
+
history. Handles are the α-conversion layer (mutable pointers over
|
|
23
|
+
immutable content); every handle reassignment is logged immutably in
|
|
24
|
+
handle_history (the η-conversion witness), preserving full temporal
|
|
25
|
+
provenance.
|
|
26
|
+
|
|
27
|
+
The repository enforces the Empty Schema Principle (Kenosis): zero
|
|
28
|
+
domain-specific columns. Domain semantics enter only through the content
|
|
29
|
+
stored in MCards, never through schema structure.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, db, clm_root: str):
|
|
33
|
+
self.db = db
|
|
34
|
+
self.clm_root = clm_root
|
|
35
|
+
|
|
36
|
+
def has_card(self, hash_val: str, pillar: str) -> bool:
|
|
37
|
+
"""Checks if a card exists in the CAS by hash."""
|
|
38
|
+
sql = "SELECT 1 FROM card WHERE hash = ? LIMIT 1;"
|
|
39
|
+
try:
|
|
40
|
+
res = self.db.execute(pillar, sql, [hash_val])
|
|
41
|
+
return res.strip() == "1"
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise DatabaseError(f"Failed to check card existence in {pillar}: {e}") from e
|
|
44
|
+
|
|
45
|
+
def get_card_content(self, hash_val: str, pillar: str) -> str | None:
|
|
46
|
+
"""Gets the content of a card from the CAS by hash."""
|
|
47
|
+
sql = "SELECT content FROM card WHERE hash = ?;"
|
|
48
|
+
try:
|
|
49
|
+
res = self.db.execute(pillar, sql, [hash_val])
|
|
50
|
+
return res.strip() if res else None
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise DatabaseError(f"Failed to get card content from {pillar}: {e}") from e
|
|
53
|
+
|
|
54
|
+
def save_card(self, hash_val: str, content: str, pillar: str, timestamp: str | None = None, replace: bool = False) -> None:
|
|
55
|
+
"""Saves a card to the CAS."""
|
|
56
|
+
g_time = timestamp or time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
57
|
+
verb = "REPLACE" if replace else "IGNORE"
|
|
58
|
+
sql = f"INSERT OR {verb} INTO card (hash, content, g_time) VALUES (?, ?, ?);"
|
|
59
|
+
try:
|
|
60
|
+
self.db.execute(pillar, sql, [hash_val, content, g_time])
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise DatabaseError(f"Failed to save card to {pillar}: {e}") from e
|
|
63
|
+
|
|
64
|
+
def save_card_and_handle(self, handle_name: str, hash_val: str, content: str, pillar: str, timestamp: str | None = None, replace: bool = True) -> None:
|
|
65
|
+
"""Saves a card to the CAS and updates/registers its handle."""
|
|
66
|
+
g_time = timestamp or time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
67
|
+
self.save_card(hash_val, content, pillar, timestamp=g_time, replace=replace)
|
|
68
|
+
|
|
69
|
+
existing_hash = self.resolve_handle_hash(handle_name, pillar)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if not existing_hash:
|
|
73
|
+
verb = "REPLACE" if replace else "IGNORE"
|
|
74
|
+
sql = f"INSERT OR {verb} INTO handle_registry (handle, current_hash, created_at, updated_at) VALUES (?, ?, ?, ?);"
|
|
75
|
+
self.db.execute(pillar, sql, [handle_name, hash_val, g_time, g_time])
|
|
76
|
+
elif existing_hash != hash_val:
|
|
77
|
+
self.db.execute(pillar, "INSERT OR IGNORE INTO handle_history (handle, previous_hash, changed_at) VALUES (?, ?, ?);", [handle_name, existing_hash, g_time])
|
|
78
|
+
self.db.execute(pillar, "UPDATE handle_registry SET current_hash = ?, updated_at = ? WHERE handle = ?;", [hash_val, g_time, handle_name])
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise DatabaseError(f"Failed to save handle {handle_name} in {pillar}: {e}") from e
|
|
81
|
+
|
|
82
|
+
def get_handle_by_hash(self, hash_val: str, pillar: str) -> str | None:
|
|
83
|
+
"""Gets the handle name associated with a given hash value."""
|
|
84
|
+
sql = "SELECT handle FROM handle_registry WHERE current_hash = ? LIMIT 1;"
|
|
85
|
+
try:
|
|
86
|
+
res = self.db.execute(pillar, sql, [hash_val])
|
|
87
|
+
return res.strip() if res else None
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise DatabaseError(f"Failed to get handle by hash from {pillar}: {e}") from e
|
|
90
|
+
|
|
91
|
+
def get_all_handles(self, pillar: str) -> list[tuple[str, str]]:
|
|
92
|
+
"""Gets all handle-to-hash mappings from the handle registry."""
|
|
93
|
+
sql = "SELECT handle, current_hash FROM handle_registry;"
|
|
94
|
+
try:
|
|
95
|
+
res = self.db.execute(pillar, sql)
|
|
96
|
+
lines = [line.strip() for line in res.strip().split("\n") if line.strip()]
|
|
97
|
+
results = []
|
|
98
|
+
for line in lines:
|
|
99
|
+
parts = line.split("|")
|
|
100
|
+
if len(parts) >= 2:
|
|
101
|
+
results.append((parts[0], parts[1]))
|
|
102
|
+
return results
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise DatabaseError(f"Failed to get all handles from {pillar}: {e}") from e
|
|
105
|
+
|
|
106
|
+
def get_latest_handle_history(self, handle_name: str, pillar: str) -> str | None:
|
|
107
|
+
"""Gets the latest previous hash from the handle history."""
|
|
108
|
+
sql = "SELECT previous_hash FROM handle_history WHERE handle = ? ORDER BY changed_at DESC LIMIT 1;"
|
|
109
|
+
try:
|
|
110
|
+
res = self.db.execute(pillar, sql, [handle_name])
|
|
111
|
+
return res.strip() if res else None
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise DatabaseError(f"Failed to get latest handle history for {handle_name} from {pillar}: {e}") from e
|
|
114
|
+
|
|
115
|
+
def resolve_handle_hash(self, handle_name: str, pillar: str = "content_memory") -> str:
|
|
116
|
+
sql = "SELECT current_hash FROM handle_registry WHERE handle = ?;"
|
|
117
|
+
res = self.db.execute(pillar, sql, [handle_name])
|
|
118
|
+
return res.strip() if res else ""
|
|
119
|
+
|
|
120
|
+
def _find_script_on_disk(self, handle_name: str) -> str | None:
|
|
121
|
+
if not handle_name.startswith("script:"):
|
|
122
|
+
return None
|
|
123
|
+
script_name = handle_name.split("script:")[1].split("#")[0]
|
|
124
|
+
for ext in (".py", ".sh"):
|
|
125
|
+
for dir_name in ("tests/runners", "scripts", "tests", "computable_knowledge"):
|
|
126
|
+
dir_path = Path(self.clm_root) / dir_name
|
|
127
|
+
if dir_path.exists():
|
|
128
|
+
if dir_name == "tests/runners":
|
|
129
|
+
for sub in ("layers", "sprints", "petri", "benchmarks"):
|
|
130
|
+
cand = dir_path / sub / f"{script_name}{ext}"
|
|
131
|
+
if cand.exists():
|
|
132
|
+
return str(cand)
|
|
133
|
+
for p in dir_path.rglob(f"{script_name}{ext}"):
|
|
134
|
+
return str(p)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def resolve_handle_content(self, handle_name: str, pillar: str = "content_memory") -> str:
|
|
138
|
+
hash_val = self.resolve_handle_hash(handle_name, pillar)
|
|
139
|
+
|
|
140
|
+
if not hash_val and handle_name.startswith("script:"):
|
|
141
|
+
script_path = self._find_script_on_disk(handle_name)
|
|
142
|
+
if script_path:
|
|
143
|
+
self.register_handle(handle_name, script_path)
|
|
144
|
+
hash_val = self.resolve_handle_hash(handle_name, pillar)
|
|
145
|
+
|
|
146
|
+
if hash_val and handle_name.startswith("script:"):
|
|
147
|
+
script_path = self._find_script_on_disk(handle_name)
|
|
148
|
+
if script_path:
|
|
149
|
+
with open(script_path, encoding="utf-8") as f:
|
|
150
|
+
disk_content = f.read()
|
|
151
|
+
norm_content = disk_content.replace("\r\n", "\n").replace("\r", "\n")
|
|
152
|
+
disk_hash = clm_hash_string(norm_content)
|
|
153
|
+
if disk_hash != hash_val:
|
|
154
|
+
_logger.info(f"[PAL:python] Stale handle detected: '{handle_name}' (DB={hash_val}, disk={disk_hash}). Re-reading from file.")
|
|
155
|
+
self.register_handle(handle_name, script_path)
|
|
156
|
+
hash_val = disk_hash
|
|
157
|
+
|
|
158
|
+
if not hash_val:
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
sql = "SELECT content FROM card WHERE hash = ?;"
|
|
162
|
+
res = self.db.execute(pillar, sql, [hash_val])
|
|
163
|
+
content_val = res.strip() if res else ""
|
|
164
|
+
if handle_name.startswith("script:") and not content_val:
|
|
165
|
+
script_path = self._find_script_on_disk(handle_name)
|
|
166
|
+
if script_path:
|
|
167
|
+
with open(script_path, encoding="utf-8") as f:
|
|
168
|
+
disk_content = f.read()
|
|
169
|
+
content_val = disk_content.replace("\r\n", "\n").replace("\r", "\n")
|
|
170
|
+
|
|
171
|
+
return content_val
|
|
172
|
+
|
|
173
|
+
def register_handle(self, handle_name: str, content_source: str) -> int:
|
|
174
|
+
import sys
|
|
175
|
+
if content_source == "-":
|
|
176
|
+
content = sys.stdin.read()
|
|
177
|
+
elif os.path.exists(content_source):
|
|
178
|
+
with open(content_source, encoding="utf-8") as f:
|
|
179
|
+
content = f.read()
|
|
180
|
+
else:
|
|
181
|
+
content = content_source
|
|
182
|
+
|
|
183
|
+
if not content:
|
|
184
|
+
_logger.error(f"[REGISTER_HANDLE] Empty content for handle: {handle_name}")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
norm_content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
188
|
+
content_hash = clm_hash_string(norm_content)
|
|
189
|
+
|
|
190
|
+
existing_hash = self.resolve_handle_hash(handle_name, "content_memory")
|
|
191
|
+
g_time = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
192
|
+
|
|
193
|
+
self.db.execute("content_memory", "INSERT OR IGNORE INTO card (hash, content, g_time) VALUES (?, ?, ?);", [content_hash, content, g_time])
|
|
194
|
+
|
|
195
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
196
|
+
if not existing_hash:
|
|
197
|
+
self.db.execute("content_memory", "INSERT OR REPLACE INTO handle_registry (handle, current_hash, created_at, updated_at) VALUES (?, ?, ?, ?);", [handle_name, content_hash, now, now])
|
|
198
|
+
_logger.info(f"[REGISTER_HANDLE] Created handle: {handle_name}")
|
|
199
|
+
_logger.info(f"[REGISTER_HANDLE] hash: {content_hash}")
|
|
200
|
+
elif existing_hash == content_hash:
|
|
201
|
+
_logger.info(f"[REGISTER_HANDLE] No change for handle: {handle_name}")
|
|
202
|
+
_logger.info(f"[REGISTER_HANDLE] hash: {content_hash}")
|
|
203
|
+
else:
|
|
204
|
+
self.db.execute("content_memory", "INSERT INTO handle_history (handle, previous_hash, changed_at) VALUES (?, ?, ?);", [handle_name, existing_hash, now])
|
|
205
|
+
self.db.execute("content_memory", "UPDATE handle_registry SET current_hash = ?, updated_at = ? WHERE handle = ?;", [content_hash, now, handle_name])
|
|
206
|
+
_logger.info(f"[REGISTER_HANDLE] Updated handle: {handle_name}")
|
|
207
|
+
_logger.info(f"[REGISTER_HANDLE] old_hash: {existing_hash}")
|
|
208
|
+
_logger.info(f"[REGISTER_HANDLE] new_hash: {content_hash}")
|
|
209
|
+
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
def tombstone_handle(self, handle_name: str, pillar: str = "content_memory") -> bool:
|
|
213
|
+
"""Tombstones a handle by dereferencing it and appending a tombstone record to handle_history.
|
|
214
|
+
|
|
215
|
+
Preserves G-Set CRDT monotonicity: the referenced CAS card remains untouched,
|
|
216
|
+
while the mutable handle pointer is removed and an audit tombstone event is logged.
|
|
217
|
+
"""
|
|
218
|
+
existing_hash = self.resolve_handle_hash(handle_name, pillar)
|
|
219
|
+
if not existing_hash:
|
|
220
|
+
return False
|
|
221
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
222
|
+
self.db.execute(
|
|
223
|
+
pillar,
|
|
224
|
+
"INSERT INTO handle_history (handle, previous_hash, changed_at, change_type) VALUES (?, ?, ?, 'TOMBSTONE');",
|
|
225
|
+
[handle_name, existing_hash, now],
|
|
226
|
+
)
|
|
227
|
+
self.db.execute(pillar, "DELETE FROM handle_registry WHERE handle = ?;", [handle_name])
|
|
228
|
+
_logger.info(f"[TOMBSTONE_HANDLE] Tombstoned handle '{handle_name}' (was {existing_hash})")
|
|
229
|
+
return True
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""SQLite Savepoint Transaction Guard & Cordis Disposable List (Sprint 177 / Sprint 387).
|
|
2
|
+
|
|
3
|
+
Implements nested transaction savepoints mirroring Rust Sprint 165 SavepointGuard,
|
|
4
|
+
and DisposableList for strict LIFO rollback and zero-residual-state guarantees.
|
|
5
|
+
|
|
6
|
+
Canonical Path: clm.layer2.storage.savepoint
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Callable, Generator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextmanager
|
|
18
|
+
def savepoint(conn: sqlite3.Connection, name: str | None = None) -> Generator[DisposableList, None, None]:
|
|
19
|
+
"""Execute within a named SQLite savepoint with automatic LIFO rollback on exception."""
|
|
20
|
+
sp_name = name or f"sp_{uuid.uuid4().hex[:8]}"
|
|
21
|
+
disposables = DisposableList()
|
|
22
|
+
disposables.name = sp_name
|
|
23
|
+
conn.execute(f"SAVEPOINT {sp_name};")
|
|
24
|
+
try:
|
|
25
|
+
yield disposables
|
|
26
|
+
conn.execute(f"RELEASE SAVEPOINT {sp_name};")
|
|
27
|
+
except Exception:
|
|
28
|
+
disposables.dispose()
|
|
29
|
+
conn.execute(f"ROLLBACK TO SAVEPOINT {sp_name};")
|
|
30
|
+
conn.execute(f"RELEASE SAVEPOINT {sp_name};")
|
|
31
|
+
raise
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DisposableList:
|
|
35
|
+
"""LIFO stack of reversible effects and cleanup callbacks (Cordis parity)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._disposers: list[Callable[[], None]] = []
|
|
39
|
+
|
|
40
|
+
def push(self, disposer: Callable[[], None]) -> None:
|
|
41
|
+
"""Pushes a cleanup function to the top of the LIFO stack."""
|
|
42
|
+
self._disposers.append(disposer)
|
|
43
|
+
|
|
44
|
+
def register(self, disposer: Callable[[], None]) -> None:
|
|
45
|
+
"""Alias for push."""
|
|
46
|
+
self.push(disposer)
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
return len(self._disposers)
|
|
50
|
+
|
|
51
|
+
def is_empty(self) -> bool:
|
|
52
|
+
return len(self._disposers) == 0
|
|
53
|
+
|
|
54
|
+
def dispose(self) -> list[Exception]:
|
|
55
|
+
"""Unwinds all registered disposers in strict LIFO order.
|
|
56
|
+
|
|
57
|
+
Executes all disposers even if some raise exceptions, accumulating errors.
|
|
58
|
+
"""
|
|
59
|
+
errors: list[Exception] = []
|
|
60
|
+
while self._disposers:
|
|
61
|
+
disposer = self._disposers.pop()
|
|
62
|
+
try:
|
|
63
|
+
disposer()
|
|
64
|
+
except Exception as e:
|
|
65
|
+
errors.append(e)
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
def unwind_lifo(self) -> list[Exception]:
|
|
69
|
+
"""Alias for dispose() matching Cordis LIFO unwind convention."""
|
|
70
|
+
return self.dispose()
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> DisposableList:
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
76
|
+
errors = self.dispose()
|
|
77
|
+
if errors and exc_type is None:
|
|
78
|
+
raise errors[0]
|