semantics: complete DEF-to-region migration, fix regressions

- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
This commit is contained in:
2026-05-12 23:54:55 +03:00
parent fe8978f716
commit 306c5ae742
331 changed files with 9630 additions and 10312 deletions

View File

@@ -1,8 +1,10 @@
# #region test_encryption_manager [C:3] [TYPE Module] [SEMANTICS encryption, security, fernet, api-keys, tests]
# @BRIEF Unit tests for EncryptionManager encrypt/decrypt functionality.
# @LAYER Domain
# @INVARIANT Encrypt+decrypt roundtrip always returns original plaintext.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:test_encryption_manager:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: encryption, security, fernet, api-keys, tests
# @PURPOSE: Unit tests for EncryptionManager encrypt/decrypt functionality.
# @LAYER: Domain
# @INVARIANT: Encrypt+decrypt roundtrip always returns original plaintext.
import sys
from pathlib import Path
@@ -13,11 +15,11 @@ from unittest.mock import patch
from cryptography.fernet import Fernet, InvalidToken
# #region TestEncryptionManager [TYPE Class]
# @BRIEF Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling.
# @PRE cryptography package installed.
# @POST All encrypt/decrypt invariants verified.
# @RELATION BINDS_TO -> [test_encryption_manager]
# [DEF:TestEncryptionManager:Class]
# @RELATION: BINDS_TO -> test_encryption_manager
# @PURPOSE: Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling.
# @PRE: cryptography package installed.
# @POST: All encrypt/decrypt invariants verified.
class TestEncryptionManager:
"""Tests for the EncryptionManager class."""
@@ -39,10 +41,10 @@ class TestEncryptionManager:
return EncryptionManager()
# #region test_encrypt_decrypt_roundtrip [TYPE Function]
# @BRIEF Encrypt then decrypt returns original plaintext.
# @PRE Valid plaintext string.
# @POST Decrypted output equals original input.
# [DEF:test_encrypt_decrypt_roundtrip:Function]
# @PURPOSE: Encrypt then decrypt returns original plaintext.
# @PRE: Valid plaintext string.
# @POST: Decrypted output equals original input.
def test_encrypt_decrypt_roundtrip(self):
mgr = self._make_manager()
original = "my-secret-api-key-12345"
@@ -50,69 +52,69 @@ class TestEncryptionManager:
assert encrypted != original
decrypted = mgr.decrypt(encrypted)
assert decrypted == original
# #endregion test_encrypt_decrypt_roundtrip
# [/DEF:test_encrypt_decrypt_roundtrip:Function]
# #region test_encrypt_produces_different_output [TYPE Function]
# @BRIEF Same plaintext produces different ciphertext (Fernet uses random IV).
# @PRE Two encrypt calls with same input.
# @POST Ciphertexts differ but both decrypt to same value.
# [DEF:test_encrypt_produces_different_output:Function]
# @PURPOSE: Same plaintext produces different ciphertext (Fernet uses random IV).
# @PRE: Two encrypt calls with same input.
# @POST: Ciphertexts differ but both decrypt to same value.
def test_encrypt_produces_different_output(self):
mgr = self._make_manager()
ct1 = mgr.encrypt("same-key")
ct2 = mgr.encrypt("same-key")
assert ct1 != ct2
assert mgr.decrypt(ct1) == mgr.decrypt(ct2) == "same-key"
# #endregion test_encrypt_produces_different_output
# [/DEF:test_encrypt_produces_different_output:Function]
# #region test_different_inputs_yield_different_ciphertext [TYPE Function]
# @BRIEF Different inputs produce different ciphertexts.
# @PRE Two different plaintext values.
# @POST Encrypted outputs differ.
# [DEF:test_different_inputs_yield_different_ciphertext:Function]
# @PURPOSE: Different inputs produce different ciphertexts.
# @PRE: Two different plaintext values.
# @POST: Encrypted outputs differ.
def test_different_inputs_yield_different_ciphertext(self):
mgr = self._make_manager()
ct1 = mgr.encrypt("key-one")
ct2 = mgr.encrypt("key-two")
assert ct1 != ct2
# #endregion test_different_inputs_yield_different_ciphertext
# [/DEF:test_different_inputs_yield_different_ciphertext:Function]
# #region test_decrypt_invalid_data_raises [TYPE Function]
# @BRIEF Decrypting invalid data raises InvalidToken.
# @PRE Invalid ciphertext string.
# @POST Exception raised.
# [DEF:test_decrypt_invalid_data_raises:Function]
# @PURPOSE: Decrypting invalid data raises InvalidToken.
# @PRE: Invalid ciphertext string.
# @POST: Exception raised.
def test_decrypt_invalid_data_raises(self):
mgr = self._make_manager()
with pytest.raises(Exception):
mgr.decrypt("not-a-valid-fernet-token")
# #endregion test_decrypt_invalid_data_raises
# [/DEF:test_decrypt_invalid_data_raises:Function]
# #region test_encrypt_empty_string [TYPE Function]
# @BRIEF Encrypting and decrypting an empty string works.
# @PRE Empty string input.
# @POST Decrypted output equals empty string.
# [DEF:test_encrypt_empty_string:Function]
# @PURPOSE: Encrypting and decrypting an empty string works.
# @PRE: Empty string input.
# @POST: Decrypted output equals empty string.
def test_encrypt_empty_string(self):
mgr = self._make_manager()
encrypted = mgr.encrypt("")
assert encrypted
decrypted = mgr.decrypt(encrypted)
assert decrypted == ""
# #endregion test_encrypt_empty_string
# [/DEF:test_encrypt_empty_string:Function]
# #region test_missing_key_fails_fast [TYPE Function]
# @BRIEF Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
# @PRE ENCRYPTION_KEY is unset.
# @POST RuntimeError raised during EncryptionManager construction.
# [DEF:test_missing_key_fails_fast:Function]
# @PURPOSE: Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
# @PRE: ENCRYPTION_KEY is unset.
# @POST: RuntimeError raised during EncryptionManager construction.
def test_missing_key_fails_fast(self):
from src.services.llm_provider import EncryptionManager
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
EncryptionManager()
# #endregion test_missing_key_fails_fast
# [/DEF:test_missing_key_fails_fast:Function]
# #region test_custom_key_roundtrip [TYPE Function]
# @BRIEF Custom Fernet key produces valid roundtrip.
# @PRE Generated Fernet key.
# @POST Encrypt/decrypt with custom key succeeds.
# [DEF:test_custom_key_roundtrip:Function]
# @PURPOSE: Custom Fernet key produces valid roundtrip.
# @PRE: Generated Fernet key.
# @POST: Encrypt/decrypt with custom key succeeds.
def test_custom_key_roundtrip(self):
custom_key = Fernet.generate_key()
fernet = Fernet(custom_key)
@@ -130,7 +132,7 @@ class TestEncryptionManager:
encrypted = mgr.encrypt("test-with-custom-key")
decrypted = mgr.decrypt(encrypted)
assert decrypted == "test-with-custom-key"
# #endregion test_custom_key_roundtrip
# [/DEF:test_custom_key_roundtrip:Function]
# #endregion TestEncryptionManager
# #endregion test_encryption_manager
# [/DEF:TestEncryptionManager:Class]
# [/DEF:test_encryption_manager:Module]

View File

@@ -4,9 +4,10 @@ from unittest.mock import MagicMock, patch
from src.services.health_service import HealthService
from src.models.llm import ValidationRecord
# #region test_health_service [C:3] [TYPE Module]
# @BRIEF Unit tests for HealthService aggregation logic.
# @RELATION VERIFIES -> [src.services.health_service.HealthService]
# [DEF:test_health_service:Module]
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for HealthService aggregation logic.
# @RELATION: VERIFIES ->[src.services.health_service.HealthService]
@pytest.mark.asyncio
@@ -161,9 +162,9 @@ async def test_get_health_summary_reuses_dashboard_metadata_cache_across_service
HealthService._dashboard_summary_cache.clear()
# #region test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks [TYPE Function]
# @BRIEF Verify that deleting a validation report also removes dashboard scope and linked tasks.
# @RELATION BINDS_TO -> [test_health_service]
# [DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
# @RELATION: BINDS_TO ->[test_health_service]
# @PURPOSE: Verify that deleting a validation report also removes dashboard scope and linked tasks.
def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
db = MagicMock()
config_manager = MagicMock()
@@ -234,12 +235,12 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
assert "task-3" in task_manager.tasks
# #endregion test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks
# [/DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
# #region test_delete_validation_report_returns_false_for_unknown_record [TYPE Function]
# @BRIEF Verify delete returns False when validation record does not exist.
# @RELATION BINDS_TO -> [test_health_service]
# [DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
# @RELATION: BINDS_TO ->[test_health_service]
# @PURPOSE: Verify delete returns False when validation record does not exist.
def test_delete_validation_report_returns_false_for_unknown_record():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
@@ -249,12 +250,12 @@ def test_delete_validation_report_returns_false_for_unknown_record():
assert service.delete_validation_report("missing") is False
# #endregion test_delete_validation_report_returns_false_for_unknown_record
# [/DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
# #region test_delete_validation_report_swallows_linked_task_cleanup_failure [TYPE Function]
# @BRIEF Verify delete swallows exceptions when cleaning up linked tasks.
# @RELATION BINDS_TO -> [test_health_service]
# [DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
# @RELATION: BINDS_TO ->[test_health_service]
# @PURPOSE: Verify delete swallows exceptions when cleaning up linked tasks.
def test_delete_validation_report_swallows_linked_task_cleanup_failure():
db = MagicMock()
config_manager = MagicMock()
@@ -304,5 +305,5 @@ def test_delete_validation_report_swallows_linked_task_cleanup_failure():
assert "task-1" not in task_manager.tasks
# #endregion test_delete_validation_report_swallows_linked_task_cleanup_failure
# #endregion test_health_service
# [/DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
# [/DEF:test_health_service:Module]

View File

@@ -1,6 +1,7 @@
# #region test_llm_plugin_persistence [C:3] [TYPE Module]
# @BRIEF Regression test for ValidationRecord persistence fields populated from task context.
# @RELATION VERIFIES -> [DashboardValidationPlugin:Class]
# [DEF:test_llm_plugin_persistence:Module]
# @RELATION: VERIFIES -> [DashboardValidationPlugin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
import types
import pytest
@@ -8,10 +9,11 @@ import pytest
from src.plugins.llm_analysis import plugin as plugin_module
# #region _DummyLogger [C:1] [TYPE Class]
# @BRIEF Minimal logger shim for TaskContext-like objects used in tests.
# @INVARIANT Logging methods are no-ops and must not mutate test state.
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
# [DEF:_DummyLogger:Class]
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
# @COMPLEXITY: 1
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
# @INVARIANT: Logging methods are no-ops and must not mutate test state.
class _DummyLogger:
def with_source(self, _source: str):
return self
@@ -29,13 +31,14 @@ class _DummyLogger:
return None
# #endregion _DummyLogger
# [/DEF:_DummyLogger:Class]
# #region _FakeDBSession [C:2] [TYPE Class]
# @BRIEF Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
# @INVARIANT add/commit/close provide only persistence signals asserted by this test.
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
# [DEF:_FakeDBSession:Class]
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
# @COMPLEXITY: 2
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
# @INVARIANT: add/commit/close provide only persistence signals asserted by this test.
class _FakeDBSession:
def __init__(self):
self.added = None
@@ -52,14 +55,15 @@ class _FakeDBSession:
self.closed = True
# #endregion _FakeDBSession
# [/DEF:_FakeDBSession:Class]
# #region test_dashboard_validation_plugin_persists_task_and_environment_ids [C:2] [TYPE Function]
# @BRIEF Ensure db ValidationRecord includes context.task_id and params.environment_id.
# @INVARIANT Assertions remain restricted to persisted task/environment identity fields and session lifecycle signals.
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
# @RELATION VERIFIES -> [DashboardValidationPlugin:Class]
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
# @RELATION: VERIFIES -> [DashboardValidationPlugin:Class]
# @COMPLEXITY: 2
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
# @INVARIANT: Assertions remain restricted to persisted task/environment identity fields and session lifecycle signals.
@pytest.mark.asyncio
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
tmp_path, monkeypatch
@@ -76,10 +80,11 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
is_active=True,
)
# #region _FakeProviderService [C:1] [TYPE Class]
# @BRIEF LLM provider service stub returning deterministic provider and decrypted API key for plugin tests.
# @INVARIANT Returns same provider and key regardless of provider_id argument; no lookup logic.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeProviderService:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 1
# @PURPOSE: LLM provider service stub returning deterministic provider and decrypted API key for plugin tests.
# @INVARIANT: Returns same provider and key regardless of provider_id argument; no lookup logic.
class _FakeProviderService:
def __init__(self, _db):
return None
@@ -90,12 +95,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
def get_decrypted_api_key(self, _provider_id):
return "a" * 32
# #endregion _FakeProviderService
# [/DEF:_FakeProviderService:Class]
# #region _FakeScreenshotService [C:1] [TYPE Class]
# @BRIEF Screenshot service stub that accepts capture_dashboard calls without side effects.
# @INVARIANT capture_dashboard is intentionally permissive for this persistence-focused test and does not validate argument values.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeScreenshotService:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 1
# @PURPOSE: Screenshot service stub that accepts capture_dashboard calls without side effects.
# @INVARIANT: capture_dashboard is intentionally permissive for this persistence-focused test and does not validate argument values.
class _FakeScreenshotService:
def __init__(self, _env):
return None
@@ -103,12 +109,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
return None
# #endregion _FakeScreenshotService
# [/DEF:_FakeScreenshotService:Class]
# #region _FakeLLMClient [C:2] [TYPE Class]
# @BRIEF Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
# @INVARIANT analyze_dashboard is side-effect free and returns schema-compatible PASS result.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeLLMClient:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 2
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
# @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result.
class _FakeLLMClient:
"""Fake LLM client for persistence tests.
@@ -126,12 +133,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
"issues": [],
}
# #endregion _FakeLLMClient
# [/DEF:_FakeLLMClient:Class]
# #region _FakeNotificationService [C:1] [TYPE Class]
# @BRIEF Notification service stub that accepts plugin dispatch_report payload without introducing side effects.
# @INVARIANT dispatch_report accepts arbitrary keyword payloads because this test verifies persistence fields, not notification payload schema.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeNotificationService:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 1
# @PURPOSE: Notification service stub that accepts plugin dispatch_report payload without introducing side effects.
# @INVARIANT: dispatch_report accepts arbitrary keyword payloads because this test verifies persistence fields, not notification payload schema.
class _FakeNotificationService:
def __init__(self, *_args, **_kwargs):
return None
@@ -139,12 +147,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
async def dispatch_report(self, **_kwargs):
return None
# #endregion _FakeNotificationService
# [/DEF:_FakeNotificationService:Class]
# #region _FakeConfigManager [C:1] [TYPE Class]
# @BRIEF Config manager stub providing storage root path and minimal settings for plugin execution path.
# @INVARIANT Only storage.root_path and llm fields are safe to access; all other settings fields are absent.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 1
# @PURPOSE: Config manager stub providing storage root path and minimal settings for plugin execution path.
# @INVARIANT: Only storage.root_path and llm fields are safe to access; all other settings fields are absent.
class _FakeConfigManager:
def get_environment(self, _env_id):
return env
@@ -157,19 +166,20 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
)
)
# #endregion _FakeConfigManager
# [/DEF:_FakeConfigManager:Class]
# #region _FakeSupersetClient [C:1] [TYPE Class]
# @BRIEF Superset client stub exposing network.request as a lambda that returns empty result list.
# @INVARIANT network.request intentionally accepts arbitrary keyword payloads because response shape, not request signature, is the persistence-path dependency.
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [DEF:_FakeSupersetClient:Class]
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# @COMPLEXITY: 1
# @PURPOSE: Superset client stub exposing network.request as a lambda that returns empty result list.
# @INVARIANT: network.request intentionally accepts arbitrary keyword payloads because response shape, not request signature, is the persistence-path dependency.
class _FakeSupersetClient:
def __init__(self, _env):
self.network = types.SimpleNamespace(
request=lambda **_kwargs: {"result": []}
)
# #endregion _FakeSupersetClient
# [/DEF:_FakeSupersetClient:Class]
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
@@ -205,4 +215,7 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
assert fake_db.added.environment_id == "env-42"
# #endregion test_dashboard_validation_plugin_persists_task_and_environment_ids
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
# [/DEF:test_llm_plugin_persistence:Module]

View File

@@ -1,8 +1,10 @@
# #region test_llm_prompt_templates [C:3] [TYPE Module] [SEMANTICS tests, llm, prompts, templates, settings]
# @BRIEF Validate normalization and rendering behavior for configurable LLM prompt templates.
# @LAYER Domain Tests
# @INVARIANT All required prompt keys remain available after normalization.
# @RELATION DEPENDS_ON -> [llm_prompt_templates]
# [DEF:test_llm_prompt_templates:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, llm, prompts, templates, settings
# @PURPOSE: Validate normalization and rendering behavior for configurable LLM prompt templates.
# @LAYER: Domain Tests
# @RELATION: DEPENDS_ON -> [llm_prompt_templates]
# @INVARIANT: All required prompt keys remain available after normalization.
from src.services.llm_prompt_templates import (
DEFAULT_LLM_ASSISTANT_SETTINGS,
@@ -15,11 +17,12 @@ from src.services.llm_prompt_templates import (
)
# #region test_normalize_llm_settings_adds_default_prompts [C:3] [TYPE Function]
# @BRIEF Ensure legacy/partial llm settings are expanded with all prompt defaults.
# @PRE Input llm settings do not contain complete prompts object.
# @POST Returned structure includes required prompt templates with fallback defaults.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_normalize_llm_settings_adds_default_prompts:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 3
# @PURPOSE: Ensure legacy/partial llm settings are expanded with all prompt defaults.
# @PRE: Input llm settings do not contain complete prompts object.
# @POST: Returned structure includes required prompt templates with fallback defaults.
def test_normalize_llm_settings_adds_default_prompts():
normalized = normalize_llm_settings({"default_provider": "x"})
@@ -35,14 +38,15 @@ def test_normalize_llm_settings_adds_default_prompts():
assert key in normalized
# #endregion test_normalize_llm_settings_adds_default_prompts
# [/DEF:test_normalize_llm_settings_adds_default_prompts:Function]
# #region test_normalize_llm_settings_keeps_custom_prompt_values [C:3] [TYPE Function]
# @BRIEF Ensure user-customized prompt values are preserved during normalization.
# @PRE Input llm settings contain custom prompt override.
# @POST Custom prompt value remains unchanged in normalized output.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 3
# @PURPOSE: Ensure user-customized prompt values are preserved during normalization.
# @PRE: Input llm settings contain custom prompt override.
# @POST: Custom prompt value remains unchanged in normalized output.
def test_normalize_llm_settings_keeps_custom_prompt_values():
custom = "Doc for {dataset_name} using {columns_json}"
normalized = normalize_llm_settings({"prompts": {"documentation_prompt": custom}})
@@ -50,14 +54,15 @@ def test_normalize_llm_settings_keeps_custom_prompt_values():
assert normalized["prompts"]["documentation_prompt"] == custom
# #endregion test_normalize_llm_settings_keeps_custom_prompt_values
# [/DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
# #region test_render_prompt_replaces_known_placeholders [C:3] [TYPE Function]
# @BRIEF Ensure template placeholders are deterministically replaced.
# @PRE Template contains placeholders matching provided variables.
# @POST Rendered prompt string contains substituted values.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_render_prompt_replaces_known_placeholders:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 3
# @PURPOSE: Ensure template placeholders are deterministically replaced.
# @PRE: Template contains placeholders matching provided variables.
# @POST: Rendered prompt string contains substituted values.
def test_render_prompt_replaces_known_placeholders():
rendered = render_prompt(
"Hello {name}, diff={diff}",
@@ -67,12 +72,13 @@ def test_render_prompt_replaces_known_placeholders():
assert rendered == "Hello bot, diff=A->B"
# #endregion test_render_prompt_replaces_known_placeholders
# [/DEF:test_render_prompt_replaces_known_placeholders:Function]
# #region test_is_multimodal_model_detects_known_vision_models [C:2] [TYPE Function]
# @BRIEF Ensure multimodal model detection recognizes common vision-capable model names.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_is_multimodal_model_detects_known_vision_models:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 2
# @PURPOSE: Ensure multimodal model detection recognizes common vision-capable model names.
def test_is_multimodal_model_detects_known_vision_models():
assert is_multimodal_model("gpt-4o") is True
assert is_multimodal_model("claude-3-5-sonnet") is True
@@ -80,12 +86,13 @@ def test_is_multimodal_model_detects_known_vision_models():
assert is_multimodal_model("text-only-model") is False
# #endregion test_is_multimodal_model_detects_known_vision_models
# [/DEF:test_is_multimodal_model_detects_known_vision_models:Function]
# #region test_resolve_bound_provider_id_prefers_binding_then_default [C:2] [TYPE Function]
# @BRIEF Verify provider binding resolution priority.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_resolve_bound_provider_id_prefers_binding_then_default:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 2
# @PURPOSE: Verify provider binding resolution priority.
def test_resolve_bound_provider_id_prefers_binding_then_default():
settings = {
"default_provider": "default-1",
@@ -95,12 +102,13 @@ def test_resolve_bound_provider_id_prefers_binding_then_default():
assert resolve_bound_provider_id(settings, "documentation") == "default-1"
# #endregion test_resolve_bound_provider_id_prefers_binding_then_default
# [/DEF:test_resolve_bound_provider_id_prefers_binding_then_default:Function]
# #region test_normalize_llm_settings_keeps_assistant_planner_settings [C:2] [TYPE Function]
# @BRIEF Ensure assistant planner provider/model fields are preserved and normalized.
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
# [DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function]
# @RELATION: BINDS_TO -> test_llm_prompt_templates
# @COMPLEXITY: 2
# @PURPOSE: Ensure assistant planner provider/model fields are preserved and normalized.
def test_normalize_llm_settings_keeps_assistant_planner_settings():
normalized = normalize_llm_settings(
{
@@ -112,7 +120,7 @@ def test_normalize_llm_settings_keeps_assistant_planner_settings():
assert normalized["assistant_planner_model"] == "gpt-4.1-mini"
# #endregion test_normalize_llm_settings_keeps_assistant_planner_settings
# [/DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function]
# #endregion test_llm_prompt_templates
# [/DEF:test_llm_prompt_templates:Module]

View File

@@ -1,7 +1,9 @@
# #region test_llm_provider [C:3] [TYPE Module] [SEMANTICS tests, llm-provider, encryption, contract]
# @BRIEF Contract testing for LLMProviderService and EncryptionManager
# @RELATION VERIFIES -> [src.services.llm_provider:Module]
# #endregion test_llm_provider
# [DEF:test_llm_provider:Module]
# @RELATION: VERIFIES -> [src.services.llm_provider:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, llm-provider, encryption, contract
# @PURPOSE: Contract testing for LLMProviderService and EncryptionManager
# [/DEF:test_llm_provider:Module]
import pytest
import os
@@ -12,18 +14,18 @@ from src.services.llm_provider import EncryptionManager, LLMProviderService
from src.models.llm import LLMProvider
from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
# #region _test_encryption_key_fixture [TYPE Global]
# @BRIEF Ensure encryption-dependent provider tests run with a valid Fernet key.
# @RELATION DEPENDS_ON -> [pytest:Module]
# [DEF:_test_encryption_key_fixture:Global]
# @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key.
# @RELATION: DEPENDS_ON -> [pytest:Module]
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
# #endregion _test_encryption_key_fixture
# [/DEF:_test_encryption_key_fixture:Global]
# @TEST_CONTRACT: EncryptionManagerModel -> Invariants
# @TEST_INVARIANT: symmetric_encryption
# #region test_encryption_cycle [TYPE Function]
# @BRIEF Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_encryption_cycle:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
def test_encryption_cycle():
"""Verify encrypted data can be decrypted back to original string."""
manager = EncryptionManager()
@@ -34,12 +36,12 @@ def test_encryption_cycle():
# @TEST_EDGE: empty_string_encryption
# #endregion test_encryption_cycle
# [/DEF:test_encryption_cycle:Function]
# #region test_empty_string_encryption [TYPE Function]
# @BRIEF Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_empty_string_encryption:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
def test_empty_string_encryption():
manager = EncryptionManager()
original = ""
@@ -48,12 +50,12 @@ def test_empty_string_encryption():
# @TEST_EDGE: decrypt_invalid_data
# #endregion test_empty_string_encryption
# [/DEF:test_empty_string_encryption:Function]
# #region test_decrypt_invalid_data [TYPE Function]
# @BRIEF Ensure decrypt rejects invalid ciphertext input by raising an exception.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_decrypt_invalid_data:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Ensure decrypt rejects invalid ciphertext input by raising an exception.
def test_decrypt_invalid_data():
manager = EncryptionManager()
with pytest.raises(Exception):
@@ -61,48 +63,50 @@ def test_decrypt_invalid_data():
# @TEST_FIXTURE: mock_db_session
# #endregion test_decrypt_invalid_data
# [/DEF:test_decrypt_invalid_data:Function]
# #region mock_db [C:1] [TYPE Fixture]
# @BRIEF MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
# @INVARIANT Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:mock_db:Fixture]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @COMPLEXITY: 1
# @PURPOSE: MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
# @INVARIANT: Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
@pytest.fixture
def mock_db():
# @RISK: query() returns unconstrained MagicMock — chain beyond query() has no spec protection. Consider create_autospec(Session) for full chain safety.
return MagicMock(spec=Session)
# #endregion mock_db
# [/DEF:mock_db:Fixture]
# #region service [C:1] [TYPE Fixture]
# @BRIEF LLMProviderService fixture wired to mock_db for provider CRUD tests.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:service:Fixture]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @COMPLEXITY: 1
# @PURPOSE: LLMProviderService fixture wired to mock_db for provider CRUD tests.
@pytest.fixture
def service(mock_db):
return LLMProviderService(db=mock_db)
# #endregion service
# [/DEF:service:Fixture]
# #region test_get_all_providers [TYPE Function]
# @BRIEF Verify provider list retrieval issues query/all calls on the backing DB session.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_get_all_providers:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Verify provider list retrieval issues query/all calls on the backing DB session.
def test_get_all_providers(service, mock_db):
service.get_all_providers()
mock_db.query.assert_called()
mock_db.query().all.assert_called()
# #endregion test_get_all_providers
# [/DEF:test_get_all_providers:Function]
# #region test_create_provider [TYPE Function]
# @BRIEF Ensure provider creation persists entity and stores API key in encrypted form.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_create_provider:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Ensure provider creation persists entity and stores API key in encrypted form.
def test_create_provider(service, mock_db):
config = LLMProviderConfig(
provider_type=LLMProviderType.OPENAI,
@@ -123,12 +127,12 @@ def test_create_provider(service, mock_db):
assert EncryptionManager().decrypt(provider.api_key) == "sk-test"
# #endregion test_create_provider
# [/DEF:test_create_provider:Function]
# #region test_get_decrypted_api_key [TYPE Function]
# @BRIEF Verify service decrypts stored provider API key for an existing provider record.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_get_decrypted_api_key:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Verify service decrypts stored provider API key for an existing provider record.
def test_get_decrypted_api_key(service, mock_db):
# Setup mock provider
encrypted_key = EncryptionManager().encrypt("secret-value")
@@ -139,23 +143,23 @@ def test_get_decrypted_api_key(service, mock_db):
assert key == "secret-value"
# #endregion test_get_decrypted_api_key
# [/DEF:test_get_decrypted_api_key:Function]
# #region test_get_decrypted_api_key_not_found [TYPE Function]
# @BRIEF Verify missing provider lookup returns None instead of attempting decryption.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_get_decrypted_api_key_not_found:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Verify missing provider lookup returns None instead of attempting decryption.
def test_get_decrypted_api_key_not_found(service, mock_db):
mock_db.query().filter().first.return_value = None
assert service.get_decrypted_api_key("missing") is None
# #endregion test_get_decrypted_api_key_not_found
# [/DEF:test_get_decrypted_api_key_not_found:Function]
# #region test_update_provider_ignores_masked_placeholder_api_key [TYPE Function]
# @BRIEF Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
# @RELATION BINDS_TO -> [test_llm_provider:Module]
# [DEF:test_update_provider_ignores_masked_placeholder_api_key:Function]
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
# @PURPOSE: Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
existing_encrypted = EncryptionManager().encrypt("secret-value")
mock_provider = LLMProvider(
@@ -186,4 +190,4 @@ def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
assert updated.is_active is False
# #endregion test_update_provider_ignores_masked_placeholder_api_key
# [/DEF:test_update_provider_ignores_masked_placeholder_api_key:Function]

View File

@@ -1,8 +1,10 @@
# #region test_rbac_permission_catalog [C:3] [TYPE Module] [SEMANTICS tests, rbac, permissions, catalog, discovery, sync]
# @BRIEF Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
# @LAYER Service Tests
# @INVARIANT Synchronization adds only missing normalized permission pairs.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:test_rbac_permission_catalog:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, rbac, permissions, catalog, discovery, sync
# @PURPOSE: Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
# @LAYER: Service Tests
# @INVARIANT: Synchronization adds only missing normalized permission pairs.
# [SECTION: IMPORTS]
from types import SimpleNamespace
@@ -12,11 +14,11 @@ import src.services.rbac_permission_catalog as catalog
# [/SECTION: IMPORTS]
# #region test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests [TYPE Function]
# @BRIEF Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
# @PRE Temporary route directory contains route and test files.
# @POST Returned set includes production route permissions and excludes test-only declarations.
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
# [DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
# @PURPOSE: Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
# @PRE: Temporary route directory contains route and test files.
# @POST: Returned set includes production route permissions and excludes test-only declarations.
def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tmp_path, monkeypatch):
routes_dir = tmp_path / "routes"
routes_dir.mkdir(parents=True, exist_ok=True)
@@ -47,14 +49,14 @@ def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tm
assert ("plugin:migration", "EXECUTE") in discovered
assert ("tasks", "WRITE") in discovered
assert ("plugin:ignored", "READ") not in discovered
# #endregion test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests
# [/DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
# #region test_discover_declared_permissions_unions_route_and_plugin_permissions [TYPE Function]
# @BRIEF Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
# @PRE Route discovery and plugin loader both return permission sources.
# @POST Result set contains union of both sources.
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
# [DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
# @PURPOSE: Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
# @PRE: Route discovery and plugin loader both return permission sources.
# @POST: Result set contains union of both sources.
def test_discover_declared_permissions_unions_route_and_plugin_permissions(monkeypatch):
monkeypatch.setattr(
catalog,
@@ -74,14 +76,14 @@ def test_discover_declared_permissions_unions_route_and_plugin_permissions(monke
assert ("plugin:migration", "READ") in discovered
assert ("plugin:superset-backup", "EXECUTE") in discovered
assert ("plugin:llm_dashboard_validation", "EXECUTE") in discovered
# #endregion test_discover_declared_permissions_unions_route_and_plugin_permissions
# [/DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
# #region test_sync_permission_catalog_inserts_only_missing_normalized_pairs [TYPE Function]
# @BRIEF Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
# @PRE DB already contains subset of permissions.
# @POST Only missing normalized pairs are inserted and commit is executed once.
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
# [DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
# @PURPOSE: Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
# @PRE: DB already contains subset of permissions.
# @POST: Only missing normalized pairs are inserted and commit is executed once.
def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
db = MagicMock()
db.query.return_value.all.return_value = [
@@ -108,14 +110,14 @@ def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
assert inserted_permission.resource == "plugin:migration"
assert inserted_permission.action == "READ"
db.commit.assert_called_once()
# #endregion test_sync_permission_catalog_inserts_only_missing_normalized_pairs
# [/DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
# #region test_sync_permission_catalog_is_noop_when_all_permissions_exist [TYPE Function]
# @BRIEF Ensures synchronization is idempotent when all declared pairs already exist.
# @PRE DB contains full declared permission set.
# @POST No inserts are added and commit is not called.
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
# [DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
# @PURPOSE: Ensures synchronization is idempotent when all declared pairs already exist.
# @PRE: DB contains full declared permission set.
# @POST: No inserts are added and commit is not called.
def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
db = MagicMock()
db.query.return_value.all.return_value = [
@@ -136,7 +138,7 @@ def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
assert inserted_count == 0
db.add.assert_not_called()
db.commit.assert_not_called()
# #endregion test_sync_permission_catalog_is_noop_when_all_permissions_exist
# [/DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
# #endregion test_rbac_permission_catalog
# [/DEF:test_rbac_permission_catalog:Module]

View File

@@ -1,17 +1,19 @@
# #region TestResourceService [C:3] [TYPE Module] [SEMANTICS resource-service, tests, dashboards, datasets, activity]
# @BRIEF Unit tests for ResourceService
# @LAYER Service
# @INVARIANT Resource summaries preserve task linkage and status projection behavior.
# @RELATION VERIFIES -> [src.services.resource_service.ResourceService]
# [DEF:TestResourceService:Module]
# @COMPLEXITY: 3
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
# @PURPOSE: Unit tests for ResourceService
# @LAYER: Service
# @RELATION: VERIFIES ->[src.services.resource_service.ResourceService]
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime, timezone
# #region test_get_dashboards_with_status [TYPE Function]
# @BRIEF Validate dashboard enrichment includes git/task status projections.
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_dashboards_with_status:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
# @TEST: get_dashboards_with_status returns dashboards with git and task status
# @PRE: SupersetClient returns dashboard list
# @POST: Each dashboard has git_status and last_task fields
@@ -71,11 +73,11 @@ async def test_get_dashboards_with_status():
assert result[0]["last_task"]["validation_status"] == "FAIL"
# #endregion test_get_dashboards_with_status
# [/DEF:test_get_dashboards_with_status:Function]
# #region test_get_datasets_with_status [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_datasets_with_status:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: get_datasets_with_status returns datasets with task status
# @PRE: SupersetClient returns dataset list
# @POST: Each dataset has last_task field
@@ -112,11 +114,11 @@ async def test_get_datasets_with_status():
assert result[0]["last_task"]["status"] == "RUNNING"
# #endregion test_get_datasets_with_status
# [/DEF:test_get_datasets_with_status:Function]
# #region test_get_activity_summary [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_activity_summary:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: get_activity_summary returns active count and recent tasks
# @PRE: tasks list provided
# @POST: Returns dict with active_count and recent_tasks
@@ -151,11 +153,11 @@ def test_get_activity_summary():
assert len(result["recent_tasks"]) == 3
# #endregion test_get_activity_summary
# [/DEF:test_get_activity_summary:Function]
# #region test_get_git_status_for_dashboard_no_repo [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_git_status_for_dashboard_no_repo:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
# @PRE: GitService returns None for repo
# @POST: Returns None
@@ -174,11 +176,11 @@ def test_get_git_status_for_dashboard_no_repo():
assert result["has_repo"] is False
# #endregion test_get_git_status_for_dashboard_no_repo
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
# #region test_get_last_task_for_resource [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_last_task_for_resource:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _get_last_task_for_resource returns most recent task for resource
# @PRE: tasks list with matching resource_id
# @POST: Returns task summary with task_id and status
@@ -208,11 +210,11 @@ def test_get_last_task_for_resource():
assert result["status"] == "RUNNING"
# #endregion test_get_last_task_for_resource
# [/DEF:test_get_last_task_for_resource:Function]
# #region test_extract_resource_name_from_task [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_extract_resource_name_from_task:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _extract_resource_name_from_task extracts name from params
# @PRE: task has resource_name in params
# @POST: Returns resource name or fallback
@@ -239,11 +241,11 @@ def test_extract_resource_name_from_task():
assert "task-456" in result2
# #endregion test_extract_resource_name_from_task
# [/DEF:test_extract_resource_name_from_task:Function]
# #region test_get_last_task_for_resource_empty_tasks [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_last_task_for_resource_empty_tasks:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _get_last_task_for_resource returns None for empty tasks list
# @PRE: tasks is empty list
# @POST: Returns None
@@ -257,11 +259,11 @@ def test_get_last_task_for_resource_empty_tasks():
assert result is None
# #endregion test_get_last_task_for_resource_empty_tasks
# [/DEF:test_get_last_task_for_resource_empty_tasks:Function]
# #region test_get_last_task_for_resource_no_match [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_last_task_for_resource_no_match:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
# @PRE: tasks list has no matching resource_id
# @POST: Returns None
@@ -281,11 +283,11 @@ def test_get_last_task_for_resource_no_match():
assert result is None
# #endregion test_get_last_task_for_resource_no_match
# [/DEF:test_get_last_task_for_resource_no_match:Function]
# #region test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
# @POST: Latest task is selected deterministically and no exception is raised.
@@ -325,11 +327,11 @@ async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_dat
assert result[0]["last_task"]["task_id"] == "task-aware"
# #endregion test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
# #region test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: get_dashboards_with_status keeps latest task identity while falling back to older decisive validation status.
# @PRE: Same dashboard has older WARN and newer UNKNOWN validation tasks.
# @POST: Returned last_task points to newest task but preserves WARN as last meaningful validation state.
@@ -375,11 +377,11 @@ async def test_get_dashboards_with_status_prefers_latest_decisive_validation_sta
assert result[0]["last_task"]["validation_status"] == "WARN"
# #endregion test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown
# [/DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
# #region test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: get_dashboards_with_status still returns newest UNKNOWN when no decisive validation exists.
# @PRE: Same dashboard has only UNKNOWN validation tasks.
# @POST: Returned last_task keeps newest UNKNOWN task.
@@ -424,11 +426,11 @@ async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_d
assert result[0]["last_task"]["validation_status"] == "UNKNOWN"
# #endregion test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history
# [/DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
# #region test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at [TYPE Function]
# @RELATION BINDS_TO -> [TestResourceService]
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
# @RELATION: BINDS_TO ->[TestResourceService]
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
# @PRE: Matching tasks include naive and aware created_at timestamps.
# @POST: Latest task is returned without raising datetime comparison errors.
@@ -458,7 +460,7 @@ def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
assert result["task_id"] == "task-new"
# #endregion test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
# #endregion TestResourceService
# [/DEF:TestResourceService:Module]

View File

@@ -1,16 +1,16 @@
# #region auth_service [C:5] [TYPE Module] [SEMANTICS auth, service, business-logic, login, jwt, adfs, jit-provisioning]
# @BRIEF Orchestrates credential authentication and ADFS JIT user provisioning.
# @LAYER Domain
# @PRE Core auth models and security utilities available.
# @POST User identity verified and session tokens issued according to role scopes.
# @SIDE_EFFECT Writes last login timestamps and JIT-provisions external users.
# @DATA_CONTRACT [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
# @INVARIANT Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [verify_password]
# @RELATION DEPENDS_ON -> [create_access_token]
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [Role]
# @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
# @PRE: Core auth models and security utilities available.
# @POST: User identity verified and session tokens issued according to role scopes.
# @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users.
# @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
from typing import Dict, Any, Optional, List
from datetime import datetime
@@ -30,28 +30,30 @@ from ..core.logger import belief_scope
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [Role]
class AuthService:
# #region AuthService_init [C:1] [TYPE Function]
# @BRIEF Initializes the authentication service with repository access over an active DB session.
# @PRE db is a valid SQLAlchemy Session instance bound to the auth persistence context.
# @POST self.repo is initialized and ready for auth user/role CRUD operations.
# @SIDE_EFFECT Allocates AuthRepository and binds it to the provided Session.
# @DATA_CONTRACT Input(Session) -> Model(AuthRepository)
# [DEF:AuthService_init:Function]
# @COMPLEXITY: 1
# @PURPOSE: Initializes the authentication service with repository access over an active DB session.
# @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context.
# @POST: self.repo is initialized and ready for auth user/role CRUD operations.
# @SIDE_EFFECT: Allocates AuthRepository and binds it to the provided Session.
# @DATA_CONTRACT: Input(Session) -> Model(AuthRepository)
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.db = db
self.repo = AuthRepository(db)
# #endregion AuthService_init
# [/DEF:AuthService_init:Function]
# #region AuthService.authenticate_user [C:3] [TYPE Function]
# @BRIEF Validates credentials and account state for local username/password authentication.
# @PRE username and password are non-empty credential inputs.
# @POST Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
# @SIDE_EFFECT Persists last_login update for successful authentications via repository.
# @DATA_CONTRACT Input(str username, str password) -> Output(User | None)
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION CALLS -> [verify_password]
# @RELATION DEPENDS_ON -> [User]
# [DEF:AuthService.authenticate_user:Function]
# @COMPLEXITY: 3
# @PURPOSE: Validates credentials and account state for local username/password authentication.
# @PRE: username and password are non-empty credential inputs.
# @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
# @SIDE_EFFECT: Persists last_login update for successful authentications via repository.
# @DATA_CONTRACT: Input(str username, str password) -> Output(User | None)
# @RELATION: [DEPENDS_ON] ->[AuthRepository]
# @RELATION: [CALLS] ->[verify_password]
# @RELATION: [DEPENDS_ON] ->[User]
# @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None.
@@ -71,17 +73,18 @@ class AuthService:
return user
# #endregion AuthService.authenticate_user
# [/DEF:AuthService.authenticate_user:Function]
# #region AuthService.create_session [C:3] [TYPE Function]
# @BRIEF Issues an access token payload for an already authenticated user.
# @PRE user is a valid User entity containing username and iterable roles with role.name values.
# @POST Returns session dict with non-empty access_token and token_type='bearer'.
# @SIDE_EFFECT Generates signed JWT via auth JWT provider.
# @DATA_CONTRACT Input(User) -> Output(Dict[str, str]{access_token, token_type})
# @RELATION CALLS -> [create_access_token]
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [Role]
# [DEF:AuthService.create_session:Function]
# @COMPLEXITY: 3
# @PURPOSE: Issues an access token payload for an already authenticated user.
# @PRE: user is a valid User entity containing username and iterable roles with role.name values.
# @POST: Returns session dict with non-empty access_token and token_type='bearer'.
# @SIDE_EFFECT: Generates signed JWT via auth JWT provider.
# @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type})
# @RELATION: [CALLS] ->[create_access_token]
# @RELATION: [DEPENDS_ON] ->[User]
# @RELATION: [DEPENDS_ON] ->[Role]
# @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data.
def create_session(self, user: User) -> Dict[str, str]:
@@ -92,17 +95,18 @@ class AuthService:
)
return {"access_token": access_token, "token_type": "bearer"}
# #endregion AuthService.create_session
# [/DEF:AuthService.create_session:Function]
# #region AuthService.provision_adfs_user [C:3] [TYPE Function]
# @BRIEF Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
# @PRE user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
# @POST Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
# @SIDE_EFFECT May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
# @DATA_CONTRACT Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [Role]
# [DEF:AuthService.provision_adfs_user:Function]
# @COMPLEXITY: 3
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
# @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
# @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
# @SIDE_EFFECT: May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
# @DATA_CONTRACT: Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
# @RELATION: [DEPENDS_ON] ->[AuthRepository]
# @RELATION: [DEPENDS_ON] ->[User]
# @RELATION: [DEPENDS_ON] ->[Role]
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user.
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
@@ -134,7 +138,9 @@ class AuthService:
return user
# #endregion AuthService.provision_adfs_user
# [/DEF:AuthService.provision_adfs_user:Function]
# #endregion AuthService
# #endregion auth_service

View File

@@ -1,6 +1,6 @@
# #region CleanReleaseContracts [C:3] [TYPE Module]
# @BRIEF Publish the canonical semantic root for the clean-release backend service cluster.
# @LAYER Domain
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
# @RELATION DEPENDS_ON -> [ManifestBuilder]
# @RELATION DEPENDS_ON -> [PolicyEngine]

View File

@@ -1,7 +1,9 @@
# #region TestAuditService [C:3] [TYPE Module] [SEMANTICS tests, clean-release, audit, logging]
# @BRIEF Validate audit hooks emit expected log patterns for clean release lifecycle.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [AuditService]
# [DEF:TestAuditService:Module]
# @RELATION: [DEPENDS_ON] ->[AuditService]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, audit, logging
# @PURPOSE: Validate audit hooks emit expected log patterns for clean release lifecycle.
# @LAYER: Infra
from unittest.mock import patch
from src.services.clean_release.audit_service import (
@@ -12,9 +14,9 @@ from src.services.clean_release.audit_service import (
@patch("src.services.clean_release.audit_service.logger")
# #region test_audit_preparation [TYPE Function]
# @BRIEF Verify audit preparation stage correctly initializes and validates candidate state.
# @RELATION BINDS_TO -> [TestAuditService]
# [DEF:test_audit_preparation:Function]
# @RELATION: BINDS_TO -> TestAuditService
# @PURPOSE: Verify audit preparation stage correctly initializes and validates candidate state.
def test_audit_preparation(mock_logger):
audit_preparation("cand-1", "PREPARED")
mock_logger.info.assert_called_with(
@@ -22,13 +24,13 @@ def test_audit_preparation(mock_logger):
)
# #endregion test_audit_preparation
# [/DEF:test_audit_preparation:Function]
@patch("src.services.clean_release.audit_service.logger")
# #region test_audit_check_run [TYPE Function]
# @BRIEF Verify audit check run executes all checks and collects results.
# @RELATION BINDS_TO -> [TestAuditService]
# [DEF:test_audit_check_run:Function]
# @RELATION: BINDS_TO -> TestAuditService
# @PURPOSE: Verify audit check run executes all checks and collects results.
def test_audit_check_run(mock_logger):
audit_check_run("check-1", "COMPLIANT")
mock_logger.info.assert_called_with(
@@ -36,13 +38,13 @@ def test_audit_check_run(mock_logger):
)
# #endregion test_audit_check_run
# [/DEF:test_audit_check_run:Function]
@patch("src.services.clean_release.audit_service.logger")
# #region test_audit_report [TYPE Function]
# @BRIEF Verify audit report generation aggregates check results into a structured report.
# @RELATION BINDS_TO -> [TestAuditService]
# [DEF:test_audit_report:Function]
# @RELATION: BINDS_TO -> TestAuditService
# @PURPOSE: Verify audit report generation aggregates check results into a structured report.
def test_audit_report(mock_logger):
audit_report("rep-1", "cand-1")
mock_logger.info.assert_called_with(
@@ -50,5 +52,5 @@ def test_audit_report(mock_logger):
)
# #endregion test_audit_report
# #endregion TestAuditService
# [/DEF:test_audit_report:Function]
# [/DEF:TestAuditService:Module]

View File

@@ -1,8 +1,10 @@
# #region TestComplianceOrchestrator [C:3] [TYPE Module] [SEMANTICS tests, clean-release, orchestrator, stage-state-machine]
# @BRIEF Validate compliance orchestrator stage transitions and final status derivation.
# @LAYER Domain
# @INVARIANT Failed mandatory stage forces BLOCKED terminal status.
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
# [DEF:TestComplianceOrchestrator:Module]
# @RELATION: [DEPENDS_ON] ->[ComplianceOrchestrator]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, orchestrator, stage-state-machine
# @PURPOSE: Validate compliance orchestrator stage transitions and final status derivation.
# @LAYER: Domain
# @INVARIANT: Failed mandatory stage forces BLOCKED terminal status.
from unittest.mock import patch
@@ -21,9 +23,9 @@ from src.services.clean_release.report_builder import ComplianceReportBuilder
from src.services.clean_release.repository import CleanReleaseRepository
# #region test_orchestrator_stage_failure_blocks_release [TYPE Function]
# @BRIEF Verify mandatory stage failure forces BLOCKED final status.
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
# [DEF:test_orchestrator_stage_failure_blocks_release:Function]
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
# @PURPOSE: Verify mandatory stage failure forces BLOCKED final status.
def test_orchestrator_stage_failure_blocks_release():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
@@ -64,12 +66,12 @@ def test_orchestrator_stage_failure_blocks_release():
assert run.final_status == CheckFinalStatus.BLOCKED
# #endregion test_orchestrator_stage_failure_blocks_release
# [/DEF:test_orchestrator_stage_failure_blocks_release:Function]
# #region test_orchestrator_compliant_candidate [TYPE Function]
# @BRIEF Verify happy path where all mandatory stages pass yields COMPLIANT.
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
# [DEF:test_orchestrator_compliant_candidate:Function]
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
# @PURPOSE: Verify happy path where all mandatory stages pass yields COMPLIANT.
def test_orchestrator_compliant_candidate():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
@@ -110,12 +112,12 @@ def test_orchestrator_compliant_candidate():
assert run.final_status == CheckFinalStatus.COMPLIANT
# #endregion test_orchestrator_compliant_candidate
# [/DEF:test_orchestrator_compliant_candidate:Function]
# #region test_orchestrator_missing_stage_result [TYPE Function]
# @BRIEF Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED.
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
# [DEF:test_orchestrator_missing_stage_result:Function]
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
# @PURPOSE: Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED.
def test_orchestrator_missing_stage_result():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
@@ -136,12 +138,12 @@ def test_orchestrator_missing_stage_result():
assert run.final_status == CheckFinalStatus.FAILED
# #endregion test_orchestrator_missing_stage_result
# [/DEF:test_orchestrator_missing_stage_result:Function]
# #region test_orchestrator_report_generation_error [TYPE Function]
# @BRIEF Verify downstream report errors do not mutate orchestrator final status.
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
# [DEF:test_orchestrator_report_generation_error:Function]
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
# @PURPOSE: Verify downstream report errors do not mutate orchestrator final status.
def test_orchestrator_report_generation_error():
repository = CleanReleaseRepository()
orchestrator = CleanComplianceOrchestrator(repository)
@@ -162,5 +164,5 @@ def test_orchestrator_report_generation_error():
assert run.final_status == CheckFinalStatus.FAILED
# #endregion test_orchestrator_report_generation_error
# #endregion TestComplianceOrchestrator
# [/DEF:test_orchestrator_report_generation_error:Function]
# [/DEF:TestComplianceOrchestrator:Module]

View File

@@ -1,21 +1,23 @@
# #region TestManifestBuilder [C:5] [TYPE Module] [SEMANTICS tests, clean-release, manifest, deterministic]
# @BRIEF Validate deterministic manifest generation behavior for US1.
# @LAYER Domain
# @PRE Test fixtures are properly initialized
# @POST All test assertions pass
# @SIDE_EFFECT None - test isolation
# @DATA_CONTRACT TestInput -> TestOutput
# @INVARIANT Same input artifacts produce identical deterministic hash.
# @RELATION DEPENDS_ON -> [ManifestBuilder]
# [DEF:TestManifestBuilder:Module]
# @COMPLEXITY: 5
# @SEMANTICS: tests, clean-release, manifest, deterministic
# @PURPOSE: Validate deterministic manifest generation behavior for US1.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[ManifestBuilder]
# @INVARIANT: Same input artifacts produce identical deterministic hash.
# @PRE: Test fixtures are properly initialized
# @POST: All test assertions pass
# @SIDE_EFFECT: None - test isolation
# @DATA_CONTRACT: TestInput -> TestOutput
from src.services.clean_release.manifest_builder import build_distribution_manifest
# #region test_manifest_deterministic_hash_for_same_input [TYPE Function]
# @BRIEF Ensure hash is stable for same candidate/policy/artifact input.
# @PRE Same input lists are passed twice.
# @POST Hash and summary remain identical.
# @RELATION BINDS_TO -> [TestManifestBuilder]
# [DEF:test_manifest_deterministic_hash_for_same_input:Function]
# @RELATION: BINDS_TO -> TestManifestBuilder
# @PURPOSE: Ensure hash is stable for same candidate/policy/artifact input.
# @PRE: Same input lists are passed twice.
# @POST: Hash and summary remain identical.
def test_manifest_deterministic_hash_for_same_input():
artifacts = [
{
@@ -52,5 +54,5 @@ def test_manifest_deterministic_hash_for_same_input():
assert manifest1.summary.excluded_count == manifest2.summary.excluded_count
# #endregion test_manifest_deterministic_hash_for_same_input
# #endregion TestManifestBuilder
# [/DEF:test_manifest_deterministic_hash_for_same_input:Function]
# [/DEF:TestManifestBuilder:Module]

View File

@@ -1,7 +1,7 @@
# #region TestPolicyEngine [TYPE Module]
# @BRIEF Contract testing for CleanPolicyEngine
# @RELATION DEPENDS_ON -> [PolicyEngine]
# #endregion TestPolicyEngine
# [DEF:TestPolicyEngine:Module]
# @RELATION: [DEPENDS_ON] ->[PolicyEngine]
# @PURPOSE: Contract testing for CleanPolicyEngine
# [/DEF:TestPolicyEngine:Module]
import pytest
from datetime import datetime
@@ -48,9 +48,9 @@ def enterprise_clean_setup():
# @TEST_SCENARIO: policy_valid
# #region test_policy_valid [TYPE Function]
# @BRIEF Verify policy validation passes when all required fields are present and valid.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_policy_valid:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify policy validation passes when all required fields are present and valid.
def test_policy_valid(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
@@ -60,12 +60,12 @@ def test_policy_valid(enterprise_clean_setup):
# @TEST_EDGE: missing_registry_ref
# #endregion test_policy_valid
# [/DEF:test_policy_valid:Function]
# #region test_missing_registry_ref [TYPE Function]
# @BRIEF Verify policy validation fails when registry_ref is missing.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_missing_registry_ref:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify policy validation fails when registry_ref is missing.
def test_missing_registry_ref(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
policy.internal_source_registry_ref = " "
@@ -76,12 +76,12 @@ def test_missing_registry_ref(enterprise_clean_setup):
# @TEST_EDGE: conflicting_registry
# #endregion test_missing_registry_ref
# [/DEF:test_missing_registry_ref:Function]
# #region test_conflicting_registry [TYPE Function]
# @BRIEF Verify policy engine rejects conflicting registry references.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_conflicting_registry:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify policy engine rejects conflicting registry references.
def test_conflicting_registry(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
registry.registry_id = "WRONG-REG"
@@ -95,12 +95,12 @@ def test_conflicting_registry(enterprise_clean_setup):
# @TEST_INVARIANT: deterministic_classification
# #endregion test_conflicting_registry
# [/DEF:test_conflicting_registry:Function]
# #region test_classify_artifact [TYPE Function]
# @BRIEF Verify policy engine correctly classifies artifacts based on source and type.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_classify_artifact:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify policy engine correctly classifies artifacts based on source and type.
def test_classify_artifact(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
@@ -120,12 +120,12 @@ def test_classify_artifact(enterprise_clean_setup):
# @TEST_EDGE: external_endpoint
# #endregion test_classify_artifact
# [/DEF:test_classify_artifact:Function]
# #region test_validate_resource_source [TYPE Function]
# @BRIEF Verify validate_resource_source correctly validates or rejects resource source identifiers.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_validate_resource_source:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify validate_resource_source correctly validates or rejects resource source identifiers.
def test_validate_resource_source(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
@@ -141,12 +141,12 @@ def test_validate_resource_source(enterprise_clean_setup):
assert res_fail.violation["blocked_release"] is True
# #endregion test_validate_resource_source
# [/DEF:test_validate_resource_source:Function]
# #region test_evaluate_candidate [TYPE Function]
# @BRIEF Verify policy engine evaluates release candidates against configured policies.
# @RELATION BINDS_TO -> [TestPolicyEngine]
# [DEF:test_evaluate_candidate:Function]
# @RELATION: BINDS_TO -> TestPolicyEngine
# @PURPOSE: Verify policy engine evaluates release candidates against configured policies.
def test_evaluate_candidate(enterprise_clean_setup):
policy, registry = enterprise_clean_setup
engine = CleanPolicyEngine(policy, registry)
@@ -169,4 +169,4 @@ def test_evaluate_candidate(enterprise_clean_setup):
assert violations[1]["category"] == "external-source"
# #endregion test_evaluate_candidate
# [/DEF:test_evaluate_candidate:Function]

View File

@@ -1,8 +1,10 @@
# #region TestPreparationService [C:3] [TYPE Module] [SEMANTICS tests, clean-release, preparation, flow]
# @BRIEF Validate release candidate preparation flow, including policy evaluation and manifest persisting.
# @LAYER Domain
# @INVARIANT Candidate preparation always persists manifest and candidate status deterministically.
# @RELATION DEPENDS_ON -> [PreparationService]
# [DEF:TestPreparationService:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, preparation, flow
# @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[PreparationService]
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
import pytest
from unittest.mock import MagicMock, patch
@@ -20,9 +22,9 @@ from src.models.clean_release import (
from src.services.clean_release.preparation_service import prepare_candidate
# #region _mock_policy [TYPE Function]
# @BRIEF Build a valid clean profile policy fixture for preparation tests.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:_mock_policy:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Build a valid clean profile policy fixture for preparation tests.
def _mock_policy() -> CleanProfilePolicy:
return CleanProfilePolicy(
policy_id="pol-1",
@@ -37,12 +39,12 @@ def _mock_policy() -> CleanProfilePolicy:
)
# #endregion _mock_policy
# [/DEF:_mock_policy:Function]
# #region _mock_registry [TYPE Function]
# @BRIEF Build an internal-only source registry fixture for preparation tests.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:_mock_registry:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Build an internal-only source registry fixture for preparation tests.
def _mock_registry() -> ResourceSourceRegistry:
return ResourceSourceRegistry(
registry_id="reg-1",
@@ -61,12 +63,12 @@ def _mock_registry() -> ResourceSourceRegistry:
)
# #endregion _mock_registry
# [/DEF:_mock_registry:Function]
# #region _mock_candidate [TYPE Function]
# @BRIEF Build a draft release candidate fixture with provided identifier.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:_mock_candidate:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Build a draft release candidate fixture with provided identifier.
def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
return ReleaseCandidate(
candidate_id=candidate_id,
@@ -79,12 +81,12 @@ def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
)
# #endregion _mock_candidate
# [/DEF:_mock_candidate:Function]
# #region test_prepare_candidate_success [TYPE Function]
# @BRIEF Verify candidate transitions to PREPARED when evaluation returns no violations.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:test_prepare_candidate_success:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Verify candidate transitions to PREPARED when evaluation returns no violations.
# @TEST_CONTRACT: [valid_candidate + active_policy + internal_sources + no_violations] -> [status=PREPARED, manifest_persisted, candidate_saved]
# @TEST_SCENARIO: [prepare_success] -> [prepared status and persistence side effects are produced]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
@@ -131,12 +133,12 @@ def test_prepare_candidate_success():
repository.save_candidate.assert_called_with(candidate)
# #endregion test_prepare_candidate_success
# [/DEF:test_prepare_candidate_success:Function]
# #region test_prepare_candidate_with_violations [TYPE Function]
# @BRIEF Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:test_prepare_candidate_with_violations:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
# @TEST_CONTRACT: [valid_candidate + active_policy + evaluation_with_violations] -> [status=BLOCKED, violations_exposed]
# @TEST_SCENARIO: [prepare_blocked_due_to_policy] -> [blocked status and violation list are produced]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
@@ -182,12 +184,12 @@ def test_prepare_candidate_with_violations():
assert len(result["violations"]) == 1
# #endregion test_prepare_candidate_with_violations
# [/DEF:test_prepare_candidate_with_violations:Function]
# #region test_prepare_candidate_not_found [TYPE Function]
# @BRIEF Verify preparation raises ValueError when candidate does not exist.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:test_prepare_candidate_not_found:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Verify preparation raises ValueError when candidate does not exist.
# @TEST_CONTRACT: [missing_candidate] -> [ValueError('Candidate not found')]
# @TEST_SCENARIO: [prepare_missing_candidate] -> [raises candidate not found error]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
@@ -201,12 +203,12 @@ def test_prepare_candidate_not_found():
prepare_candidate(repository, "non-existent", [], [], "op")
# #endregion test_prepare_candidate_not_found
# [/DEF:test_prepare_candidate_not_found:Function]
# #region test_prepare_candidate_no_active_policy [TYPE Function]
# @BRIEF Verify preparation raises ValueError when no active policy is available.
# @RELATION BINDS_TO -> [TestPreparationService]
# [DEF:test_prepare_candidate_no_active_policy:Function]
# @RELATION: BINDS_TO -> TestPreparationService
# @PURPOSE: Verify preparation raises ValueError when no active policy is available.
# @TEST_CONTRACT: [candidate_present + missing_active_policy] -> [ValueError('Active clean policy not found')]
# @TEST_SCENARIO: [prepare_missing_policy] -> [raises active policy missing error]
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
@@ -221,7 +223,7 @@ def test_prepare_candidate_no_active_policy():
prepare_candidate(repository, "cand-1", [], [], "op")
# #endregion test_prepare_candidate_no_active_policy
# [/DEF:test_prepare_candidate_no_active_policy:Function]
# #endregion TestPreparationService
# [/DEF:TestPreparationService:Module]

View File

@@ -1,8 +1,10 @@
# #region TestReportBuilder [C:3] [TYPE Module] [SEMANTICS tests, clean-release, report-builder, counters]
# @BRIEF Validate compliance report builder counter integrity and blocked-run constraints.
# @LAYER Domain
# @INVARIANT blocked run requires at least one blocking violation.
# @RELATION DEPENDS_ON -> [ReportBuilder]
# [DEF:TestReportBuilder:Module]
# @RELATION: [DEPENDS_ON] ->[ReportBuilder]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, report-builder, counters
# @PURPOSE: Validate compliance report builder counter integrity and blocked-run constraints.
# @LAYER: Domain
# @INVARIANT: blocked run requires at least one blocking violation.
from datetime import datetime, timezone
@@ -20,9 +22,9 @@ from src.services.clean_release.report_builder import ComplianceReportBuilder
from src.services.clean_release.repository import CleanReleaseRepository
# #region _terminal_run [TYPE Function]
# @BRIEF Build terminal/non-terminal run fixtures for report builder tests.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:_terminal_run:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Build terminal/non-terminal run fixtures for report builder tests.
def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun:
return ComplianceCheckRun(
check_run_id="check-1",
@@ -37,12 +39,12 @@ def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun:
)
# #endregion _terminal_run
# [/DEF:_terminal_run:Function]
# #region _blocking_violation [TYPE Function]
# @BRIEF Build a blocking violation fixture for blocked report scenarios.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:_blocking_violation:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Build a blocking violation fixture for blocked report scenarios.
def _blocking_violation() -> ComplianceViolation:
return ComplianceViolation(
violation_id="viol-1",
@@ -56,12 +58,12 @@ def _blocking_violation() -> ComplianceViolation:
)
# #endregion _blocking_violation
# [/DEF:_blocking_violation:Function]
# #region test_report_builder_blocked_requires_blocking_violations [TYPE Function]
# @BRIEF Verify BLOCKED run requires at least one blocking violation.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:test_report_builder_blocked_requires_blocking_violations:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Verify BLOCKED run requires at least one blocking violation.
def test_report_builder_blocked_requires_blocking_violations():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
@@ -70,12 +72,12 @@ def test_report_builder_blocked_requires_blocking_violations():
builder.build_report_payload(run, [])
# #endregion test_report_builder_blocked_requires_blocking_violations
# [/DEF:test_report_builder_blocked_requires_blocking_violations:Function]
# #region test_report_builder_blocked_with_two_violations [TYPE Function]
# @BRIEF Verify report builder generates conformant payload for a BLOCKED run with violations.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:test_report_builder_blocked_with_two_violations:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Verify report builder generates conformant payload for a BLOCKED run with violations.
def test_report_builder_blocked_with_two_violations():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
@@ -93,12 +95,12 @@ def test_report_builder_blocked_with_two_violations():
assert report.blocking_violations_count == 2
# #endregion test_report_builder_blocked_with_two_violations
# [/DEF:test_report_builder_blocked_with_two_violations:Function]
# #region test_report_builder_counter_consistency [TYPE Function]
# @BRIEF Verify violations counters remain consistent for blocking payload.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:test_report_builder_counter_consistency:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Verify violations counters remain consistent for blocking payload.
def test_report_builder_counter_consistency():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.BLOCKED)
@@ -108,12 +110,12 @@ def test_report_builder_counter_consistency():
assert report.blocking_violations_count == 1
# #endregion test_report_builder_counter_consistency
# [/DEF:test_report_builder_counter_consistency:Function]
# #region test_missing_operator_summary [TYPE Function]
# @BRIEF Validate non-terminal run prevents operator summary/report generation.
# @RELATION BINDS_TO -> [TestReportBuilder]
# [DEF:test_missing_operator_summary:Function]
# @RELATION: BINDS_TO -> TestReportBuilder
# @PURPOSE: Validate non-terminal run prevents operator summary/report generation.
def test_missing_operator_summary():
builder = ComplianceReportBuilder(CleanReleaseRepository())
run = _terminal_run(CheckFinalStatus.RUNNING)
@@ -124,5 +126,5 @@ def test_missing_operator_summary():
assert "Cannot build report for non-terminal run" in str(exc.value)
# #endregion test_missing_operator_summary
# #endregion TestReportBuilder
# [/DEF:test_missing_operator_summary:Function]
# [/DEF:TestReportBuilder:Module]

View File

@@ -1,8 +1,10 @@
# #region TestSourceIsolation [C:3] [TYPE Module] [SEMANTICS tests, clean-release, source-isolation, internal-only]
# @BRIEF Verify internal source registry validation behavior.
# @LAYER Domain
# @INVARIANT External endpoints always produce blocking violations.
# @RELATION DEPENDS_ON -> [SourceIsolation]
# [DEF:TestSourceIsolation:Module]
# @RELATION: [DEPENDS_ON] ->[SourceIsolation]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, source-isolation, internal-only
# @PURPOSE: Verify internal source registry validation behavior.
# @LAYER: Domain
# @INVARIANT: External endpoints always produce blocking violations.
from datetime import datetime, timezone
@@ -10,8 +12,8 @@ from src.models.clean_release import ResourceSourceEntry, ResourceSourceRegistry
from src.services.clean_release.source_isolation import validate_internal_sources
# #region _registry [TYPE Function]
# @RELATION BINDS_TO -> [TestSourceIsolation]
# [DEF:_registry:Function]
# @RELATION: BINDS_TO -> TestSourceIsolation
def _registry() -> ResourceSourceRegistry:
return ResourceSourceRegistry(
registry_id="registry-internal-v1",
@@ -38,12 +40,12 @@ def _registry() -> ResourceSourceRegistry:
)
# #endregion _registry
# [/DEF:_registry:Function]
# #region test_validate_internal_sources_all_internal_ok [TYPE Function]
# @BRIEF Verify validate_internal_sources passes when all sources are internal and allowed.
# @RELATION BINDS_TO -> [TestSourceIsolation]
# [DEF:test_validate_internal_sources_all_internal_ok:Function]
# @RELATION: BINDS_TO -> TestSourceIsolation
# @PURPOSE: Verify validate_internal_sources passes when all sources are internal and allowed.
def test_validate_internal_sources_all_internal_ok():
result = validate_internal_sources(
registry=_registry(),
@@ -53,12 +55,12 @@ def test_validate_internal_sources_all_internal_ok():
assert result["violations"] == []
# #endregion test_validate_internal_sources_all_internal_ok
# [/DEF:test_validate_internal_sources_all_internal_ok:Function]
# #region test_validate_internal_sources_external_blocked [TYPE Function]
# @BRIEF Verify validate_internal_sources blocks external sources when policy requires internal-only.
# @RELATION BINDS_TO -> [TestSourceIsolation]
# [DEF:test_validate_internal_sources_external_blocked:Function]
# @RELATION: BINDS_TO -> TestSourceIsolation
# @PURPOSE: Verify validate_internal_sources blocks external sources when policy requires internal-only.
def test_validate_internal_sources_external_blocked():
result = validate_internal_sources(
registry=_registry(),
@@ -70,5 +72,5 @@ def test_validate_internal_sources_external_blocked():
assert result["violations"][0]["blocked_release"] is True
# #endregion test_validate_internal_sources_external_blocked
# #endregion TestSourceIsolation
# [/DEF:test_validate_internal_sources_external_blocked:Function]
# [/DEF:TestSourceIsolation:Module]

View File

@@ -1,7 +1,9 @@
# #region TestStages [C:3] [TYPE Module] [SEMANTICS tests, clean-release, compliance, stages]
# @BRIEF Validate final status derivation logic from stage results.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [ComplianceStages]
# [DEF:TestStages:Module]
# @RELATION: [DEPENDS_ON] ->[ComplianceStages]
# @COMPLEXITY: 3
# @SEMANTICS: tests, clean-release, compliance, stages
# @PURPOSE: Validate final status derivation logic from stage results.
# @LAYER: Domain
from src.models.clean_release import (
CheckFinalStatus,
@@ -12,9 +14,9 @@ from src.models.clean_release import (
from src.services.clean_release.stages import derive_final_status, MANDATORY_STAGE_ORDER
# #region test_derive_final_status_compliant [TYPE Function]
# @BRIEF Verify derive_final_status returns compliant when all stages pass.
# @RELATION BINDS_TO -> [TestStages]
# [DEF:test_derive_final_status_compliant:Function]
# @RELATION: BINDS_TO -> TestStages
# @PURPOSE: Verify derive_final_status returns compliant when all stages pass.
def test_derive_final_status_compliant():
results = [
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
@@ -23,12 +25,12 @@ def test_derive_final_status_compliant():
assert derive_final_status(results) == CheckFinalStatus.COMPLIANT
# #endregion test_derive_final_status_compliant
# [/DEF:test_derive_final_status_compliant:Function]
# #region test_derive_final_status_blocked [TYPE Function]
# @BRIEF Verify derive_final_status returns blocked when any stage fails.
# @RELATION BINDS_TO -> [TestStages]
# [DEF:test_derive_final_status_blocked:Function]
# @RELATION: BINDS_TO -> TestStages
# @PURPOSE: Verify derive_final_status returns blocked when any stage fails.
def test_derive_final_status_blocked():
results = [
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
@@ -38,12 +40,12 @@ def test_derive_final_status_blocked():
assert derive_final_status(results) == CheckFinalStatus.BLOCKED
# #endregion test_derive_final_status_blocked
# [/DEF:test_derive_final_status_blocked:Function]
# #region test_derive_final_status_failed_missing [TYPE Function]
# @BRIEF Verify derive_final_status returns failed when required stages are missing.
# @RELATION BINDS_TO -> [TestStages]
# [DEF:test_derive_final_status_failed_missing:Function]
# @RELATION: BINDS_TO -> TestStages
# @PURPOSE: Verify derive_final_status returns failed when required stages are missing.
def test_derive_final_status_failed_missing():
results = [
CheckStageResult(
@@ -53,12 +55,12 @@ def test_derive_final_status_failed_missing():
assert derive_final_status(results) == CheckFinalStatus.FAILED
# #endregion test_derive_final_status_failed_missing
# [/DEF:test_derive_final_status_failed_missing:Function]
# #region test_derive_final_status_failed_skipped [TYPE Function]
# @BRIEF Verify derive_final_status returns failed when critical stages are skipped.
# @RELATION BINDS_TO -> [TestStages]
# [DEF:test_derive_final_status_failed_skipped:Function]
# @RELATION: BINDS_TO -> TestStages
# @PURPOSE: Verify derive_final_status returns failed when critical stages are skipped.
def test_derive_final_status_failed_skipped():
results = [
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
@@ -68,5 +70,5 @@ def test_derive_final_status_failed_skipped():
assert derive_final_status(results) == CheckFinalStatus.FAILED
# #endregion test_derive_final_status_failed_skipped
# #endregion TestStages
# [/DEF:test_derive_final_status_failed_skipped:Function]
# [/DEF:TestStages:Module]

View File

@@ -1,11 +1,9 @@
# [DEF:ApprovalService:Module]
# @COMPLEXITY: 5
# @SEMANTICS: clean-release, approval, decision, lifecycle, gate
# @PURPOSE: Enforce approval/rejection gates over immutable compliance reports.
# #region ApprovalService [C:5] [TYPE Module] [SEMANTICS clean-release, approval, decision, lifecycle, gate]
# @BRIEF Enforce approval/rejection gates over immutable compliance reports.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[RepositoryRelations]
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
# @RELATION: [DEPENDS_ON] ->[AuditService]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [AuditService]
# @INVARIANT: Approval is allowed only for PASSED report bound to candidate; decisions are append-only.
from __future__ import annotations
@@ -24,8 +22,8 @@ from .repository import CleanReleaseRepository
# #region _get_or_init_decisions_store [TYPE Function]
# @BRIEF Provide append-only in-memory storage for approval decisions.
# @PRE repository is initialized.
# @POST Returns mutable decision list attached to repository.
# @PRE: repository is initialized.
# @POST: Returns mutable decision list attached to repository.
def _get_or_init_decisions_store(
repository: CleanReleaseRepository,
) -> List[ApprovalDecision]:
@@ -41,8 +39,8 @@ def _get_or_init_decisions_store(
# #region _latest_decision_for_candidate [TYPE Function]
# @BRIEF Resolve latest approval decision for candidate from append-only store.
# @PRE candidate_id is non-empty.
# @POST Returns latest ApprovalDecision or None.
# @PRE: candidate_id is non-empty.
# @POST: Returns latest ApprovalDecision or None.
def _latest_decision_for_candidate(
repository: CleanReleaseRepository, candidate_id: str
) -> ApprovalDecision | None:
@@ -62,8 +60,8 @@ def _latest_decision_for_candidate(
# #region _resolve_candidate_and_report [TYPE Function]
# @BRIEF Validate candidate/report existence and ownership prior to decision persistence.
# @PRE candidate_id and report_id are non-empty.
# @POST Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
# @PRE: candidate_id and report_id are non-empty.
# @POST: Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
def _resolve_candidate_and_report(
repository: CleanReleaseRepository,
*,
@@ -89,8 +87,8 @@ def _resolve_candidate_and_report(
# #region approve_candidate [TYPE Function]
# @BRIEF Persist immutable APPROVED decision and advance candidate lifecycle to APPROVED.
# @PRE Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
# @POST Approval decision is appended and candidate transitions to APPROVED.
# @PRE: Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
# @POST: Approval decision is appended and candidate transitions to APPROVED.
def approve_candidate(
*,
repository: CleanReleaseRepository,
@@ -165,8 +163,8 @@ def approve_candidate(
# #region reject_candidate [TYPE Function]
# @BRIEF Persist immutable REJECTED decision without promoting candidate lifecycle.
# @PRE Candidate exists and report belongs to candidate.
# @POST Rejected decision is appended; candidate lifecycle is unchanged.
# @PRE: Candidate exists and report belongs to candidate.
# @POST: Rejected decision is appended; candidate lifecycle is unchanged.
def reject_candidate(
*,
repository: CleanReleaseRepository,
@@ -210,4 +208,4 @@ def reject_candidate(
# #endregion reject_candidate
# [/DEF:backend.src.services.clean_release.approval_service:Module]
# #endregion backend.src.services.clean_release.approval_service

View File

@@ -1,9 +1,7 @@
# [DEF:ArtifactCatalogLoader:Module]
# @COMPLEXITY: 3
# @SEMANTICS: clean-release, artifacts, bootstrap, json, tui
# @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows.
# #region ArtifactCatalogLoader [C:3] [TYPE Module] [SEMANTICS clean-release, artifacts, bootstrap, json, tui]
# @BRIEF Load bootstrap artifact catalogs for clean release real-mode flows.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @INVARIANT: Artifact catalog must produce deterministic CandidateArtifact entries with required identity and checksum fields.
from __future__ import annotations
@@ -17,8 +15,8 @@ from ...models.clean_release import CandidateArtifact
# #region load_bootstrap_artifacts [TYPE Function]
# @BRIEF Parse artifact catalog JSON into CandidateArtifact models for TUI/bootstrap flows.
# @PRE path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}.
# @POST Returns non-mutated CandidateArtifact models with required fields populated.
# @PRE: path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}.
# @POST: Returns non-mutated CandidateArtifact models with required fields populated.
def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArtifact]:
if not path or not path.strip():
return []
@@ -104,4 +102,4 @@ def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArti
# #endregion load_bootstrap_artifacts
# [/DEF:backend.src.services.clean_release.artifact_catalog_loader:Module]
# #endregion backend.src.services.clean_release.artifact_catalog_loader

View File

@@ -1,9 +1,7 @@
# [DEF:AuditService:Module]
# @COMPLEXITY: 3
# @SEMANTICS: clean-release, audit, lifecycle, logging
# @PURPOSE: Provide lightweight audit hooks for clean release preparation/check/report lifecycle.
# #region AuditService [C:3] [TYPE Module] [SEMANTICS clean-release, audit, lifecycle, logging]
# @BRIEF Provide lightweight audit hooks for clean release preparation/check/report lifecycle.
# @LAYER: Infra
# @RELATION: [DEPENDS_ON] ->[LoggerModule]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @INVARIANT: Audit hooks are append-only log actions.
from __future__ import annotations
@@ -116,4 +114,4 @@ def audit_report(
)
# [/DEF:backend.src.services.clean_release.audit_service:Module]
# #endregion backend.src.services.clean_release.audit_service

View File

@@ -1,11 +1,11 @@
# #region candidate_service [C:5] [TYPE Module] [SEMANTICS clean-release, candidate, artifacts, lifecycle, validation]
# @BRIEF Register release candidates with validated artifacts and advance lifecycle through legal transitions.
# @LAYER Domain
# @PRE candidate_id must be unique; artifacts input must be non-empty and valid.
# @POST candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
# @INVARIANT Candidate lifecycle transitions are delegated to domain guard logic.
# @RELATION DEPENDS_ON -> [backend.src.services.clean_release.repository]
# @RELATION DEPENDS_ON -> [backend.src.models.clean_release]
# @LAYER: Domain
# @RELATION DEPENDS_ON -> backend.src.services.clean_release.repository
# @RELATION DEPENDS_ON -> backend.src.models.clean_release
# @PRE: candidate_id must be unique; artifacts input must be non-empty and valid.
# @POST: candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
# @INVARIANT: Candidate lifecycle transitions are delegated to domain guard logic.
from __future__ import annotations
@@ -19,8 +19,8 @@ from .repository import CleanReleaseRepository
# #region _validate_artifacts [TYPE Function]
# @BRIEF Validate raw artifact payload list for required fields and shape.
# @PRE artifacts payload is provided by caller.
# @POST Returns normalized artifact list or raises ValueError.
# @PRE: artifacts payload is provided by caller.
# @POST: Returns normalized artifact list or raises ValueError.
def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized = list(artifacts)
if not normalized:
@@ -47,8 +47,8 @@ def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, A
# #region register_candidate [TYPE Function]
# @BRIEF Register a candidate and persist its artifacts with legal lifecycle transition.
# @PRE candidate_id must be unique and artifacts must pass validation.
# @POST Candidate exists in repository with PREPARED status and artifacts persisted.
# @PRE: candidate_id must be unique and artifacts must pass validation.
# @POST: Candidate exists in repository with PREPARED status and artifacts persisted.
def register_candidate(
repository: CleanReleaseRepository,
candidate_id: str,
@@ -102,4 +102,4 @@ def register_candidate(
return candidate
# #endregion register_candidate
# #endregion candidate_service
# #endregion backend.src.services.clean_release.candidate_service

View File

@@ -1,15 +1,15 @@
# #region ComplianceExecutionService [C:5] [TYPE Module] [SEMANTICS clean-release, compliance, execution, stages, immutable-evidence]
# @BRIEF Create and execute compliance runs with trusted snapshots, deterministic stages, violations and immutable report persistence.
# @LAYER Domain
# @PRE Repository adapters, trusted policy resolution, and deterministic stage implementations are available for the run request.
# @POST Candidate-scoped compliance runs persist stage evidence, terminal status, and immutable report artifacts when execution succeeds.
# @SIDE_EFFECT Persists runs, stage results, violations, and reports through repository adapters and audit helpers.
# @DATA_CONTRACT Input[candidate_id, requested_by, manifest_id?, policy snapshots, stage results] -> Output[ComplianceExecutionResult]
# @INVARIANT A run binds to exactly one candidate/manifest/policy/registry snapshot set.
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [PolicyResolutionService]
# @RELATION DEPENDS_ON -> [ComplianceStages]
# @RELATION DEPENDS_ON -> [ReportBuilder]
# @LAYER: Domain
# @RELATION DEPENDS_ON -> RepositoryRelations
# @RELATION DEPENDS_ON -> PolicyResolutionService
# @RELATION DEPENDS_ON -> ComplianceStages
# @RELATION DEPENDS_ON -> ReportBuilder
# @PRE: Repository adapters, trusted policy resolution, and deterministic stage implementations are available for the run request.
# @POST: Candidate-scoped compliance runs persist stage evidence, terminal status, and immutable report artifacts when execution succeeds.
# @SIDE_EFFECT: Persists runs, stage results, violations, and reports through repository adapters and audit helpers.
# @DATA_CONTRACT: Input[candidate_id, requested_by, manifest_id?, policy snapshots, stage results] -> Output[ComplianceExecutionResult]
# @INVARIANT: A run binds to exactly one candidate/manifest/policy/registry snapshot set.
from __future__ import annotations
@@ -54,10 +54,10 @@ class ComplianceExecutionResult:
# #region ComplianceExecutionService [TYPE Class]
# @BRIEF Execute clean-release compliance lifecycle over trusted snapshots and immutable evidence.
# @PRE Database session active, candidate registered
# @POST Returns ComplianceReport with pass/fail status and violation details
# @SIDE_EFFECT Updates compliance status in database, logs violations
# @DATA_CONTRACT ComplianceCheckResult, ComplianceReport, Violation
# @PRE: Database session active, candidate registered
# @POST: Returns ComplianceReport with pass/fail status and violation details
# @SIDE_EFFECT: Updates compliance status in database, logs violations
# @DATA_CONTRACT: ComplianceCheckResult, ComplianceReport, Violation
class ComplianceExecutionService:
TASK_PLUGIN_ID = "clean-release-compliance"
@@ -73,10 +73,10 @@ class ComplianceExecutionService:
self.stages = list(stages) if stages is not None else build_default_stages()
self.report_builder = ComplianceReportBuilder(repository)
# #region _resolve_manifest [TYPE Function]
# @BRIEF Resolve explicit manifest or fallback to latest candidate manifest.
# @PRE candidate exists.
# @POST Returns manifest snapshot or raises ComplianceRunError.
# [DEF:_resolve_manifest:Function]
# @PURPOSE: Resolve explicit manifest or fallback to latest candidate manifest.
# @PRE: candidate exists.
# @POST: Returns manifest snapshot or raises ComplianceRunError.
def _resolve_manifest(
self, candidate_id: str, manifest_id: Optional[str]
) -> DistributionManifest:
@@ -99,29 +99,29 @@ class ComplianceExecutionService:
manifests, key=lambda item: item.manifest_version, reverse=True
)[0]
# #endregion _resolve_manifest
# [/DEF:_resolve_manifest:Function]
# #region _persist_stage_run [TYPE Function]
# @BRIEF Persist stage run if repository supports stage records.
# @POST Stage run is persisted when adapter is available, otherwise no-op.
# [DEF:_persist_stage_run:Function]
# @PURPOSE: Persist stage run if repository supports stage records.
# @POST: Stage run is persisted when adapter is available, otherwise no-op.
def _persist_stage_run(self, stage_run: ComplianceStageRun) -> None:
self.repository.save_stage_run(stage_run)
# #endregion _persist_stage_run
# [/DEF:_persist_stage_run:Function]
# #region _persist_violations [TYPE Function]
# @BRIEF Persist stage violations via repository adapters.
# @POST Violations are appended to repository evidence store.
# [DEF:_persist_violations:Function]
# @PURPOSE: Persist stage violations via repository adapters.
# @POST: Violations are appended to repository evidence store.
def _persist_violations(self, violations: List[ComplianceViolation]) -> None:
for violation in violations:
self.repository.save_violation(violation)
# #endregion _persist_violations
# [/DEF:_persist_violations:Function]
# #region execute_run [TYPE Function]
# @BRIEF Execute compliance run stages and finalize immutable report on terminal success.
# @PRE candidate exists and trusted policy/registry snapshots are resolvable.
# @POST Run and evidence are persisted; report exists for SUCCEEDED runs.
# [DEF:execute_run:Function]
# @PURPOSE: Execute compliance run stages and finalize immutable report on terminal success.
# @PRE: candidate exists and trusted policy/registry snapshots are resolvable.
# @POST: Run and evidence are persisted; report exists for SUCCEEDED runs.
def execute_run(
self,
*,
@@ -223,7 +223,7 @@ class ComplianceExecutionService:
violations=violations,
)
# #endregion execute_run
# [/DEF:execute_run:Function]
# #endregion ComplianceExecutionService

View File

@@ -1,10 +1,10 @@
# #region ComplianceOrchestrator [C:5] [TYPE Module] [SEMANTICS clean-release, orchestrator, compliance-gate, stages]
# @BRIEF Execute mandatory clean compliance stages and produce final COMPLIANT/BLOCKED/FAILED outcome.
# @LAYER Domain
# @INVARIANT COMPLIANT is impossible when any mandatory stage fails.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [ComplianceStages]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @INVARIANT: COMPLIANT is impossible when any mandatory stage fails.
# @TEST_CONTRACT: ComplianceCheckRun -> ComplianceCheckRun
# @TEST_FIXTURE: compliant_candidate -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
# @TEST_EDGE: stage_failure_blocks_release -> Mandatory stage returns FAIL and final status becomes BLOCKED
@@ -44,24 +44,24 @@ from ...core.logger import belief_scope, logger
# #region CleanComplianceOrchestrator [TYPE Class]
# @BRIEF Coordinate clean-release compliance verification stages.
class CleanComplianceOrchestrator:
# #region __init__ [TYPE Function]
# @BRIEF Bind repository dependency used for orchestrator persistence and lookups.
# @PRE repository is a valid CleanReleaseRepository instance with required methods.
# @POST self.repository is assigned and used by all orchestration steps.
# @SIDE_EFFECT Stores repository reference on orchestrator instance.
# @DATA_CONTRACT Input -> CleanReleaseRepository, Output -> None
# [DEF:__init__:Function]
# @PURPOSE: Bind repository dependency used for orchestrator persistence and lookups.
# @PRE: repository is a valid CleanReleaseRepository instance with required methods.
# @POST: self.repository is assigned and used by all orchestration steps.
# @SIDE_EFFECT: Stores repository reference on orchestrator instance.
# @DATA_CONTRACT: Input -> CleanReleaseRepository, Output -> None
def __init__(self, repository: CleanReleaseRepository):
with belief_scope("CleanComplianceOrchestrator.__init__"):
self.repository = repository
# #endregion __init__
# [/DEF:__init__:Function]
# #region start_check_run [TYPE Function]
# @BRIEF Initiate a new compliance run session.
# @PRE candidate_id and policy_id are provided; legacy callers may omit persisted manifest/policy records.
# @POST Returns initialized ComplianceRun in RUNNING state persisted in repository.
# @SIDE_EFFECT Reads manifest/policy when present and writes new ComplianceRun via repository.save_check_run.
# @DATA_CONTRACT Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str|None), Output -> ComplianceRun
# [DEF:start_check_run:Function]
# @PURPOSE: Initiate a new compliance run session.
# @PRE: candidate_id and policy_id are provided; legacy callers may omit persisted manifest/policy records.
# @POST: Returns initialized ComplianceRun in RUNNING state persisted in repository.
# @SIDE_EFFECT: Reads manifest/policy when present and writes new ComplianceRun via repository.save_check_run.
# @DATA_CONTRACT: Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str|None), Output -> ComplianceRun
def start_check_run(
self,
candidate_id: str,
@@ -149,14 +149,14 @@ class CleanComplianceOrchestrator:
)
return self.repository.save_check_run(check_run)
# #endregion start_check_run
# [/DEF:start_check_run:Function]
# #region execute_stages [TYPE Function]
# @BRIEF Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
# @PRE check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
# @POST Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
# @SIDE_EFFECT Reads candidate/policy/registry/manifest and persists updated check_run.
# @DATA_CONTRACT Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
# [DEF:execute_stages:Function]
# @PURPOSE: Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
# @PRE: check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
# @POST: Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
# @SIDE_EFFECT: Reads candidate/policy/registry/manifest and persists updated check_run.
# @DATA_CONTRACT: Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
def execute_stages(
self,
check_run: ComplianceRun,
@@ -211,14 +211,14 @@ class CleanComplianceOrchestrator:
return self.repository.save_check_run(check_run)
# #endregion execute_stages
# [/DEF:execute_stages:Function]
# #region finalize_run [TYPE Function]
# @BRIEF Finalize run status based on cumulative stage results.
# @PRE check_run was started and may already contain a derived final_status from stage execution.
# @POST Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
# @SIDE_EFFECT Mutates check_run terminal fields and persists via repository.save_check_run.
# @DATA_CONTRACT Input -> ComplianceRun, Output -> ComplianceRun
# [DEF:finalize_run:Function]
# @PURPOSE: Finalize run status based on cumulative stage results.
# @PRE: check_run was started and may already contain a derived final_status from stage execution.
# @POST: Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
# @SIDE_EFFECT: Mutates check_run terminal fields and persists via repository.save_check_run.
# @DATA_CONTRACT: Input -> ComplianceRun, Output -> ComplianceRun
def finalize_run(self, check_run: ComplianceRun) -> ComplianceRun:
with belief_scope("finalize_run"):
if check_run.status == RunStatus.FAILED:
@@ -238,7 +238,7 @@ class CleanComplianceOrchestrator:
check_run.finished_at = datetime.now(timezone.utc)
return self.repository.save_check_run(check_run)
# #endregion finalize_run
# [/DEF:finalize_run:Function]
# #endregion CleanComplianceOrchestrator
@@ -246,10 +246,10 @@ class CleanComplianceOrchestrator:
# #region run_check_legacy [TYPE Function]
# @BRIEF Legacy wrapper for compatibility with previous orchestrator call style.
# @PRE repository and identifiers are valid and resolvable by orchestrator dependencies.
# @POST Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
# @SIDE_EFFECT Reads/writes compliance entities through repository during orchestrator calls.
# @DATA_CONTRACT Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
# @PRE: repository and identifiers are valid and resolvable by orchestrator dependencies.
# @POST: Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
# @SIDE_EFFECT: Reads/writes compliance entities through repository during orchestrator calls.
# @DATA_CONTRACT: Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
def run_check_legacy(
repository: CleanReleaseRepository,
candidate_id: str,

View File

@@ -1,8 +1,8 @@
# #region DemoDataService [C:3] [TYPE Module] [SEMANTICS clean-release, demo-mode, namespace, isolation, repository]
# @BRIEF Provide deterministic namespace helpers and isolated in-memory repository creation for demo and real modes.
# @LAYER Domain
# @INVARIANT Demo and real namespaces must never collide for generated physical identifiers.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @INVARIANT: Demo and real namespaces must never collide for generated physical identifiers.
from __future__ import annotations
@@ -11,8 +11,8 @@ from .repository import CleanReleaseRepository
# #region resolve_namespace [TYPE Function]
# @BRIEF Resolve canonical clean-release namespace for requested mode.
# @PRE mode is a non-empty string identifying runtime mode.
# @POST Returns deterministic namespace key for demo/real separation.
# @PRE: mode is a non-empty string identifying runtime mode.
# @POST: Returns deterministic namespace key for demo/real separation.
def resolve_namespace(mode: str) -> str:
normalized = (mode or "").strip().lower()
if normalized == "demo":
@@ -25,8 +25,8 @@ def resolve_namespace(mode: str) -> str:
# #region build_namespaced_id [TYPE Function]
# @BRIEF Build storage-safe physical identifier under mode namespace.
# @PRE namespace and logical_id are non-empty strings.
# @POST Returns deterministic "{namespace}::{logical_id}" identifier.
# @PRE: namespace and logical_id are non-empty strings.
# @POST: Returns deterministic "{namespace}::{logical_id}" identifier.
def build_namespaced_id(namespace: str, logical_id: str) -> str:
if not namespace or not namespace.strip():
raise ValueError("namespace must be non-empty")
@@ -40,8 +40,8 @@ def build_namespaced_id(namespace: str, logical_id: str) -> str:
# #region create_isolated_repository [TYPE Function]
# @BRIEF Create isolated in-memory repository instance for selected mode namespace.
# @PRE mode is a valid runtime mode marker.
# @POST Returns repository instance tagged with namespace metadata.
# @PRE: mode is a valid runtime mode marker.
# @POST: Returns repository instance tagged with namespace metadata.
def create_isolated_repository(mode: str) -> CleanReleaseRepository:
namespace = resolve_namespace(mode)
repository = CleanReleaseRepository()

View File

@@ -1,7 +1,7 @@
# #region clean_release_dto [C:3] [TYPE Module]
# @BRIEF Data Transfer Objects for clean release compliance subsystem.
# @LAYER Application
# @RELATION DEPENDS_ON -> [pydantic]
# @LAYER: Application
# @RELATION DEPENDS_ON -> pydantic
from datetime import datetime
from typing import List, Optional, Dict, Any
@@ -82,4 +82,4 @@ class CandidateOverviewDTO(BaseModel):
latest_publication_id: Optional[str] = None
latest_publication_status: Optional[str] = None
# #endregion clean_release_dto
# #endregion clean_release_dto

View File

@@ -1,7 +1,7 @@
# #region clean_release_enums [C:3] [TYPE Module]
# @BRIEF Canonical enums for clean release lifecycle and compliance.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [enum]
# @LAYER: Domain
# @RELATION DEPENDS_ON -> enum
from enum import Enum
@@ -69,4 +69,4 @@ class ViolationCategory(str, Enum):
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
EXTERNAL_ENDPOINT = "EXTERNAL_ENDPOINT"
# #endregion clean_release_enums
# #endregion clean_release_enums

View File

@@ -1,7 +1,7 @@
# #region clean_release_exceptions [C:3] [TYPE Module]
# @BRIEF Domain exceptions for clean release compliance subsystem.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [Exception]
# @LAYER: Domain
# @RELATION DEPENDS_ON -> Exception
class CleanReleaseError(Exception):
"""Base exception for clean release subsystem."""
@@ -35,4 +35,4 @@ class PublicationGateError(CleanReleaseError):
"""Raised when publication requirements are not met."""
pass
# #endregion clean_release_exceptions
# #endregion clean_release_exceptions

View File

@@ -1,7 +1,7 @@
# #region clean_release_facade [C:3] [TYPE Module]
# @BRIEF Unified entry point for clean release operations.
# @LAYER Application
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
# @LAYER: Application
# @RELATION DEPENDS_ON -> ComplianceOrchestrator
from typing import List, Optional
from src.services.clean_release.repositories import (
@@ -119,4 +119,4 @@ class CleanReleaseFacade:
candidates = self.candidate_repo.list_all()
return [self.get_candidate_overview(c.id) for c in candidates]
# #endregion clean_release_facade
# #endregion clean_release_facade

View File

@@ -1,8 +1,8 @@
# #region ManifestBuilder [C:3] [TYPE Module] [SEMANTICS clean-release, manifest, deterministic-hash, summary]
# @BRIEF Build deterministic distribution manifest from classified artifact input.
# @LAYER Domain
# @INVARIANT Equal semantic artifact sets produce identical deterministic hash values.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @INVARIANT: Equal semantic artifact sets produce identical deterministic hash values.
from __future__ import annotations
@@ -54,8 +54,8 @@ def _stable_hash_payload(
# #region build_distribution_manifest [TYPE Function]
# @BRIEF Build DistributionManifest with deterministic hash and validated counters.
# @PRE artifacts list contains normalized classification values.
# @POST Returns DistributionManifest with summary counts matching items cardinality.
# @PRE: artifacts list contains normalized classification values.
# @POST: Returns DistributionManifest with summary counts matching items cardinality.
def build_distribution_manifest(
manifest_id: str,
candidate_id: str,
@@ -111,8 +111,8 @@ def build_distribution_manifest(
# #region build_manifest [TYPE Function]
# @BRIEF Legacy compatibility wrapper for old manifest builder import paths.
# @PRE Same as build_distribution_manifest.
# @POST Returns DistributionManifest produced by canonical builder.
# @PRE: Same as build_distribution_manifest.
# @POST: Returns DistributionManifest produced by canonical builder.
def build_manifest(
manifest_id: str,
candidate_id: str,

View File

@@ -1,14 +1,14 @@
# #region ManifestService [C:5] [TYPE Module] [SEMANTICS clean-release, manifest, versioning, immutability, lifecycle]
# @BRIEF Build immutable distribution manifests with deterministic digest and version increment.
# @LAYER Domain
# @PRE Candidate exists and is PREPARED or MANIFEST_BUILT; artifacts are present.
# @POST New immutable manifest is persisted with incremented version and deterministic digest.
# @SIDE_EFFECT May modify manifest state during processing
# @DATA_CONTRACT Manifest -> ManifestRecord; Candidate -> ManifestRecord
# @INVARIANT Existing manifests are never mutated.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [ManifestBuilder]
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @PRE: Candidate exists and is PREPARED or MANIFEST_BUILT; artifacts are present.
# @POST: New immutable manifest is persisted with incremented version and deterministic digest.
# @INVARIANT: Existing manifests are never mutated.
# @SIDE_EFFECT: May modify manifest state during processing
# @DATA_CONTRACT: Manifest -> ManifestRecord; Candidate -> ManifestRecord
from __future__ import annotations
@@ -22,8 +22,8 @@ from .repository import CleanReleaseRepository
# #region build_manifest_snapshot [TYPE Function]
# @BRIEF Create a new immutable manifest version for a candidate.
# @PRE Candidate is prepared, artifacts are available, candidate_id is valid.
# @POST Returns persisted DistributionManifest with monotonically incremented version.
# @PRE: Candidate is prepared, artifacts are available, candidate_id is valid.
# @POST: Returns persisted DistributionManifest with monotonically incremented version.
def build_manifest_snapshot(
repository: CleanReleaseRepository,
candidate_id: str,

View File

@@ -1,7 +1,7 @@
# #region clean_release_mappers [C:3] [TYPE Module]
# @BRIEF Map between domain entities (SQLAlchemy models) and DTOs.
# @LAYER Application
# @RELATION DEPENDS_ON -> [clean_release_dto]
# @LAYER: Application
# @RELATION DEPENDS_ON -> clean_release_dto
from typing import List
from src.models.clean_release import (
@@ -64,4 +64,4 @@ def map_report_to_dto(report: ComplianceReport) -> ReportDTO:
generated_at=report.generated_at
)
# #endregion clean_release_mappers
# #endregion clean_release_mappers

View File

@@ -1,13 +1,13 @@
# #region PolicyEngine [C:5] [TYPE Module] [SEMANTICS clean-release, policy, classification, source-isolation]
# @BRIEF Evaluate artifact/source policies for enterprise clean profile with deterministic outcomes.
# @LAYER Domain
# @PRE PolicyRepository is accessible
# @POST PolicyDecision returned with approval status
# @SIDE_EFFECT Read-only policy evaluation; no state changes
# @DATA_CONTRACT Candidate -> PolicyDecision
# @INVARIANT Enterprise-clean policy always treats non-registry sources as violations.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @INVARIANT: Enterprise-clean policy always treats non-registry sources as violations.
# @DATA_CONTRACT: Candidate -> PolicyDecision
# @PRE: PolicyRepository is accessible
# @POST: PolicyDecision returned with approval status
# @SIDE_EFFECT: Read-only policy evaluation; no state changes
from __future__ import annotations
@@ -36,8 +36,8 @@ class SourceValidationResult:
# #region CleanPolicyEngine [TYPE Class]
# @PRE Active policy exists and is internally consistent.
# @POST Deterministic classification and source validation are available.
# @PRE: Active policy exists and is internally consistent.
# @POST: Deterministic classification and source validation are available.
# @TEST_CONTRACT: CandidateEvaluationInput -> PolicyValidationResult|SourceValidationResult
# @TEST_SCENARIO: policy_valid -> Enterprise clean policy with matching registry returns ok=True
# @TEST_FIXTURE: policy_enterprise_clean -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json

View File

@@ -1,14 +1,14 @@
# #region PolicyResolutionService [C:5] [TYPE Module] [SEMANTICS clean-release, policy, registry, trusted-resolution, immutable-snapshots]
# @BRIEF Resolve trusted policy and registry snapshots from ConfigManager without runtime overrides.
# @LAYER Domain
# @PRE PolicyRepository and Manifest are available
# @POST ResolutionResult with matched policies
# @SIDE_EFFECT Read-only policy evaluation; logs resolution decisions
# @DATA_CONTRACT PolicyRequest -> ResolutionResult
# @INVARIANT Trusted snapshot resolution is based only on ConfigManager active identifiers.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [clean_release_exceptions]
# @INVARIANT: Trusted snapshot resolution is based only on ConfigManager active identifiers.
# @DATA_CONTRACT: PolicyRequest -> ResolutionResult
# @PRE: PolicyRepository and Manifest are available
# @POST: ResolutionResult with matched policies
# @SIDE_EFFECT: Read-only policy evaluation; logs resolution decisions
from __future__ import annotations
@@ -21,9 +21,9 @@ from .repository import CleanReleaseRepository
# #region resolve_trusted_policy_snapshots [TYPE Function]
# @BRIEF Resolve immutable trusted policy and registry snapshots using active config IDs only.
# @PRE ConfigManager provides active_policy_id and active_registry_id; repository contains referenced snapshots.
# @POST Returns immutable policy and registry snapshots; runtime override attempts are rejected.
# @SIDE_EFFECT None.
# @PRE: ConfigManager provides active_policy_id and active_registry_id; repository contains referenced snapshots.
# @POST: Returns immutable policy and registry snapshots; runtime override attempts are rejected.
# @SIDE_EFFECT: None.
def resolve_trusted_policy_snapshots(
*,
config_manager,

View File

@@ -1,10 +1,10 @@
# #region PreparationService [C:3] [TYPE Module] [SEMANTICS clean-release, preparation, manifest, policy-evaluation]
# @BRIEF Prepare release candidate by policy evaluation and deterministic manifest creation.
# @LAYER Domain
# @INVARIANT Candidate preparation always persists manifest and candidate status deterministically.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [PolicyEngine]
# @RELATION DEPENDS_ON -> [ManifestBuilder]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
from __future__ import annotations
@@ -89,8 +89,8 @@ def prepare_candidate(
# #region prepare_candidate_legacy [TYPE Function]
# @BRIEF Legacy compatibility wrapper kept for migration period.
# @PRE Same as prepare_candidate.
# @POST Delegates to canonical prepare_candidate and preserves response shape.
# @PRE: Same as prepare_candidate.
# @POST: Delegates to canonical prepare_candidate and preserves response shape.
def prepare_candidate_legacy(
repository: CleanReleaseRepository,
candidate_id: str,

View File

@@ -1,12 +1,10 @@
# [DEF:PublicationService:Module]
# @COMPLEXITY: 5
# @SEMANTICS: clean-release, publication, revoke, gate, lifecycle
# @PURPOSE: Enforce publication and revocation gates with append-only publication records.
# #region PublicationService [C:5] [TYPE Module] [SEMANTICS clean-release, publication, revoke, gate, lifecycle]
# @BRIEF Enforce publication and revocation gates with append-only publication records.
# @LAYER: Domain
# @RELATION: [DEPENDS_ON] ->[RepositoryRelations]
# @RELATION: [DEPENDS_ON] ->[ApprovalService]
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
# @RELATION: [DEPENDS_ON] ->[AuditService]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @RELATION DEPENDS_ON -> [ApprovalService]
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [AuditService]
# @INVARIANT: Publication records are append-only snapshots; revoke mutates only publication status for targeted record.
from __future__ import annotations
@@ -25,8 +23,8 @@ from .repository import CleanReleaseRepository
# #region _get_or_init_publications_store [TYPE Function]
# @BRIEF Provide in-memory append-only publication storage.
# @PRE repository is initialized.
# @POST Returns publication list attached to repository.
# @PRE: repository is initialized.
# @POST: Returns publication list attached to repository.
def _get_or_init_publications_store(
repository: CleanReleaseRepository,
) -> List[PublicationRecord]:
@@ -42,8 +40,8 @@ def _get_or_init_publications_store(
# #region _latest_publication_for_candidate [TYPE Function]
# @BRIEF Resolve latest publication record for candidate.
# @PRE candidate_id is non-empty.
# @POST Returns latest record or None.
# @PRE: candidate_id is non-empty.
# @POST: Returns latest record or None.
def _latest_publication_for_candidate(
repository: CleanReleaseRepository,
candidate_id: str,
@@ -67,8 +65,8 @@ def _latest_publication_for_candidate(
# #region _latest_approval_for_candidate [TYPE Function]
# @BRIEF Resolve latest approval decision from repository decision store.
# @PRE candidate_id is non-empty.
# @POST Returns latest decision object or None.
# @PRE: candidate_id is non-empty.
# @POST: Returns latest decision object or None.
def _latest_approval_for_candidate(
repository: CleanReleaseRepository, candidate_id: str
):
@@ -88,8 +86,8 @@ def _latest_approval_for_candidate(
# #region publish_candidate [TYPE Function]
# @BRIEF Create immutable publication record for approved candidate.
# @PRE Candidate exists, report belongs to candidate, latest approval is APPROVED.
# @POST New ACTIVE publication record is appended.
# @PRE: Candidate exists, report belongs to candidate, latest approval is APPROVED.
# @POST: New ACTIVE publication record is appended.
def publish_candidate(
*,
repository: CleanReleaseRepository,
@@ -168,8 +166,8 @@ def publish_candidate(
# #region revoke_publication [TYPE Function]
# @BRIEF Revoke existing publication record without deleting history.
# @PRE publication_id exists in repository publication store.
# @POST Target publication status becomes REVOKED and updated record is returned.
# @PRE: publication_id exists in repository publication store.
# @POST: Target publication status becomes REVOKED and updated record is returned.
def revoke_publication(
*,
repository: CleanReleaseRepository,
@@ -212,4 +210,4 @@ def revoke_publication(
# #endregion revoke_publication
# [/DEF:backend.src.services.clean_release.publication_service:Module]
# #endregion backend.src.services.clean_release.publication_service

View File

@@ -1,9 +1,9 @@
# #region ReportBuilder [C:5] [TYPE Module] [SEMANTICS clean-release, report, audit, counters, violations]
# @BRIEF Build and persist compliance reports with consistent counter invariants.
# @LAYER Domain
# @INVARIANT blocking_violations_count never exceeds violations_count.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [RepositoryRelations]
# @INVARIANT: blocking_violations_count never exceeds violations_count.
# @TEST_CONTRACT: ComplianceCheckRun,List[ComplianceViolation] -> ComplianceReport
# @TEST_FIXTURE: blocked_with_two_violations -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
# @TEST_EDGE: empty_violations_for_blocked -> BLOCKED run with zero blocking violations raises ValueError

View File

@@ -1,6 +1,6 @@
# #region clean_release_repositories [C:3] [TYPE Module]
# @BRIEF Export all clean release repositories.
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @RELATION DEPENDS_ON -> sqlalchemy
from .candidate_repository import CandidateRepository
from .artifact_repository import ArtifactRepository
@@ -25,4 +25,4 @@ __all__ = [
"CleanReleaseAuditLog"
]
# #endregion clean_release_repositories
# #endregion clean_release_repositories

View File

@@ -1,7 +1,7 @@
# #region approval_repository [C:3] [TYPE Module]
# @BRIEF Persist and query approval decisions.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -50,4 +50,4 @@ class ApprovalRepository:
with belief_scope("ApprovalRepository.list_by_candidate"):
return self.db.query(ApprovalDecision).filter(ApprovalDecision.candidate_id == candidate_id).all()
# #endregion approval_repository
# #endregion approval_repository

View File

@@ -1,7 +1,7 @@
# #region artifact_repository [C:3] [TYPE Module]
# @BRIEF Persist and query candidate artifacts.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -51,4 +51,4 @@ class ArtifactRepository:
with belief_scope("ArtifactRepository.list_by_candidate"):
return self.db.query(CandidateArtifact).filter(CandidateArtifact.candidate_id == candidate_id).all()
# #endregion artifact_repository
# #endregion artifact_repository

View File

@@ -1,7 +1,7 @@
# #region audit_repository [C:3] [TYPE Module]
# @BRIEF Persist and query audit logs for clean release operations.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -43,4 +43,4 @@ class AuditRepository:
with belief_scope("AuditRepository.list_by_candidate"):
return self.db.query(CleanReleaseAuditLog).filter(CleanReleaseAuditLog.candidate_id == candidate_id).all()
# #endregion audit_repository
# #endregion audit_repository

View File

@@ -1,7 +1,7 @@
# #region candidate_repository [C:3] [TYPE Module]
# @BRIEF Persist and query release candidates.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -44,4 +44,4 @@ class CandidateRepository:
with belief_scope("CandidateRepository.list_all"):
return self.db.query(ReleaseCandidate).all()
# #endregion candidate_repository
# #endregion candidate_repository

View File

@@ -1,7 +1,7 @@
# #region compliance_repository [C:3] [TYPE Module]
# @BRIEF Persist and query compliance runs, stage runs, and violations.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -84,4 +84,4 @@ class ComplianceRepository:
with belief_scope("ComplianceRepository.list_violations_by_run"):
return self.db.query(ComplianceViolation).filter(ComplianceViolation.run_id == run_id).all()
# #endregion compliance_repository
# #endregion compliance_repository

View File

@@ -1,9 +1,9 @@
# #region ManifestRepositoryModule [C:3] [TYPE Module]
# @BRIEF Persist and query distribution manifests.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [DistributionManifest]
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @RELATION DEPENDS_ON -> [belief_scope]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> DistributionManifest
# @RELATION DEPENDS_ON -> sqlalchemy
# @RELATION DEPENDS_ON -> belief_scope
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -13,50 +13,54 @@ from src.core.logger import belief_scope
# #region ManifestRepository [C:3] [TYPE Class]
# @BRIEF Encapsulates database CRUD operations for DistributionManifest entities.
# @RELATION DEPENDS_ON -> [DistributionManifest]
# @RELATION DEPENDS_ON -> [sqlalchemy.Session]
# @RELATION DEPENDS_ON -> DistributionManifest
# @RELATION DEPENDS_ON -> sqlalchemy.Session
class ManifestRepository:
"""Repository for distribution manifest persistence."""
# #region ManifestRepository.__init__ [C:1] [TYPE Function]
# @BRIEF Initialize repository with an active SQLAlchemy session.
# @PRE db is a valid SQLAlchemy Session instance.
# @POST Repository is ready for database operations.
# [DEF:ManifestRepository.__init__:Function]
# @COMPLEXITY: 1
# @PURPOSE: Initialize repository with an active SQLAlchemy session.
# @PRE: db is a valid SQLAlchemy Session instance.
# @POST: Repository is ready for database operations.
def __init__(self, db: Session):
self.db = db
# #endregion ManifestRepository.__init__
# [/DEF:ManifestRepository.__init__:Function]
# #region ManifestRepository.save [C:3] [TYPE Function]
# @BRIEF Persist a DistributionManifest to the database.
# @PRE manifest is a valid DistributionManifest instance with required fields populated.
# @POST Manifest is committed to database and refreshed with generated ID.
# @SIDE_EFFECT Database commit via session.commit().
# @RELATION DEPENDS_ON -> [DistributionManifest]
# [DEF:ManifestRepository.save:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist a DistributionManifest to the database.
# @PRE: manifest is a valid DistributionManifest instance with required fields populated.
# @POST: Manifest is committed to database and refreshed with generated ID.
# @SIDE_EFFECT: Database commit via session.commit().
# @RELATION: DEPENDS_ON -> DistributionManifest
def save(self, manifest: DistributionManifest) -> DistributionManifest:
with belief_scope("ManifestRepository.save"):
self.db.add(manifest)
self.db.commit()
self.db.refresh(manifest)
return manifest
# #endregion ManifestRepository.save
# [/DEF:ManifestRepository.save:Function]
# #region ManifestRepository.get_by_id [C:2] [TYPE Function]
# @BRIEF Retrieve a single DistributionManifest by its primary key.
# @PRE manifest_id is a valid string identifier.
# @POST Returns DistributionManifest if found, None otherwise.
# @RELATION DEPENDS_ON -> [DistributionManifest]
# [DEF:ManifestRepository.get_by_id:Function]
# @COMPLEXITY: 2
# @PURPOSE: Retrieve a single DistributionManifest by its primary key.
# @PRE: manifest_id is a valid string identifier.
# @POST: Returns DistributionManifest if found, None otherwise.
# @RELATION: DEPENDS_ON -> DistributionManifest
def get_by_id(self, manifest_id: str) -> Optional[DistributionManifest]:
with belief_scope("ManifestRepository.get_by_id"):
return self.db.query(DistributionManifest).filter(
DistributionManifest.id == manifest_id
).first()
# #endregion ManifestRepository.get_by_id
# [/DEF:ManifestRepository.get_by_id:Function]
# #region ManifestRepository.get_latest_for_candidate [C:3] [TYPE Function]
# @BRIEF Retrieve the most recent manifest version for a given candidate.
# @PRE candidate_id is a valid string identifier.
# @POST Returns the highest manifest_version manifest for the candidate, or None.
# @RELATION DEPENDS_ON -> [DistributionManifest]
# [DEF:ManifestRepository.get_latest_for_candidate:Function]
# @COMPLEXITY: 3
# @PURPOSE: Retrieve the most recent manifest version for a given candidate.
# @PRE: candidate_id is a valid string identifier.
# @POST: Returns the highest manifest_version manifest for the candidate, or None.
# @RELATION: DEPENDS_ON -> DistributionManifest
def get_latest_for_candidate(self, candidate_id: str) -> Optional[DistributionManifest]:
with belief_scope("ManifestRepository.get_latest_for_candidate"):
return (
@@ -65,13 +69,14 @@ class ManifestRepository:
.order_by(DistributionManifest.manifest_version.desc())
.first()
)
# #endregion ManifestRepository.get_latest_for_candidate
# [/DEF:ManifestRepository.get_latest_for_candidate:Function]
# #region ManifestRepository.list_by_candidate [C:2] [TYPE Function]
# @BRIEF List all manifests for a specific candidate, ordered by version.
# @PRE candidate_id is a valid string identifier.
# @POST Returns a list of DistributionManifest instances (may be empty).
# @RELATION DEPENDS_ON -> [DistributionManifest]
# [DEF:ManifestRepository.list_by_candidate:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all manifests for a specific candidate, ordered by version.
# @PRE: candidate_id is a valid string identifier.
# @POST: Returns a list of DistributionManifest instances (may be empty).
# @RELATION: DEPENDS_ON -> DistributionManifest
def list_by_candidate(self, candidate_id: str) -> List[DistributionManifest]:
with belief_scope("ManifestRepository.list_by_candidate"):
return (
@@ -79,6 +84,8 @@ class ManifestRepository:
.filter(DistributionManifest.candidate_id == candidate_id)
.all()
)
# #endregion ManifestRepository.list_by_candidate
# [/DEF:ManifestRepository.list_by_candidate:Function]
# #endregion ManifestRepository
# #endregion ManifestRepositoryModule

View File

@@ -1,7 +1,7 @@
# #region policy_repository [C:3] [TYPE Module]
# @BRIEF Persist and query policy and registry snapshots.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -49,4 +49,4 @@ class PolicyRepository:
with belief_scope("PolicyRepository.get_registry_snapshot"):
return self.db.query(SourceRegistrySnapshot).filter(SourceRegistrySnapshot.id == snapshot_id).first()
# #endregion policy_repository
# #endregion policy_repository

View File

@@ -1,7 +1,7 @@
# #region publication_repository [C:3] [TYPE Module]
# @BRIEF Persist and query publication records.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -50,4 +50,4 @@ class PublicationRepository:
with belief_scope("PublicationRepository.list_by_candidate"):
return self.db.query(PublicationRecord).filter(PublicationRecord.candidate_id == candidate_id).all()
# #endregion publication_repository
# #endregion publication_repository

View File

@@ -1,7 +1,7 @@
# #region report_repository [C:3] [TYPE Module]
# @BRIEF Persist and query compliance reports.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [sqlalchemy]
# @LAYER: Infra
# @RELATION DEPENDS_ON -> sqlalchemy
from typing import Optional, List
from sqlalchemy.orm import Session
@@ -47,4 +47,4 @@ class ReportRepository:
with belief_scope("ReportRepository.list_by_candidate"):
return self.db.query(ComplianceReport).filter(ComplianceReport.candidate_id == candidate_id).all()
# #endregion report_repository
# #endregion report_repository

View File

@@ -1,8 +1,8 @@
# #region RepositoryRelations [C:3] [TYPE Module] [SEMANTICS clean-release, repository, persistence, in-memory]
# @BRIEF Provide repository adapter for clean release entities with deterministic access methods.
# @LAYER Infra
# @INVARIANT Repository operations are side-effect free outside explicit save/update calls.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @INVARIANT: Repository operations are side-effect free outside explicit save/update calls.
from __future__ import annotations

View File

@@ -1,8 +1,8 @@
# #region SourceIsolation [C:3] [TYPE Module] [SEMANTICS clean-release, source-isolation, internal-only, validation]
# @BRIEF Validate that all resource endpoints belong to the approved internal source registry.
# @LAYER Domain
# @INVARIANT Any endpoint outside enabled registry entries is treated as external-source violation.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @INVARIANT: Any endpoint outside enabled registry entries is treated as external-source violation.
from __future__ import annotations

View File

@@ -1,9 +1,9 @@
# #region ComplianceStages [C:3] [TYPE Module] [SEMANTICS clean-release, compliance, stages, state-machine]
# @BRIEF Define compliance stage order and helper functions for deterministic run-state evaluation.
# @LAYER Domain
# @INVARIANT Stage order remains deterministic for all compliance runs.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
# @INVARIANT: Stage order remains deterministic for all compliance runs.
from __future__ import annotations
@@ -32,8 +32,8 @@ MANDATORY_STAGE_ORDER: List[ComplianceStageName] = [
# #region build_default_stages [TYPE Function]
# @BRIEF Build default deterministic stage pipeline implementation order.
# @PRE None.
# @POST Returns stage instances in mandatory execution order.
# @PRE: None.
# @POST: Returns stage instances in mandatory execution order.
def build_default_stages() -> List[ComplianceStage]:
return [
DataPurityStage(),
@@ -48,8 +48,8 @@ def build_default_stages() -> List[ComplianceStage]:
# #region stage_result_map [TYPE Function]
# @BRIEF Convert stage result list to dictionary by stage name.
# @PRE stage_results may be empty or contain unique stage names.
# @POST Returns stage->status dictionary for downstream evaluation.
# @PRE: stage_results may be empty or contain unique stage names.
# @POST: Returns stage->status dictionary for downstream evaluation.
def stage_result_map(
stage_results: Iterable[ComplianceStageRun | CheckStageResult],
) -> Dict[ComplianceStageName, CheckStageStatus]:
@@ -87,8 +87,8 @@ def stage_result_map(
# #region missing_mandatory_stages [TYPE Function]
# @BRIEF Identify mandatory stages that are absent from run results.
# @PRE stage_status_map contains zero or more known stage statuses.
# @POST Returns ordered list of missing mandatory stages.
# @PRE: stage_status_map contains zero or more known stage statuses.
# @POST: Returns ordered list of missing mandatory stages.
def missing_mandatory_stages(
stage_status_map: Dict[ComplianceStageName, CheckStageStatus],
) -> List[ComplianceStageName]:
@@ -100,8 +100,8 @@ def missing_mandatory_stages(
# #region derive_final_status [TYPE Function]
# @BRIEF Derive final run status from stage results with deterministic blocking behavior.
# @PRE Stage statuses correspond to compliance checks.
# @POST Returns one of PASSED/BLOCKED/ERROR according to mandatory stage outcomes.
# @PRE: Stage statuses correspond to compliance checks.
# @POST: Returns one of PASSED/BLOCKED/ERROR according to mandatory stage outcomes.
def derive_final_status(
stage_results: Iterable[ComplianceStageRun | CheckStageResult],
) -> CheckFinalStatus:

View File

@@ -1,9 +1,9 @@
# #region ComplianceStageBase [C:3] [TYPE Module] [SEMANTICS clean-release, compliance, stages, contracts, base]
# @BRIEF Define shared contracts and helpers for pluggable clean-release compliance stages.
# @LAYER Domain
# @INVARIANT Stage execution is deterministic for equal input context.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @INVARIANT: Stage execution is deterministic for equal input context.
from __future__ import annotations
@@ -65,8 +65,8 @@ class ComplianceStage(Protocol):
# #region build_stage_run_record [TYPE Function]
# @BRIEF Build persisted stage run record from stage result.
# @PRE run_id and stage_name are non-empty.
# @POST Returns ComplianceStageRun with deterministic identifiers and timestamps.
# @PRE: run_id and stage_name are non-empty.
# @POST: Returns ComplianceStageRun with deterministic identifiers and timestamps.
def build_stage_run_record(
*,
run_id: str,
@@ -96,8 +96,8 @@ def build_stage_run_record(
# #region build_violation [TYPE Function]
# @BRIEF Construct a compliance violation with normalized defaults.
# @PRE run_id, stage_name, code and message are non-empty.
# @POST Returns immutable-style violation payload ready for persistence.
# @PRE: run_id, stage_name, code and message are non-empty.
# @POST: Returns immutable-style violation payload ready for persistence.
def build_violation(
*,
run_id: str,

View File

@@ -1,9 +1,9 @@
# #region data_purity [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, data-purity]
# @BRIEF Evaluate manifest purity counters and emit blocking violations for prohibited artifacts.
# @LAYER Domain
# @INVARIANT prohibited_detected_count > 0 always yields BLOCKED stage decision.
# @LAYER: Domain
# @RELATION IMPLEMENTS -> [ComplianceStage]
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
# @INVARIANT: prohibited_detected_count > 0 always yields BLOCKED stage decision.
from __future__ import annotations
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
# #region DataPurityStage [TYPE Class]
# @BRIEF Validate manifest summary for prohibited artifacts.
# @PRE context.manifest.content_json contains summary block or defaults to safe counters.
# @POST Returns PASSED when no prohibited artifacts are detected, otherwise BLOCKED with violations.
# @PRE: context.manifest.content_json contains summary block or defaults to safe counters.
# @POST: Returns PASSED when no prohibited artifacts are detected, otherwise BLOCKED with violations.
class DataPurityStage:
stage_name = ComplianceStageName.DATA_PURITY

View File

@@ -1,9 +1,9 @@
# #region internal_sources_only [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, source-isolation, registry]
# @BRIEF Verify manifest-declared sources belong to trusted internal registry allowlist.
# @LAYER Domain
# @INVARIANT Any source host outside allowed_hosts yields BLOCKED decision with at least one violation.
# @LAYER: Domain
# @RELATION IMPLEMENTS -> [ComplianceStage]
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
# @INVARIANT: Any source host outside allowed_hosts yields BLOCKED decision with at least one violation.
from __future__ import annotations
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
# #region InternalSourcesOnlyStage [TYPE Class]
# @BRIEF Enforce internal-source-only policy from trusted registry snapshot.
# @PRE context.registry.allowed_hosts is available.
# @POST Returns PASSED when all hosts are allowed; otherwise BLOCKED and violations captured.
# @PRE: context.registry.allowed_hosts is available.
# @POST: Returns PASSED when all hosts are allowed; otherwise BLOCKED and violations captured.
class InternalSourcesOnlyStage:
stage_name = ComplianceStageName.INTERNAL_SOURCES_ONLY

View File

@@ -1,9 +1,9 @@
# #region manifest_consistency [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, manifest, consistency, digest]
# @BRIEF Ensure run is bound to the exact manifest snapshot and digest used at run creation time.
# @LAYER Domain
# @INVARIANT Digest mismatch between run and manifest yields ERROR with blocking violation evidence.
# @LAYER: Domain
# @RELATION IMPLEMENTS -> [ComplianceStage]
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
# @INVARIANT: Digest mismatch between run and manifest yields ERROR with blocking violation evidence.
from __future__ import annotations
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
# #region ManifestConsistencyStage [TYPE Class]
# @BRIEF Validate run/manifest linkage consistency.
# @PRE context.run and context.manifest are loaded from repository for same run.
# @POST Returns PASSED when digests match, otherwise ERROR with one violation.
# @PRE: context.run and context.manifest are loaded from repository for same run.
# @POST: Returns PASSED when digests match, otherwise ERROR with one violation.
class ManifestConsistencyStage:
stage_name = ComplianceStageName.MANIFEST_CONSISTENCY

View File

@@ -1,9 +1,9 @@
# #region no_external_endpoints [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, endpoints, network]
# @BRIEF Block manifest payloads that expose external endpoints outside trusted schemes and hosts.
# @LAYER Domain
# @INVARIANT Endpoint outside allowed scheme/host always yields BLOCKED stage decision.
# @LAYER: Domain
# @RELATION IMPLEMENTS -> [ComplianceStage]
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
# @INVARIANT: Endpoint outside allowed scheme/host always yields BLOCKED stage decision.
from __future__ import annotations
@@ -16,8 +16,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
# #region NoExternalEndpointsStage [TYPE Class]
# @BRIEF Validate endpoint references from manifest against trusted registry.
# @PRE context.registry includes allowed hosts and schemes.
# @POST Returns PASSED when all endpoints are trusted, otherwise BLOCKED with endpoint violations.
# @PRE: context.registry includes allowed hosts and schemes.
# @POST: Returns PASSED when all endpoints are trusted, otherwise BLOCKED with endpoint violations.
class NoExternalEndpointsStage:
stage_name = ComplianceStageName.NO_EXTERNAL_ENDPOINTS

View File

@@ -1,7 +1,7 @@
# #region dataset_review [TYPE Module] [SEMANTICS dataset, review, orchestration]
#
# @BRIEF Provides services for dataset-centered orchestration flow.
# @LAYER Services
# @RELATION EXPORTS -> [DatasetReviewOrchestrator:Class]
# @LAYER: Services
#
#
# #endregion dataset_review
# #endregion dataset_review

View File

@@ -1,19 +1,19 @@
# #region ClarificationEngine [C:4] [TYPE Module] [SEMANTICS dataset_review, clarification, question_payload, answer_persistence, readiness, findings]
# @BRIEF Manage one-question-at-a-time clarification state, deterministic answer persistence, and readiness/finding updates.
# @LAYER Domain
# @PRE Target session contains a persisted clarification aggregate in the current ownership scope.
# @POST Active clarification payload exposes one highest-priority unresolved question, and each recorded answer is persisted before pointer/readiness mutation.
# @SIDE_EFFECT Persists clarification answers, question/session states, and related readiness/finding changes.
# @DATA_CONTRACT Input[DatasetReviewSession|ClarificationAnswerCommand] -> Output[ClarificationStateResult]
# @INVARIANT Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
# @RELATION DEPENDS_ON -> [ClarificationSession]
# @RELATION DEPENDS_ON -> [ClarificationQuestion]
# @RELATION DEPENDS_ON -> [ClarificationAnswer]
# @RELATION DEPENDS_ON -> [ValidationFinding]
# @RELATION DISPATCHES -> [ClarificationHelpers:Module]
# @RATIONALE Original 635-line file exceeded INV_7 (400-line module limit). Extracted pure helpers into _helpers sub-module.
# @REJECTED Keeping all clarification logic in one file because it exceeded the fractal limit.
# @PRE: Target session contains a persisted clarification aggregate in the current ownership scope.
# @POST: Active clarification payload exposes one highest-priority unresolved question, and each recorded answer is persisted before pointer/readiness mutation.
# @SIDE_EFFECT: Persists clarification answers, question/session states, and related readiness/finding changes.
# @DATA_CONTRACT: Input[DatasetReviewSession|ClarificationAnswerCommand] -> Output[ClarificationStateResult]
# @INVARIANT: Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible.
# @RATIONALE: Original 635-line file exceeded INV_7 (400-line module limit). Extracted pure helpers into _helpers sub-module.
# @REJECTED: Keeping all clarification logic in one file because it exceeded the fractal limit.
from __future__ import annotations
@@ -21,8 +21,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.models.auth import User
from src.models.dataset_review import (
AnswerKind,
@@ -51,8 +50,6 @@ from src.services.dataset_review.clarification_pkg._helpers import (
derive_recommended_action,
)
log = MarkerLogger("ClarificationEngine")
# #region ClarificationQuestionPayload [C:2] [TYPE Class]
# @BRIEF Typed active-question payload returned to the API layer.
@@ -101,31 +98,33 @@ class ClarificationAnswerCommand:
# #region ClarificationEngine [C:4] [TYPE Class]
# @BRIEF Provide deterministic one-question-at-a-time clarification selection and answer persistence.
# @PRE Repository is bound to the current request transaction scope.
# @POST Returned clarification state is persistence-backed and aligned with session readiness/recommended action.
# @SIDE_EFFECT Mutates clarification answers, session flags, and related clarification findings.
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
# @RELATION CALLS -> [ClarificationHelpers:Module]
# @PRE: Repository is bound to the current request transaction scope.
# @POST: Returned clarification state is persistence-backed and aligned with session readiness/recommended action.
# @SIDE_EFFECT: Mutates clarification answers, session flags, and related clarification findings.
class ClarificationEngine:
# #region ClarificationEngine_init [C:2] [TYPE Function]
# @BRIEF Bind repository dependency for clarification persistence operations.
# [DEF:ClarificationEngine_init:Function]
# @COMPLEXITY: 2
# @PURPOSE: Bind repository dependency for clarification persistence operations.
def __init__(self, repository: DatasetReviewSessionRepository) -> None:
self.repository = repository
# #endregion ClarificationEngine_init
# [/DEF:ClarificationEngine_init:Function]
# #region build_question_payload [C:4] [TYPE Function]
# @BRIEF Return the one active highest-priority clarification question payload.
# @PRE Session contains unresolved clarification state or a resumable clarification session.
# @POST Returns exactly one active/open question payload or None when no unresolved question remains.
# @SIDE_EFFECT Normalizes the active-question pointer and clarification status in persistence.
# [DEF:build_question_payload:Function]
# @COMPLEXITY: 4
# @PURPOSE: Return the one active highest-priority clarification question payload.
# @PRE: Session contains unresolved clarification state or a resumable clarification session.
# @POST: Returns exactly one active/open question payload or None when no unresolved question remains.
# @SIDE_EFFECT: Normalizes the active-question pointer and clarification status in persistence.
def build_question_payload(
self, session: DatasetReviewSession,
) -> Optional[ClarificationQuestionPayload]:
with belief_scope("ClarificationEngine.build_question_payload"):
clarification_session = self._get_latest_clarification_session(session)
if clarification_session is None:
log.reason("No clarification session found", payload={"session_id": session.session_id})
logger.reason("No clarification session found", extra={"session_id": session.session_id})
return None
active_questions = [
@@ -141,7 +140,7 @@ class ClarificationEngine:
if session.current_phase == SessionPhase.CLARIFICATION:
session.current_phase = SessionPhase.REVIEW
self.repository.db.commit()
log.reflect("No unresolved clarification question remains", payload={"session_id": session.session_id})
logger.reflect("No unresolved clarification question remains", extra={"session_id": session.session_id})
return None
selected_question = active_questions[0]
@@ -151,7 +150,7 @@ class ClarificationEngine:
session.recommended_action = RecommendedAction.ANSWER_NEXT_QUESTION
session.current_phase = SessionPhase.CLARIFICATION
log.reason("Selected active clarification question", payload={"session_id": session.session_id, "question_id": selected_question.question_id, "priority": selected_question.priority})
logger.reason("Selected active clarification question", extra={"session_id": session.session_id, "question_id": selected_question.question_id, "priority": selected_question.priority})
self.repository.db.commit()
payload = ClarificationQuestionPayload(
@@ -168,40 +167,41 @@ class ClarificationEngine:
for o in sorted(selected_question.options, key=lambda item: (item.display_order, item.label, item.option_id))
],
)
log.reflect("Clarification payload built", payload={"session_id": session.session_id, "question_id": payload.question_id, "option_count": len(payload.options)})
logger.reflect("Clarification payload built", extra={"session_id": session.session_id, "question_id": payload.question_id, "option_count": len(payload.options)})
return payload
# #endregion build_question_payload
# [/DEF:build_question_payload:Function]
# #region record_answer [C:4] [TYPE Function]
# @BRIEF Persist one clarification answer before any pointer/readiness mutation.
# @PRE Target question belongs to the session's active clarification session and is still open.
# @POST Answer row is persisted before current-question pointer advances.
# @SIDE_EFFECT Inserts answer row, mutates question/session states, updates clarification findings, and commits.
# [DEF:record_answer:Function]
# @COMPLEXITY: 4
# @PURPOSE: Persist one clarification answer before any pointer/readiness mutation.
# @PRE: Target question belongs to the session's active clarification session and is still open.
# @POST: Answer row is persisted before current-question pointer advances.
# @SIDE_EFFECT: Inserts answer row, mutates question/session states, updates clarification findings, and commits.
def record_answer(self, command: ClarificationAnswerCommand) -> ClarificationStateResult:
with belief_scope("ClarificationEngine.record_answer"):
session = command.session
clarification_session = self._get_latest_clarification_session(session)
if clarification_session is None:
log.explore("Cannot record clarification answer because no clarification session exists", payload={"session_id": session.session_id}, error="No clarification session found")
logger.explore("Cannot record clarification answer because no clarification session exists", extra={"session_id": session.session_id})
raise ValueError("Clarification session not found")
question = self._find_question(clarification_session, command.question_id)
if question is None:
log.explore("Cannot record clarification answer for foreign or missing question", payload={"session_id": session.session_id, "question_id": command.question_id}, error="Question not found in clarification session")
logger.explore("Cannot record clarification answer for foreign or missing question", extra={"session_id": session.session_id, "question_id": command.question_id})
raise ValueError("Clarification question not found")
if question.answer is not None:
log.explore("Rejected duplicate clarification answer submission", payload={"session_id": session.session_id, "question_id": command.question_id}, error="Question already answered")
logger.explore("Rejected duplicate clarification answer submission", extra={"session_id": session.session_id, "question_id": command.question_id})
raise ValueError("Clarification question already answered")
if clarification_session.current_question_id and clarification_session.current_question_id != question.question_id:
log.explore("Rejected answer for non-active clarification question", payload={"session_id": session.session_id, "question_id": question.question_id, "current_question_id": clarification_session.current_question_id}, error="Only active question can be answered")
logger.explore("Rejected answer for non-active clarification question", extra={"session_id": session.session_id, "question_id": question.question_id, "current_question_id": clarification_session.current_question_id})
raise ValueError("Only the active clarification question can be answered")
normalized_answer_value = normalize_answer_value(command.answer_kind, command.answer_value, question)
log.reason("Persisting clarification answer before state advancement", payload={"session_id": session.session_id, "question_id": question.question_id, "answer_kind": command.answer_kind.value})
logger.reason("Persisting clarification answer before state advancement", extra={"session_id": session.session_id, "question_id": question.question_id, "answer_kind": command.answer_kind.value})
persisted_answer = ClarificationAnswer(
question_id=question.question_id,
answer_kind=command.answer_kind,
@@ -248,7 +248,7 @@ class ClarificationEngine:
self.repository.db.commit()
self.repository.db.refresh(session)
log.reflect("Clarification answer recorded and session advanced", payload={"session_id": session.session_id, "question_id": question.question_id, "next_question_id": clarification_session.current_question_id, "readiness_state": session.readiness_state.value, "remaining_count": clarification_session.remaining_count})
logger.reflect("Clarification answer recorded and session advanced", extra={"session_id": session.session_id, "question_id": question.question_id, "next_question_id": clarification_session.current_question_id, "readiness_state": session.readiness_state.value, "remaining_count": clarification_session.remaining_count})
return ClarificationStateResult(
clarification_session=clarification_session,
@@ -257,36 +257,41 @@ class ClarificationEngine:
changed_findings=[changed_finding] if changed_finding else [],
)
# #endregion record_answer
# [/DEF:record_answer:Function]
# #region summarize_progress [C:1] [TYPE Function]
# @BRIEF Produce a compact progress summary for pause/resume and completion UX.
# [DEF:summarize_progress:Function]
# @COMPLEXITY: 1
# @PURPOSE: Produce a compact progress summary for pause/resume and completion UX.
def summarize_progress(self, clarification_session: ClarificationSession) -> str:
resolved = count_resolved_questions(clarification_session)
remaining = count_remaining_questions(clarification_session)
return f"{resolved} resolved, {remaining} unresolved"
# #endregion summarize_progress
# [/DEF:summarize_progress:Function]
# #region _get_latest_clarification_session [C:2] [TYPE Function]
# @BRIEF Select the latest clarification session for the current dataset review aggregate.
# [DEF:_get_latest_clarification_session:Function]
# @COMPLEXITY: 2
# @PURPOSE: Select the latest clarification session for the current dataset review aggregate.
def _get_latest_clarification_session(self, session: DatasetReviewSession) -> Optional[ClarificationSession]:
if not session.clarification_sessions:
return None
ordered = sorted(session.clarification_sessions, key=lambda item: (item.started_at, item.clarification_session_id), reverse=True)
return ordered[0]
# #endregion _get_latest_clarification_session
# [/DEF:_get_latest_clarification_session:Function]
# #region _find_question [C:1] [TYPE Function]
# @BRIEF Resolve a clarification question from the active clarification aggregate.
# [DEF:_find_question:Function]
# @COMPLEXITY: 1
# @PURPOSE: Resolve a clarification question from the active clarification aggregate.
def _find_question(self, clarification_session: ClarificationSession, question_id: str) -> Optional[ClarificationQuestion]:
for q in clarification_session.questions:
if q.question_id == question_id:
return q
return None
# #endregion _find_question
# [/DEF:_find_question:Function]
# #endregion ClarificationEngine
# #endregion ClarificationEngine

View File

@@ -1,6 +1,6 @@
# #region ClarificationHelpers [C:3] [TYPE Module]
# @BRIEF Pure helper functions for clarification engine — question selection, counting, normalization, finding upsert, and readiness derivation.
# @LAYER Domain
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
from __future__ import annotations

View File

@@ -1,12 +1,12 @@
# #region SessionEventLoggerModule [C:4] [TYPE Module] [SEMANTICS dataset_review, audit, session_events, persistence, observability]
# @BRIEF Persist explicit session mutation events for dataset-review audit trails without weakening ownership or approval invariants.
# @LAYER Domain
# @PRE Caller provides an owned session scope and an authenticated actor identifier for each persisted mutation event.
# @POST Every logged event is committed as an explicit, queryable audit record with deterministic event metadata.
# @SIDE_EFFECT Inserts persisted session event rows and emits runtime belief-state logs for audit-sensitive mutations.
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [SessionEvent]
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
# @PRE: Caller provides an owned session scope and an authenticated actor identifier for each persisted mutation event.
# @POST: Every logged event is committed as an explicit, queryable audit record with deterministic event metadata.
# @SIDE_EFFECT: Inserts persisted session event rows and emits runtime belief-state logs for audit-sensitive mutations.
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
from __future__ import annotations
@@ -16,13 +16,10 @@ from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.models.dataset_review import DatasetReviewSession, SessionEvent
# #endregion SessionEventLoggerImports
log = MarkerLogger("SessionEventLogger")
# #region SessionEventPayload [C:2] [TYPE Class]
# @BRIEF Typed input contract for one persisted dataset-review session audit event.
@@ -40,26 +37,28 @@ class SessionEventPayload:
# #region SessionEventLogger [C:4] [TYPE Class]
# @BRIEF Persist explicit dataset-review session audit events with meaningful runtime reasoning logs.
# @PRE The database session is live and payload identifiers are non-empty.
# @POST Returns the committed session event row with a stable identifier and stored detail payload.
# @SIDE_EFFECT Writes one audit row to persistence and emits logger.reason/logger.reflect traces.
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
# @RELATION DEPENDS_ON -> [SessionEvent]
# @RELATION DEPENDS_ON -> [SessionEventPayload]
# @PRE: The database session is live and payload identifiers are non-empty.
# @POST: Returns the committed session event row with a stable identifier and stored detail payload.
# @SIDE_EFFECT: Writes one audit row to persistence and emits logger.reason/logger.reflect traces.
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
class SessionEventLogger:
# #region SessionEventLogger_init [C:2] [TYPE Function]
# @BRIEF Bind a live SQLAlchemy session to the session-event logger.
# [DEF:SessionEventLogger_init:Function]
# @COMPLEXITY: 2
# @PURPOSE: Bind a live SQLAlchemy session to the session-event logger.
def __init__(self, db: Session) -> None:
self.db = db
# #endregion SessionEventLogger_init
# [/DEF:SessionEventLogger_init:Function]
# #region log_event [C:4] [TYPE Function]
# @BRIEF Persist one explicit session event row for an owned dataset-review mutation.
# @PRE session_id, actor_user_id, event_type, and event_summary are non-empty.
# @POST Returns the committed SessionEvent record with normalized detail payload.
# @SIDE_EFFECT Inserts and commits one session_events row.
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
# @RELATION DEPENDS_ON -> [SessionEvent]
# [DEF:log_event:Function]
# @COMPLEXITY: 4
# @PURPOSE: Persist one explicit session event row for an owned dataset-review mutation.
# @RELATION: [DEPENDS_ON] ->[SessionEvent]
# @PRE: session_id, actor_user_id, event_type, and event_summary are non-empty.
# @POST: Returns the committed SessionEvent record with normalized detail payload.
# @SIDE_EFFECT: Inserts and commits one session_events row.
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
def log_event(self, payload: SessionEventPayload) -> SessionEvent:
with belief_scope("SessionEventLogger.log_event"):
session_id = str(payload.session_id or "").strip()
@@ -68,22 +67,31 @@ class SessionEventLogger:
event_summary = str(payload.event_summary or "").strip()
if not session_id:
log.explore("Session event logging rejected because session_id is empty", error="session_id is empty")
logger.explore("Session event logging rejected because session_id is empty")
raise ValueError("session_id must be non-empty")
if not actor_user_id:
log.explore("Session event logging rejected because actor_user_id is empty", error="actor_user_id is empty", payload={"session_id": session_id})
logger.explore(
"Session event logging rejected because actor_user_id is empty",
extra={"session_id": session_id},
)
raise ValueError("actor_user_id must be non-empty")
if not event_type:
log.explore("Session event logging rejected because event_type is empty", error="event_type is empty", payload={"session_id": session_id, "actor_user_id": actor_user_id})
logger.explore(
"Session event logging rejected because event_type is empty",
extra={"session_id": session_id, "actor_user_id": actor_user_id},
)
raise ValueError("event_type must be non-empty")
if not event_summary:
log.explore("Session event logging rejected because event_summary is empty", error="event_summary is empty", payload={"session_id": session_id, "event_type": event_type})
logger.explore(
"Session event logging rejected because event_summary is empty",
extra={"session_id": session_id, "event_type": event_type},
)
raise ValueError("event_summary must be non-empty")
normalized_details = dict(payload.event_details or {})
log.reason(
logger.reason(
"Persisting explicit dataset-review session audit event",
payload={
extra={
"session_id": session_id,
"actor_user_id": actor_user_id,
"event_type": event_type,
@@ -105,20 +113,21 @@ class SessionEventLogger:
self.db.commit()
self.db.refresh(event)
log.reflect(
logger.reflect(
"Dataset-review session audit event persisted",
payload={
extra={
"session_id": session_id,
"session_event_id": event.session_event_id,
"event_type": event.event_type,
},
)
return event
# #endregion log_event
# [/DEF:log_event:Function]
# #region log_for_session [C:2] [TYPE Function]
# @BRIEF Convenience wrapper for logging an event directly from a session aggregate root.
# @RELATION CALLS -> [SessionEventLogger.log_event]
# [DEF:log_for_session:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convenience wrapper for logging an event directly from a session aggregate root.
# @RELATION: [CALLS] ->[SessionEventLogger.log_event]
def log_for_session(
self,
session: DatasetReviewSession,
@@ -139,5 +148,7 @@ class SessionEventLogger:
event_details=dict(event_details or {}),
)
)
# #endregion log_for_session
# [/DEF:log_for_session:Function]
# #endregion SessionEventLogger
# #endregion SessionEventLoggerModule

View File

@@ -1,11 +1,6 @@
# #region DatasetReviewOrchestrator [C:5] [TYPE Module] [SEMANTICS dataset_review, orchestration, session_lifecycle, intake, recovery]
# @BRIEF Coordinate dataset review session startup and lifecycle-safe intake recovery for one authenticated user.
# @LAYER Domain
# @PRE session mutations must execute inside a persisted session boundary scoped to one authenticated user.
# @POST state transitions are persisted atomically and emit observable progress for long-running steps.
# @SIDE_EFFECT creates task records, updates session aggregates, triggers upstream Superset calls, persists audit artifacts.
# @DATA_CONTRACT Input[SessionCommand] -> Output[DatasetReviewSession | CompiledPreview | DatasetRunContext]
# @INVARIANT Launch is blocked unless a current session has no open blocking findings, all launch-sensitive mappings are approved, and a non-stale Superset-generated compiled preview matches the current input fingerprint.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
# @RELATION DEPENDS_ON -> [SemanticSourceResolver]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
@@ -13,8 +8,13 @@
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DISPATCHES -> [OrchestratorHelpers:Module]
# @RELATION DISPATCHES -> [OrchestratorCommands:Module]
# @RATIONALE Original 1198-line monolith violated INV_7 (400-line module limit). Decomposed into commands and helpers sub-modules while preserving the orchestrator class as the single entry point.
# @REJECTED Keeping all orchestration logic in one file because it exceeded the fractal limit by 3x.
# @PRE: session mutations must execute inside a persisted session boundary scoped to one authenticated user.
# @POST: state transitions are persisted atomically and emit observable progress for long-running steps.
# @SIDE_EFFECT: creates task records, updates session aggregates, triggers upstream Superset calls, persists audit artifacts.
# @DATA_CONTRACT: Input[SessionCommand] -> Output[DatasetReviewSession | CompiledPreview | DatasetRunContext]
# @INVARIANT: Launch is blocked unless a current session has no open blocking findings, all launch-sensitive mappings are approved, and a non-stale Superset-generated compiled preview matches the current input fingerprint.
# @RATIONALE: Original 1198-line monolith violated INV_7 (400-line module limit). Decomposed into commands and helpers sub-modules while preserving the orchestrator class as the single entry point.
# @REJECTED: Keeping all orchestration logic in one file because it exceeded the fractal limit by 3x.
from __future__ import annotations
@@ -23,8 +23,7 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, cast
from src.core.config_manager import ConfigManager
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.core.task_manager import TaskManager
from src.core.utils.superset_compilation_adapter import (
PreviewCompilationPayload,
@@ -88,27 +87,28 @@ from src.services.dataset_review.orchestrator_pkg._helpers import (
extract_effective_filter_value,
)
log = MarkerLogger("Orchestrator")
logger = cast(Any, logger)
# #region DatasetReviewOrchestrator [C:5] [TYPE Class]
# @BRIEF Coordinate safe session startup while preserving cross-user isolation and explicit partial recovery.
# @PRE constructor dependencies are valid and tied to the current request/task scope.
# @POST orchestrator instance can execute session-scoped mutations for one authenticated user.
# @SIDE_EFFECT downstream operations may persist session/profile/finding state and enqueue background tasks.
# @DATA_CONTRACT Input[StartSessionCommand] -> Output[StartSessionResult]
# @INVARIANT session ownership is preserved on every mutation and recovery remains explicit when partial.
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SemanticSourceResolver]
# @RELATION CALLS -> [OrchestratorHelpers:Module]
# @PRE: constructor dependencies are valid and tied to the current request/task scope.
# @POST: orchestrator instance can execute session-scoped mutations for one authenticated user.
# @SIDE_EFFECT: downstream operations may persist session/profile/finding state and enqueue background tasks.
# @DATA_CONTRACT: Input[StartSessionCommand] -> Output[StartSessionResult]
# @INVARIANT: session ownership is preserved on every mutation and recovery remains explicit when partial.
class DatasetReviewOrchestrator:
# #region DatasetReviewOrchestrator_init [C:3] [TYPE Function]
# @BRIEF Bind repository, config, and task dependencies required by the orchestration boundary.
# @PRE repository/config_manager are valid collaborators for the current request scope.
# @POST Instance holds collaborator references used by start/preview/launch orchestration methods.
# [DEF:DatasetReviewOrchestrator_init:Function]
# @COMPLEXITY: 3
# @PURPOSE: Bind repository, config, and task dependencies required by the orchestration boundary.
# @PRE: repository/config_manager are valid collaborators for the current request scope.
# @POST: Instance holds collaborator references used by start/preview/launch orchestration methods.
def __init__(
self,
repository: DatasetReviewSessionRepository,
@@ -121,17 +121,18 @@ class DatasetReviewOrchestrator:
self.task_manager = task_manager
self.semantic_resolver = semantic_resolver or SemanticSourceResolver()
# #endregion DatasetReviewOrchestrator_init
# [/DEF:DatasetReviewOrchestrator_init:Function]
# #region start_session [C:5] [TYPE Function]
# @BRIEF Initialize a new session from a Superset link or dataset selection and trigger context recovery.
# @PRE source input is non-empty and environment is accessible.
# @POST session exists in persisted storage with intake/recovery state and task linkage when async work is required.
# @SIDE_EFFECT persists session and may enqueue recovery task.
# @DATA_CONTRACT Input[StartSessionCommand] -> Output[StartSessionResult]
# @INVARIANT no cross-user session leakage occurs; session and follow-up task remain owned by the authenticated user.
# @RELATION CALLS -> [SupersetContextExtractor.parse_superset_link]
# @RELATION CALLS -> [TaskManager.create_task]
# [DEF:start_session:Function]
# @COMPLEXITY: 5
# @PURPOSE: Initialize a new session from a Superset link or dataset selection and trigger context recovery.
# @RELATION: CALLS -> [SupersetContextExtractor.parse_superset_link]
# @RELATION: CALLS -> [TaskManager.create_task]
# @PRE: source input is non-empty and environment is accessible.
# @POST: session exists in persisted storage with intake/recovery state and task linkage when async work is required.
# @SIDE_EFFECT: persists session and may enqueue recovery task.
# @DATA_CONTRACT: Input[StartSessionCommand] -> Output[StartSessionResult]
# @INVARIANT: no cross-user session leakage occurs; session and follow-up task remain owned by the authenticated user.
def start_session(self, command: StartSessionCommand) -> StartSessionResult:
with belief_scope("DatasetReviewOrchestrator.start_session"):
normalized_source_kind = str(command.source_kind or "").strip()
@@ -139,19 +140,19 @@ class DatasetReviewOrchestrator:
normalized_environment_id = str(command.environment_id or "").strip()
if not normalized_source_input:
log.explore("Blocked dataset review session start due to empty source input", error="source_input was empty")
logger.explore("Blocked dataset review session start due to empty source input")
raise ValueError("source_input must be non-empty")
if normalized_source_kind not in {"superset_link", "dataset_selection"}:
log.explore("Blocked dataset review session start due to unsupported source kind", payload={"source_kind": normalized_source_kind}, error="Unsupported source_kind")
logger.explore("Blocked dataset review session start due to unsupported source kind", extra={"source_kind": normalized_source_kind})
raise ValueError("source_kind must be 'superset_link' or 'dataset_selection'")
environment = self.config_manager.get_environment(normalized_environment_id)
if environment is None:
log.explore("Blocked dataset review session start because environment was not found", payload={"environment_id": normalized_environment_id}, error="Environment not found in config")
logger.explore("Blocked dataset review session start because environment was not found", extra={"environment_id": normalized_environment_id})
raise ValueError("Environment not found")
log.reason("Starting dataset review session", payload={"user_id": command.user.id, "environment_id": normalized_environment_id, "source_kind": normalized_source_kind})
logger.reason("Starting dataset review session", extra={"user_id": command.user.id, "environment_id": normalized_environment_id, "source_kind": normalized_source_kind})
parsed_context: Optional[SupersetParsedContext] = None
findings: List[ValidationFinding] = []
@@ -213,7 +214,7 @@ class DatasetReviewOrchestrator:
parsed_context=parsed_context,
dataset_ref=dataset_ref,
)
self.repository.event_log.log_event(
self.repository.event_logger.log_event(
SessionEventPayload(
session_id=persisted_session.session_id,
actor_user_id=command.user.id,
@@ -255,7 +256,7 @@ class DatasetReviewOrchestrator:
self.repository.bump_session_version(persisted_session)
self.repository.db.commit()
self.repository.db.refresh(persisted_session)
self.repository.event_log.log_event(
self.repository.event_logger.log_event(
SessionEventPayload(
session_id=persisted_session.session_id,
actor_user_id=command.user.id,
@@ -266,29 +267,30 @@ class DatasetReviewOrchestrator:
event_details={"task_id": active_task_id},
)
)
log.reason("Linked recovery task to started dataset review session", payload={"session_id": persisted_session.session_id, "task_id": active_task_id})
logger.reason("Linked recovery task to started dataset review session", extra={"session_id": persisted_session.session_id, "task_id": active_task_id})
log.reflect("Dataset review session start completed", payload={"session_id": persisted_session.session_id, "dataset_ref": persisted_session.dataset_ref, "readiness_state": persisted_session.readiness_state.value, "active_task_id": persisted_session.active_task_id, "finding_count": len(findings)})
logger.reflect("Dataset review session start completed", extra={"session_id": persisted_session.session_id, "dataset_ref": persisted_session.dataset_ref, "readiness_state": persisted_session.readiness_state.value, "active_task_id": persisted_session.active_task_id, "finding_count": len(findings)})
return StartSessionResult(
session=persisted_session,
parsed_context=parsed_context,
findings=findings,
)
# #endregion start_session
# [/DEF:start_session:Function]
# #region prepare_launch_preview [C:4] [TYPE Function]
# @BRIEF Assemble effective execution inputs and trigger Superset-side preview compilation.
# @PRE all required variables have candidate values or explicitly accepted defaults.
# @POST returns preview artifact in pending, ready, failed, or stale state.
# @SIDE_EFFECT persists preview attempt and upstream compilation diagnostics.
# @DATA_CONTRACT Input[PreparePreviewCommand] -> Output[PreparePreviewResult]
# @RELATION CALLS -> [SupersetCompilationAdapter.compile_preview]
# [DEF:prepare_launch_preview:Function]
# @COMPLEXITY: 4
# @PURPOSE: Assemble effective execution inputs and trigger Superset-side preview compilation.
# @RELATION: CALLS -> [SupersetCompilationAdapter.compile_preview]
# @PRE: all required variables have candidate values or explicitly accepted defaults.
# @POST: returns preview artifact in pending, ready, failed, or stale state.
# @SIDE_EFFECT: persists preview attempt and upstream compilation diagnostics.
# @DATA_CONTRACT: Input[PreparePreviewCommand] -> Output[PreparePreviewResult]
def prepare_launch_preview(self, command: PreparePreviewCommand) -> PreparePreviewResult:
with belief_scope("DatasetReviewOrchestrator.prepare_launch_preview"):
session = self.repository.load_session_detail(command.session_id, command.user.id)
if session is None or session.user_id != command.user.id:
log.explore("Preview preparation rejected because owned session was not found", payload={"session_id": command.session_id, "user_id": command.user.id}, error="Session not found or access denied")
logger.explore("Preview preparation rejected because owned session was not found", extra={"session_id": command.session_id, "user_id": command.user.id})
raise ValueError("Session not found")
if command.expected_version is not None:
@@ -304,7 +306,7 @@ class DatasetReviewOrchestrator:
execution_snapshot = build_execution_snapshot(session)
preview_blockers = execution_snapshot["preview_blockers"]
if preview_blockers:
log.explore("Preview preparation blocked by incomplete execution context", payload={"session_id": session.session_id, "blocked_reasons": preview_blockers}, error="Preview blockers present")
logger.explore("Preview preparation blocked by incomplete execution context", extra={"session_id": session.session_id, "blocked_reasons": preview_blockers})
raise ValueError("Preview blocked: " + "; ".join(preview_blockers))
adapter = SupersetCompilationAdapter(environment)
@@ -339,7 +341,7 @@ class DatasetReviewOrchestrator:
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
self.repository.db.commit()
self.repository.db.refresh(session)
self.repository.event_log.log_event(
self.repository.event_logger.log_event(
SessionEventPayload(
session_id=session.session_id,
actor_user_id=command.user.id,
@@ -351,24 +353,25 @@ class DatasetReviewOrchestrator:
)
)
log.reflect("Superset preview preparation completed", payload={"session_id": session.session_id, "preview_id": persisted_preview.preview_id, "preview_status": persisted_preview.preview_status.value})
logger.reflect("Superset preview preparation completed", extra={"session_id": session.session_id, "preview_id": persisted_preview.preview_id, "preview_status": persisted_preview.preview_status.value})
return PreparePreviewResult(session=session, preview=persisted_preview, blocked_reasons=[])
# #endregion prepare_launch_preview
# [/DEF:prepare_launch_preview:Function]
# #region launch_dataset [C:5] [TYPE Function]
# @BRIEF Start the approved dataset execution through SQL Lab and persist run context for audit/replay.
# @PRE session is run-ready and compiled preview is current.
# @POST returns persisted run context with SQL Lab session reference and launch outcome.
# @SIDE_EFFECT creates SQL Lab execution session and audit snapshot.
# @DATA_CONTRACT Input[LaunchDatasetCommand] -> Output[LaunchDatasetResult]
# @INVARIANT launch remains blocked unless blocking findings are closed, approvals are satisfied, and the latest preview fingerprint matches current execution inputs.
# @RELATION CALLS -> [SupersetCompilationAdapter.create_sql_lab_session]
# [DEF:launch_dataset:Function]
# @COMPLEXITY: 5
# @PURPOSE: Start the approved dataset execution through SQL Lab and persist run context for audit/replay.
# @RELATION: CALLS -> [SupersetCompilationAdapter.create_sql_lab_session]
# @PRE: session is run-ready and compiled preview is current.
# @POST: returns persisted run context with SQL Lab session reference and launch outcome.
# @SIDE_EFFECT: creates SQL Lab execution session and audit snapshot.
# @DATA_CONTRACT: Input[LaunchDatasetCommand] -> Output[LaunchDatasetResult]
# @INVARIANT: launch remains blocked unless blocking findings are closed, approvals are satisfied, and the latest preview fingerprint matches current execution inputs.
def launch_dataset(self, command: LaunchDatasetCommand) -> LaunchDatasetResult:
with belief_scope("DatasetReviewOrchestrator.launch_dataset"):
session = self.repository.load_session_detail(command.session_id, command.user.id)
if session is None or session.user_id != command.user.id:
log.explore("Launch rejected because owned session was not found", payload={"session_id": command.session_id, "user_id": command.user.id}, error="Session not found or access denied")
logger.explore("Launch rejected because owned session was not found", extra={"session_id": command.session_id, "user_id": command.user.id})
raise ValueError("Session not found")
if command.expected_version is not None:
@@ -385,7 +388,7 @@ class DatasetReviewOrchestrator:
current_preview = get_latest_preview(session)
launch_blockers_list = build_launch_blockers(session=session, execution_snapshot=execution_snapshot, preview=current_preview)
if launch_blockers_list:
log.explore("Launch gate blocked dataset execution", payload={"session_id": session.session_id, "blocked_reasons": launch_blockers_list}, error="Launch blockers present")
logger.explore("Launch gate blocked dataset execution", extra={"session_id": session.session_id, "blocked_reasons": launch_blockers_list})
raise ValueError("Launch blocked: " + "; ".join(launch_blockers_list))
adapter = SupersetCompilationAdapter(environment)
@@ -402,7 +405,7 @@ class DatasetReviewOrchestrator:
launch_status = LaunchStatus.STARTED
launch_error = None
except Exception as exc:
log.explore("SQL Lab launch failed after passing gates", payload={"session_id": session.session_id}, error=str(exc))
logger.explore("SQL Lab launch failed after passing gates", extra={"session_id": session.session_id, "error": str(exc)})
sql_lab_session_ref = "unavailable"
launch_status = LaunchStatus.FAILED
launch_error = str(exc)
@@ -438,7 +441,7 @@ class DatasetReviewOrchestrator:
session.recommended_action = RecommendedAction.EXPORT_OUTPUTS
self.repository.db.commit()
self.repository.db.refresh(session)
self.repository.event_log.log_event(
self.repository.event_logger.log_event(
SessionEventPayload(
session_id=session.session_id,
actor_user_id=command.user.id,
@@ -450,16 +453,17 @@ class DatasetReviewOrchestrator:
)
)
log.reflect("Dataset launch orchestration completed with audited run context", payload={"session_id": session.session_id, "run_context_id": persisted_run_context.run_context_id, "launch_status": persisted_run_context.launch_status.value})
logger.reflect("Dataset launch orchestration completed with audited run context", extra={"session_id": session.session_id, "run_context_id": persisted_run_context.run_context_id, "launch_status": persisted_run_context.launch_status.value})
return LaunchDatasetResult(session=session, run_context=persisted_run_context, blocked_reasons=[])
# #endregion launch_dataset
# [/DEF:launch_dataset:Function]
# #region _build_recovery_bootstrap [C:4] [TYPE Function]
# @BRIEF Recover and materialize initial imported filters, template variables, and draft execution mappings after session creation.
# @PRE session belongs to the just-created review aggregate and parsed_context was produced for the same environment scope.
# @POST Returns bootstrap imported filters, template variables, execution mappings, and updated findings without persisting them directly.
# @SIDE_EFFECT Performs Superset reads through the extractor and may append warning findings for incomplete recovery.
# [DEF:_build_recovery_bootstrap:Function]
# @COMPLEXITY: 4
# @PURPOSE: Recover and materialize initial imported filters, template variables, and draft execution mappings after session creation.
# @PRE: session belongs to the just-created review aggregate and parsed_context was produced for the same environment scope.
# @POST: Returns bootstrap imported filters, template variables, execution mappings, and updated findings without persisting them directly.
# @SIDE_EFFECT: Performs Superset reads through the extractor and may append warning findings for incomplete recovery.
def _build_recovery_bootstrap(
self,
environment,
@@ -525,7 +529,7 @@ class DatasetReviewOrchestrator:
caused_by_ref="dataset_template_variable_discovery_failed",
)
)
log.explore("Template variable discovery failed during session bootstrap", payload={"session_id": session_record.session_id, "dataset_id": session_record.dataset_id}, error=str(exc))
logger.explore("Template variable discovery failed during session bootstrap", extra={"session_id": session_record.session_id, "dataset_id": session_record.dataset_id, "error": str(exc)})
filter_lookup = {str(f.filter_name or "").strip().lower(): f for f in imported_filters if str(f.filter_name or "").strip()}
for tv in template_variables:
@@ -552,13 +556,14 @@ class DatasetReviewOrchestrator:
return imported_filters, template_variables, execution_mappings, findings
# #endregion _build_recovery_bootstrap
# [/DEF:_build_recovery_bootstrap:Function]
# #region _enqueue_recovery_task [C:3] [TYPE Function]
# @BRIEF Link session start to observable async recovery when task infrastructure is available.
# @PRE session is already persisted.
# @POST returns task identifier when a task could be enqueued, otherwise None.
# @SIDE_EFFECT may create one background task for progressive recovery.
# [DEF:_enqueue_recovery_task:Function]
# @COMPLEXITY: 3
# @PURPOSE: Link session start to observable async recovery when task infrastructure is available.
# @PRE: session is already persisted.
# @POST: returns task identifier when a task could be enqueued, otherwise None.
# @SIDE_EFFECT: may create one background task for progressive recovery.
def _enqueue_recovery_task(
self,
command: StartSessionCommand,
@@ -567,7 +572,7 @@ class DatasetReviewOrchestrator:
) -> Optional[str]:
session_record = cast(Any, session)
if self.task_manager is None:
log.reason("Dataset review session started without task manager; continuing synchronously", payload={"session_id": session_record.session_id})
logger.reason("Dataset review session started without task manager; continuing synchronously", extra={"session_id": session_record.session_id})
return None
task_params: Dict[str, Any] = {
@@ -584,19 +589,21 @@ class DatasetReviewOrchestrator:
create_task = getattr(self.task_manager, "create_task", None)
if create_task is None:
log.explore("Task manager has no create_task method; skipping recovery enqueue", error="create_task method not found")
logger.explore("Task manager has no create_task method; skipping recovery enqueue")
return None
try:
task_object = create_task(plugin_id="dataset-review-recovery", params=task_params)
except TypeError:
log.explore("Recovery task enqueue skipped because task manager create_task contract is incompatible", payload={"session_id": session_record.session_id}, error="TypeError from create_task")
logger.explore("Recovery task enqueue skipped because task manager create_task contract is incompatible", extra={"session_id": session_record.session_id})
return None
task_id = getattr(task_object, "id", None)
return str(task_id) if task_id else None
# #endregion _enqueue_recovery_task
# [/DEF:_enqueue_recovery_task:Function]
# #endregion DatasetReviewOrchestrator
# #endregion DatasetReviewOrchestrator

View File

@@ -1,6 +1,6 @@
# #region OrchestratorCommands [C:2] [TYPE Module]
# @BRIEF Typed command and result dataclasses for dataset review orchestration boundary.
# @LAYER Domain
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]

View File

@@ -1,10 +1,10 @@
# #region OrchestratorHelpers [C:4] [TYPE Module]
# @BRIEF Pure helper methods extracted from DatasetReviewOrchestrator for INV_7 compliance — snapshot, blockers, fingerprint, recovery bootstrap.
# @LAYER Domain
# @PRE Caller provides a loaded session aggregate with hydrated child collections.
# @POST Helper results are deterministic and do not mutate persistence directly.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
# @PRE: Caller provides a loaded session aggregate with hydrated child collections.
# @POST: Helper results are deterministic and do not mutate persistence directly.
from __future__ import annotations
@@ -13,7 +13,7 @@ import json
from datetime import datetime
from typing import Any, Dict, List, Optional, cast
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope, logger
from src.models.dataset_review import (
ApprovalState,
CompiledPreview,
@@ -37,7 +37,8 @@ from src.models.dataset_review import (
BusinessSummarySource,
)
log = MarkerLogger("OrchestratorHelpers")
logger = cast(Any, logger)
# #region parse_dataset_selection [C:2] [TYPE Function]
# @BRIEF Normalize dataset-selection payload into canonical session references.
@@ -103,8 +104,8 @@ def build_initial_profile(
# #region build_partial_recovery_findings [C:3] [TYPE Function]
# @BRIEF Project partial Superset intake recovery into explicit findings without blocking session usability.
# @PRE parsed_context.partial_recovery is true.
# @POST Returns warning-level findings that preserve usable but incomplete state.
# @PRE: parsed_context.partial_recovery is true.
# @POST: Returns warning-level findings that preserve usable but incomplete state.
def build_partial_recovery_findings(parsed_context: Any) -> List[ValidationFinding]:
findings: List[ValidationFinding] = []
for unresolved_ref in getattr(parsed_context, "unresolved_references", []):
@@ -146,8 +147,8 @@ def extract_effective_filter_value(
# #region build_execution_snapshot [C:4] [TYPE Function]
# @BRIEF Build effective filters, template params, approvals, and fingerprint for preview and launch gating.
# @PRE Session aggregate includes imported filters, template variables, and current execution mappings.
# @POST Returns deterministic execution snapshot for current session state without mutating persistence.
# @PRE: Session aggregate includes imported filters, template variables, and current execution mappings.
# @POST: Returns deterministic execution snapshot for current session state without mutating persistence.
def build_execution_snapshot(session: DatasetReviewSession) -> Dict[str, Any]:
session_record = cast(Any, session)
filter_lookup = {
@@ -275,8 +276,8 @@ def build_execution_snapshot(session: DatasetReviewSession) -> Dict[str, Any]:
# #region build_launch_blockers [C:3] [TYPE Function]
# @BRIEF Enforce launch gates from findings, approvals, and current preview truth.
# @PRE execution_snapshot was computed from current session state.
# @POST Returns explicit blocker codes for every unmet launch invariant.
# @PRE: execution_snapshot was computed from current session state.
# @POST: Returns explicit blocker codes for every unmet launch invariant.
def build_launch_blockers(
session: DatasetReviewSession,
execution_snapshot: Dict[str, Any],

View File

@@ -26,16 +26,18 @@ from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionVersionConflictError,
)
# #region SessionRepositoryTests [C:2] [TYPE Module]
# @BRIEF Unit tests for DatasetReviewSessionRepository.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:SessionRepositoryTests:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 2
# @PURPOSE: Unit tests for DatasetReviewSessionRepository.
@pytest.fixture
def db_session():
# #region db_session [C:2] [TYPE Function]
# @BRIEF Build isolated in-memory SQLAlchemy session seeded with baseline user/environment rows.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:db_session:Function]
# @COMPLEXITY: 2
# @PURPOSE: Build isolated in-memory SQLAlchemy session seeded with baseline user/environment rows.
# @RELATION: BINDS_TO -> [SessionRepositoryTests]
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
@@ -55,11 +57,11 @@ def db_session():
session.close()
# #endregion db_session
# [/DEF:db_session:Function]
# #region test_create_session [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_create_session:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_create_session(db_session):
# @PURPOSE: Verify session creation and persistence.
repo = DatasetReviewSessionRepository(db_session)
@@ -82,12 +84,12 @@ def test_create_session(db_session):
assert loaded.version == 0
# #endregion test_create_session
# [/DEF:test_create_session:Function]
# #region test_require_session_version_conflict [TYPE Function]
# @BRIEF Verify optimistic-lock conflict is raised when caller version is stale.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_require_session_version_conflict:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify optimistic-lock conflict is raised when caller version is stale.
def test_require_session_version_conflict(db_session):
repo = DatasetReviewSessionRepository(db_session)
session = DatasetReviewSession(
@@ -107,12 +109,12 @@ def test_require_session_version_conflict(db_session):
assert exc_info.value.actual_version == 2
# #endregion test_require_session_version_conflict
# [/DEF:test_require_session_version_conflict:Function]
# #region test_bump_session_version_updates_last_activity [TYPE Function]
# @BRIEF Verify repository version bump increments monotonically and refreshes last activity.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_bump_session_version_updates_last_activity:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify repository version bump increments monotonically and refreshes last activity.
def test_bump_session_version_updates_last_activity(db_session):
repo = DatasetReviewSessionRepository(db_session)
session = DatasetReviewSession(
@@ -132,12 +134,12 @@ def test_bump_session_version_updates_last_activity(db_session):
assert session.last_activity_at >= before_activity
# #endregion test_bump_session_version_updates_last_activity
# [/DEF:test_bump_session_version_updates_last_activity:Function]
# #region test_save_recovery_state_preserves_raw_value_masked_flag [TYPE Function]
# @BRIEF Verify imported-filter masking metadata persists with recovery bootstrap state.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_recovery_state_preserves_raw_value_masked_flag:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify imported-filter masking metadata persists with recovery bootstrap state.
def test_save_recovery_state_preserves_raw_value_masked_flag(db_session):
repo = DatasetReviewSessionRepository(db_session)
session = DatasetReviewSession(
@@ -173,11 +175,11 @@ def test_save_recovery_state_preserves_raw_value_masked_flag(db_session):
assert updated.version == 1
# #endregion test_save_recovery_state_preserves_raw_value_masked_flag
# [/DEF:test_save_recovery_state_preserves_raw_value_masked_flag:Function]
# #region test_load_session_detail_ownership [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_load_session_detail_ownership:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_load_session_detail_ownership(db_session):
# @PURPOSE: Verify ownership enforcement in detail loading.
repo = DatasetReviewSessionRepository(db_session)
@@ -199,11 +201,11 @@ def test_load_session_detail_ownership(db_session):
assert loaded_wrong is None
# #endregion test_load_session_detail_ownership
# [/DEF:test_load_session_detail_ownership:Function]
# #region test_load_session_detail_collaborator [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_load_session_detail_collaborator:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_load_session_detail_collaborator(db_session):
# @PURPOSE: Verify collaborator access in detail loading.
repo = DatasetReviewSessionRepository(db_session)
@@ -236,11 +238,11 @@ def test_load_session_detail_collaborator(db_session):
assert loaded.session_id == session.session_id
# #endregion test_load_session_detail_collaborator
# [/DEF:test_load_session_detail_collaborator:Function]
# #region test_save_preview_marks_stale [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_preview_marks_stale:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_save_preview_marks_stale(db_session):
# @PURPOSE: Verify that saving a new preview marks old ones as stale.
repo = DatasetReviewSessionRepository(db_session)
@@ -269,12 +271,12 @@ def test_save_preview_marks_stale(db_session):
assert session.last_preview_id == p2.preview_id
# #endregion test_save_preview_marks_stale
# [/DEF:test_save_preview_marks_stale:Function]
# #region test_save_preview_increments_session_version_once_per_call [TYPE Function]
# @BRIEF Verify preview persistence itself contributes exactly one optimistic-lock version increment so higher orchestration layers do not need to bump again for the same preview mutation.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_preview_increments_session_version_once_per_call:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify preview persistence itself contributes exactly one optimistic-lock version increment so higher orchestration layers do not need to bump again for the same preview mutation.
def test_save_preview_increments_session_version_once_per_call(db_session):
repo = DatasetReviewSessionRepository(db_session)
session = DatasetReviewSession(
@@ -311,11 +313,11 @@ def test_save_preview_increments_session_version_once_per_call(db_session):
assert session.version == 2
# #endregion test_save_preview_increments_session_version_once_per_call
# [/DEF:test_save_preview_increments_session_version_once_per_call:Function]
# #region test_save_profile_and_findings [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_profile_and_findings:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_save_profile_and_findings(db_session):
# @PURPOSE: Verify persistence of profile and findings.
repo = DatasetReviewSessionRepository(db_session)
@@ -372,12 +374,12 @@ def test_save_profile_and_findings(db_session):
assert final_session.version == 2
# #endregion test_save_profile_and_findings
# [/DEF:test_save_profile_and_findings:Function]
# #region test_save_profile_and_findings_rejects_stale_concurrent_write [TYPE Function]
# @BRIEF Verify repository save path translates concurrent stale session writes into deterministic optimistic-lock conflicts.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_profile_and_findings_rejects_stale_concurrent_write:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify repository save path translates concurrent stale session writes into deterministic optimistic-lock conflicts.
def test_save_profile_and_findings_rejects_stale_concurrent_write(tmp_path: Path):
db_path = tmp_path / "dataset_review_session_repository.sqlite"
engine = create_engine(f"sqlite:///{db_path}")
@@ -472,11 +474,11 @@ def test_save_profile_and_findings_rejects_stale_concurrent_write(tmp_path: Path
writer_b.close()
# #endregion test_save_profile_and_findings_rejects_stale_concurrent_write
# [/DEF:test_save_profile_and_findings_rejects_stale_concurrent_write:Function]
# #region test_save_run_context [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_save_run_context:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_save_run_context(db_session):
# @PURPOSE: Verify saving of run context.
repo = DatasetReviewSessionRepository(db_session)
@@ -507,12 +509,12 @@ def test_save_run_context(db_session):
assert session.last_run_context_id == rc.run_context_id
# #endregion test_save_run_context
# [/DEF:test_save_run_context:Function]
# #region test_ensure_dataset_review_session_columns_adds_missing_legacy_columns [TYPE Function]
# @BRIEF Verify additive dataset review migration creates missing legacy columns for session and imported-filter tables without dropping rows.
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_ensure_dataset_review_session_columns_adds_missing_legacy_columns:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
# @PURPOSE: Verify additive dataset review migration creates missing legacy columns for session and imported-filter tables without dropping rows.
def test_ensure_dataset_review_session_columns_adds_missing_legacy_columns():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as connection:
@@ -674,11 +676,11 @@ def test_ensure_dataset_review_session_columns_adds_missing_legacy_columns():
assert raw_value_masked in (False, 0)
# #endregion test_ensure_dataset_review_session_columns_adds_missing_legacy_columns
# [/DEF:test_ensure_dataset_review_session_columns_adds_missing_legacy_columns:Function]
# #region test_list_sessions_for_user [TYPE Function]
# @RELATION BINDS_TO -> [SessionRepositoryTests]
# [DEF:test_list_sessions_for_user:Function]
# @RELATION: BINDS_TO -> SessionRepositoryTests
def test_list_sessions_for_user(db_session):
# @PURPOSE: Verify listing of sessions by user.
repo = DatasetReviewSessionRepository(db_session)
@@ -712,5 +714,5 @@ def test_list_sessions_for_user(db_session):
assert all(s.user_id == "user1" for s in sessions)
# #endregion test_list_sessions_for_user
# #endregion SessionRepositoryTests
# [/DEF:test_list_sessions_for_user:Function]
# [/DEF:SessionRepositoryTests:Module]

View File

@@ -1,10 +1,10 @@
# #region SessionRepositoryMutations [C:4] [TYPE Module]
# @BRIEF Persistence mutation operations for dataset review session aggregates — profile/findings, recovery state, preview, run context.
# @LAYER Domain
# @PRE All mutations execute within authenticated request or task scope.
# @POST Session aggregate writes preserve ownership and version semantics.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
# @RELATION DEPENDS_ON -> [SessionEventLogger]
# @PRE: All mutations execute within authenticated request or task scope.
# @POST: Session aggregate writes preserve ownership and version semantics.
from __future__ import annotations
@@ -13,8 +13,7 @@ from typing import Any, List, Optional, cast
from sqlalchemy.orm import Session
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.models.dataset_review import (
ClarificationQuestion,
ClarificationSession,
@@ -32,14 +31,14 @@ from src.models.dataset_review import (
)
from src.services.dataset_review.event_logger import SessionEventLogger
log = MarkerLogger("SessionMutations")
logger = cast(Any, logger)
# #region save_profile_and_findings [C:4] [TYPE Function]
# @BRIEF Persist profile state and replace validation findings for an owned session in one transaction.
# @PRE session_id belongs to user_id and the supplied profile/findings belong to the same aggregate scope.
# @POST stored profile matches the current session and findings are replaced by the supplied collection.
# @SIDE_EFFECT updates profile rows, deletes stale findings, inserts current findings, and commits the transaction.
# @PRE: session_id belongs to user_id and the supplied profile/findings belong to the same aggregate scope.
# @POST: stored profile matches the current session and findings are replaced by the supplied collection.
# @SIDE_EFFECT: updates profile rows, deletes stale findings, inserts current findings, and commits the transaction.
def save_profile_and_findings(
db: Session,
event_logger: SessionEventLogger,
@@ -56,7 +55,7 @@ def save_profile_and_findings(
session = get_owned_session(session_id, user_id)
if expected_version is not None:
require_session_version(session, expected_version)
log.reason("Persisting dataset profile and replacing validation findings", payload={"session_id": session_id, "user_id": user_id, "has_profile": bool(profile), "findings_count": len(findings)})
logger.reason("Persisting dataset profile and replacing validation findings", extra={"session_id": session_id, "user_id": user_id, "has_profile": bool(profile), "findings_count": len(findings)})
if profile:
existing_profile = db.query(DatasetProfile).filter_by(session_id=session_id).first()
@@ -70,7 +69,7 @@ def save_profile_and_findings(
db.add(finding)
commit_session_mutation(session, expected_version=expected_version)
log.reflect("Dataset profile and validation findings committed", payload={"session_id": session.session_id, "user_id": user_id, "findings_count": len(findings)})
logger.reflect("Dataset profile and validation findings committed", extra={"session_id": session.session_id, "user_id": user_id, "findings_count": len(findings)})
from src.services.dataset_review.repositories.session_repository import DatasetReviewSessionRepository
return session
@@ -81,9 +80,9 @@ def save_profile_and_findings(
# #region save_recovery_state [C:4] [TYPE Function]
# @BRIEF Persist imported filters, template variables, and initial execution mappings for one owned session.
# @PRE session_id belongs to user_id.
# @POST Recovery state persisted to database.
# @SIDE_EFFECT Writes to database.
# @PRE: session_id belongs to user_id.
# @POST: Recovery state persisted to database.
# @SIDE_EFFECT: Writes to database.
def save_recovery_state(
db: Session,
get_owned_session,
@@ -101,7 +100,7 @@ def save_recovery_state(
session = get_owned_session(session_id, user_id)
if expected_version is not None:
require_session_version(session, expected_version)
log.reason("Persisting dataset review recovery bootstrap state", payload={"session_id": session_id, "user_id": user_id, "imported_filters_count": len(imported_filters), "template_variables_count": len(template_variables), "execution_mappings_count": len(execution_mappings)})
logger.reason("Persisting dataset review recovery bootstrap state", extra={"session_id": session_id, "user_id": user_id, "imported_filters_count": len(imported_filters), "template_variables_count": len(template_variables), "execution_mappings_count": len(execution_mappings)})
db.query(ExecutionMapping).filter(ExecutionMapping.session_id == session_id).delete()
db.query(TemplateVariable).filter(TemplateVariable.session_id == session_id).delete()
@@ -119,7 +118,7 @@ def save_recovery_state(
db.add(em)
commit_session_mutation(session, expected_version=expected_version)
log.reflect("Dataset review recovery bootstrap state committed", payload={"session_id": session.session_id, "user_id": user_id})
logger.reflect("Dataset review recovery bootstrap state committed", extra={"session_id": session.session_id, "user_id": user_id})
return load_session_detail_fn(session_id, user_id)
@@ -128,9 +127,9 @@ def save_recovery_state(
# #region save_preview [C:3] [TYPE Function]
# @BRIEF Persist a preview snapshot and mark prior session previews stale.
# @PRE session_id belongs to user_id and preview is prepared for the same session aggregate.
# @POST preview is persisted and the session points to the latest preview identifier.
# @SIDE_EFFECT updates prior preview statuses, inserts a preview row, mutates the parent session, and commits.
# @PRE: session_id belongs to user_id and preview is prepared for the same session aggregate.
# @POST: preview is persisted and the session points to the latest preview identifier.
# @SIDE_EFFECT: updates prior preview statuses, inserts a preview row, mutates the parent session, and commits.
def save_preview(
db: Session,
get_owned_session,
@@ -146,7 +145,7 @@ def save_preview(
session_record = cast(Any, session)
if expected_version is not None:
require_session_version(session, expected_version)
log.reason("Persisting compiled preview and staling previous preview snapshots", payload={"session_id": session_id, "user_id": user_id})
logger.reason("Persisting compiled preview and staling previous preview snapshots", extra={"session_id": session_id, "user_id": user_id})
db.query(CompiledPreview).filter(CompiledPreview.session_id == session_id).update({"preview_status": "stale"})
db.add(preview)
@@ -154,7 +153,7 @@ def save_preview(
session_record.last_preview_id = preview.preview_id
commit_session_mutation(session, refresh_targets=[preview], expected_version=expected_version)
log.reflect("Compiled preview committed as latest session preview", payload={"session_id": session.session_id, "preview_id": preview.preview_id})
logger.reflect("Compiled preview committed as latest session preview", extra={"session_id": session.session_id, "preview_id": preview.preview_id})
return preview
@@ -163,9 +162,9 @@ def save_preview(
# #region save_run_context [C:3] [TYPE Function]
# @BRIEF Persist an immutable launch audit snapshot for an owned session.
# @PRE session_id belongs to user_id and run_context targets the same aggregate.
# @POST run context is persisted and linked as the latest launch snapshot for the session.
# @SIDE_EFFECT inserts a run-context row, mutates the parent session pointer, and commits.
# @PRE: session_id belongs to user_id and run_context targets the same aggregate.
# @POST: run context is persisted and linked as the latest launch snapshot for the session.
# @SIDE_EFFECT: inserts a run-context row, mutates the parent session pointer, and commits.
def save_run_context(
db: Session,
get_owned_session,
@@ -181,14 +180,14 @@ def save_run_context(
session_record = cast(Any, session)
if expected_version is not None:
require_session_version(session, expected_version)
log.reason("Persisting dataset run context audit snapshot", payload={"session_id": session_id, "user_id": user_id})
logger.reason("Persisting dataset run context audit snapshot", extra={"session_id": session_id, "user_id": user_id})
db.add(run_context)
db.flush()
session_record.last_run_context_id = run_context.run_context_id
commit_session_mutation(session, refresh_targets=[run_context], expected_version=expected_version)
log.reflect("Dataset run context committed as latest launch snapshot", payload={"session_id": session.session_id, "run_context_id": run_context.run_context_id})
logger.reflect("Dataset run context committed as latest launch snapshot", extra={"session_id": session.session_id, "run_context_id": run_context.run_context_id})
return run_context

View File

@@ -1,26 +1,24 @@
# #region DatasetReviewSessionRepository [C:5] [TYPE Module]
# @BRIEF Persist and retrieve dataset review session aggregates, including readiness, findings, semantic decisions, clarification state, previews, and run contexts.
# @LAYER Domain
# @PRE repository operations execute within authenticated request or task scope.
# @POST session aggregate reads are structurally consistent and writes preserve ownership and version semantics.
# @SIDE_EFFECT reads and writes SQLAlchemy-backed session aggregates.
# @DATA_CONTRACT Input[SessionMutation] -> Output[PersistedSessionAggregate]
# @INVARIANT answers, mapping approvals, preview artifacts, and launch snapshots are never attributed to the wrong user or session.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
# @RELATION DEPENDS_ON -> [DatasetProfile]
# @RELATION DEPENDS_ON -> [ValidationFinding]
# @RELATION DEPENDS_ON -> [CompiledPreview]
# @RELATION DISPATCHES -> [SessionRepositoryMutations:Module]
# @RATIONALE Original 627-line file exceeded INV_7 (400-line module limit). Extracted mutation operations into _mutations sub-module.
# @REJECTED Keeping all repository operations in one file because it exceeded the fractal limit.
# @PRE: repository operations execute within authenticated request or task scope.
# @POST: session aggregate reads are structurally consistent and writes preserve ownership and version semantics.
# @SIDE_EFFECT: reads and writes SQLAlchemy-backed session aggregates.
# @DATA_CONTRACT: Input[SessionMutation] -> Output[PersistedSessionAggregate]
# @INVARIANT: answers, mapping approvals, preview artifacts, and launch snapshots are never attributed to the wrong user or session.
# @RATIONALE: Original 627-line file exceeded INV_7 (400-line module limit). Extracted mutation operations into _mutations sub-module.
# @REJECTED: Keeping all repository operations in one file because it exceeded the fractal limit.
from datetime import datetime
from typing import Any, Optional, List
from typing import Any, Optional, List, cast
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm.exc import StaleDataError
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.models.dataset_review import (
ClarificationQuestion,
ClarificationSession,
@@ -36,9 +34,10 @@ from src.models.dataset_review import (
SessionEvent,
TemplateVariable,
)
from src.core.logger import belief_scope, logger
from src.services.dataset_review.event_logger import SessionEventLogger
log = MarkerLogger("SessionRepository")
logger = cast(Any, logger)
# #region DatasetReviewSessionVersionConflictError [C:2] [TYPE Class]
@@ -58,93 +57,99 @@ class DatasetReviewSessionVersionConflictError(ValueError):
# #region DatasetReviewSessionRepository [C:4] [TYPE Class]
# @BRIEF Enforce ownership-scoped persistence and retrieval for dataset review session aggregates.
# @PRE constructor receives a live SQLAlchemy session and callers provide authenticated user scope.
# @POST repository methods return ownership-scoped aggregates or persisted child records without changing domain meaning.
# @SIDE_EFFECT mutates and queries the persistence layer through the injected database session.
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
# @RELATION DEPENDS_ON -> [SessionEventLogger]
# @PRE: constructor receives a live SQLAlchemy session and callers provide authenticated user scope.
# @POST: repository methods return ownership-scoped aggregates or persisted child records without changing domain meaning.
# @SIDE_EFFECT: mutates and queries the persistence layer through the injected database session.
class DatasetReviewSessionRepository:
# #region init_repo [C:2] [TYPE Function]
# @BRIEF Bind one live SQLAlchemy session to the repository instance.
# @PRE db_session is not None
# @POST Repository instance initialized with valid session
# [DEF:init_repo:Function]
# @COMPLEXITY: 2
# @PURPOSE: Bind one live SQLAlchemy session to the repository instance.
# @PRE: db_session is not None
# @POST: Repository instance initialized with valid session
def __init__(self, db: Session):
self.db = db
self.event_logger = SessionEventLogger(db)
# #endregion init_repo
# [/DEF:init_repo:Function]
# #region get_owned_session [C:3] [TYPE Function]
# @BRIEF Resolve one owner-scoped dataset review session for mutation paths.
# @PRE session_id and user_id are non-empty identifiers from the authenticated ownership scope.
# @POST returns the owned session or raises a deterministic access error.
# [DEF:get_owned_session:Function]
# @COMPLEXITY: 3
# @PURPOSE: Resolve one owner-scoped dataset review session for mutation paths.
# @PRE: session_id and user_id are non-empty identifiers from the authenticated ownership scope.
# @POST: returns the owned session or raises a deterministic access error.
def _get_owned_session(self, session_id: str, user_id: str) -> DatasetReviewSession:
with belief_scope("DatasetReviewSessionRepository.get_owned_session"):
log.reason("Resolving owner-scoped dataset review session", payload={"session_id": session_id, "user_id": user_id})
logger.reason("Resolving owner-scoped dataset review session", extra={"session_id": session_id, "user_id": user_id})
session = (
self.db.query(DatasetReviewSession)
.filter(DatasetReviewSession.session_id == session_id, DatasetReviewSession.user_id == user_id)
.first()
)
if not session:
log.explore("Owner-scoped dataset review session lookup failed", payload={"session_id": session_id, "user_id": user_id}, error="Session not found or access denied")
logger.explore("Owner-scoped dataset review session lookup failed", extra={"session_id": session_id, "user_id": user_id})
raise ValueError("Session not found or access denied")
log.reflect("Owner-scoped dataset review session resolved", payload={"session_id": session.session_id})
logger.reflect("Owner-scoped dataset review session resolved", extra={"session_id": session.session_id})
return session
# #endregion get_owned_session
# [/DEF:get_owned_session:Function]
# #region create_sess [C:3] [TYPE Function]
# @BRIEF Persist an initial dataset review session shell.
# @POST session is committed, refreshed, and returned with persisted identifiers.
# [DEF:create_sess:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist an initial dataset review session shell.
# @POST: session is committed, refreshed, and returned with persisted identifiers.
def create_session(self, session: DatasetReviewSession) -> DatasetReviewSession:
with belief_scope("DatasetReviewSessionRepository.create_session"):
log.reason("Persisting dataset review session shell", payload={"user_id": session.user_id, "environment_id": session.environment_id})
logger.reason("Persisting dataset review session shell", extra={"user_id": session.user_id, "environment_id": session.environment_id})
self.db.add(session)
self.db.commit()
self.db.refresh(session)
log.reflect("Dataset review session shell persisted", payload={"session_id": session.session_id})
logger.reflect("Dataset review session shell persisted", extra={"session_id": session.session_id})
return session
# #endregion create_sess
# [/DEF:create_sess:Function]
# #region require_session_version [C:3] [TYPE Function]
# @BRIEF Enforce optimistic-lock version matching before a session mutation is persisted.
# @POST returns the same session when versions match; otherwise raises deterministic conflict error.
# [DEF:require_session_version:Function]
# @COMPLEXITY: 3
# @PURPOSE: Enforce optimistic-lock version matching before a session mutation is persisted.
# @POST: returns the same session when versions match; otherwise raises deterministic conflict error.
def require_session_version(self, session: DatasetReviewSession, expected_version: int) -> DatasetReviewSession:
with belief_scope("DatasetReviewSessionRepository.require_session_version"):
actual_version = int(getattr(session, "version", 0) or 0)
log.reason("Checking optimistic-lock version", payload={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
logger.reason("Checking optimistic-lock version", extra={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
if actual_version != expected_version:
log.explore("Rejected mutation due to stale session version", payload={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version}, error="Optimistic lock version mismatch")
logger.explore("Rejected mutation due to stale session version", extra={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
raise DatasetReviewSessionVersionConflictError(str(session.session_id), expected_version, actual_version)
log.reflect("Optimistic-lock version accepted", payload={"session_id": session.session_id, "version": actual_version})
logger.reflect("Optimistic-lock version accepted", extra={"session_id": session.session_id, "version": actual_version})
return session
# #endregion require_session_version
# [/DEF:require_session_version:Function]
# #region bump_session_version [C:2] [TYPE Function]
# @BRIEF Increment optimistic-lock version after a successful session mutation is assembled.
# @POST session version increments monotonically.
# [DEF:bump_session_version:Function]
# @COMPLEXITY: 2
# @PURPOSE: Increment optimistic-lock version after a successful session mutation is assembled.
# @POST: session version increments monotonically.
def bump_session_version(self, session: DatasetReviewSession) -> int:
with belief_scope("DatasetReviewSessionRepository.bump_session_version"):
next_version = int(getattr(session, "version", 0) or 0) + 1
setattr(session, "version", next_version)
session.last_activity_at = datetime.utcnow()
log.reflect("Prepared incremented session version", payload={"session_id": session.session_id, "version": next_version})
logger.reflect("Prepared incremented session version", extra={"session_id": session.session_id, "version": next_version})
return next_version
# #endregion bump_session_version
# [/DEF:bump_session_version:Function]
# #region commit_session_mutation [C:4] [TYPE Function]
# @BRIEF Commit one prepared session mutation and translate stale writes into deterministic conflicts.
# @POST session mutation is committed with one version increment or a deterministic conflict error is raised.
# [DEF:commit_session_mutation:Function]
# @COMPLEXITY: 4
# @PURPOSE: Commit one prepared session mutation and translate stale writes into deterministic conflicts.
# @POST: session mutation is committed with one version increment or a deterministic conflict error is raised.
def commit_session_mutation(
self, session: DatasetReviewSession, *, refresh_targets: Optional[List[Any]] = None, expected_version: Optional[int] = None,
) -> DatasetReviewSession:
with belief_scope("DatasetReviewSessionRepository.commit_session_mutation"):
observed_version = int(expected_version if expected_version is not None else getattr(session, "version", 0) or 0)
log.reason("Committing session mutation with optimistic lock", payload={"session_id": session.session_id, "observed_version": observed_version})
logger.reason("Committing session mutation with optimistic lock", extra={"session_id": session.session_id, "observed_version": observed_version})
self.bump_session_version(session)
try:
self.db.commit()
@@ -152,22 +157,23 @@ class DatasetReviewSessionRepository:
self.db.rollback()
actual_version_row = self.db.query(DatasetReviewSession.version).filter(DatasetReviewSession.session_id == session.session_id).first()
actual_version = int(actual_version_row[0] or 0) if actual_version_row else 0
log.explore("Session commit rejected by optimistic lock", payload={"session_id": session.session_id, "expected_version": observed_version, "actual_version": actual_version}, error="StaleDataError on session commit")
logger.explore("Session commit rejected by optimistic lock", extra={"session_id": session.session_id, "expected_version": observed_version, "actual_version": actual_version})
raise DatasetReviewSessionVersionConflictError(session.session_id, observed_version, actual_version) from exc
self.db.refresh(session)
for target in refresh_targets or []:
self.db.refresh(target)
log.reflect("Session mutation committed", payload={"session_id": session.session_id, "version": getattr(session, "version", None)})
logger.reflect("Session mutation committed", extra={"session_id": session.session_id, "version": getattr(session, "version", None)})
return session
# #endregion commit_session_mutation
# [/DEF:commit_session_mutation:Function]
# #region load_detail [C:3] [TYPE Function]
# @BRIEF Return the full session aggregate for API and frontend resume flows.
# @POST Returns SessionDetail with all fields populated or None.
# [DEF:load_detail:Function]
# @COMPLEXITY: 3
# @PURPOSE: Return the full session aggregate for API and frontend resume flows.
# @POST: Returns SessionDetail with all fields populated or None.
def load_session_detail(self, session_id: str, user_id: str) -> Optional[DatasetReviewSession]:
with belief_scope("DatasetReviewSessionRepository.load_session_detail"):
log.reason("Loading dataset review session detail", payload={"session_id": session_id, "user_id": user_id})
logger.reason("Loading dataset review session detail", extra={"session_id": session_id, "user_id": user_id})
session = (
self.db.query(DatasetReviewSession)
.outerjoin(SessionCollaborator, DatasetReviewSession.session_id == SessionCollaborator.session_id)
@@ -190,14 +196,15 @@ class DatasetReviewSessionRepository:
.filter(or_(DatasetReviewSession.user_id == user_id, SessionCollaborator.user_id == user_id))
.first()
)
log.reflect("Session detail lookup completed", payload={"session_id": session_id, "found": bool(session)})
logger.reflect("Session detail lookup completed", extra={"session_id": session_id, "found": bool(session)})
return session
# #endregion load_detail
# [/DEF:load_detail:Function]
# #region save_profile_and_findings [C:4] [TYPE Function]
# @BRIEF Persist profile state and replace validation findings for an owned session.
# @POST stored profile matches the current session and findings are replaced.
# [DEF:save_profile_and_findings:Function]
# @COMPLEXITY: 4
# @PURPOSE: Persist profile state and replace validation findings for an owned session.
# @POST: stored profile matches the current session and findings are replaced.
def save_profile_and_findings(
self, session_id: str, user_id: str, profile: DatasetProfile, findings: List[ValidationFinding], expected_version: Optional[int] = None,
) -> DatasetReviewSession:
@@ -207,10 +214,11 @@ class DatasetReviewSessionRepository:
self.commit_session_mutation, session_id, user_id, profile, findings, expected_version,
)
# #endregion save_profile_and_findings
# [/DEF:save_profile_and_findings:Function]
# #region save_recovery_state [C:3] [TYPE Function]
# @BRIEF Persist imported filters, template variables, and initial execution mappings.
# [DEF:save_recovery_state:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist imported filters, template variables, and initial execution mappings.
def save_recovery_state(
self, session_id: str, user_id: str, imported_filters: List[ImportedFilter],
template_variables: List[TemplateVariable], execution_mappings: List[ExecutionMapping],
@@ -223,10 +231,11 @@ class DatasetReviewSessionRepository:
session_id, user_id, imported_filters, template_variables, execution_mappings, expected_version,
)
# #endregion save_recovery_state
# [/DEF:save_recovery_state:Function]
# #region save_preview [C:3] [TYPE Function]
# @BRIEF Persist a preview snapshot and mark prior session previews stale.
# [DEF:save_preview:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist a preview snapshot and mark prior session previews stale.
def save_preview(
self, session_id: str, user_id: str, preview: CompiledPreview, expected_version: Optional[int] = None,
) -> CompiledPreview:
@@ -236,10 +245,11 @@ class DatasetReviewSessionRepository:
self.commit_session_mutation, session_id, user_id, preview, expected_version,
)
# #endregion save_preview
# [/DEF:save_preview:Function]
# #region save_run_context [C:3] [TYPE Function]
# @BRIEF Persist an immutable launch audit snapshot for an owned session.
# [DEF:save_run_context:Function]
# @COMPLEXITY: 3
# @PURPOSE: Persist an immutable launch audit snapshot for an owned session.
def save_run_context(
self, session_id: str, user_id: str, run_context: DatasetRunContext, expected_version: Optional[int] = None,
) -> DatasetRunContext:
@@ -249,23 +259,26 @@ class DatasetReviewSessionRepository:
self.commit_session_mutation, session_id, user_id, run_context, expected_version,
)
# #endregion save_run_context
# [/DEF:save_run_context:Function]
# #region list_user_sess [C:2] [TYPE Function]
# @BRIEF List review sessions owned by a specific user ordered by most recent update.
# [DEF:list_user_sess:Function]
# @COMPLEXITY: 2
# @PURPOSE: List review sessions owned by a specific user ordered by most recent update.
def list_sessions_for_user(self, user_id: str) -> List[DatasetReviewSession]:
with belief_scope("DatasetReviewSessionRepository.list_sessions_for_user"):
log.reason("Listing dataset review sessions for owner scope", payload={"user_id": user_id})
logger.reason("Listing dataset review sessions for owner scope", extra={"user_id": user_id})
sessions = (
self.db.query(DatasetReviewSession)
.filter(DatasetReviewSession.user_id == user_id)
.order_by(DatasetReviewSession.updated_at.desc())
.all()
)
log.reflect("Session list assembled", payload={"user_id": user_id, "session_count": len(sessions)})
logger.reflect("Session list assembled", extra={"user_id": user_id, "session_count": len(sessions)})
return sessions
# #endregion list_user_sess
# [/DEF:list_user_sess:Function]
# #endregion DatasetReviewSessionRepository
# #endregion DatasetReviewSessionRepository

View File

@@ -1,14 +1,14 @@
# #region SemanticSourceResolver [C:4] [TYPE Module] [SEMANTICS dataset_review, semantic_resolution, dictionary, trusted_sources, ranking]
# @BRIEF Resolve and rank semantic candidates from trusted dictionary-like sources before any inferred fallback.
# @LAYER Domain
# @PRE selected source and target field set must be known.
# @POST candidate ranking follows the configured confidence hierarchy and unresolved fuzzy matches remain reviewable.
# @SIDE_EFFECT may create conflict findings and semantic candidate records.
# @INVARIANT Manual overrides are never silently replaced by imported, inferred, or AI-generated values.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [SemanticSource]
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
# @RELATION DEPENDS_ON -> [SemanticCandidate]
# @PRE: selected source and target field set must be known.
# @POST: candidate ranking follows the configured confidence hierarchy and unresolved fuzzy matches remain reviewable.
# @SIDE_EFFECT: may create conflict findings and semantic candidate records.
# @INVARIANT: Manual overrides are never silently replaced by imported, inferred, or AI-generated values.
from __future__ import annotations
@@ -17,8 +17,7 @@ from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Any, Dict, Iterable, List, Mapping, Optional
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.models.dataset_review import (
CandidateMatchType,
CandidateStatus,
@@ -27,8 +26,6 @@ from src.models.dataset_review import (
)
# #endregion imports
log = MarkerLogger("SemanticSourceResolver")
# #region DictionaryResolutionResult [C:2] [TYPE Class]
# @BRIEF Carries field-level dictionary resolution output with explicit review and partial-recovery state.
@@ -43,26 +40,28 @@ class DictionaryResolutionResult:
# #region SemanticSourceResolver [C:4] [TYPE Class]
# @BRIEF Resolve semantic candidates from trusted sources while preserving manual locks and confidence ordering.
# @PRE source payload and target field collection are provided by the caller.
# @POST result contains confidence-ranked candidates and does not overwrite manual locks implicitly.
# @SIDE_EFFECT emits semantic trace logs for ranking and fallback decisions.
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
# @RELATION DEPENDS_ON -> [SemanticCandidate]
# @PRE: source payload and target field collection are provided by the caller.
# @POST: result contains confidence-ranked candidates and does not overwrite manual locks implicitly.
# @SIDE_EFFECT: emits semantic trace logs for ranking and fallback decisions.
class SemanticSourceResolver:
# #region resolve_from_file [C:2] [TYPE Function]
# @BRIEF Normalize uploaded semantic file records into field-level candidates.
# [DEF:resolve_from_file:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize uploaded semantic file records into field-level candidates.
def resolve_from_file(self, source_payload: Mapping[str, Any], fields: Iterable[Mapping[str, Any]]) -> DictionaryResolutionResult:
return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "uploaded_file"))
# #endregion resolve_from_file
# [/DEF:resolve_from_file:Function]
# #region resolve_from_dictionary [C:4] [TYPE Function]
# @BRIEF Resolve candidates from connected tabular dictionary sources.
# @PRE dictionary source exists and fields contain stable field_name values.
# @POST returns confidence-ranked candidates where exact dictionary matches outrank fuzzy matches and unresolved fields stay explicit.
# @SIDE_EFFECT emits belief-state logs describing trusted-match and partial-recovery outcomes.
# @DATA_CONTRACT Input[source_payload:Mapping,fields:Iterable] -> Output[DictionaryResolutionResult]
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
# @RELATION DEPENDS_ON -> [SemanticCandidate]
# [DEF:resolve_from_dictionary:Function]
# @COMPLEXITY: 4
# @PURPOSE: Resolve candidates from connected tabular dictionary sources.
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
# @RELATION: [DEPENDS_ON] ->[SemanticCandidate]
# @PRE: dictionary source exists and fields contain stable field_name values.
# @POST: returns confidence-ranked candidates where exact dictionary matches outrank fuzzy matches and unresolved fields stay explicit.
# @SIDE_EFFECT: emits belief-state logs describing trusted-match and partial-recovery outcomes.
# @DATA_CONTRACT: Input[source_payload:Mapping,fields:Iterable] -> Output[DictionaryResolutionResult]
def resolve_from_dictionary(
self,
source_payload: Mapping[str, Any],
@@ -73,16 +72,19 @@ class SemanticSourceResolver:
dictionary_rows = source_payload.get("rows")
if not source_ref:
log.explore("Dictionary semantic source is missing source_ref", error="No source_ref in dictionary source")
logger.explore("Dictionary semantic source is missing source_ref")
raise ValueError("Dictionary semantic source must include source_ref")
if not isinstance(dictionary_rows, list) or not dictionary_rows:
log.explore("Dictionary semantic source has no usable rows", error="Dictionary rows is empty or not a list", payload={"source_ref": source_ref})
logger.explore(
"Dictionary semantic source has no usable rows",
extra={"source_ref": source_ref},
)
raise ValueError("Dictionary semantic source must include non-empty rows")
log.reason(
logger.reason(
"Resolving semantics from trusted dictionary source",
payload={"source_ref": source_ref, "row_count": len(dictionary_rows)},
extra={"source_ref": source_ref, "row_count": len(dictionary_rows)},
)
normalized_rows = [self._normalize_dictionary_row(row) for row in dictionary_rows if isinstance(row, Mapping)]
@@ -102,9 +104,9 @@ class SemanticSourceResolver:
is_locked = bool(raw_field.get("is_locked"))
if is_locked:
log.reason(
logger.reason(
"Preserving manual lock during dictionary resolution",
payload={"field_name": field_name},
extra={"field_name": field_name},
)
resolved_fields.append(
{
@@ -124,9 +126,9 @@ class SemanticSourceResolver:
candidates: List[Dict[str, Any]] = []
if exact_match is not None:
log.reason(
logger.reason(
"Resolved exact dictionary match",
payload={"field_name": field_name, "source_ref": source_ref},
extra={"field_name": field_name, "source_ref": source_ref},
)
candidates.append(
self._build_candidate_payload(
@@ -162,7 +164,10 @@ class SemanticSourceResolver:
"status": "unresolved",
}
)
log.explore("No trusted dictionary match found for field", error="No dictionary match found for field", payload={"field_name": field_name, "source_ref": source_ref})
logger.explore(
"No trusted dictionary match found for field",
extra={"field_name": field_name, "source_ref": source_ref},
)
continue
ranked_candidates = self.rank_candidates(candidates)
@@ -194,9 +199,9 @@ class SemanticSourceResolver:
unresolved_fields=unresolved_fields,
partial_recovery=bool(unresolved_fields),
)
log.reflect(
logger.reflect(
"Dictionary resolution completed",
payload={
extra={
"source_ref": source_ref,
"resolved_fields": len(resolved_fields),
"unresolved_fields": len(unresolved_fields),
@@ -204,21 +209,23 @@ class SemanticSourceResolver:
},
)
return result
# #endregion resolve_from_dictionary
# [/DEF:resolve_from_dictionary:Function]
# #region resolve_from_reference_dataset [C:2] [TYPE Function]
# @BRIEF Reuse semantic metadata from trusted Superset datasets.
# [DEF:resolve_from_reference_dataset:Function]
# @COMPLEXITY: 2
# @PURPOSE: Reuse semantic metadata from trusted Superset datasets.
def resolve_from_reference_dataset(
self,
source_payload: Mapping[str, Any],
fields: Iterable[Mapping[str, Any]],
) -> DictionaryResolutionResult:
return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "reference_dataset"))
# #endregion resolve_from_reference_dataset
# [/DEF:resolve_from_reference_dataset:Function]
# #region rank_candidates [C:2] [TYPE Function]
# @BRIEF Apply confidence ordering and determine best candidate per field.
# @RELATION DEPENDS_ON -> [SemanticCandidate]
# [DEF:rank_candidates:Function]
# @COMPLEXITY: 2
# @PURPOSE: Apply confidence ordering and determine best candidate per field.
# @RELATION: [DEPENDS_ON] ->[SemanticCandidate]
def rank_candidates(self, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
ranked = sorted(
candidates,
@@ -231,30 +238,33 @@ class SemanticSourceResolver:
for index, candidate in enumerate(ranked, start=1):
candidate["candidate_rank"] = index
return ranked
# #endregion rank_candidates
# [/DEF:rank_candidates:Function]
# #region detect_conflicts [C:2] [TYPE Function]
# @BRIEF Mark competing candidate sets that require explicit user review.
# [DEF:detect_conflicts:Function]
# @COMPLEXITY: 2
# @PURPOSE: Mark competing candidate sets that require explicit user review.
def detect_conflicts(self, candidates: List[Dict[str, Any]]) -> bool:
return len(candidates) > 1
# #endregion detect_conflicts
# [/DEF:detect_conflicts:Function]
# #region apply_field_decision [C:2] [TYPE Function]
# @BRIEF Accept, reject, or manually override a field-level semantic value.
# [DEF:apply_field_decision:Function]
# @COMPLEXITY: 2
# @PURPOSE: Accept, reject, or manually override a field-level semantic value.
def apply_field_decision(self, field_state: Mapping[str, Any], decision: Mapping[str, Any]) -> Dict[str, Any]:
merged = dict(field_state)
merged.update(decision)
return merged
# #endregion apply_field_decision
# [/DEF:apply_field_decision:Function]
# #region propagate_source_version_update [C:4] [TYPE Function]
# @BRIEF Propagate a semantic source version change to unlocked field entries without silently overwriting manual or locked values.
# @PRE source is persisted and fields belong to the same session aggregate.
# @POST unlocked fields linked to the source carry the new source version and are marked reviewable; manual or locked fields keep their active values untouched.
# @SIDE_EFFECT mutates in-memory field state for the caller to persist.
# @DATA_CONTRACT Input[SemanticSource,List[SemanticFieldEntry]] -> Output[Dict[str,int]]
# @RELATION DEPENDS_ON -> [SemanticSource]
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
# [DEF:propagate_source_version_update:Function]
# @COMPLEXITY: 4
# @PURPOSE: Propagate a semantic source version change to unlocked field entries without silently overwriting manual or locked values.
# @RELATION: [DEPENDS_ON] ->[SemanticSource]
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
# @PRE: source is persisted and fields belong to the same session aggregate.
# @POST: unlocked fields linked to the source carry the new source version and are marked reviewable; manual or locked fields keep their active values untouched.
# @SIDE_EFFECT: mutates in-memory field state for the caller to persist.
# @DATA_CONTRACT: Input[SemanticSource,List[SemanticFieldEntry]] -> Output[Dict[str,int]]
def propagate_source_version_update(
self,
source: SemanticSource,
@@ -264,7 +274,10 @@ class SemanticSourceResolver:
source_id = str(source.source_id or "").strip()
source_version = str(source.source_version or "").strip()
if not source_id or not source_version:
log.explore("Semantic source version propagation rejected due to incomplete source metadata", error="Source metadata incomplete (missing source_id or source_version)", payload={"source_id": source_id, "source_version": source_version})
logger.explore(
"Semantic source version propagation rejected due to incomplete source metadata",
extra={"source_id": source_id, "source_version": source_version},
)
raise ValueError("Semantic source must provide source_id and source_version")
propagated = 0
@@ -283,9 +296,9 @@ class SemanticSourceResolver:
field.has_conflict = bool(getattr(field, "has_conflict", False))
propagated += 1
log.reflect(
logger.reflect(
"Semantic source version propagation completed",
payload={
extra={
"source_id": source_id,
"source_version": source_version,
"propagated": propagated,
@@ -298,10 +311,11 @@ class SemanticSourceResolver:
"preserved_locked": preserved_locked,
"untouched": untouched,
}
# #endregion propagate_source_version_update
# [/DEF:propagate_source_version_update:Function]
# #region _normalize_dictionary_row [C:2] [TYPE Function]
# @BRIEF Normalize one dictionary row into a consistent lookup structure.
# [DEF:_normalize_dictionary_row:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize one dictionary row into a consistent lookup structure.
def _normalize_dictionary_row(self, row: Mapping[str, Any]) -> Dict[str, Any]:
field_name = (
row.get("field_name")
@@ -317,10 +331,11 @@ class SemanticSourceResolver:
"description": row.get("description"),
"display_format": row.get("display_format") or row.get("format"),
}
# #endregion _normalize_dictionary_row
# [/DEF:_normalize_dictionary_row:Function]
# #region _find_fuzzy_matches [C:2] [TYPE Function]
# @BRIEF Produce confidence-scored fuzzy matches while keeping them reviewable.
# [DEF:_find_fuzzy_matches:Function]
# @COMPLEXITY: 2
# @PURPOSE: Produce confidence-scored fuzzy matches while keeping them reviewable.
def _find_fuzzy_matches(self, field_name: str, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized_target = self._normalize_key(field_name)
fuzzy_matches: List[Dict[str, Any]] = []
@@ -334,10 +349,11 @@ class SemanticSourceResolver:
fuzzy_matches.append({"row": row, "score": round(score, 3)})
fuzzy_matches.sort(key=lambda item: item["score"], reverse=True)
return fuzzy_matches[:3]
# #endregion _find_fuzzy_matches
# [/DEF:_find_fuzzy_matches:Function]
# #region _build_candidate_payload [C:2] [TYPE Function]
# @BRIEF Project normalized dictionary rows into semantic candidate payloads.
# [DEF:_build_candidate_payload:Function]
# @COMPLEXITY: 2
# @PURPOSE: Project normalized dictionary rows into semantic candidate payloads.
def _build_candidate_payload(
self,
rank: int,
@@ -354,10 +370,11 @@ class SemanticSourceResolver:
"proposed_display_format": row.get("display_format"),
"status": CandidateStatus.PROPOSED.value,
}
# #endregion _build_candidate_payload
# [/DEF:_build_candidate_payload:Function]
# #region _match_priority [C:2] [TYPE Function]
# @BRIEF Encode trusted-confidence ordering so exact dictionary reuse beats fuzzy invention.
# [DEF:_match_priority:Function]
# @COMPLEXITY: 2
# @PURPOSE: Encode trusted-confidence ordering so exact dictionary reuse beats fuzzy invention.
def _match_priority(self, match_type: Optional[str]) -> int:
priority = {
CandidateMatchType.EXACT.value: 0,
@@ -366,11 +383,14 @@ class SemanticSourceResolver:
CandidateMatchType.GENERATED.value: 3,
}
return priority.get(str(match_type or ""), 99)
# #endregion _match_priority
# [/DEF:_match_priority:Function]
# #region _normalize_key [C:2] [TYPE Function]
# @BRIEF Normalize field identifiers for stable exact/fuzzy comparisons.
# [DEF:_normalize_key:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize field identifiers for stable exact/fuzzy comparisons.
def _normalize_key(self, value: str) -> str:
return "".join(ch for ch in str(value or "").strip().lower() if ch.isalnum() or ch == "_")
# #endregion _normalize_key
# [/DEF:_normalize_key:Function]
# #endregion SemanticSourceResolver
# #endregion SemanticSourceResolver

View File

@@ -1,6 +1,6 @@
# #region GitServiceModule [C:3] [TYPE Module] [SEMANTICS git, service, decomposition, mixin, composition]
# @LAYER: Infra
# @BRIEF Composed GitService via multiple inheritance from domain-specific mixins.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [GitServiceBase]
# @RELATION DEPENDS_ON -> [GitServiceBranchMixin]
# @RELATION DEPENDS_ON -> [GitServiceSyncMixin]
@@ -10,8 +10,8 @@
# @RELATION DEPENDS_ON -> [GitServiceGiteaMixin]
# @RELATION DEPENDS_ON -> [GitServiceGithubMixin]
# @RELATION DEPENDS_ON -> [GitServiceGitlabMixin]
# @RATIONALE Decomposed from monolithic git_service.py (2101 lines) into
#
# @RATIONALE: Decomposed from monolithic git_service.py (2101 lines) into
# domain-scoped mixins to satisfy INV_7 (module < 400 lines). The composed class
# preserves the original public API surface — all consumers continue to import
# `from src.services.git_service import GitService` without changes.
@@ -32,15 +32,15 @@ __all__ = ["GitService"]
# #region GitService [C:3] [TYPE Class]
# @BRIEF Composed GitService class providing all Git operations — init, clone, branches, commits, push, pull,
# merge, status, diff, history, and provider API operations (Gitea, GitHub, GitLab).
# @RELATION: INHERITS -> [GitServiceBase]
# @RELATION: INHERITS -> [GitServiceBranchMixin]
# @RELATION: INHERITS -> [GitServiceSyncMixin]
# @RELATION: INHERITS -> [GitServiceStatusMixin]
# @RELATION: INHERITS -> [GitServiceMergeMixin]
# @RELATION: INHERITS -> [GitServiceUrlMixin]
# @RELATION: INHERITS -> [GitServiceGiteaMixin]
# @RELATION: INHERITS -> [GitServiceGithubMixin]
# @RELATION: INHERITS -> [GitServiceGitlabMixin]
# @RELATION INHERITS -> [GitServiceBase]
# @RELATION INHERITS -> [GitServiceBranchMixin]
# @RELATION INHERITS -> [GitServiceSyncMixin]
# @RELATION INHERITS -> [GitServiceStatusMixin]
# @RELATION INHERITS -> [GitServiceMergeMixin]
# @RELATION INHERITS -> [GitServiceUrlMixin]
# @RELATION INHERITS -> [GitServiceGiteaMixin]
# @RELATION INHERITS -> [GitServiceGithubMixin]
# @RELATION INHERITS -> [GitServiceGitlabMixin]
class GitService(
GitServiceGiteaMixin,
GitServiceGithubMixin,

View File

@@ -1,6 +1,6 @@
# #region GitServiceBase [C:3] [TYPE Module] [SEMANTICS git, service, repository, version_control, initialization]
# @LAYER: Infra
# @BRIEF Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), and identity configuration.
# @LAYER Infra
# @RELATION INHERITED_BY -> [GitService]
# @RELATION DEPENDS_ON -> [SessionLocal]
# @RELATION DEPENDS_ON -> [AppConfigRecord]
@@ -14,10 +14,7 @@ from typing import Any, Dict, List, Optional
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from fastapi import HTTPException
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitBase")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository
from src.models.config import AppConfigRecord
from src.core.database import SessionLocal
@@ -26,8 +23,8 @@ from src.core.database import SessionLocal
# #region GitServiceBase [C:3] [TYPE Class]
# @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, and identity.
class GitServiceBase:
# #region GitService_init [TYPE Function]
# @BRIEF Initializes the GitService with a base path for repositories.
# [DEF:GitService_init:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
# @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists.
@@ -38,12 +35,12 @@ class GitServiceBase:
self._uses_default_base_path = base_path == "git_repos"
self.base_path = self._resolve_base_path(base_path)
self._ensure_base_path_exists()
# #endregion GitService_init
# [/DEF:GitService_init:Function]
# #region _ensure_base_path_exists [TYPE Function]
# @BRIEF Ensure the repositories root directory exists and is a directory.
# @PRE self.base_path is resolved to filesystem path.
# @POST self.base_path exists as directory or raises ValueError.
# [DEF:_ensure_base_path_exists:Function]
# @PURPOSE: Ensure the repositories root directory exists and is a directory.
# @PRE: self.base_path is resolved to filesystem path.
# @POST: self.base_path exists as directory or raises ValueError.
def _ensure_base_path_exists(self) -> None:
base = Path(self.base_path)
if base.exists() and not base.is_dir():
@@ -51,15 +48,17 @@ class GitServiceBase:
try:
base.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError) as e:
log.explore("Cannot create Git repositories base path", payload={"base_path": self.base_path}, error=str(e))
logger.warning(
f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}"
)
raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}")
# #endregion _ensure_base_path_exists
# [/DEF:_ensure_base_path_exists:Function]
# #region _resolve_base_path [TYPE Function]
# @BRIEF Resolve base repository directory from explicit argument or global storage settings.
# @PRE base_path is a string path.
# @POST Returns absolute path for Git repositories root.
# @RETURN str
# [DEF:_resolve_base_path:Function]
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
# @PRE: base_path is a string path.
# @POST: Returns absolute path for Git repositories root.
# @RETURN: str
def _resolve_base_path(self, base_path: str) -> str:
backend_root = Path(__file__).parents[3]
fallback_path = str((backend_root / base_path).resolve())
@@ -86,25 +85,25 @@ class GitServiceBase:
return str(repo_root.resolve())
return str((root / repo_root).resolve())
except Exception as e:
log.explore("Falling back to default base path", error=str(e))
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
return fallback_path
# #endregion _resolve_base_path
# [/DEF:_resolve_base_path:Function]
# #region _normalize_repo_key [TYPE Function]
# @BRIEF Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE repo_key can be None/empty.
# @POST Returns normalized non-empty key.
# @RETURN str
# [DEF:_normalize_repo_key:Function]
# @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE: repo_key can be None/empty.
# @POST: Returns normalized non-empty key.
# @RETURN: str
def _normalize_repo_key(self, repo_key: Optional[str]) -> str:
raw_key = str(repo_key or "").strip().lower()
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
return normalized or "dashboard"
# #endregion _normalize_repo_key
# [/DEF:_normalize_repo_key:Function]
# #region _update_repo_local_path [TYPE Function]
# @BRIEF Persist repository local_path in GitRepository table when record exists.
# @PRE dashboard_id is valid integer.
# @POST local_path is updated for existing record.
# [DEF:_update_repo_local_path:Function]
# @PURPOSE: Persist repository local_path in GitRepository table when record exists.
# @PRE: dashboard_id is valid integer.
# @POST: local_path is updated for existing record.
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
try:
session = SessionLocal()
@@ -120,21 +119,21 @@ class GitServiceBase:
finally:
session.close()
except Exception as e:
log.explore("Failed to update repo local path", error=str(e))
# #endregion _update_repo_local_path
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
# [/DEF:_update_repo_local_path:Function]
# #region _migrate_repo_directory [TYPE Function]
# @BRIEF Move legacy repository directory to target path and sync DB metadata.
# @PRE source_path exists.
# @POST Repository content available at target_path.
# @RETURN str
# [DEF:_migrate_repo_directory:Function]
# @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
# @PRE: source_path exists.
# @POST: Repository content available at target_path.
# @RETURN: str
def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
source_abs = os.path.abspath(source_path)
target_abs = os.path.abspath(target_path)
if source_abs == target_abs:
return source_abs
if os.path.exists(target_abs):
log.explore(f"Target already exists, keeping source path: {target_abs}", error="Target path exists")
logger.warning(f"[_migrate_repo_directory][Action] Target already exists, keeping source path: {target_abs}")
return source_abs
Path(target_abs).parent.mkdir(parents=True, exist_ok=True)
try:
@@ -142,12 +141,14 @@ class GitServiceBase:
except OSError:
shutil.move(source_abs, target_abs)
self._update_repo_local_path(dashboard_id, target_abs)
log.reflect(f"Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}")
logger.info(
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
)
return target_abs
# #endregion _migrate_repo_directory
# [/DEF:_migrate_repo_directory:Function]
# #region _get_repo_path [TYPE Function]
# @BRIEF Resolves the local filesystem path for a dashboard's repository.
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
# @PRE: dashboard_id is an integer.
@@ -183,17 +184,17 @@ class GitServiceBase:
return self._migrate_repo_directory(dashboard_id, db_path, target_path)
return db_path
except Exception as e:
log.explore("Could not resolve local_path from DB", error=str(e))
logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")
legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
return self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
if os.path.exists(target_path):
self._update_repo_local_path(dashboard_id, target_path)
return target_path
# #endregion _get_repo_path
# [/DEF:_get_repo_path:Function]
# #region init_repo [TYPE Function]
# @BRIEF Initialize or clone a repository for a dashboard.
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST: Repository is cloned or opened at the local path.
@@ -209,11 +210,11 @@ class GitServiceBase:
else:
auth_url = remote_url
if os.path.exists(repo_path):
log.reason(f"Opening existing repo at {repo_path}")
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
try:
repo = Repo(repo_path)
except (InvalidGitRepositoryError, NoSuchPathError):
log.explore(f"Existing path is not a Git repository, recreating: {repo_path}", error="Not a git repository")
logger.warning(f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}")
stale_path = Path(repo_path)
if stale_path.exists():
shutil.rmtree(stale_path, ignore_errors=True)
@@ -225,16 +226,16 @@ class GitServiceBase:
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
log.reason(f"Cloning {remote_url} to {repo_path}")
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
# #endregion init_repo
# [/DEF:init_repo:Function]
# #region delete_repo [TYPE Function]
# @BRIEF Remove local repository and DB binding for a dashboard.
# @PRE dashboard_id is a valid integer.
# @POST Local path is deleted when present and GitRepository row is removed.
# [DEF:delete_repo:Function]
# @PURPOSE: Remove local repository and DB binding for a dashboard.
# @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed.
def delete_repo(self, dashboard_id: int) -> None:
with belief_scope("GitService.delete_repo"):
repo_path = self._get_repo_path(dashboard_id)
@@ -267,34 +268,34 @@ class GitServiceBase:
raise
except Exception as e:
session.rollback()
log.explore(f"Failed to delete repository for dashboard {dashboard_id}", error=str(e))
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}")
finally:
session.close()
# #endregion delete_repo
# [/DEF:delete_repo:Function]
# #region get_repo [TYPE Function]
# @BRIEF Get Repo object for a dashboard.
# @PRE Repository must exist on disk for the given dashboard_id.
# @POST Returns a GitPython Repo instance for the dashboard.
# @RETURN Repo
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id.
# @POST: Returns a GitPython Repo instance for the dashboard.
# @RETURN: Repo
def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
log.explore(f"Repository for dashboard {dashboard_id} does not exist", error="Repository not found")
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
except Exception as e:
log.explore(f"Failed to open repository at {repo_path}", error=str(e))
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
# #endregion get_repo
# [/DEF:get_repo:Function]
# #region configure_identity [TYPE Function]
# @BRIEF Configure repository-local Git committer identity for user-scoped operations.
# @PRE dashboard_id repository exists; git_username/git_email may be empty.
# @POST Repository config has user.name and user.email when both identity values are provided.
# [DEF:configure_identity:Function]
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
# @PRE: dashboard_id repository exists; git_username/git_email may be empty.
# @POST: Repository config has user.name and user.email when both identity values are provided.
def configure_identity(self, dashboard_id: int, git_username: Optional[str], git_email: Optional[str]) -> None:
with belief_scope("GitService.configure_identity"):
normalized_username = str(git_username or "").strip()
@@ -306,10 +307,10 @@ class GitServiceBase:
with repo.config_writer(config_level="repository") as config_writer:
config_writer.set_value("user", "name", normalized_username)
config_writer.set_value("user", "email", normalized_email)
log.reason(f"Applied repository-local git identity for dashboard {dashboard_id}")
logger.info("[configure_identity][Action] Applied repository-local git identity for dashboard %s", dashboard_id)
except Exception as e:
log.explore("Failed to configure git identity", error=str(e))
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}")
# #endregion configure_identity
# [/DEF:configure_identity:Function]
# #endregion GitServiceBase
# #endregion GitServiceBase

View File

@@ -1,6 +1,6 @@
# #region GitServiceBranchMixin [C:3] [TYPE Module] [SEMANTICS git, branches, commits, checkout, gitflow]
# @LAYER: Infra
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import os
@@ -9,19 +9,16 @@ from datetime import datetime
from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitBranch")
from src.core.logger import logger, belief_scope
# #region GitServiceBranchMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing branch and commit operations for GitService.
class GitServiceBranchMixin:
# #region _ensure_gitflow_branches [TYPE Function]
# @BRIEF Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE repo is a valid GitPython Repo instance.
# @POST main, dev, preprod are available in local repository and pushed to origin when available.
# [DEF:_ensure_gitflow_branches:Function]
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE: repo is a valid GitPython Repo instance.
# @POST: main, dev, preprod are available in local repository and pushed to origin when available.
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"]
@@ -34,20 +31,26 @@ class GitServiceBranchMixin:
if "main" in local_heads:
base_commit = local_heads["main"].commit
if base_commit is None:
log.explore("Branch bootstrap skipped - no commits", payload={"dashboard_id": dashboard_id}, error="Repository has no initial commit to branch from")
logger.warning(
f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits"
)
return
if "main" not in local_heads:
local_heads["main"] = repo.create_head("main", base_commit)
log.reason("Created local branch main", payload={"dashboard_id": dashboard_id})
logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}")
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
log.reason("Created local branch", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
logger.info(
f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
)
try:
origin = repo.remote(name="origin")
except ValueError:
log.reason("Remote origin not configured; skipping remote branch creation", payload={"dashboard_id": dashboard_id})
logger.info(
f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation"
)
return
remote_branch_names = set()
try:
@@ -57,35 +60,37 @@ class GitServiceBranchMixin:
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
log.explore("Failed to fetch origin refs", error=str(e))
logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}")
for branch_name in required_branches:
if branch_name in remote_branch_names:
continue
try:
origin.push(refspec=f"{branch_name}:{branch_name}")
log.reason("Pushed branch to origin", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
logger.info(
f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
)
except Exception as e:
log.explore("Failed to push branch to origin", error=str(e))
logger.error(f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
)
try:
repo.git.checkout("dev")
log.reason("Checked out default branch dev", payload={"dashboard_id": dashboard_id})
logger.info(f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}")
except Exception as e:
log.explore("Could not checkout dev branch", payload={"dashboard_id": dashboard_id}, error=str(e))
# #endregion _ensure_gitflow_branches
logger.warning(f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}")
# [/DEF:_ensure_gitflow_branches:Function]
# #region list_branches [TYPE Function]
# @BRIEF List all branches for a dashboard's repository.
# @PRE Repository for dashboard_id exists.
# @POST Returns a list of branch metadata dictionaries.
# @RETURN List[dict]
# [DEF:list_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of branch metadata dictionaries.
# @RETURN: List[dict]
def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
log.reason("Listing branches", payload={"dashboard_id": dashboard_id})
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
for ref in repo.refs:
try:
@@ -99,7 +104,7 @@ class GitServiceBranchMixin:
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
log.explore("Skipping ref", payload={"ref": str(ref)}, error=str(e))
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
@@ -108,17 +113,17 @@ class GitServiceBranchMixin:
"is_remote": False, "last_updated": datetime.utcnow()
})
except Exception as e:
log.explore("Could not determine active branch", error=str(e))
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
if not branches:
branches.append({
"name": "dev", "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.utcnow()
})
return branches
# #endregion list_branches
# [/DEF:list_branches:Function]
# #region create_branch [TYPE Function]
# @BRIEF Create a new branch from an existing one.
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch.
# @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
@@ -126,9 +131,9 @@ class GitServiceBranchMixin:
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
log.reason("Creating branch", payload={"name": name, "from_branch": from_branch})
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
if not repo.heads and not repo.remotes:
log.explore("Repository is empty; creating initial commit to enable branching", error="No branches or remotes exist; initializing from scratch")
logger.warning("[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
readme_path = os.path.join(repo.working_dir, "README.md")
if not os.path.exists(readme_path):
with open(readme_path, "w") as f:
@@ -138,29 +143,29 @@ class GitServiceBranchMixin:
try:
repo.commit(from_branch)
except Exception:
log.explore("Source branch not found, using HEAD", payload={"from_branch": from_branch}, error=f"Branch '{from_branch}' does not exist, falling back to HEAD")
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
from_branch = repo.head
try:
new_branch = repo.create_head(name, from_branch)
return new_branch
except Exception as e:
log.explore("Failed to create branch", error=str(e))
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# #endregion create_branch
# [/DEF:create_branch:Function]
# #region checkout_branch [TYPE Function]
# @BRIEF Switch to a specific branch.
# @PRE Repository exists and the specified branch name exists.
# @POST The repository working directory is updated to the specified branch.
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch to a specific branch.
# @PRE: Repository exists and the specified branch name exists.
# @POST: The repository working directory is updated to the specified branch.
def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
log.reason("Checking out branch", payload={"name": name})
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# #endregion checkout_branch
# [/DEF:checkout_branch:Function]
# #region commit_changes [TYPE Function]
# @BRIEF Stage and commit changes.
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage.
# @PRE: Repository exists and has changes (dirty) or files are specified.
@@ -169,16 +174,16 @@ class GitServiceBranchMixin:
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
if not repo.is_dirty(untracked_files=True) and not files:
log.reason("No changes to commit", payload={"dashboard_id": dashboard_id})
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
log.reason("Staging files", payload={"files": files})
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
log.reason("Staging all changes")
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
log.reflect("Committed changes", payload={"message": message})
# #endregion commit_changes
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# #endregion GitServiceBranchMixin
# #endregion GitServiceBranchMixin

View File

@@ -1,24 +1,21 @@
# #region GitServiceGiteaMixin [C:3] [TYPE Module] [SEMANTICS git, gitea, api, provider, connection_test]
# @LAYER: Infra
# @BRIEF Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import httpx
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from fastapi import HTTPException
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitGitea")
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# #region GitServiceGiteaMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing Gitea API operations for GitService.
class GitServiceGiteaMixin:
# #region test_connection [TYPE Function]
# @BRIEF Test connection to Git provider using PAT.
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider), url (str), pat (str)
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
# @POST: Returns True if connection to the provider's API succeeds.
@@ -26,13 +23,13 @@ class GitServiceGiteaMixin:
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
if ".local" in url or "localhost" in url:
log.reason("Local/Offline mode detected for URL")
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
log.explore(f"Invalid URL protocol: {url}", error="Invalid URL protocol")
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
log.explore("Git PAT is missing or empty", error="Git PAT is missing or empty")
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
@@ -52,18 +49,18 @@ class GitServiceGiteaMixin:
else:
return False
if resp.status_code != 200:
log.explore(f"Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}", error="Git connection test failed")
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
return resp.status_code == 200
except Exception as e:
log.explore(f"Error testing git connection: {e}", error=str(e))
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# #endregion test_connection
# [/DEF:test_connection:Function]
# #region _gitea_headers [TYPE Function]
# @BRIEF Build Gitea API authorization headers.
# @PRE pat is provided.
# @POST Returns headers with token auth.
# @RETURN Dict[str, str]
# [DEF:_gitea_headers:Function]
# @PURPOSE: Build Gitea API authorization headers.
# @PRE: pat is provided.
# @POST: Returns headers with token auth.
# @RETURN: Dict[str, str]
def _gitea_headers(self, pat: str) -> Dict[str, str]:
token = (pat or "").strip()
if not token:
@@ -73,13 +70,13 @@ class GitServiceGiteaMixin:
"Content-Type": "application/json",
"Accept": "application/json",
}
# #endregion _gitea_headers
# [/DEF:_gitea_headers:Function]
# #region _gitea_request [TYPE Function]
# @BRIEF Execute HTTP request against Gitea API with stable error mapping.
# @PRE method and endpoint are valid.
# @POST Returns decoded JSON payload.
# @RETURN Any
# [DEF:_gitea_request:Function]
# @PURPOSE: Execute HTTP request against Gitea API with stable error mapping.
# @PRE: method and endpoint are valid.
# @POST: Returns decoded JSON payload.
# @RETURN: Any
async def _gitea_request(
self, method: str, server_url: str, pat: str, endpoint: str,
payload: Optional[Dict[str, Any]] = None,
@@ -91,7 +88,7 @@ class GitServiceGiteaMixin:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.request(method=method, url=url, headers=headers, json=payload)
except Exception as e:
log.explore(f"Network error: {e}", error=str(e))
logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
@@ -100,43 +97,43 @@ class GitServiceGiteaMixin:
detail = parsed.get("message") or parsed.get("error") or detail
except Exception:
pass
log.explore(f"Gitea API error ({endpoint})", payload={"method": method, "status": response.status_code}, error=detail)
logger.error(f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}")
raise HTTPException(status_code=response.status_code, detail=f"Gitea API error: {detail}")
if response.status_code == 204:
return None
return response.json()
# #endregion _gitea_request
# [/DEF:_gitea_request:Function]
# #region get_gitea_current_user [TYPE Function]
# @BRIEF Resolve current Gitea user for PAT.
# @PRE server_url and pat are valid.
# @POST Returns current username.
# @RETURN str
# [DEF:get_gitea_current_user:Function]
# @PURPOSE: Resolve current Gitea user for PAT.
# @PRE: server_url and pat are valid.
# @POST: Returns current username.
# @RETURN: str
async def get_gitea_current_user(self, server_url: str, pat: str) -> str:
payload = await self._gitea_request("GET", server_url, pat, "/user")
username = payload.get("login") or payload.get("username")
if not username:
raise HTTPException(status_code=500, detail="Failed to resolve Gitea username")
return str(username)
# #endregion get_gitea_current_user
# [/DEF:get_gitea_current_user:Function]
# #region list_gitea_repositories [TYPE Function]
# @BRIEF List repositories visible to authenticated Gitea user.
# @PRE server_url and pat are valid.
# @POST Returns repository list from Gitea.
# @RETURN List[dict]
# [DEF:list_gitea_repositories:Function]
# @PURPOSE: List repositories visible to authenticated Gitea user.
# @PRE: server_url and pat are valid.
# @POST: Returns repository list from Gitea.
# @RETURN: List[dict]
async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]:
payload = await self._gitea_request("GET", server_url, pat, "/user/repos?limit=100&page=1")
if not isinstance(payload, list):
return []
return payload
# #endregion list_gitea_repositories
# [/DEF:list_gitea_repositories:Function]
# #region create_gitea_repository [TYPE Function]
# @BRIEF Create repository in Gitea for authenticated user.
# @PRE name is non-empty and PAT has repo creation permission.
# @POST Returns created repository payload.
# @RETURN dict
# [DEF:create_gitea_repository:Function]
# @PURPOSE: Create repository in Gitea for authenticated user.
# @PRE: name is non-empty and PAT has repo creation permission.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_gitea_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
@@ -150,23 +147,23 @@ class GitServiceGiteaMixin:
if not isinstance(created, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
return created
# #endregion create_gitea_repository
# [/DEF:create_gitea_repository:Function]
# #region delete_gitea_repository [TYPE Function]
# @BRIEF Delete repository in Gitea.
# @PRE owner and repo_name are non-empty.
# @POST Repository deleted on Gitea server.
# [DEF:delete_gitea_repository:Function]
# @PURPOSE: Delete repository in Gitea.
# @PRE: owner and repo_name are non-empty.
# @POST: Repository deleted on Gitea server.
async def delete_gitea_repository(self, server_url: str, pat: str, owner: str, repo_name: str) -> None:
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="owner and repo_name are required")
await self._gitea_request("DELETE", server_url, pat, f"/repos/{owner}/{repo_name}")
# #endregion delete_gitea_repository
# [/DEF:delete_gitea_repository:Function]
# #region _gitea_branch_exists [TYPE Function]
# @BRIEF Check whether a branch exists in Gitea repository.
# @PRE owner/repo/branch are non-empty.
# @POST Returns True when branch exists, False when 404.
# @RETURN bool
# [DEF:_gitea_branch_exists:Function]
# @PURPOSE: Check whether a branch exists in Gitea repository.
# @PRE: owner/repo/branch are non-empty.
# @POST: Returns True when branch exists, False when 404.
# @RETURN: bool
async def _gitea_branch_exists(self, server_url: str, pat: str, owner: str, repo: str, branch: str) -> bool:
if not owner or not repo or not branch:
return False
@@ -178,13 +175,13 @@ class GitServiceGiteaMixin:
if exc.status_code == 404:
return False
raise
# #endregion _gitea_branch_exists
# [/DEF:_gitea_branch_exists:Function]
# #region _build_gitea_pr_404_detail [TYPE Function]
# @BRIEF Build actionable error detail for Gitea PR 404 responses.
# @PRE owner/repo/from_branch/to_branch are provided.
# @POST Returns specific branch-missing message when detected.
# @RETURN Optional[str]
# [DEF:_build_gitea_pr_404_detail:Function]
# @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
# @PRE: owner/repo/from_branch/to_branch are provided.
# @POST: Returns specific branch-missing message when detected.
# @RETURN: Optional[str]
async def _build_gitea_pr_404_detail(
self, server_url: str, pat: str, owner: str, repo: str, from_branch: str, to_branch: str,
) -> Optional[str]:
@@ -199,13 +196,13 @@ class GitServiceGiteaMixin:
if not target_exists:
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
return None
# #endregion _build_gitea_pr_404_detail
# [/DEF:_build_gitea_pr_404_detail:Function]
# #region create_gitea_pull_request [TYPE Function]
# @BRIEF Create pull request in Gitea.
# @PRE Config and remote URL are valid.
# @POST Returns normalized PR metadata.
# @RETURN Dict[str, Any]
# [DEF:create_gitea_pull_request:Function]
# @PURPOSE: Create pull request in Gitea.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any]
async def create_gitea_pull_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None,
@@ -223,7 +220,10 @@ class GitServiceGiteaMixin:
exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
)
if should_retry_with_fallback:
log.explore(f"Primary Gitea URL not found, retrying with remote host: {fallback_url}", error=fallback_url)
logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url,
)
active_server_url = fallback_url
try:
data = await self._gitea_request("POST", active_server_url, pat, endpoint, payload=req_payload)
@@ -254,6 +254,6 @@ class GitServiceGiteaMixin:
"url": data.get("html_url") or data.get("url"),
"status": data.get("state") or "open",
}
# #endregion create_gitea_pull_request
# [/DEF:create_gitea_pull_request:Function]
# #endregion GitServiceGiteaMixin
# #endregion GitServiceGiteaMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceMergeMixin [C:3] [TYPE Module] [SEMANTICS git, merge, conflicts, resolution, promote]
# @LAYER: Infra
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import os
@@ -16,8 +16,8 @@ from src.core.logger import logger, belief_scope
# #region GitServiceMergeMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing merge operations for GitService.
class GitServiceMergeMixin:
# #region _read_blob_text [TYPE Function]
# @BRIEF Read text from a Git blob.
# [DEF:_read_blob_text:Function]
# @PURPOSE: Read text from a Git blob.
def _read_blob_text(self, blob: Blob) -> str:
with belief_scope("GitService._read_blob_text"):
if blob is None:
@@ -26,20 +26,20 @@ class GitServiceMergeMixin:
return blob.data_stream.read().decode("utf-8", errors="replace")
except Exception:
return ""
# #endregion _read_blob_text
# [/DEF:_read_blob_text:Function]
# #region _get_unmerged_file_paths [TYPE Function]
# @BRIEF List files with merge conflicts.
# [DEF:_get_unmerged_file_paths:Function]
# @PURPOSE: List files with merge conflicts.
def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
with belief_scope("GitService._get_unmerged_file_paths"):
try:
return sorted(list(repo.index.unmerged_blobs().keys()))
except Exception:
return []
# #endregion _get_unmerged_file_paths
# [/DEF:_get_unmerged_file_paths:Function]
# #region _build_unfinished_merge_payload [TYPE Function]
# @BRIEF Build payload for unfinished merge state.
# [DEF:_build_unfinished_merge_payload:Function]
# @PURPOSE: Build payload for unfinished merge state.
def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
with belief_scope("GitService._build_unfinished_merge_payload"):
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
@@ -83,10 +83,10 @@ class GitServiceMergeMixin:
],
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
}
# #endregion _build_unfinished_merge_payload
# [/DEF:_build_unfinished_merge_payload:Function]
# #region get_merge_status [TYPE Function]
# @BRIEF Get current merge status for a dashboard repository.
# [DEF:get_merge_status:Function]
# @PURPOSE: Get current merge status for a dashboard repository.
def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.get_merge_status"):
repo = self.get_repo(dashboard_id)
@@ -116,10 +116,10 @@ class GitServiceMergeMixin:
"merge_message_preview": payload["merge_message_preview"],
"conflicts_count": int(payload.get("conflicts_count") or 0),
}
# #endregion get_merge_status
# [/DEF:get_merge_status:Function]
# #region get_merge_conflicts [TYPE Function]
# @BRIEF List all files with conflicts and their contents.
# [DEF:get_merge_conflicts:Function]
# @PURPOSE: List all files with conflicts and their contents.
def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
with belief_scope("GitService.get_merge_conflicts"):
repo = self.get_repo(dashboard_id)
@@ -139,10 +139,10 @@ class GitServiceMergeMixin:
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
})
return sorted(conflicts, key=lambda item: item["file_path"])
# #endregion get_merge_conflicts
# [/DEF:get_merge_conflicts:Function]
# #region resolve_merge_conflicts [TYPE Function]
# @BRIEF Resolve conflicts using specified strategy.
# [DEF:resolve_merge_conflicts:Function]
# @PURPOSE: Resolve conflicts using specified strategy.
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
with belief_scope("GitService.resolve_merge_conflicts"):
repo = self.get_repo(dashboard_id)
@@ -172,10 +172,10 @@ class GitServiceMergeMixin:
repo.git.add(file_path)
resolved_files.append(file_path)
return resolved_files
# #endregion resolve_merge_conflicts
# [/DEF:resolve_merge_conflicts:Function]
# #region abort_merge [TYPE Function]
# @BRIEF Abort ongoing merge.
# [DEF:abort_merge:Function]
# @PURPOSE: Abort ongoing merge.
def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.abort_merge"):
repo = self.get_repo(dashboard_id)
@@ -188,10 +188,10 @@ class GitServiceMergeMixin:
return {"status": "no_merge_in_progress"}
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
return {"status": "aborted"}
# #endregion abort_merge
# [/DEF:abort_merge:Function]
# #region continue_merge [TYPE Function]
# @BRIEF Finalize merge after conflict resolution.
# [DEF:continue_merge:Function]
# @PURPOSE: Finalize merge after conflict resolution.
def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
with belief_scope("GitService.continue_merge"):
repo = self.get_repo(dashboard_id)
@@ -223,13 +223,13 @@ class GitServiceMergeMixin:
except Exception:
commit_hash = ""
return {"status": "committed", "commit_hash": commit_hash}
# #endregion continue_merge
# [/DEF:continue_merge:Function]
# #region promote_direct_merge [TYPE Function]
# @BRIEF Perform direct merge between branches in local repo and push target branch.
# @PRE Repository exists and both branches are valid.
# @POST Target branch contains merged changes from source branch.
# @RETURN Dict[str, Any]
# [DEF:promote_direct_merge:Function]
# @PURPOSE: Perform direct merge between branches in local repo and push target branch.
# @PRE: Repository exists and both branches are valid.
# @POST: Target branch contains merged changes from source branch.
# @RETURN: Dict[str, Any]
def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> Dict[str, Any]:
with belief_scope("GitService.promote_direct_merge"):
if not from_branch or not to_branch:
@@ -270,6 +270,6 @@ class GitServiceMergeMixin:
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
# #endregion promote_direct_merge
# [/DEF:promote_direct_merge:Function]
# #endregion GitServiceMergeMixin
# #endregion GitServiceMergeMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceRemoteMixin [C:3] [TYPE Module] [SEMANTICS git, github, gitlab, api, provider, repository, pull_request, merge_request]
# @LAYER: Infra
# @BRIEF GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import httpx
@@ -10,15 +10,14 @@ from fastapi import HTTPException
from src.core.logger import logger, belief_scope
# [DEF:GitServiceGithubMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing GitHub API operations for GitService.
# #region GitServiceGithubMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing GitHub API operations for GitService.
class GitServiceGithubMixin:
# #region create_github_repository [TYPE Function]
# @BRIEF Create repository in GitHub or GitHub Enterprise.
# @PRE PAT has repository create permission.
# @POST Returns created repository payload.
# @RETURN dict
# [DEF:create_github_repository:Function]
# @PURPOSE: Create repository in GitHub or GitHub Enterprise.
# @PRE: PAT has repository create permission.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_github_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
@@ -52,13 +51,13 @@ class GitServiceGithubMixin:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
return response.json()
# #endregion create_github_repository
# [/DEF:create_github_repository:Function]
# #region create_github_pull_request [TYPE Function]
# @BRIEF Create pull request in GitHub or GitHub Enterprise.
# @PRE Config and remote URL are valid.
# @POST Returns normalized PR metadata.
# @RETURN Dict[str, Any]
# [DEF:create_github_pull_request:Function]
# @PURPOSE: Create pull request in GitHub or GitHub Enterprise.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any]
async def create_github_pull_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None, draft: bool = False,
@@ -92,17 +91,17 @@ class GitServiceGithubMixin:
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
data = response.json()
return {"id": data.get("number") or data.get("id"), "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open"}
# #endregion create_github_pull_request
# [/DEF:create_github_pull_request:Function]
# #region GitServiceGitlabMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing GitLab API operations for GitService.
class GitServiceGitlabMixin:
# #region create_gitlab_repository [TYPE Function]
# @BRIEF Create repository(project) in GitLab.
# @PRE PAT has api scope.
# @POST Returns created repository payload.
# @RETURN dict
# [DEF:create_gitlab_repository:Function]
# @PURPOSE: Create repository(project) in GitLab.
# @PRE: PAT has api scope.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_gitlab_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
@@ -142,13 +141,13 @@ class GitServiceGitlabMixin:
if "full_name" not in data:
data["full_name"] = data.get("path_with_namespace") or data.get("name")
return data
# #endregion create_gitlab_repository
# [/DEF:create_gitlab_repository:Function]
# #region create_gitlab_merge_request [TYPE Function]
# @BRIEF Create merge request in GitLab.
# @PRE Config and remote URL are valid.
# @POST Returns normalized MR metadata.
# @RETURN Dict[str, Any]
# [DEF:create_gitlab_merge_request:Function]
# @PURPOSE: Create merge request in GitLab.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized MR metadata.
# @RETURN: Dict[str, Any]
async def create_gitlab_merge_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None, remove_source_branch: bool = False,
@@ -178,6 +177,7 @@ class GitServiceGitlabMixin:
raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")
data = response.json()
return {"id": data.get("iid") or data.get("id"), "url": data.get("web_url") or data.get("url"), "status": data.get("state") or "opened"}
# #endregion create_gitlab_merge_request
# [/DEF:create_gitlab_merge_request:Function]
# #endregion GitServiceGitlabMixin
# #endregion GitServiceRemoteMixin
# #endregion GitServiceRemoteMixin

View File

@@ -1,25 +1,23 @@
# #region GitServiceStatusMixin [C:3] [TYPE Module] [SEMANTICS git, status, diff, history, porcelain]
# @LAYER: Infra
# @BRIEF Status, diff, and commit history operations for GitService using git status --porcelain to avoid --cached flag issues.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
from typing import Any, Dict, List
from datetime import datetime
from git import Repo
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitStatus")
from src.core.logger import logger, belief_scope
# #region GitServiceStatusMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing repository status, diff, and commit history for GitService.
class GitServiceStatusMixin:
# #region _parse_status_porcelain [C:2] [TYPE Function]
# @BRIEF Parse git status --porcelain output into staged, modified, and untracked file lists.
# @PRE `repo` is an open GitPython Repo instance.
# @POST Returns (staged, modified, untracked) tuple of file path lists.
# @RATIONALE Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
# [DEF:_parse_status_porcelain:Function]
# @COMPLEXITY: 2
# @PURPOSE: Parse git status --porcelain output into staged, modified, and untracked file lists.
# @PRE: `repo` is an open GitPython Repo instance.
# @POST: Returns (staged, modified, untracked) tuple of file path lists.
# @RATIONALE: Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
# call git diff --cached, a flag unsupported in some Git environments (exit 129).
# Using git status --porcelain is self-contained and avoids the --cached flag entirely.
def _parse_status_porcelain(self, repo) -> tuple[list[str], list[str], list[str]]:
@@ -30,7 +28,7 @@ class GitServiceStatusMixin:
try:
output = repo.git.status("--porcelain")
except Exception:
log.explore("git status --porcelain failed", error="git status --porcelain command failed")
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
return staged, modified, untracked
for line in output.split("\n"):
if not line:
@@ -52,13 +50,13 @@ class GitServiceStatusMixin:
if path not in modified:
modified.append(path)
return staged, modified, untracked
# #endregion _parse_status_porcelain
# [/DEF:_parse_status_porcelain:Function]
# #region get_status [TYPE Function]
# @BRIEF Get current repository status (dirty files, untracked, etc.)
# @PRE Repository for dashboard_id exists.
# @POST Returns a dictionary representing the Git status.
# @RETURN dict
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a dictionary representing the Git status.
# @RETURN: dict
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
@@ -112,10 +110,10 @@ class GitServiceStatusMixin:
"is_diverged": is_diverged,
"sync_state": sync_state,
}
# #endregion get_status
# [/DEF:get_status:Function]
# #region get_diff [TYPE Function]
# @BRIEF Generate diff for a file or the whole repository.
# [DEF:get_diff:Function]
# @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes.
# @PRE: Repository for dashboard_id exists.
@@ -130,10 +128,10 @@ class GitServiceStatusMixin:
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# #endregion get_diff
# [/DEF:get_diff:Function]
# #region get_commit_history [TYPE Function]
# @BRIEF Retrieve commit history for a repository.
# [DEF:get_commit_history:Function]
# @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of dictionaries for each commit in history.
@@ -155,8 +153,9 @@ class GitServiceStatusMixin:
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
log.explore(f"Could not retrieve commit history for dashboard {dashboard_id}: {e}", error=str(e))
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# #endregion get_commit_history
# [/DEF:get_commit_history:Function]
# #endregion GitServiceStatusMixin
# #endregion GitServiceStatusMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceSyncMixin [C:3] [TYPE Module] [SEMANTICS git, push, pull, sync, remote]
# @LAYER: Infra
# @BRIEF Push and pull operations for GitService with origin host auto-alignment.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
@@ -10,30 +10,27 @@ from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitSync")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# #region GitServiceSyncMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing push and pull operations with origin host alignment.
class GitServiceSyncMixin:
# #region push_changes [TYPE Function]
# @BRIEF Push local commits to remote.
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Local branch commits are pushed to origin.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
if not repo.heads:
log.explore("No local branches to push", payload={"dashboard_id": dashboard_id}, error="Repository has no local branch heads to push")
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
try:
origin = repo.remote(name='origin')
except ValueError:
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
try:
origin_urls = list(origin.urls)
@@ -63,7 +60,10 @@ class GitServiceSyncMixin:
finally:
session.close()
except Exception as diag_error:
log.explore("Failed to load repository binding diagnostics", payload={"dashboard_id": dashboard_id}, error=str(diag_error))
logger.warning(
"[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
dashboard_id, diag_error,
)
realigned_origin_url = self._align_origin_host_with_config(
dashboard_id=dashboard_id,
origin=origin,
@@ -75,17 +75,13 @@ class GitServiceSyncMixin:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
log.reason(
"Push diagnostics",
payload={
"dashboard_id": dashboard_id, "config_id": binding_config_id,
"config_url": binding_config_url, "binding_remote_url": binding_remote_url,
"origin_urls": origin_urls, "origin_realigned": bool(realigned_origin_url),
},
logger.info(
"[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
dashboard_id, binding_config_id, binding_config_url, binding_remote_url, origin_urls, bool(realigned_origin_url),
)
try:
current_branch = repo.active_branch
log.reason("Pushing branch", payload={"branch": current_branch.name, "origin": True})
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
tracking_branch = None
try:
tracking_branch = current_branch.tracking_branch()
@@ -97,7 +93,7 @@ class GitServiceSyncMixin:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
log.explore("Error pushing ref", payload={"ref": info.remote_ref_string, "summary": info.summary}, error=f"Git push error: {info.summary}")
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
except GitCommandError as e:
details = str(e)
@@ -107,31 +103,28 @@ class GitServiceSyncMixin:
status_code=409,
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
)
log.explore("Failed to push changes", error=str(e))
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
except Exception as e:
log.explore("Failed to push changes", error=str(e))
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
# #endregion push_changes
# [/DEF:push_changes:Function]
# #region pull_changes [TYPE Function]
# @BRIEF Pull changes from remote.
# @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch.
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Changes from origin are pulled and merged into the active branch.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if os.path.exists(merge_head_path):
payload = self._build_unfinished_merge_payload(repo)
log.explore("Unfinished merge detected", error="Unfinished merge state found",
payload={
"dashboard_id": dashboard_id,
"repo_path": payload["repository_path"],
"git_dir": payload["git_dir"],
"branch": payload["current_branch"],
"merge_head": payload["merge_head"],
})
logger.warning(
"[pull_changes][Action] Unfinished merge detected for dashboard %s (repo_path=%s git_dir=%s branch=%s merge_head=%s merge_msg=%s)",
dashboard_id, payload["repository_path"], payload["git_dir"],
payload["current_branch"], payload["merge_head"], payload["merge_message_preview"],
)
raise HTTPException(status_code=409, detail=payload)
try:
origin = repo.remote(name='origin')
@@ -140,36 +133,26 @@ class GitServiceSyncMixin:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
log.reason(
"Pull diagnostics",
payload={
"dashboard_id": dashboard_id,
"repo_path": repo.working_tree_dir,
"branch": current_branch,
"origin_urls": origin_urls,
},
logger.info(
"[pull_changes][Action] Pull diagnostics dashboard=%s repo_path=%s branch=%s origin_urls=%s",
dashboard_id, repo.working_tree_dir, current_branch, origin_urls,
)
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
log.reason(
"Pull remote branch check",
payload={
"dashboard_id": dashboard_id,
"branch": current_branch,
"remote_ref": remote_ref,
"exists": has_remote_branch,
},
logger.info(
"[pull_changes][Action] Pull remote branch check dashboard=%s branch=%s remote_ref=%s exists=%s",
dashboard_id, current_branch, remote_ref, has_remote_branch,
)
if not has_remote_branch:
raise HTTPException(
status_code=409,
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
)
log.reason("Pulling changes", payload={"branch": current_branch})
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
repo.git.pull("--no-rebase", "origin", current_branch)
except ValueError:
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
except GitCommandError as e:
details = str(e)
@@ -179,13 +162,13 @@ class GitServiceSyncMixin:
status_code=409,
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
)
log.explore("Failed to pull changes", error=str(e))
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
except HTTPException:
raise
except Exception as e:
log.explore("Failed to pull changes", error=str(e))
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# #endregion pull_changes
# [/DEF:pull_changes:Function]
# #endregion GitServiceSyncMixin
# #endregion GitServiceSyncMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceUrlMixin [C:3] [TYPE Module] [SEMANTICS git, url, parsing, normalization, host_alignment]
# @LAYER: Infra
# @BRIEF URL helper mixin for GitService — parse, normalize, align, and strip credentials from Git URLs.
# @LAYER Infra
# @RELATION USED_BY -> [GitServiceSyncMixin]
# @RELATION USED_BY -> [GitServiceGiteaMixin]
# @RELATION USED_BY -> [GitServiceRemoteMixin]
@@ -10,20 +10,17 @@ from typing import Any, Dict, List, Optional
from urllib.parse import quote, urlparse
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitUrl")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# #region GitServiceUrlMixin [C:3] [TYPE Class]
# @BRIEF URL helper methods for GitService — extract host, strip credentials, replace host, align origin, parse remote identity, derive server URL, normalize server URL.
class GitServiceUrlMixin:
# #region _extract_http_host [TYPE Function]
# @BRIEF Extract normalized host[:port] from HTTP(S) URL.
# @PRE url_value may be empty.
# @POST Returns lowercase host token or None.
# @RETURN Optional[str]
# [DEF:_extract_http_host:Function]
# @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
# @PRE: url_value may be empty.
# @POST: Returns lowercase host token or None.
# @RETURN: Optional[str]
def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
normalized = str(url_value or "").strip()
if not normalized:
@@ -40,13 +37,13 @@ class GitServiceUrlMixin:
if parsed.port:
return f"{host.lower()}:{parsed.port}"
return host.lower()
# #endregion _extract_http_host
# [/DEF:_extract_http_host:Function]
# #region _strip_url_credentials [TYPE Function]
# @BRIEF Remove credentials from URL while preserving scheme/host/path.
# @PRE url_value may contain credentials.
# @POST Returns URL without username/password.
# @RETURN str
# [DEF:_strip_url_credentials:Function]
# @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
# @PRE: url_value may contain credentials.
# @POST: Returns URL without username/password.
# @RETURN: str
def _strip_url_credentials(self, url_value: str) -> str:
normalized = str(url_value or "").strip()
if not normalized:
@@ -61,13 +58,13 @@ class GitServiceUrlMixin:
if parsed.port:
host = f"{host}:{parsed.port}"
return parsed._replace(netloc=host).geturl()
# #endregion _strip_url_credentials
# [/DEF:_strip_url_credentials:Function]
# #region _replace_host_in_url [TYPE Function]
# @BRIEF Replace source URL host with host from configured server URL.
# @PRE source_url and config_url are HTTP(S) URLs.
# @POST Returns source URL with updated host (credentials preserved) or None.
# @RETURN Optional[str]
# [DEF:_replace_host_in_url:Function]
# @PURPOSE: Replace source URL host with host from configured server URL.
# @PRE: source_url and config_url are HTTP(S) URLs.
# @POST: Returns source URL with updated host (credentials preserved) or None.
# @RETURN: Optional[str]
def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
source = str(source_url or "").strip()
config = str(config_url or "").strip()
@@ -93,13 +90,13 @@ class GitServiceUrlMixin:
auth_part = f"{auth_part}@"
new_netloc = f"{auth_part}{target_host}"
return source_parsed._replace(netloc=new_netloc).geturl()
# #endregion _replace_host_in_url
# [/DEF:_replace_host_in_url:Function]
# #region _align_origin_host_with_config [TYPE Function]
# @BRIEF Auto-align local origin host to configured Git server host when they drift.
# @PRE origin remote exists.
# @POST origin URL host updated and DB binding normalized when mismatch detected.
# @RETURN Optional[str]
# [DEF:_align_origin_host_with_config:Function]
# @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
# @PRE: origin remote exists.
# @POST: origin URL host updated and DB binding normalized when mismatch detected.
# @RETURN: Optional[str]
def _align_origin_host_with_config(
self,
dashboard_id: int,
@@ -118,11 +115,17 @@ class GitServiceUrlMixin:
aligned_url = self._replace_host_in_url(source_origin_url, config_url)
if not aligned_url:
return None
log.explore(f"Host mismatch for dashboard {dashboard_id}: config_host={config_host} origin_host={origin_host}, applying origin.set_url", error=f"Host mismatch for dashboard {dashboard_id}")
logger.warning(
"[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url",
dashboard_id, config_host, origin_host,
)
try:
origin.set_url(aligned_url)
except Exception as e:
log.explore(f"Failed to set origin URL for dashboard {dashboard_id}: {e}", error=str(e))
logger.warning(
"[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
dashboard_id, e,
)
return None
try:
session = SessionLocal()
@@ -138,15 +141,18 @@ class GitServiceUrlMixin:
finally:
session.close()
except Exception as e:
log.explore(f"Failed to persist aligned remote_url for dashboard {dashboard_id}: {e}", error=str(e))
logger.warning(
"[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s",
dashboard_id, e,
)
return aligned_url
# #endregion _align_origin_host_with_config
# [/DEF:_align_origin_host_with_config:Function]
# #region _parse_remote_repo_identity [TYPE Function]
# @BRIEF Parse owner/repo from remote URL for Git server API operations.
# @PRE remote_url is a valid git URL.
# @POST Returns owner/repo tokens.
# @RETURN Dict[str, str]
# [DEF:_parse_remote_repo_identity:Function]
# @PURPOSE: Parse owner/repo from remote URL for Git server API operations.
# @PRE: remote_url is a valid git URL.
# @POST: Returns owner/repo tokens.
# @RETURN: Dict[str, str]
def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]:
normalized = str(remote_url or "").strip()
if not normalized:
@@ -166,13 +172,13 @@ class GitServiceUrlMixin:
repo = parts[-1]
namespace = "/".join(parts[:-1])
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
# #endregion _parse_remote_repo_identity
# [/DEF:_parse_remote_repo_identity:Function]
# #region _derive_server_url_from_remote [TYPE Function]
# @BRIEF Build API base URL from remote repository URL without credentials.
# @PRE remote_url may be any git URL.
# @POST Returns normalized http(s) base URL or None when derivation is impossible.
# @RETURN Optional[str]
# [DEF:_derive_server_url_from_remote:Function]
# @PURPOSE: Build API base URL from remote repository URL without credentials.
# @PRE: remote_url may be any git URL.
# @POST: Returns normalized http(s) base URL or None when derivation is impossible.
# @RETURN: Optional[str]
def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]:
normalized = str(remote_url or "").strip()
if not normalized or normalized.startswith("git@"):
@@ -186,18 +192,18 @@ class GitServiceUrlMixin:
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return f"{parsed.scheme}://{netloc}".rstrip("/")
# #endregion _derive_server_url_from_remote
# [/DEF:_derive_server_url_from_remote:Function]
# #region _normalize_git_server_url [TYPE Function]
# @BRIEF Normalize Git server URL for provider API calls.
# @PRE raw_url is non-empty.
# @POST Returns URL without trailing slash.
# @RETURN str
# [DEF:_normalize_git_server_url:Function]
# @PURPOSE: Normalize Git server URL for provider API calls.
# @PRE: raw_url is non-empty.
# @POST: Returns URL without trailing slash.
# @RETURN: str
def _normalize_git_server_url(self, raw_url: str) -> str:
normalized = (raw_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="Git server URL is required")
return normalized.rstrip("/")
# #endregion _normalize_git_server_url
# [/DEF:_normalize_git_server_url:Function]
# #endregion GitServiceUrlMixin
# #endregion GitServiceUrlMixin

View File

@@ -1,10 +1,11 @@
# #region git_service [C:1] [TYPE Module:Tombstone]
# [DEF:git_service:Module:Tombstone]
# @COMPLEXITY: 1
# @BRIEF Re-export shim — GitService has been decomposed into services/git/ package.
# All consumers continue to import from this same path without changes.
# @RELATION: REDIRECTS_TO -> [GitServiceModule]
# @RELATION REDIRECTS_TO -> [GitServiceModule]
# @RATIONALE: Monolithic GitService (2101 lines) was decomposed into 8 domain-specific mixins
# under services/git/ to satisfy INV_7 (< 400 lines per module). This shim preserves
# the original import path for all 5 consumers.
# @REJECTED: Breaking 5 consumer imports to remove this shim — unacceptable migration cost.
from src.services.git import GitService # noqa: F401
# #endregion git_service
# [/DEF:git_service:Module:Tombstone]

View File

@@ -1,6 +1,6 @@
# #region health_service [C:3] [TYPE Module] [SEMANTICS health, aggregation, dashboards]
# @BRIEF Business logic for aggregating dashboard health status from validation records.
# @LAYER Domain/Service
# @LAYER: Domain/Service
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [TaskCleanupService]
@@ -25,10 +25,10 @@ def _empty_dashboard_meta() -> Dict[str, Optional[str]]:
# #region HealthService [C:4] [TYPE Class]
# @BRIEF Aggregate latest dashboard validation state and manage persisted health report lifecycle.
# @PRE Service is constructed with a live SQLAlchemy session and optional config manager.
# @POST Exposes health summary aggregation and validation report deletion operations.
# @SIDE_EFFECT Maintains in-memory dashboard metadata caches and may coordinate cleanup through collaborators.
# @DATA_CONTRACT Input[Session, Optional[Any]] -> Output[HealthSummaryResponse|bool]
# @PRE: Service is constructed with a live SQLAlchemy session and optional config manager.
# @POST: Exposes health summary aggregation and validation report deletion operations.
# @SIDE_EFFECT: Maintains in-memory dashboard metadata caches and may coordinate cleanup through collaborators.
# @DATA_CONTRACT: Input[Session, Optional[Any]] -> Output[HealthSummaryResponse|bool]
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [DashboardHealthItem]
# @RELATION DEPENDS_ON -> [HealthSummaryResponse]
@@ -45,29 +45,31 @@ class HealthService:
@PURPOSE: Service for managing and querying dashboard health data.
"""
# #region HealthService_init [C:3] [TYPE Function]
# @BRIEF Initialize health service with DB session and optional config access for dashboard metadata resolution.
# @PRE db is a valid SQLAlchemy session.
# @POST Service is ready to aggregate summaries and delete health reports.
# @SIDE_EFFECT Initializes per-instance dashboard metadata cache.
# @DATA_CONTRACT Input[db: Session, config_manager: Optional[Any]] -> Output[HealthService]
# @RELATION BINDS_TO -> [HealthService]
# [DEF:HealthService_init:Function]
# @COMPLEXITY: 3
# @PURPOSE: Initialize health service with DB session and optional config access for dashboard metadata resolution.
# @PRE: db is a valid SQLAlchemy session.
# @POST: Service is ready to aggregate summaries and delete health reports.
# @SIDE_EFFECT: Initializes per-instance dashboard metadata cache.
# @DATA_CONTRACT: Input[db: Session, config_manager: Optional[Any]] -> Output[HealthService]
# @RELATION: [BINDS_TO] ->[HealthService]
def __init__(self, db: Session, config_manager=None):
self.db = db
self.config_manager = config_manager
self._dashboard_meta_cache: Dict[Tuple[str, str], Dict[str, Optional[str]]] = {}
# #endregion HealthService_init
# [/DEF:HealthService_init:Function]
# #region _prime_dashboard_meta_cache [C:3] [TYPE Function]
# @BRIEF Warm dashboard slug/title cache with one Superset list fetch per environment.
# @PRE records may contain mixed numeric and slug dashboard identifiers.
# @POST Numeric dashboard ids for known environments are cached when discoverable.
# @SIDE_EFFECT May call Superset dashboard list API once per referenced environment.
# @DATA_CONTRACT Input[records: List[ValidationRecord]] -> Output[None]
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# [DEF:_prime_dashboard_meta_cache:Function]
# @COMPLEXITY: 3
# @PURPOSE: Warm dashboard slug/title cache with one Superset list fetch per environment.
# @PRE: records may contain mixed numeric and slug dashboard identifiers.
# @POST: Numeric dashboard ids for known environments are cached when discoverable.
# @SIDE_EFFECT: May call Superset dashboard list API once per referenced environment.
# @DATA_CONTRACT: Input[records: List[ValidationRecord]] -> Output[None]
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
# @RELATION: [DEPENDS_ON] ->[SupersetClient]
def _prime_dashboard_meta_cache(self, records: List[ValidationRecord]) -> None:
if not self.config_manager or not records:
return
@@ -148,13 +150,14 @@ class HealthService:
_empty_dashboard_meta()
)
# #endregion _prime_dashboard_meta_cache
# [/DEF:_prime_dashboard_meta_cache:Function]
# #region _resolve_dashboard_meta [C:1] [TYPE Function]
# @BRIEF Resolve slug/title for a dashboard referenced by persisted validation record.
# @PRE dashboard_id may be numeric or slug-like; environment_id may be empty.
# @POST Returns dict with `slug` and `title` keys, using cache when possible.
# @SIDE_EFFECT Writes default cache entries for unresolved numeric dashboard ids.
# [DEF:_resolve_dashboard_meta:Function]
# @COMPLEXITY: 1
# @PURPOSE: Resolve slug/title for a dashboard referenced by persisted validation record.
# @PRE: dashboard_id may be numeric or slug-like; environment_id may be empty.
# @POST: Returns dict with `slug` and `title` keys, using cache when possible.
# @SIDE_EFFECT: Writes default cache entries for unresolved numeric dashboard ids.
def _resolve_dashboard_meta(
self, dashboard_id: str, environment_id: Optional[str]
) -> Dict[str, Optional[str]]:
@@ -178,16 +181,17 @@ class HealthService:
self._dashboard_meta_cache[cache_key] = meta
return meta
# #endregion _resolve_dashboard_meta
# [/DEF:_resolve_dashboard_meta:Function]
# #region get_health_summary [C:3] [TYPE Function]
# @BRIEF Aggregate latest validation status per dashboard and enrich rows with dashboard slug/title.
# @PRE environment_id may be omitted to aggregate across all environments.
# @POST Returns HealthSummaryResponse with counts and latest record row per dashboard.
# @SIDE_EFFECT May call Superset API to resolve dashboard metadata.
# @DATA_CONTRACT Input[environment_id: Optional[str]] -> Output[HealthSummaryResponse]
# @RELATION CALLS -> [_prime_dashboard_meta_cache]
# @RELATION CALLS -> [_resolve_dashboard_meta]
# [DEF:get_health_summary:Function]
# @COMPLEXITY: 3
# @PURPOSE: Aggregate latest validation status per dashboard and enrich rows with dashboard slug/title.
# @PRE: environment_id may be omitted to aggregate across all environments.
# @POST: Returns HealthSummaryResponse with counts and latest record row per dashboard.
# @SIDE_EFFECT: May call Superset API to resolve dashboard metadata.
# @DATA_CONTRACT: Input[environment_id: Optional[str]] -> Output[HealthSummaryResponse]
# @RELATION: [CALLS] ->[_prime_dashboard_meta_cache]
# @RELATION: [CALLS] ->[_resolve_dashboard_meta]
async def get_health_summary(
self, environment_id: str = ""
) -> HealthSummaryResponse:
@@ -281,17 +285,18 @@ class HealthService:
unknown_count=unknown_count,
)
# #endregion get_health_summary
# [/DEF:get_health_summary:Function]
# #region delete_validation_report [C:3] [TYPE Function]
# @BRIEF Delete one persisted health report and optionally clean linked task/log artifacts.
# @PRE record_id is a validation record identifier.
# @POST Returns True only when a matching record was deleted.
# @SIDE_EFFECT Deletes DB rows, optional screenshot file, and optional task/log persistence.
# @DATA_CONTRACT Input[record_id: str, task_manager: Optional[TaskManager]] -> Output[bool]
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [TaskCleanupService]
# [DEF:delete_validation_report:Function]
# @COMPLEXITY: 3
# @PURPOSE: Delete one persisted health report and optionally clean linked task/log artifacts.
# @PRE: record_id is a validation record identifier.
# @POST: Returns True only when a matching record was deleted.
# @SIDE_EFFECT: Deletes DB rows, optional screenshot file, and optional task/log persistence.
# @DATA_CONTRACT: Input[record_id: str, task_manager: Optional[TaskManager]] -> Output[bool]
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
# @RELATION: [DEPENDS_ON] ->[TaskManager]
# @RELATION: [DEPENDS_ON] ->[TaskCleanupService]
def delete_validation_report(
self, record_id: str, task_manager: Optional[TaskManager] = None
) -> bool:
@@ -368,7 +373,9 @@ class HealthService:
return True
# #endregion delete_validation_report
# [/DEF:delete_validation_report:Function]
# #endregion HealthService
# #endregion health_service

View File

@@ -1,8 +1,8 @@
# #region llm_prompt_templates [C:2] [TYPE Module] [SEMANTICS llm, prompts, templates, settings]
# @BRIEF Provide default LLM prompt templates and normalization helpers for runtime usage.
# @LAYER Domain
# @INVARIANT All required prompt template keys are always present after normalization.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [backend.src.core.config_manager:Function]
# @INVARIANT: All required prompt template keys are always present after normalization.
from __future__ import annotations
@@ -79,9 +79,9 @@ DEFAULT_LLM_ASSISTANT_SETTINGS: Dict[str, str] = {
# #region normalize_llm_settings [C:3] [TYPE Function]
# @BRIEF Ensure llm settings contain stable schema with prompts section and default templates.
# @PRE llm_settings is dictionary-like value or None.
# @POST Returned dict contains prompts with all required template keys.
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @PRE: llm_settings is dictionary-like value or None.
# @POST: Returned dict contains prompts with all required template keys.
# @RELATION DEPENDS_ON -> LLMProviderService
def normalize_llm_settings(llm_settings: Any) -> Dict[str, Any]:
normalized: Dict[str, Any] = {
"providers": [],
@@ -123,9 +123,9 @@ def normalize_llm_settings(llm_settings: Any) -> Dict[str, Any]:
# #region is_multimodal_model [C:3] [TYPE Function]
# @BRIEF Heuristically determine whether model supports image input required for dashboard validation.
# @PRE model_name may be empty or mixed-case.
# @POST Returns True when model likely supports multimodal input.
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @PRE: model_name may be empty or mixed-case.
# @POST: Returns True when model likely supports multimodal input.
# @RELATION DEPENDS_ON -> LLMProviderService
def is_multimodal_model(model_name: str, provider_type: Optional[str] = None) -> bool:
token = (model_name or "").strip().lower()
if not token:
@@ -165,9 +165,9 @@ def is_multimodal_model(model_name: str, provider_type: Optional[str] = None) ->
# #region resolve_bound_provider_id [C:3] [TYPE Function]
# @BRIEF Resolve provider id configured for a task binding with fallback to default provider.
# @PRE llm_settings is normalized or raw dict from config.
# @POST Returns configured provider id or fallback id/empty string when not defined.
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @PRE: llm_settings is normalized or raw dict from config.
# @POST: Returns configured provider id or fallback id/empty string when not defined.
# @RELATION DEPENDS_ON -> LLMProviderService
def resolve_bound_provider_id(llm_settings: Any, task_key: str) -> str:
normalized = normalize_llm_settings(llm_settings)
bindings = normalized.get("provider_bindings", {})
@@ -181,9 +181,9 @@ def resolve_bound_provider_id(llm_settings: Any, task_key: str) -> str:
# #region render_prompt [C:3] [TYPE Function]
# @BRIEF Render prompt template using deterministic placeholder replacement with graceful fallback.
# @PRE template is a string and variables values are already stringifiable.
# @POST Returns rendered prompt text with known placeholders substituted.
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @PRE: template is a string and variables values are already stringifiable.
# @POST: Returns rendered prompt text with known placeholders substituted.
# @RELATION DEPENDS_ON -> LLMProviderService
def render_prompt(template: str, variables: Dict[str, Any]) -> str:
rendered = template
for key, value in variables.items():

View File

@@ -1,6 +1,6 @@
# #region llm_provider [C:3] [TYPE Module] [SEMANTICS service, llm, provider, encryption]
# @BRIEF Service for managing LLM provider configurations with encrypted API keys.
# @LAYER Domain
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [LLMProvider]
# @RELATION DEPENDS_ON -> [EncryptionManager]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
@@ -19,12 +19,12 @@ MASKED_API_KEY_PLACEHOLDER = "********"
# #region _require_fernet_key [C:5] [TYPE Function]
# @BRIEF Load and validate the Fernet key used for secret encryption.
# @PRE ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
# @POST Returns validated key bytes ready for Fernet initialization.
# @SIDE_EFFECT Emits belief-state logs for missing or invalid encryption configuration.
# @DATA_CONTRACT Input[ENCRYPTION_KEY:str] -> Output[bytes]
# @INVARIANT Encryption initialization never falls back to a hardcoded secret.
# @PRE: ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
# @POST: Returns validated key bytes ready for Fernet initialization.
# @RELATION DEPENDS_ON -> [backend.src.core.logger:Function]
# @SIDE_EFFECT: Emits belief-state logs for missing or invalid encryption configuration.
# @DATA_CONTRACT: Input[ENCRYPTION_KEY:str] -> Output[bytes]
# @INVARIANT: Encryption initialization never falls back to a hardcoded secret.
def _require_fernet_key() -> bytes:
with belief_scope("_require_fernet_key"):
raw_key = os.getenv("ENCRYPTION_KEY", "").strip()
@@ -52,12 +52,12 @@ def _require_fernet_key() -> bytes:
# #region EncryptionManager [C:5] [TYPE Class]
# @BRIEF Handles encryption and decryption of sensitive data like API keys.
# @PRE ENCRYPTION_KEY is configured with a valid Fernet key before instantiation.
# @POST Manager exposes reversible encrypt/decrypt operations for persisted secrets.
# @SIDE_EFFECT Initializes Fernet cryptography state from process environment.
# @DATA_CONTRACT Input[str] -> Output[str]
# @INVARIANT Uses only a validated secret key from environment.
# @RELATION CALLS -> [_require_fernet_key]
# @PRE: ENCRYPTION_KEY is configured with a valid Fernet key before instantiation.
# @POST: Manager exposes reversible encrypt/decrypt operations for persisted secrets.
# @SIDE_EFFECT: Initializes Fernet cryptography state from process environment.
# @DATA_CONTRACT: Input[str] -> Output[str]
# @INVARIANT: Uses only a validated secret key from environment.
#
# @TEST_CONTRACT: EncryptionManagerModel ->
# {
@@ -71,35 +71,35 @@ def _require_fernet_key() -> bytes:
# @TEST_EDGE: empty_string_encryption -> {"data": ""}
# @TEST_INVARIANT: symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption]
class EncryptionManager:
# #region EncryptionManager_init [TYPE Function]
# @BRIEF Initialize the encryption manager with a Fernet key.
# @PRE ENCRYPTION_KEY env var must be set to a valid Fernet key.
# @POST Fernet instance ready for encryption/decryption.
# [DEF:EncryptionManager_init:Function]
# @PURPOSE: Initialize the encryption manager with a Fernet key.
# @PRE: ENCRYPTION_KEY env var must be set to a valid Fernet key.
# @POST: Fernet instance ready for encryption/decryption.
def __init__(self):
self.key = _require_fernet_key()
self.fernet = Fernet(self.key)
# #endregion EncryptionManager_init
# [/DEF:EncryptionManager_init:Function]
# #region encrypt [TYPE Function]
# @BRIEF Encrypt a plaintext string.
# @PRE data must be a non-empty string.
# @POST Returns encrypted string.
# [DEF:encrypt:Function]
# @PURPOSE: Encrypt a plaintext string.
# @PRE: data must be a non-empty string.
# @POST: Returns encrypted string.
def encrypt(self, data: str) -> str:
with belief_scope("encrypt"):
return self.fernet.encrypt(data.encode()).decode()
# #endregion encrypt
# [/DEF:encrypt:Function]
# #region decrypt [TYPE Function]
# @BRIEF Decrypt an encrypted string.
# @PRE encrypted_data must be a valid Fernet-encrypted string.
# @POST Returns original plaintext string.
# [DEF:decrypt:Function]
# @PURPOSE: Decrypt an encrypted string.
# @PRE: encrypted_data must be a valid Fernet-encrypted string.
# @POST: Returns original plaintext string.
def decrypt(self, encrypted_data: str) -> str:
with belief_scope("decrypt"):
return self.fernet.decrypt(encrypted_data.encode()).decode()
# #endregion decrypt
# [/DEF:decrypt:Function]
# #endregion EncryptionManager
@@ -111,48 +111,51 @@ class EncryptionManager:
# @RELATION DEPENDS_ON -> [EncryptionManager]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
class LLMProviderService:
# #region LLMProviderService_init [TYPE Function]
# @BRIEF Initialize the service with database session.
# @PRE db must be a valid SQLAlchemy Session.
# @POST Service ready for provider operations.
# @RELATION DEPENDS_ON -> [EncryptionManager]
# [DEF:LLMProviderService_init:Function]
# @PURPOSE: Initialize the service with database session.
# @PRE: db must be a valid SQLAlchemy Session.
# @POST: Service ready for provider operations.
# @RELATION: [DEPENDS_ON] ->[EncryptionManager]
def __init__(self, db: Session):
self.db = db
self.encryption = EncryptionManager()
# #endregion LLMProviderService_init
# [/DEF:LLMProviderService_init:Function]
# #region get_all_providers [C:3] [TYPE Function]
# @BRIEF Returns all configured LLM providers.
# @PRE Database connection must be active.
# @POST Returns list of all LLMProvider records.
# @RELATION DEPENDS_ON -> [LLMProvider]
# [DEF:get_all_providers:Function]
# @COMPLEXITY: 3
# @PURPOSE: Returns all configured LLM providers.
# @PRE: Database connection must be active.
# @POST: Returns list of all LLMProvider records.
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
def get_all_providers(self) -> List[LLMProvider]:
with belief_scope("get_all_providers"):
return self.db.query(LLMProvider).all()
# #endregion get_all_providers
# [/DEF:get_all_providers:Function]
# #region get_provider [C:3] [TYPE Function]
# @BRIEF Returns a single LLM provider by ID.
# @PRE provider_id must be a valid string.
# @POST Returns LLMProvider or None if not found.
# @RELATION DEPENDS_ON -> [LLMProvider]
# [DEF:get_provider:Function]
# @COMPLEXITY: 3
# @PURPOSE: Returns a single LLM provider by ID.
# @PRE: provider_id must be a valid string.
# @POST: Returns LLMProvider or None if not found.
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
def get_provider(self, provider_id: str) -> Optional[LLMProvider]:
with belief_scope("get_provider"):
return (
self.db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
)
# #endregion get_provider
# [/DEF:get_provider:Function]
# #region create_provider [C:3] [TYPE Function]
# @BRIEF Creates a new LLM provider with encrypted API key.
# @PRE config must contain valid provider configuration.
# @POST New provider created and persisted to database.
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
# @RELATION DEPENDS_ON -> [LLMProvider]
# @RELATION CALLS -> [encrypt]
# [DEF:create_provider:Function]
# @COMPLEXITY: 3
# @PURPOSE: Creates a new LLM provider with encrypted API key.
# @PRE: config must contain valid provider configuration.
# @POST: New provider created and persisted to database.
# @RELATION: [DEPENDS_ON] ->[LLMProviderConfig]
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
# @RELATION: [CALLS] ->[encrypt]
def create_provider(self, config: "LLMProviderConfig") -> LLMProvider:
with belief_scope("create_provider"):
encrypted_key = self.encryption.encrypt(config.api_key)
@@ -169,15 +172,16 @@ class LLMProviderService:
self.db.refresh(db_provider)
return db_provider
# #endregion create_provider
# [/DEF:create_provider:Function]
# #region update_provider [C:3] [TYPE Function]
# @BRIEF Updates an existing LLM provider.
# @PRE provider_id must exist, config must be valid.
# @POST Provider updated and persisted to database.
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
# @RELATION DEPENDS_ON -> [LLMProvider]
# @RELATION CALLS -> [encrypt]
# [DEF:update_provider:Function]
# @COMPLEXITY: 3
# @PURPOSE: Updates an existing LLM provider.
# @PRE: provider_id must exist, config must be valid.
# @POST: Provider updated and persisted to database.
# @RELATION: [DEPENDS_ON] ->[LLMProviderConfig]
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
# @RELATION: [CALLS] ->[encrypt]
def update_provider(
self, provider_id: str, config: "LLMProviderConfig"
) -> Optional[LLMProvider]:
@@ -203,13 +207,14 @@ class LLMProviderService:
self.db.refresh(db_provider)
return db_provider
# #endregion update_provider
# [/DEF:update_provider:Function]
# #region delete_provider [C:3] [TYPE Function]
# @BRIEF Deletes an LLM provider.
# @PRE provider_id must exist.
# @POST Provider removed from database.
# @RELATION DEPENDS_ON -> [LLMProvider]
# [DEF:delete_provider:Function]
# @COMPLEXITY: 3
# @PURPOSE: Deletes an LLM provider.
# @PRE: provider_id must exist.
# @POST: Provider removed from database.
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
def delete_provider(self, provider_id: str) -> bool:
with belief_scope("delete_provider"):
db_provider = self.get_provider(provider_id)
@@ -219,14 +224,15 @@ class LLMProviderService:
self.db.commit()
return True
# #endregion delete_provider
# [/DEF:delete_provider:Function]
# #region get_decrypted_api_key [C:3] [TYPE Function]
# @BRIEF Returns the decrypted API key for a provider.
# @PRE provider_id must exist with valid encrypted key.
# @POST Returns decrypted API key or None on failure.
# @RELATION DEPENDS_ON -> [LLMProvider]
# @RELATION CALLS -> [decrypt]
# [DEF:get_decrypted_api_key:Function]
# @COMPLEXITY: 3
# @PURPOSE: Returns the decrypted API key for a provider.
# @PRE: provider_id must exist with valid encrypted key.
# @POST: Returns decrypted API key or None on failure.
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
# @RELATION: [CALLS] ->[decrypt]
def get_decrypted_api_key(self, provider_id: str) -> Optional[str]:
with belief_scope("get_decrypted_api_key"):
db_provider = self.get_provider(provider_id)
@@ -251,7 +257,9 @@ class LLMProviderService:
logger.error(f"[get_decrypted_api_key] Decryption failed: {str(e)}")
return None
# #endregion get_decrypted_api_key
# [/DEF:get_decrypted_api_key:Function]
# #endregion LLMProviderService
# #endregion llm_provider

View File

@@ -1,36 +1,35 @@
# #region mapping_service [C:3] [TYPE Module] [SEMANTICS service, mapping, fuzzy-matching, superset]
#
# @BRIEF Orchestrates database fetching and fuzzy matching suggestions.
# @LAYER Service
# @PRE source/target environment identifiers are provided by caller.
# @POST Exposes stateless mapping suggestion orchestration over configured environments.
# @SIDE_EFFECT Performs remote metadata reads through Superset API clients.
# @DATA_CONTRACT Input[source_env_id: str, target_env_id: str] -> Output[List[Dict]]
# @INVARIANT Suggestions are based on database names.
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [suggest_mappings]
#
# @LAYER: Service
# @PRE: source/target environment identifiers are provided by caller.
# @POST: Exposes stateless mapping suggestion orchestration over configured environments.
# @SIDE_EFFECT: Performs remote metadata reads through Superset API clients.
# @DATA_CONTRACT: Input[source_env_id: str, target_env_id: str] -> Output[List[Dict]]
# @RELATION DEPENDS_ON -> SupersetClient
# @RELATION DEPENDS_ON -> suggest_mappings
#
# @INVARIANT: Suggestions are based on database names.
# [SECTION: IMPORTS]
from typing import List, Dict
from ..core.logger import belief_scope
from ..core.superset_client import SupersetClient
from ..core.utils.matching import suggest_mappings
# [/SECTION]
# #region MappingService [C:3] [TYPE Class]
# @BRIEF Service for handling database mapping logic.
# @PRE config_manager exposes get_environments() with environment objects containing id.
# @POST Provides client resolution and mapping suggestion methods.
# @SIDE_EFFECT Instantiates Superset clients and performs upstream metadata reads.
# @DATA_CONTRACT Input[config_manager] -> Output[List[Dict]]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [suggest_mappings]
# @PRE: config_manager exposes get_environments() with environment objects containing id.
# @POST: Provides client resolution and mapping suggestion methods.
# @SIDE_EFFECT: Instantiates Superset clients and performs upstream metadata reads.
# @DATA_CONTRACT: Input[config_manager] -> Output[List[Dict]]
# @RELATION DEPENDS_ON -> SupersetClient
# @RELATION DEPENDS_ON -> suggest_mappings
class MappingService:
# #region init [C:3] [TYPE Function]
# @BRIEF Initializes the mapping service with a config manager.
# @PRE config_manager is provided.
# [DEF:init:Function]
# @PURPOSE: Initializes the mapping service with a config manager.
# @COMPLEXITY: 3
# @PRE: config_manager is provided.
# @PARAM: config_manager (ConfigManager) - The configuration manager.
# @POST: Service is initialized.
# @RELATION: DEPENDS_ON -> MappingService
@@ -38,10 +37,11 @@ class MappingService:
with belief_scope("MappingService.__init__"):
self.config_manager = config_manager
# #endregion init
# [/DEF:init:Function]
# #region _get_client [C:3] [TYPE Function]
# @BRIEF Helper to get an initialized SupersetClient for an environment.
# [DEF:_get_client:Function]
# @PURPOSE: Helper to get an initialized SupersetClient for an environment.
# @COMPLEXITY: 3
# @PARAM: env_id (str) - The ID of the environment.
# @PRE: environment must exist in config.
# @POST: Returns an initialized SupersetClient.
@@ -56,10 +56,11 @@ class MappingService:
return SupersetClient(env)
# #endregion _get_client
# [/DEF:_get_client:Function]
# #region get_suggestions [C:3] [TYPE Function]
# @BRIEF Fetches databases from both environments and returns fuzzy matching suggestions.
# [DEF:get_suggestions:Function]
# @PURPOSE: Fetches databases from both environments and returns fuzzy matching suggestions.
# @COMPLEXITY: 3
# @PARAM: source_env_id (str) - Source environment ID.
# @PARAM: target_env_id (str) - Target environment ID.
# @PRE: Both environments must be accessible.
@@ -85,7 +86,9 @@ class MappingService:
return suggest_mappings(source_dbs, target_dbs)
# #endregion get_suggestions
# [/DEF:get_suggestions:Function]
# #endregion MappingService
# #endregion mapping_service

View File

@@ -1,6 +1,7 @@
# #region test_notification_service [C:2] [TYPE Module]
# @BRIEF Unit tests for NotificationService routing and dispatch logic.
# @RELATION TESTS -> [NotificationService:Class]
# [DEF:test_notification_service:Module]
# @COMPLEXITY: 2
# @PURPOSE: Unit tests for NotificationService routing and dispatch logic.
# @RELATION: TESTS ->[NotificationService:Class]
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
@@ -117,4 +118,4 @@ async def test_dispatch_report_calls_providers(service, mock_db):
service._providers["TELEGRAM"].send.assert_called_once()
service._providers["SMTP"].send.assert_called_once()
# #endregion test_notification_service
# [/DEF:test_notification_service:Module]

View File

@@ -1,20 +1,20 @@
# #region providers [C:5] [TYPE Module] [SEMANTICS notifications, providers, smtp, slack, telegram, abstraction]
#
# @BRIEF Defines abstract base and concrete implementations for external notification delivery.
# @LAYER Infra
# @PRE Provider configuration dictionaries are supplied by trusted configuration sources.
# @POST Each provider exposes async send contract returning boolean delivery outcome.
# @SIDE_EFFECT Performs outbound network I/O to SMTP or HTTP endpoints.
# @DATA_CONTRACT Input[target, subject, body, context?] -> Output[bool]
# @INVARIANT Concrete providers preserve boolean send contract and swallow transport exceptions into False.
# @INVARIANT Providers must be stateless and resilient to network failures.
# @INVARIANT Sensitive credentials must be handled via encrypted config.
# @RELATION DEPENDED_ON_BY -> [NotificationService]
# @RELATION DEPENDS_ON -> [NotificationProvider]
# @RELATION DEPENDS_ON -> [SMTPProvider]
# @RELATION DEPENDS_ON -> [TelegramProvider]
# @RELATION DEPENDS_ON -> [SlackProvider]
# @LAYER: Infra
# @PRE: Provider configuration dictionaries are supplied by trusted configuration sources.
# @POST: Each provider exposes async send contract returning boolean delivery outcome.
# @SIDE_EFFECT: Performs outbound network I/O to SMTP or HTTP endpoints.
# @DATA_CONTRACT: Input[target, subject, body, context?] -> Output[bool]
# @INVARIANT: Concrete providers preserve boolean send contract and swallow transport exceptions into False.
#
#
# @INVARIANT: Providers must be stateless and resilient to network failures.
# @INVARIANT: Sensitive credentials must be handled via encrypted config.
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional

View File

@@ -1,11 +1,7 @@
# #region service [C:5] [TYPE Module] [SEMANTICS notifications, service, routing, dispatch, background-tasks]
#
# @BRIEF Orchestrates notification routing based on user preferences and policy context.
# @LAYER Domain
# @PRE channel_config is loaded
# @POST Notification dispatched via configured providers
# @SIDE_EFFECT Sends notifications via configured providers
# @DATA_CONTRACT NotificationChannelConfig -> NotificationRecipient
# @INVARIANT NotificationService maintains singleton pattern for per-channel notifications
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [NotificationProvider]
# @RELATION DEPENDS_ON -> [SMTPProvider]
# @RELATION DEPENDS_ON -> [TelegramProvider]
@@ -14,7 +10,11 @@
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
#
#
# @INVARIANT: NotificationService maintains singleton pattern for per-channel notifications
# @DATA_CONTRACT: NotificationChannelConfig -> NotificationRecipient
# @PRE: channel_config is loaded
# @POST: Notification dispatched via configured providers
# @SIDE_EFFECT: Sends notifications via configured providers
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks
@@ -34,32 +34,34 @@ from .providers import (
# #region NotificationService [C:4] [TYPE Class]
# @BRIEF Routes validation reports to appropriate users and channels.
# @PRE Service receives a live DB session and configuration manager with notification payload settings.
# @POST Service can resolve targets and dispatch provider sends without mutating validation records.
# @SIDE_EFFECT Reads notification configuration, queries user preferences, and dispatches provider I/O.
# @DATA_CONTRACT Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
# @RELATION DEPENDS_ON -> [NotificationProvider]
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @PRE: Service receives a live DB session and configuration manager with notification payload settings.
# @POST: Service can resolve targets and dispatch provider sends without mutating validation records.
# @SIDE_EFFECT: Reads notification configuration, queries user preferences, and dispatches provider I/O.
# @DATA_CONTRACT: Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
class NotificationService:
# #region NotificationService_init [C:3] [TYPE Function]
# @BRIEF Bind DB and configuration collaborators used for provider initialization and routing.
# @RELATION BINDS_TO -> [NotificationService]
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# [DEF:NotificationService_init:Function]
# @COMPLEXITY: 3
# @PURPOSE: Bind DB and configuration collaborators used for provider initialization and routing.
# @RELATION: [BINDS_TO] ->[NotificationService]
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
def __init__(self, db: Session, config_manager: ConfigManager):
self.db = db
self.config_manager = config_manager
self._providers: Dict[str, NotificationProvider] = {}
self._initialized = False
# #endregion NotificationService_init
# [/DEF:NotificationService_init:Function]
# #region _initialize_providers [C:3] [TYPE Function]
# @BRIEF Materialize configured notification channel adapters once per service lifetime.
# @RELATION DEPENDS_ON -> [SMTPProvider]
# @RELATION DEPENDS_ON -> [TelegramProvider]
# @RELATION DEPENDS_ON -> [SlackProvider]
# [DEF:_initialize_providers:Function]
# @COMPLEXITY: 3
# @PURPOSE: Materialize configured notification channel adapters once per service lifetime.
# @RELATION: [DEPENDS_ON] ->[SMTPProvider]
# @RELATION: [DEPENDS_ON] ->[TelegramProvider]
# @RELATION: [DEPENDS_ON] ->[SlackProvider]
def _initialize_providers(self):
if self._initialized:
return
@@ -78,18 +80,19 @@ class NotificationService:
self._initialized = True
# #endregion _initialize_providers
# [/DEF:_initialize_providers:Function]
# #region dispatch_report [C:4] [TYPE Function]
# @BRIEF Route one validation record to resolved owners and configured custom channels.
# @PRE record is persisted and providers can be initialized from configuration payload.
# @POST Eligible notification sends are scheduled in background or awaited inline.
# @SIDE_EFFECT Schedules or performs outbound provider sends and emits notification logs.
# @DATA_CONTRACT Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
# @RELATION CALLS -> [_initialize_providers]
# @RELATION CALLS -> [_should_notify]
# @RELATION CALLS -> [_resolve_targets]
# @RELATION CALLS -> [_build_body]
# [DEF:dispatch_report:Function]
# @COMPLEXITY: 4
# @PURPOSE: Route one validation record to resolved owners and configured custom channels.
# @RELATION: [CALLS] ->[_initialize_providers]
# @RELATION: [CALLS] ->[_should_notify]
# @RELATION: [CALLS] ->[_resolve_targets]
# @RELATION: [CALLS] ->[_build_body]
# @PRE: record is persisted and providers can be initialized from configuration payload.
# @POST: Eligible notification sends are scheduled in background or awaited inline.
# @SIDE_EFFECT: Schedules or performs outbound provider sends and emits notification logs.
# @DATA_CONTRACT: Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
async def dispatch_report(
self,
record: ValidationRecord,
@@ -135,12 +138,13 @@ class NotificationService:
# Fallback to sync for tests or if no background_tasks provided
await provider.send(recipient, subject, body)
# #endregion dispatch_report
# [/DEF:dispatch_report:Function]
# #region _should_notify [C:3] [TYPE Function]
# @BRIEF Evaluate record status against effective alert policy.
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# [DEF:_should_notify:Function]
# @COMPLEXITY: 3
# @PURPOSE: Evaluate record status against effective alert policy.
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
def _should_notify(
self, record: ValidationRecord, policy: Optional[ValidationPolicy]
) -> bool:
@@ -152,13 +156,14 @@ class NotificationService:
return record.status in ("WARN", "FAIL")
return record.status == "FAIL"
# #endregion _should_notify
# [/DEF:_should_notify:Function]
# #region _resolve_targets [C:3] [TYPE Function]
# @BRIEF Resolve owner and policy-defined delivery targets for one validation record.
# @RELATION CALLS -> [_find_dashboard_owners]
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [ValidationPolicy]
# [DEF:_resolve_targets:Function]
# @COMPLEXITY: 3
# @PURPOSE: Resolve owner and policy-defined delivery targets for one validation record.
# @RELATION: [CALLS] ->[_find_dashboard_owners]
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
def _resolve_targets(
self, record: ValidationRecord, policy: Optional[ValidationPolicy]
) -> List[tuple]:
@@ -188,12 +193,13 @@ class NotificationService:
return targets
# #endregion _resolve_targets
# [/DEF:_resolve_targets:Function]
# #region _find_dashboard_owners [C:3] [TYPE Function]
# @BRIEF Load candidate dashboard owners from persisted profile preferences.
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# [DEF:_find_dashboard_owners:Function]
# @COMPLEXITY: 3
# @PURPOSE: Load candidate dashboard owners from persisted profile preferences.
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
# @RELATION: [DEPENDS_ON] ->[UserDashboardPreference]
def _find_dashboard_owners(
self, record: ValidationRecord
) -> List[UserDashboardPreference]:
@@ -209,11 +215,12 @@ class NotificationService:
.all()
)
# #endregion _find_dashboard_owners
# [/DEF:_find_dashboard_owners:Function]
# #region _build_body [C:2] [TYPE Function]
# @BRIEF Format one validation record into provider-ready body text.
# @RELATION DEPENDS_ON -> [ValidationRecord]
# [DEF:_build_body:Function]
# @COMPLEXITY: 2
# @PURPOSE: Format one validation record into provider-ready body text.
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
def _build_body(self, record: ValidationRecord) -> str:
return (
f"Dashboard ID: {record.dashboard_id}\n"
@@ -223,7 +230,9 @@ class NotificationService:
f"Issues found: {len(record.issues)}"
)
# #endregion _build_body
# [/DEF:_build_body:Function]
# #endregion NotificationService
# #endregion service

View File

@@ -1,7 +1,7 @@
# #region profile_service [C:5] [TYPE Module] [SEMANTICS profile, service, validation, ownership, filtering, superset, preferences]
#
# @BRIEF Orchestrates profile preference persistence, Superset account lookup, and deterministic actor matching.
# @LAYER Domain
# @INVARIANT Profile ID needs to unique per-user session
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @RELATION DEPENDS_ON -> [ProfilePreferenceResponse]
# @RELATION DEPENDS_ON -> [SupersetClient]
@@ -9,7 +9,7 @@
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [sqlalchemy.orm.Session]
#
#
# @INVARIANT: Profile ID needs to unique per-user session
#
# @TEST_CONTRACT: ProfilePreferenceUpdateRequest -> ProfilePreferenceResponse
# @TEST_FIXTURE: valid_profile_update -> {"user_id":"u-1","superset_username":"John_Doe","show_only_my_dashboards":true}
@@ -22,7 +22,6 @@
# @POST: Profile with updated fields populated and
# @SIDE_EFFECT: Database read/write operations
# [SECTION: IMPORTS]
from datetime import datetime
from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple
from sqlalchemy.orm import Session
@@ -45,15 +44,14 @@ from ..schemas.profile import (
SupersetAccountLookupResponse,
SupersetAccountCandidate,
)
# [/SECTION]
SUPPORTED_START_PAGES = {"dashboards", "datasets", "reports"}
SUPPORTED_DENSITIES = {"compact", "comfortable"}
# #region ProfileValidationError [C:2] [TYPE Class]
# @RELATION INHERITS -> Exception
# @BRIEF Domain validation error for profile preference update requests.
# @RELATION INHERITS -> [Exception]
class ProfileValidationError(Exception):
def __init__(self, errors: Sequence[str]):
self.errors = list(errors)
@@ -64,8 +62,8 @@ class ProfileValidationError(Exception):
# #region EnvironmentNotFoundError [C:2] [TYPE Class]
# @RELATION INHERITS -> Exception
# @BRIEF Raised when environment_id from lookup request is unknown in app configuration.
# @RELATION INHERITS -> [Exception]
class EnvironmentNotFoundError(Exception):
pass
@@ -74,8 +72,8 @@ class EnvironmentNotFoundError(Exception):
# #region ProfileAuthorizationError [C:2] [TYPE Class]
# @RELATION INHERITS -> Exception
# @BRIEF Raised when caller attempts cross-user preference mutation.
# @RELATION INHERITS -> [Exception]
class ProfileAuthorizationError(Exception):
pass
@@ -84,24 +82,24 @@ class ProfileAuthorizationError(Exception):
# #region ProfileService [C:5] [TYPE Class]
# @BRIEF Implements profile preference read/update flow and Superset account lookup degradation strategy.
# @PRE Caller provides authenticated User context for external service methods.
# @POST Preference operations remain user-scoped and return normalized profile/lookup responses.
# @SIDE_EFFECT Writes preference records and encrypted tokens; performs external account lookups when requested.
# @DATA_CONTRACT Input[User,ProfilePreferenceUpdateRequest|SupersetAccountLookupRequest] -> Output[ProfilePreferenceResponse|SupersetAccountLookupResponse|bool]
# @INVARIANT Profile data integrity maintained, cache consistency with database state
# @RELATION DEPENDS_ON -> [sqlalchemy.orm.Session]
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [SupersetAccountLookupAdapter]
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @RELATION CALLS -> [discover_declared_permissions]
# @BRIEF Implements profile preference read/update flow and Superset account lookup degradation strategy.
# @PRE: Caller provides authenticated User context for external service methods.
# @POST: Preference operations remain user-scoped and return normalized profile/lookup responses.
# @SIDE_EFFECT: Writes preference records and encrypted tokens; performs external account lookups when requested.
# @DATA_CONTRACT: Input[User,ProfilePreferenceUpdateRequest|SupersetAccountLookupRequest] -> Output[ProfilePreferenceResponse|SupersetAccountLookupResponse|bool]
# @INVARIANT: Profile data integrity maintained, cache consistency with database state
class ProfileService:
# #region init [TYPE Function]
# @BRIEF Initialize service with DB session and config manager.
# @PRE db session is active and config_manager supports get_environments().
# @POST Service is ready for preference persistence and lookup operations.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:init:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Initialize service with DB session and config manager.
# @PRE: db session is active and config_manager supports get_environments().
# @POST: Service is ready for preference persistence and lookup operations.
def __init__(self, db: Session, config_manager: Any, plugin_loader: Any = None):
self.db = db
self.config_manager = config_manager
@@ -109,13 +107,13 @@ class ProfileService:
self.auth_repository = AuthRepository(db)
self.encryption = EncryptionManager()
# #endregion init
# [/DEF:init:Function]
# #region get_my_preference [TYPE Function]
# @BRIEF Return current user's persisted preference or default non-configured view.
# @PRE current_user is authenticated.
# @POST Returned payload belongs to current_user only.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:get_my_preference:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Return current user's persisted preference or default non-configured view.
# @PRE: current_user is authenticated.
# @POST: Returned payload belongs to current_user only.
def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse:
with belief_scope(
"ProfileService.get_my_preference", f"user_id={current_user.id}"
@@ -140,13 +138,13 @@ class ProfileService:
security=security_summary,
)
# #endregion get_my_preference
# [/DEF:get_my_preference:Function]
# #region get_dashboard_filter_binding [TYPE Function]
# @BRIEF Return only dashboard-filter fields required by dashboards listing hot path.
# @PRE current_user is authenticated.
# @POST Returns normalized username and profile-default filter toggles without security summary expansion.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:get_dashboard_filter_binding:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Return only dashboard-filter fields required by dashboards listing hot path.
# @PRE: current_user is authenticated.
# @POST: Returns normalized username and profile-default filter toggles without security summary expansion.
def get_dashboard_filter_binding(self, current_user: User) -> dict:
with belief_scope(
"ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}"
@@ -175,13 +173,13 @@ class ProfileService:
),
}
# #endregion get_dashboard_filter_binding
# [/DEF:get_dashboard_filter_binding:Function]
# #region update_my_preference [TYPE Function]
# @BRIEF Validate and persist current user's profile preference in self-scoped mode.
# @PRE current_user is authenticated and payload is provided.
# @POST Preference row for current_user is created/updated when validation passes.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:update_my_preference:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Validate and persist current user's profile preference in self-scoped mode.
# @PRE: current_user is authenticated and payload is provided.
# @POST: Preference row for current_user is created/updated when validation passes.
def update_my_preference(
self,
current_user: User,
@@ -324,13 +322,13 @@ class ProfileService:
security=self._build_security_summary(current_user),
)
# #endregion update_my_preference
# [/DEF:update_my_preference:Function]
# #region lookup_superset_accounts [TYPE Function]
# @BRIEF Query Superset users in selected environment and project canonical account candidates.
# @PRE current_user is authenticated and environment_id exists.
# @POST Returns success payload or degraded payload with warning while preserving manual fallback.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:lookup_superset_accounts:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Query Superset users in selected environment and project canonical account candidates.
# @PRE: current_user is authenticated and environment_id exists.
# @POST: Returns success payload or degraded payload with warning while preserving manual fallback.
def lookup_superset_accounts(
self,
current_user: User,
@@ -406,13 +404,13 @@ class ProfileService:
items=[],
)
# #endregion lookup_superset_accounts
# [/DEF:lookup_superset_accounts:Function]
# #region matches_dashboard_actor [TYPE Function]
# @BRIEF Apply trim+case-insensitive actor match across owners OR modified_by.
# @PRE bound_username can be empty; owners may contain mixed payload.
# @POST Returns True when normalized username matches owners or modified_by.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:matches_dashboard_actor:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Apply trim+case-insensitive actor match across owners OR modified_by.
# @PRE: bound_username can be empty; owners may contain mixed payload.
# @POST: Returns True when normalized username matches owners or modified_by.
def matches_dashboard_actor(
self,
bound_username: Optional[str],
@@ -432,13 +430,13 @@ class ProfileService:
return True
return False
# #endregion matches_dashboard_actor
# [/DEF:matches_dashboard_actor:Function]
# #region _build_security_summary [TYPE Function]
# @BRIEF Build read-only security snapshot with role and permission badges.
# @PRE current_user is authenticated.
# @POST Returns deterministic security projection for profile UI.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_build_security_summary:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Build read-only security snapshot with role and permission badges.
# @PRE: current_user is authenticated.
# @POST: Returns deterministic security projection for profile UI.
def _build_security_summary(self, current_user: User) -> ProfileSecuritySummary:
role_names_set: Set[str] = set()
roles = getattr(current_user, "roles", []) or []
@@ -496,13 +494,13 @@ class ProfileService:
permissions=permission_states,
)
# #endregion _build_security_summary
# [/DEF:_build_security_summary:Function]
# #region _collect_user_permission_pairs [TYPE Function]
# @BRIEF Collect effective permission tuples from current user's roles.
# @PRE current_user can include role/permission graph.
# @POST Returns unique normalized (resource, ACTION) tuples.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_collect_user_permission_pairs:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Collect effective permission tuples from current user's roles.
# @PRE: current_user can include role/permission graph.
# @POST: Returns unique normalized (resource, ACTION) tuples.
def _collect_user_permission_pairs(
self, current_user: User
) -> Set[Tuple[str, str]]:
@@ -517,13 +515,13 @@ class ProfileService:
collected.add((resource, action))
return collected
# #endregion _collect_user_permission_pairs
# [/DEF:_collect_user_permission_pairs:Function]
# #region _format_permission_key [TYPE Function]
# @BRIEF Convert normalized permission pair to compact UI key.
# @PRE resource and action are normalized.
# @POST Returns user-facing badge key.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_format_permission_key:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Convert normalized permission pair to compact UI key.
# @PRE: resource and action are normalized.
# @POST: Returns user-facing badge key.
def _format_permission_key(self, resource: str, action: str) -> str:
normalized_resource = self._sanitize_text(resource) or ""
normalized_action = str(action or "").strip().upper()
@@ -531,13 +529,13 @@ class ProfileService:
return normalized_resource
return f"{normalized_resource}:{normalized_action.lower()}"
# #endregion _format_permission_key
# [/DEF:_format_permission_key:Function]
# #region _to_preference_payload [TYPE Function]
# @BRIEF Map ORM preference row to API DTO with token metadata.
# @PRE preference row can contain nullable optional fields.
# @POST Returns normalized ProfilePreference object.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_to_preference_payload:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Map ORM preference row to API DTO with token metadata.
# @PRE: preference row can contain nullable optional fields.
# @POST: Returns normalized ProfilePreference object.
def _to_preference_payload(
self,
preference: UserDashboardPreference,
@@ -591,13 +589,13 @@ class ProfileService:
updated_at=updated_at,
)
# #endregion _to_preference_payload
# [/DEF:_to_preference_payload:Function]
# #region _mask_secret_value [TYPE Function]
# @BRIEF Build a safe display value for sensitive secrets.
# @PRE secret may be None or plaintext.
# @POST Returns masked representation or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_mask_secret_value:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Build a safe display value for sensitive secrets.
# @PRE: secret may be None or plaintext.
# @POST: Returns masked representation or None.
def _mask_secret_value(self, secret: Optional[str]) -> Optional[str]:
sanitized_secret = self._sanitize_secret(secret)
if sanitized_secret is None:
@@ -606,26 +604,26 @@ class ProfileService:
return "***"
return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}"
# #endregion _mask_secret_value
# [/DEF:_mask_secret_value:Function]
# #region _sanitize_text [TYPE Function]
# @BRIEF Normalize optional text into trimmed form or None.
# @PRE value may be empty or None.
# @POST Returns trimmed value or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_sanitize_text:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize optional text into trimmed form or None.
# @PRE: value may be empty or None.
# @POST: Returns trimmed value or None.
def _sanitize_text(self, value: Optional[str]) -> Optional[str]:
normalized = str(value or "").strip()
if not normalized:
return None
return normalized
# #endregion _sanitize_text
# [/DEF:_sanitize_text:Function]
# #region _sanitize_secret [TYPE Function]
# @BRIEF Normalize secret input into trimmed form or None.
# @PRE value may be None or blank.
# @POST Returns trimmed secret or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_sanitize_secret:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize secret input into trimmed form or None.
# @PRE: value may be None or blank.
# @POST: Returns trimmed secret or None.
def _sanitize_secret(self, value: Optional[str]) -> Optional[str]:
if value is None:
return None
@@ -634,13 +632,13 @@ class ProfileService:
return None
return normalized
# #endregion _sanitize_secret
# [/DEF:_sanitize_secret:Function]
# #region _normalize_start_page [TYPE Function]
# @BRIEF Normalize supported start page aliases to canonical values.
# @PRE value may be None or alias.
# @POST Returns one of SUPPORTED_START_PAGES.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_normalize_start_page:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize supported start page aliases to canonical values.
# @PRE: value may be None or alias.
# @POST: Returns one of SUPPORTED_START_PAGES.
def _normalize_start_page(self, value: Optional[str]) -> str:
normalized = str(value or "").strip().lower()
if normalized == "reports-logs":
@@ -649,13 +647,13 @@ class ProfileService:
return normalized
return "dashboards"
# #endregion _normalize_start_page
# [/DEF:_normalize_start_page:Function]
# #region _normalize_density [TYPE Function]
# @BRIEF Normalize supported density aliases to canonical values.
# @PRE value may be None or alias.
# @POST Returns one of SUPPORTED_DENSITIES.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_normalize_density:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize supported density aliases to canonical values.
# @PRE: value may be None or alias.
# @POST: Returns one of SUPPORTED_DENSITIES.
def _normalize_density(self, value: Optional[str]) -> str:
normalized = str(value or "").strip().lower()
if normalized == "free":
@@ -664,13 +662,13 @@ class ProfileService:
return normalized
return "comfortable"
# #endregion _normalize_density
# [/DEF:_normalize_density:Function]
# #region _resolve_environment [TYPE Function]
# @BRIEF Resolve environment model from configured environments by id.
# @PRE environment_id is provided.
# @POST Returns environment object when found else None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_resolve_environment:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Resolve environment model from configured environments by id.
# @PRE: environment_id is provided.
# @POST: Returns environment object when found else None.
def _resolve_environment(self, environment_id: str):
environments = self.config_manager.get_environments()
for env in environments:
@@ -678,36 +676,36 @@ class ProfileService:
return env
return None
# #endregion _resolve_environment
# [/DEF:_resolve_environment:Function]
# #region _get_preference_row [TYPE Function]
# @BRIEF Return persisted preference row for user or None.
# @PRE user_id is provided.
# @POST Returns matching row or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_get_preference_row:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Return persisted preference row for user or None.
# @PRE: user_id is provided.
# @POST: Returns matching row or None.
def _get_preference_row(self, user_id: str) -> Optional[UserDashboardPreference]:
return self.auth_repository.get_user_dashboard_preference(str(user_id))
# #endregion _get_preference_row
# [/DEF:_get_preference_row:Function]
# #region _get_or_create_preference_row [TYPE Function]
# @BRIEF Return existing preference row or create new unsaved row.
# @PRE user_id is provided.
# @POST Returned row always contains user_id.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_get_or_create_preference_row:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Return existing preference row or create new unsaved row.
# @PRE: user_id is provided.
# @POST: Returned row always contains user_id.
def _get_or_create_preference_row(self, user_id: str) -> UserDashboardPreference:
existing = self._get_preference_row(user_id)
if existing is not None:
return existing
return UserDashboardPreference(user_id=str(user_id))
# #endregion _get_or_create_preference_row
# [/DEF:_get_or_create_preference_row:Function]
# #region _build_default_preference [TYPE Function]
# @BRIEF Build non-persisted default preference DTO for unconfigured users.
# @PRE user_id is provided.
# @POST Returns ProfilePreference with disabled toggle and empty username.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_build_default_preference:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Build non-persisted default preference DTO for unconfigured users.
# @PRE: user_id is provided.
# @POST: Returns ProfilePreference with disabled toggle and empty username.
def _build_default_preference(self, user_id: str) -> ProfilePreference:
now = datetime.utcnow()
return ProfilePreference(
@@ -730,13 +728,13 @@ class ProfileService:
updated_at=now,
)
# #endregion _build_default_preference
# [/DEF:_build_default_preference:Function]
# #region _validate_update_payload [TYPE Function]
# @BRIEF Validate username/toggle constraints for preference mutation.
# @PRE payload is provided.
# @POST Returns validation errors list; empty list means valid.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_validate_update_payload:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Validate username/toggle constraints for preference mutation.
# @PRE: payload is provided.
# @POST: Returns validation errors list; empty list means valid.
def _validate_update_payload(
self,
superset_username: Optional[str],
@@ -786,36 +784,36 @@ class ProfileService:
return errors
# #endregion _validate_update_payload
# [/DEF:_validate_update_payload:Function]
# #region _sanitize_username [TYPE Function]
# @BRIEF Normalize raw username into trimmed form or None for empty input.
# @PRE value can be empty or None.
# @POST Returns trimmed username or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_sanitize_username:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize raw username into trimmed form or None for empty input.
# @PRE: value can be empty or None.
# @POST: Returns trimmed username or None.
def _sanitize_username(self, value: Optional[str]) -> Optional[str]:
return self._sanitize_text(value)
# #endregion _sanitize_username
# [/DEF:_sanitize_username:Function]
# #region _normalize_username [TYPE Function]
# @BRIEF Apply deterministic trim+lower normalization for actor matching.
# @PRE value can be empty or None.
# @POST Returns lowercase normalized token or None.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_normalize_username:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Apply deterministic trim+lower normalization for actor matching.
# @PRE: value can be empty or None.
# @POST: Returns lowercase normalized token or None.
def _normalize_username(self, value: Optional[str]) -> Optional[str]:
sanitized = self._sanitize_username(value)
if sanitized is None:
return None
return sanitized.lower()
# #endregion _normalize_username
# [/DEF:_normalize_username:Function]
# #region _normalize_owner_tokens [TYPE Function]
# @BRIEF Normalize owners payload into deduplicated lower-cased tokens.
# @PRE owners can be iterable of scalars or dict-like values.
# @POST Returns list of unique normalized owner tokens.
# @RELATION BINDS_TO -> [ProfileService]
# [DEF:_normalize_owner_tokens:Function]
# @RELATION: BINDS_TO -> ProfileService
# @PURPOSE: Normalize owners payload into deduplicated lower-cased tokens.
# @PRE: owners can be iterable of scalars or dict-like values.
# @POST: Returns list of unique normalized owner tokens.
def _normalize_owner_tokens(self, owners: Optional[Iterable[Any]]) -> List[str]:
if owners is None:
return []
@@ -851,7 +849,7 @@ class ProfileService:
normalized.append(token)
return normalized
# #endregion _normalize_owner_tokens
# [/DEF:_normalize_owner_tokens:Function]
# #endregion ProfileService

View File

@@ -1,8 +1,7 @@
# #region rbac_permission_catalog [C:2] [TYPE Module]
# @BRIEF Discovers declared RBAC permissions from API routes/plugins and synchronizes them into auth database.
#
# @BRIEF Discovers declared RBAC permissions from API routes/plugins and synchronizes them into auth database.
# [SECTION: IMPORTS]
import re
from functools import lru_cache
from pathlib import Path
@@ -29,9 +28,8 @@ ROUTES_DIR = Path(__file__).resolve().parent.parent / "api" / "routes"
# #region _iter_route_files [TYPE Function]
# @BRIEF Iterates API route files that may contain RBAC declarations.
# @PRE ROUTES_DIR points to backend/src/api/routes.
# @POST Yields Python files excluding test and cache directories.
# @RETURN Iterable[Path] - Route file paths for permission extraction.
# @PRE: ROUTES_DIR points to backend/src/api/routes.
# @POST: Yields Python files excluding test and cache directories.
def _iter_route_files() -> Iterable[Path]:
with belief_scope("rbac_permission_catalog._iter_route_files"):
if not ROUTES_DIR.exists():
@@ -49,9 +47,8 @@ def _iter_route_files() -> Iterable[Path]:
# #region _discover_route_permissions [TYPE Function]
# @BRIEF Extracts explicit has_permission declarations from API route source code.
# @PRE Route files are readable UTF-8 text files.
# @POST Returns unique set of (resource, action) pairs declared in route guards.
# @RETURN Set[Tuple[str, str]] - Permission pairs from route-level RBAC declarations.
# @PRE: Route files are readable UTF-8 text files.
# @POST: Returns unique set of (resource, action) pairs declared in route guards.
def _discover_route_permissions() -> Set[Tuple[str, str]]:
with belief_scope("rbac_permission_catalog._discover_route_permissions"):
discovered: Set[Tuple[str, str]] = set()
@@ -77,8 +74,8 @@ def _discover_route_permissions() -> Set[Tuple[str, str]]:
# #region _discover_route_permissions_cached [TYPE Function]
# @BRIEF Cache route permission discovery because route source files are static during normal runtime.
# @PRE None.
# @POST Returns stable discovered route permission pairs without repeated filesystem scans.
# @PRE: None.
# @POST: Returns stable discovered route permission pairs without repeated filesystem scans.
@lru_cache(maxsize=1)
def _discover_route_permissions_cached() -> Tuple[Tuple[str, str], ...]:
with belief_scope("rbac_permission_catalog._discover_route_permissions_cached"):
@@ -88,9 +85,8 @@ def _discover_route_permissions_cached() -> Tuple[Tuple[str, str], ...]:
# #region _discover_plugin_execute_permissions [TYPE Function]
# @BRIEF Derives dynamic task permissions of form plugin:{plugin_id}:EXECUTE from plugin registry.
# @PRE plugin_loader is optional and may expose get_all_plugin_configs.
# @POST Returns unique plugin EXECUTE permissions if loader is available.
# @RETURN Set[Tuple[str, str]] - Permission pairs derived from loaded plugin IDs.
# @PRE: plugin_loader is optional and may expose get_all_plugin_configs.
# @POST: Returns unique plugin EXECUTE permissions if loader is available.
def _discover_plugin_execute_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
with belief_scope("rbac_permission_catalog._discover_plugin_execute_permissions"):
discovered: Set[Tuple[str, str]] = set()
@@ -116,8 +112,8 @@ def _discover_plugin_execute_permissions(plugin_loader=None) -> Set[Tuple[str, s
# #region _discover_plugin_execute_permissions_cached [TYPE Function]
# @BRIEF Cache dynamic plugin EXECUTE permission pairs by normalized plugin id tuple.
# @PRE plugin_ids is a deterministic tuple of plugin ids.
# @POST Returns stable permission tuple without repeated plugin catalog expansion.
# @PRE: plugin_ids is a deterministic tuple of plugin ids.
# @POST: Returns stable permission tuple without repeated plugin catalog expansion.
@lru_cache(maxsize=8)
def _discover_plugin_execute_permissions_cached(
plugin_ids: Tuple[str, ...],
@@ -129,9 +125,8 @@ def _discover_plugin_execute_permissions_cached(
# #region discover_declared_permissions [TYPE Function]
# @BRIEF Builds canonical RBAC permission catalog from routes and plugin registry.
# @PRE plugin_loader may be provided for dynamic task plugin permission discovery.
# @POST Returns union of route-declared and dynamic plugin EXECUTE permissions.
# @RETURN Set[Tuple[str, str]] - Complete discovered permission set.
# @PRE: plugin_loader may be provided for dynamic task plugin permission discovery.
# @POST: Returns union of route-declared and dynamic plugin EXECUTE permissions.
def discover_declared_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
with belief_scope("rbac_permission_catalog.discover_declared_permissions"):
permissions = set(_discover_route_permissions_cached())
@@ -151,11 +146,10 @@ def discover_declared_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
# #region sync_permission_catalog [TYPE Function]
# @BRIEF Persists missing RBAC permission pairs into auth database.
# @PRE db is a valid SQLAlchemy session bound to auth database.
# @PRE declared_permissions is an iterable of (resource, action) tuples.
# @POST Missing permissions are inserted; existing permissions remain untouched.
# @SIDE_EFFECT Commits auth database transaction when new permissions are added.
# @RETURN int - Number of inserted permission records.
# @PRE: db is a valid SQLAlchemy session bound to auth database.
# @PRE: declared_permissions is an iterable of (resource, action) tuples.
# @POST: Missing permissions are inserted; existing permissions remain untouched.
# @SIDE_EFFECT: Commits auth database transaction when new permissions are added.
def sync_permission_catalog(
db: Session,
declared_permissions: Iterable[Tuple[str, str]],

View File

@@ -1,8 +1,10 @@
# #region test_report_normalizer [C:2] [TYPE Module] [SEMANTICS tests, reports, normalizer, fallback]
# @BRIEF Validate unknown task type fallback and partial payload normalization behavior.
# @LAYER Domain (Tests)
# @INVARIANT Unknown plugin types are mapped to canonical unknown task type.
# @RELATION TESTS -> [normalize_report:Function]
# [DEF:test_report_normalizer:Module]
# @COMPLEXITY: 2
# @SEMANTICS: tests, reports, normalizer, fallback
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
# @RELATION: TESTS ->[normalize_report:Function]
# @LAYER: Domain (Tests)
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
from datetime import datetime
@@ -10,9 +12,9 @@ from src.core.task_manager.models import Task, TaskStatus
from src.services.reports.normalizer import normalize_task_report
# #region test_unknown_type_maps_to_unknown_profile [TYPE Function]
# @BRIEF Ensure unknown plugin IDs map to unknown profile with populated summary and error context.
# @RELATION BINDS_TO -> [test_report_normalizer]
# [DEF:test_unknown_type_maps_to_unknown_profile:Function]
# @RELATION: BINDS_TO -> test_report_normalizer
# @PURPOSE: Ensure unknown plugin IDs map to unknown profile with populated summary and error context.
def test_unknown_type_maps_to_unknown_profile():
task = Task(
id="unknown-1",
@@ -31,12 +33,12 @@ def test_unknown_type_maps_to_unknown_profile():
assert report.error_context is not None
# #endregion test_unknown_type_maps_to_unknown_profile
# [/DEF:test_unknown_type_maps_to_unknown_profile:Function]
# #region test_partial_payload_keeps_report_visible_with_placeholders [TYPE Function]
# @BRIEF Ensure missing result payload still yields visible report details with result placeholder.
# @RELATION BINDS_TO -> [test_report_normalizer]
# [DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function]
# @RELATION: BINDS_TO -> test_report_normalizer
# @PURPOSE: Ensure missing result payload still yields visible report details with result placeholder.
def test_partial_payload_keeps_report_visible_with_placeholders():
task = Task(
id="partial-1",
@@ -55,12 +57,12 @@ def test_partial_payload_keeps_report_visible_with_placeholders():
assert "result" in report.details
# #endregion test_partial_payload_keeps_report_visible_with_placeholders
# [/DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function]
# #region test_clean_release_plugin_maps_to_clean_release_task_type [TYPE Function]
# @BRIEF Ensure clean-release plugin ID maps to clean_release task profile and summary passthrough.
# @RELATION BINDS_TO -> [test_report_normalizer]
# [DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function]
# @RELATION: BINDS_TO -> test_report_normalizer
# @PURPOSE: Ensure clean-release plugin ID maps to clean_release task profile and summary passthrough.
def test_clean_release_plugin_maps_to_clean_release_task_type():
task = Task(
id="clean-release-1",
@@ -78,5 +80,5 @@ def test_clean_release_plugin_maps_to_clean_release_task_type():
assert report.summary == "Clean release compliance passed"
# #endregion test_clean_release_plugin_maps_to_clean_release_task_type
# #endregion test_report_normalizer
# [/DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function]
# [/DEF:test_report_normalizer:Module]

View File

@@ -1,7 +1,8 @@
# #region test_report_service [C:2] [TYPE Module]
# @BRIEF Unit tests for ReportsService list/detail operations
# @LAYER Domain
# @RELATION TESTS -> [ReportsService:Class]
# [DEF:test_report_service:Module]
# @COMPLEXITY: 2
# @PURPOSE: Unit tests for ReportsService list/detail operations
# @RELATION: TESTS ->[ReportsService:Class]
# @LAYER: Domain
import sys
from pathlib import Path
@@ -12,8 +13,8 @@ from unittest.mock import MagicMock, patch
from datetime import datetime, timezone, timedelta
# #region _make_task [TYPE Function]
# @RELATION BINDS_TO -> [test_report_service]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> test_report_service
def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCCESS",
started_at=None, finished_at=None, result=None, params=None, logs=None):
"""Create a mock Task object matching the Task model interface."""
@@ -29,7 +30,7 @@ def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCC
return task
# #endregion _make_task
# [/DEF:_make_task:Function]
class TestReportsServiceList:
"""Tests for ReportsService.list_reports."""
@@ -183,4 +184,4 @@ class TestReportsServiceDetail:
detail = svc.get_report_detail("ok-task")
assert detail.next_actions == []
# #endregion test_report_service
# [/DEF:test_report_service:Module]

View File

@@ -1,7 +1,7 @@
# #region __tests__/test_report_type_profiles [TYPE Module]
# @BRIEF Contract testing for task type profiles and resolution logic.
# @RELATION VERIFIES -> [../type_profiles.py]
# #endregion __tests__/test_report_type_profiles
# [DEF:__tests__/test_report_type_profiles:Module]
# @RELATION: VERIFIES -> ../type_profiles.py
# @PURPOSE: Contract testing for task type profiles and resolution logic.
# [/DEF:__tests__/test_report_type_profiles:Module]
import pytest
from src.models.report import TaskType
@@ -9,9 +9,9 @@ from src.services.reports.type_profiles import resolve_task_type, get_type_profi
# @TEST_CONTRACT: ResolveTaskType -> Invariants
# @TEST_INVARIANT: fallback_to_unknown
# #region test_resolve_task_type_fallbacks [TYPE Function]
# @BRIEF Verify resolve_task_type_fallbacks returns correct fallback type when primary is missing.
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
# [DEF:test_resolve_task_type_fallbacks:Function]
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
# @PURPOSE: Verify resolve_task_type_fallbacks returns correct fallback type when primary is missing.
def test_resolve_task_type_fallbacks():
"""Verify missing/unmapped plugin_id returns TaskType.UNKNOWN."""
assert resolve_task_type(None) == TaskType.UNKNOWN
@@ -20,11 +20,11 @@ def test_resolve_task_type_fallbacks():
assert resolve_task_type("invalid_plugin") == TaskType.UNKNOWN
# @TEST_FIXTURE: valid_plugin
# #endregion test_resolve_task_type_fallbacks
# [/DEF:test_resolve_task_type_fallbacks:Function]
# #region test_resolve_task_type_valid [TYPE Function]
# @BRIEF Verify resolve_task_type_valid returns the correct type when valid input is provided.
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
# [DEF:test_resolve_task_type_valid:Function]
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
# @PURPOSE: Verify resolve_task_type_valid returns the correct type when valid input is provided.
def test_resolve_task_type_valid():
"""Verify known plugin IDs map correctly."""
assert resolve_task_type("superset-migration") == TaskType.MIGRATION
@@ -33,11 +33,11 @@ def test_resolve_task_type_valid():
assert resolve_task_type("documentation") == TaskType.DOCUMENTATION
# @TEST_FIXTURE: valid_profile
# #endregion test_resolve_task_type_valid
# [/DEF:test_resolve_task_type_valid:Function]
# #region test_get_type_profile_valid [TYPE Function]
# @BRIEF Verify get_type_profile_valid returns the correct profile for a valid task type.
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
# [DEF:test_get_type_profile_valid:Function]
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
# @PURPOSE: Verify get_type_profile_valid returns the correct profile for a valid task type.
def test_get_type_profile_valid():
"""Verify known task types return correct profile metadata."""
profile = get_type_profile(TaskType.MIGRATION)
@@ -47,11 +47,11 @@ def test_get_type_profile_valid():
# @TEST_INVARIANT: always_returns_dict
# @TEST_EDGE: missing_profile
# #endregion test_get_type_profile_valid
# [/DEF:test_get_type_profile_valid:Function]
# #region test_get_type_profile_fallback [TYPE Function]
# @BRIEF Verify get_type_profile_fallback returns default profile when type is unknown.
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
# [DEF:test_get_type_profile_fallback:Function]
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
# @PURPOSE: Verify get_type_profile_fallback returns default profile when type is unknown.
def test_get_type_profile_fallback():
"""Verify unknown task type returns fallback profile."""
# Assuming TaskType.UNKNOWN or any non-mapped value
@@ -63,4 +63,4 @@ def test_get_type_profile_fallback():
profile_fallback = get_type_profile("non-enum-value")
assert profile_fallback["display_label"] == "Other / Unknown"
assert profile_fallback["fallback"] is True
# #endregion test_get_type_profile_fallback
# [/DEF:test_get_type_profile_fallback:Function]

View File

@@ -1,16 +1,15 @@
# #region normalizer [C:5] [TYPE Module] [SEMANTICS reports, normalization, tasks, fallback]
# @BRIEF Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
# @LAYER Domain
# @PRE session is active and valid
# @POST Returns Normalizer output with normalized fields
# @SIDE_EFFECT Read-only database operations
# @DATA_CONTRACT ReportRow -> NormalizerInput; session_id -> valid UUID
# @INVARIANT Normalizer instance maintains consistent field order
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [backend.src.core.task_manager.models.Task:Function]
# @RELATION DEPENDS_ON -> [backend.src.models.report:Function]
# @RELATION DEPENDS_ON -> [backend.src.services.reports.type_profiles:Function]
# @INVARIANT: Normalizer instance maintains consistent field order
# @DATA_CONTRACT: ReportRow -> NormalizerInput; session_id -> valid UUID
# @PRE: session is active and valid
# @POST: Returns Normalizer output with normalized fields
# @SIDE_EFFECT: Read-only database operations
# [SECTION: IMPORTS]
from datetime import datetime
from typing import Any, Dict, Optional
@@ -18,15 +17,12 @@ from ...core.logger import belief_scope
from ...core.task_manager.models import Task, TaskStatus
from ...models.report import ErrorContext, ReportStatus, TaskReport
from .type_profiles import get_type_profile, resolve_task_type
# [/SECTION]
# #region status_to_report_status [TYPE Function]
# @BRIEF Normalize internal task status to canonical report status.
# @PRE status may be known or unknown string/enum value.
# @POST Always returns one of canonical ReportStatus values.
# @PARAM: status (Any) - Internal task status value.
# @RETURN: ReportStatus - Canonical report status.
# @PRE: status may be known or unknown string/enum value.
# @POST: Always returns one of canonical ReportStatus values.
def status_to_report_status(status: Any) -> ReportStatus:
with belief_scope("status_to_report_status"):
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
@@ -42,11 +38,8 @@ def status_to_report_status(status: Any) -> ReportStatus:
# #region build_summary [TYPE Function]
# @BRIEF Build deterministic user-facing summary from task payload and status.
# @PRE report_status is canonical; plugin_id may be unknown.
# @POST Returns non-empty summary text.
# @PARAM: task (Task) - Source task object.
# @PARAM: report_status (ReportStatus) - Canonical status.
# @RETURN: str - Normalized summary.
# @PRE: report_status is canonical; plugin_id may be unknown.
# @POST: Returns non-empty summary text.
def build_summary(task: Task, report_status: ReportStatus) -> str:
with belief_scope("build_summary"):
result = task.result
@@ -67,11 +60,8 @@ def build_summary(task: Task, report_status: ReportStatus) -> str:
# #region extract_error_context [TYPE Function]
# @BRIEF Extract normalized error context and next actions for failed/partial reports.
# @PRE task is a valid Task object.
# @POST Returns ErrorContext for failed/partial when context exists; otherwise None.
# @PARAM: task (Task) - Source task.
# @PARAM: report_status (ReportStatus) - Canonical status.
# @RETURN: Optional[ErrorContext] - Error context block.
# @PRE: task is a valid Task object.
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
with belief_scope("extract_error_context"):
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
@@ -111,10 +101,8 @@ def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[E
# #region normalize_task_report [TYPE Function]
# @BRIEF Convert one Task to canonical TaskReport envelope.
# @PRE task has valid id and plugin_id fields.
# @POST Returns TaskReport with required fields and deterministic fallback behavior.
# @PARAM: task (Task) - Source task.
# @RETURN: TaskReport - Canonical normalized report.
# @PRE: task has valid id and plugin_id fields.
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
#
# @TEST_CONTRACT: NormalizeTaskReport ->
# {
@@ -170,4 +158,4 @@ def normalize_task_report(task: Task) -> TaskReport:
)
# #endregion normalize_task_report
# #endregion normalizer
# #endregion normalizer

View File

@@ -1,11 +1,6 @@
# #region report_service [C:5] [TYPE Module] [SEMANTICS reports, service, aggregation, filtering, pagination, detail]
# @BRIEF Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
# @LAYER Domain
# @PRE session is active and valid
# @POST Returns Report with generated summary
# @SIDE_EFFECT Read-only database operations; logs report generation
# @DATA_CONTRACT ReportQuery -> ReportRow; session_id -> valid UUID
# @INVARIANT ReportService maintains consistent report structure
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [TaskReport]
# @RELATION DEPENDS_ON -> [ReportQuery]
@@ -13,8 +8,12 @@
# @RELATION DEPENDS_ON -> [ReportDetailView]
# @RELATION DEPENDS_ON -> [normalize_task_report]
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# @INVARIANT: ReportService maintains consistent report structure
# @DATA_CONTRACT: ReportQuery -> ReportRow; session_id -> valid UUID
# @PRE: session is active and valid
# @POST: Returns Report with generated summary
# @SIDE_EFFECT: Read-only database operations; logs report generation
# [SECTION: IMPORTS]
from datetime import datetime, timezone
from typing import List, Optional
@@ -31,19 +30,18 @@ from ...models.report import (
)
from ..clean_release.repository import CleanReleaseRepository
from .normalizer import normalize_task_report
# [/SECTION]
# #region ReportsService [C:5] [TYPE Class]
# @BRIEF Service layer for list/detail report retrieval and normalization.
# @PRE TaskManager dependency is initialized.
# @POST Provides deterministic list/detail report responses.
# @SIDE_EFFECT Reads task history and optional clean-release repository state without mutating source records.
# @DATA_CONTRACT Input[TaskManager, Optional[CleanReleaseRepository], ReportQuery|report_id] -> Output[ReportCollection|ReportDetailView|None]
# @INVARIANT Service methods are read-only over task history source.
# @PRE: TaskManager dependency is initialized.
# @POST: Provides deterministic list/detail report responses.
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# @RELATION CALLS -> [normalize_task_report]
# @SIDE_EFFECT: Reads task history and optional clean-release repository state without mutating source records.
# @DATA_CONTRACT: Input[TaskManager, Optional[CleanReleaseRepository], ReportQuery|report_id] -> Output[ReportCollection|ReportDetailView|None]
# @INVARIANT: Service methods are read-only over task history source.
#
# @TEST_CONTRACT: ReportsServiceModel ->
# {
@@ -58,16 +56,17 @@ from .normalizer import normalize_task_report
# @TEST_EDGE: report_not_found -> get_report_detail returns None
# @TEST_INVARIANT: consistent_pagination -> verifies: [valid_service]
class ReportsService:
# #region init [C:5] [TYPE Function]
# @BRIEF Initialize service with TaskManager dependency.
# @PRE task_manager is a live TaskManager instance.
# @POST self.task_manager is assigned and ready for read operations.
# @SIDE_EFFECT Stores collaborator references for later read-only report projections.
# @DATA_CONTRACT Input[TaskManager, Optional[CleanReleaseRepository]] -> Output[ReportsService]
# @INVARIANT Constructor performs no task mutations.
# @RELATION BINDS_TO -> [ReportsService]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# [DEF:init:Function]
# @COMPLEXITY: 5
# @PURPOSE: Initialize service with TaskManager dependency.
# @PRE: task_manager is a live TaskManager instance.
# @POST: self.task_manager is assigned and ready for read operations.
# @INVARIANT: Constructor performs no task mutations.
# @RELATION: [BINDS_TO] ->[ReportsService]
# @RELATION: [DEPENDS_ON] ->[TaskManager]
# @RELATION: [DEPENDS_ON] ->[CleanReleaseRepository]
# @SIDE_EFFECT: Stores collaborator references for later read-only report projections.
# @DATA_CONTRACT: Input[TaskManager, Optional[CleanReleaseRepository]] -> Output[ReportsService]
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
def __init__(
self,
@@ -78,27 +77,27 @@ class ReportsService:
self.task_manager = task_manager
self.clean_release_repository = clean_release_repository
# #endregion init
# [/DEF:init:Function]
# #region _load_normalized_reports [TYPE Function]
# @BRIEF Build normalized reports from all available tasks.
# @PRE Task manager returns iterable task history records.
# @POST Returns normalized report list preserving source cardinality.
# @INVARIANT Every returned item is a TaskReport.
# @RETURN List[TaskReport] - Reports sorted later by list logic.
# [DEF:_load_normalized_reports:Function]
# @PURPOSE: Build normalized reports from all available tasks.
# @PRE: Task manager returns iterable task history records.
# @POST: Returns normalized report list preserving source cardinality.
# @INVARIANT: Every returned item is a TaskReport.
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
def _load_normalized_reports(self) -> List[TaskReport]:
with belief_scope("_load_normalized_reports"):
tasks = self.task_manager.get_all_tasks()
reports = [normalize_task_report(task) for task in tasks]
return reports
# #endregion _load_normalized_reports
# [/DEF:_load_normalized_reports:Function]
# #region _to_utc_datetime [TYPE Function]
# @BRIEF Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
# @PRE value is either datetime or None.
# @POST Returns UTC-aware datetime or None.
# @INVARIANT Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
# [DEF:_to_utc_datetime:Function]
# @PURPOSE: Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
# @PRE: value is either datetime or None.
# @POST: Returns UTC-aware datetime or None.
# @INVARIANT: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
# @PARAM: value (Optional[datetime]) - Source datetime value.
# @RETURN: Optional[datetime] - UTC-aware datetime or None.
def _to_utc_datetime(self, value: Optional[datetime]) -> Optional[datetime]:
@@ -109,13 +108,13 @@ class ReportsService:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
# #endregion _to_utc_datetime
# [/DEF:_to_utc_datetime:Function]
# #region _datetime_sort_key [TYPE Function]
# @BRIEF Produce stable numeric sort key for report timestamps.
# @PRE report contains updated_at datetime.
# @POST Returns float timestamp suitable for deterministic sorting.
# @INVARIANT Mixed naive/aware datetimes never raise TypeError.
# [DEF:_datetime_sort_key:Function]
# @PURPOSE: Produce stable numeric sort key for report timestamps.
# @PRE: report contains updated_at datetime.
# @POST: Returns float timestamp suitable for deterministic sorting.
# @INVARIANT: Mixed naive/aware datetimes never raise TypeError.
# @PARAM: report (TaskReport) - Report item.
# @RETURN: float - UTC timestamp key.
def _datetime_sort_key(self, report: TaskReport) -> float:
@@ -125,13 +124,13 @@ class ReportsService:
return 0.0
return updated.timestamp()
# #endregion _datetime_sort_key
# [/DEF:_datetime_sort_key:Function]
# #region _matches_query [TYPE Function]
# @BRIEF Apply query filtering to a report.
# @PRE report and query are normalized schema instances.
# @POST Returns True iff report satisfies all active query filters.
# @INVARIANT Filter evaluation is side-effect free.
# [DEF:_matches_query:Function]
# @PURPOSE: Apply query filtering to a report.
# @PRE: report and query are normalized schema instances.
# @POST: Returns True iff report satisfies all active query filters.
# @INVARIANT: Filter evaluation is side-effect free.
# @PARAM: report (TaskReport) - Candidate report.
# @PARAM: query (ReportQuery) - Applied query.
# @RETURN: bool - True if report matches all filters.
@@ -164,13 +163,13 @@ class ReportsService:
return False
return True
# #endregion _matches_query
# [/DEF:_matches_query:Function]
# #region _sort_reports [TYPE Function]
# @BRIEF Sort reports deterministically according to query settings.
# @PRE reports contains only TaskReport items.
# @POST Returns reports ordered by selected sort field and order.
# @INVARIANT Sorting criteria are deterministic for equal input.
# [DEF:_sort_reports:Function]
# @PURPOSE: Sort reports deterministically according to query settings.
# @PRE: reports contains only TaskReport items.
# @POST: Returns reports ordered by selected sort field and order.
# @INVARIANT: Sorting criteria are deterministic for equal input.
# @PARAM: reports (List[TaskReport]) - Filtered reports.
# @PARAM: query (ReportQuery) - Sort config.
# @RETURN: List[TaskReport] - Sorted reports.
@@ -189,12 +188,12 @@ class ReportsService:
return reports
# #endregion _sort_reports
# [/DEF:_sort_reports:Function]
# #region list_reports [TYPE Function]
# @BRIEF Return filtered, sorted, paginated report collection.
# @PRE query has passed schema validation.
# @POST Returns {items,total,page,page_size,has_next,applied_filters}.
# [DEF:list_reports:Function]
# @PURPOSE: Return filtered, sorted, paginated report collection.
# @PRE: query has passed schema validation.
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
# @PARAM: query (ReportQuery) - List filters and pagination.
# @RETURN: ReportCollection - Paginated unified reports payload.
def list_reports(self, query: ReportQuery) -> ReportCollection:
@@ -220,12 +219,12 @@ class ReportsService:
applied_filters=query,
)
# #endregion list_reports
# [/DEF:list_reports:Function]
# #region get_report_detail [TYPE Function]
# @BRIEF Return one normalized report with timeline/diagnostics/next actions.
# @PRE report_id exists in normalized report set.
# @POST Returns normalized detail envelope with diagnostics and next actions where applicable.
# [DEF:get_report_detail:Function]
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
# @PRE: report_id exists in normalized report set.
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
# @PARAM: report_id (str) - Stable report identifier.
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
@@ -298,8 +297,9 @@ class ReportsService:
next_actions=next_actions,
)
# #endregion get_report_detail
# [/DEF:get_report_detail:Function]
# #endregion ReportsService
# #endregion report_service

View File

@@ -1,12 +1,10 @@
# #region type_profiles [C:2] [TYPE Module]
# @BRIEF Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
# [SECTION: IMPORTS]
from typing import Any, Dict, Optional
from ...core.logger import belief_scope
from ...models.report import TaskType
# [/SECTION]
# #region PLUGIN_TO_TASK_TYPE [TYPE Data]
# @BRIEF Maps plugin identifiers to normalized report task types.
@@ -71,10 +69,8 @@ TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
# #region resolve_task_type [TYPE Function]
# @BRIEF Resolve canonical task type from plugin/task identifier with guaranteed fallback.
# @PRE plugin_id may be None or unknown.
# @POST Always returns one of TaskType enum values.
# @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record.
# @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback.
# @PRE: plugin_id may be None or unknown.
# @POST: Always returns one of TaskType enum values.
#
# @TEST_CONTRACT: ResolveTaskType ->
# {
@@ -99,10 +95,8 @@ def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
# #region get_type_profile [TYPE Function]
# @BRIEF Return deterministic profile metadata for a task type.
# @PRE task_type may be known or unknown.
# @POST Returns a profile dict and never raises for unknown types.
# @PARAM: task_type (TaskType) - Canonical task type.
# @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts.
# @PRE: task_type may be known or unknown.
# @POST: Returns a profile dict and never raises for unknown types.
#
# @TEST_CONTRACT: GetTypeProfile ->
# {

View File

@@ -1,44 +1,41 @@
# #region ResourceServiceModule [C:3] [TYPE Module] [SEMANTICS service, resources, dashboards, datasets, tasks, git]
# @BRIEF Shared service for fetching resource data with Git status and task status
# @LAYER Service
# @INVARIANT All resources include metadata about their current state
# @LAYER: Service
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [TaskManagerPackage]
# @RELATION DEPENDS_ON -> [TaskManagerModels]
# @RELATION DEPENDS_ON -> [GitService]
# @INVARIANT: All resources include metadata about their current state
# [SECTION: IMPORTS]
from typing import List, Dict, Optional, Any
from datetime import datetime, timezone
from ..core.superset_client import SupersetClient
from ..core.task_manager.models import Task
from ..services.git_service import GitService
from ..core.cot_logger import MarkerLogger
from ..core.logger import logger, belief_scope
log = MarkerLogger("ResourceService")
# [/SECTION]
# #region ResourceService [C:3] [TYPE Class]
# @BRIEF Provides centralized access to resource data with enhanced metadata
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [GitService]
class ResourceService:
# #region ResourceService_init [C:1] [TYPE Function]
# @BRIEF Initialize the resource service with dependencies
# @PRE None
# @POST ResourceService is ready to fetch resources
# [DEF:ResourceService_init:Function]
# @COMPLEXITY: 1
# @PURPOSE: Initialize the resource service with dependencies
# @PRE: None
# @POST: ResourceService is ready to fetch resources
def __init__(self):
with belief_scope("ResourceService.__init__"):
self.git_service = GitService()
log.reason("Initialized ResourceService")
# #endregion ResourceService_init
logger.info("[ResourceService][Action] Initialized ResourceService")
# [/DEF:ResourceService_init:Function]
# #region get_dashboards_with_status [C:3] [TYPE Function]
# @BRIEF Fetch dashboards from environment with Git status and last task status
# @PRE env is a valid Environment object
# @POST Returns list of dashboards with enhanced metadata
# [DEF:get_dashboards_with_status:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch dashboards from environment with Git status and last task status
# @PRE: env is a valid Environment object
# @POST: Returns list of dashboards with enhanced metadata
# @PARAM: env (Environment) - The environment to fetch from
# @PARAM: tasks (List[Task]) - List of tasks to check for status
# @RETURN: List[Dict] - Dashboards with git_status and last_task fields
@@ -80,14 +77,15 @@ class ResourceService:
result.append(dashboard_dict)
log.reflect(f"Fetched {len(result)} dashboards with status")
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} dashboards with status")
return result
# #endregion get_dashboards_with_status
# [/DEF:get_dashboards_with_status:Function]
# #region get_dashboards_page_with_status [C:3] [TYPE Function]
# @BRIEF Fetch one dashboard page from environment and enrich only that page with status metadata.
# @PRE env is valid; page >= 1; page_size > 0.
# @POST Returns page items plus total counters without scanning all pages locally.
# [DEF:get_dashboards_page_with_status:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch one dashboard page from environment and enrich only that page with status metadata.
# @PRE: env is valid; page >= 1; page_size > 0.
# @POST: Returns page items plus total counters without scanning all pages locally.
# @PARAM: env (Environment) - Source environment.
# @PARAM: tasks (Optional[List[Task]]) - Tasks for latest LLM status.
# @PARAM: page (int) - 1-based page number.
@@ -136,18 +134,25 @@ class ResourceService:
result.append(dashboard_dict)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
log.reflect(f"Fetched dashboards page {page}/{total_pages} ({len(result)} items, total={total})")
logger.info(
"[ResourceService][Coherence:OK] Fetched dashboards page %s/%s (%s items, total=%s)",
page,
total_pages,
len(result),
total,
)
return {
"dashboards": result,
"total": total,
"total_pages": total_pages,
}
# #endregion get_dashboards_page_with_status
# [/DEF:get_dashboards_page_with_status:Function]
# #region _get_last_llm_task_for_dashboard [C:3] [TYPE Function]
# @BRIEF Get most recent LLM validation task for a dashboard in an environment
# @PRE dashboard_id is a valid integer identifier
# @POST Returns the newest llm_dashboard_validation task summary or None
# [DEF:_get_last_llm_task_for_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Get most recent LLM validation task for a dashboard in an environment
# @PRE: dashboard_id is a valid integer identifier
# @POST: Returns the newest llm_dashboard_validation task summary or None
# @PARAM: dashboard_id (int) - The dashboard ID
# @PARAM: env_id (Optional[str]) - Environment ID to match task params
# @PARAM: tasks (Optional[List[Task]]) - List of tasks to search
@@ -225,12 +230,13 @@ class ResourceService:
"status": self._normalize_task_status(getattr(latest_task, "status", "")),
"validation_status": validation_status,
}
# #endregion _get_last_llm_task_for_dashboard
# [/DEF:_get_last_llm_task_for_dashboard:Function]
# #region _normalize_task_status [C:3] [TYPE Function]
# @BRIEF Normalize task status to stable uppercase values for UI/API projections
# @PRE raw_status can be enum or string
# @POST Returns uppercase status without enum class prefix
# [DEF:_normalize_task_status:Function]
# @COMPLEXITY: 3
# @PURPOSE: Normalize task status to stable uppercase values for UI/API projections
# @PRE: raw_status can be enum or string
# @POST: Returns uppercase status without enum class prefix
# @PARAM: raw_status (Any) - Raw task status object/value
# @RETURN: str - Normalized status token
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
@@ -242,12 +248,13 @@ class ResourceService:
if "." in status_text:
status_text = status_text.split(".")[-1]
return status_text.upper()
# #endregion _normalize_task_status
# [/DEF:_normalize_task_status:Function]
# #region _normalize_validation_status [C:3] [TYPE Function]
# @BRIEF Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN
# @PRE raw_status can be any scalar type
# @POST Returns normalized validation status token or None
# [DEF:_normalize_validation_status:Function]
# @COMPLEXITY: 3
# @PURPOSE: Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN
# @PRE: raw_status can be any scalar type
# @POST: Returns normalized validation status token or None
# @PARAM: raw_status (Any) - Raw validation status from task result
# @RETURN: Optional[str] - PASS|FAIL|WARN|UNKNOWN
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
@@ -258,12 +265,13 @@ class ResourceService:
if status_text in {"PASS", "FAIL", "WARN"}:
return status_text
return "UNKNOWN"
# #endregion _normalize_validation_status
# [/DEF:_normalize_validation_status:Function]
# #region _normalize_datetime_for_compare [C:3] [TYPE Function]
# @BRIEF Normalize datetime values to UTC-aware values for safe comparisons.
# @PRE value may be datetime or any scalar.
# @POST Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime.
# [DEF:_normalize_datetime_for_compare:Function]
# @COMPLEXITY: 3
# @PURPOSE: Normalize datetime values to UTC-aware values for safe comparisons.
# @PRE: value may be datetime or any scalar.
# @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime.
# @PARAM: value (Any) - Candidate datetime-like value.
# @RETURN: datetime - UTC-aware comparable datetime.
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
@@ -274,12 +282,13 @@ class ResourceService:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
return datetime.min.replace(tzinfo=timezone.utc)
# #endregion _normalize_datetime_for_compare
# [/DEF:_normalize_datetime_for_compare:Function]
# #region get_datasets_with_status [C:3] [TYPE Function]
# @BRIEF Fetch datasets from environment with mapping progress and last task status
# @PRE env is a valid Environment object
# @POST Returns list of datasets with enhanced metadata
# [DEF:get_datasets_with_status:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch datasets from environment with mapping progress and last task status
# @PRE: env is a valid Environment object
# @POST: Returns list of datasets with enhanced metadata
# @PARAM: env (Environment) - The environment to fetch from
# @PARAM: tasks (List[Task]) - List of tasks to check for status
# @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields
@@ -310,14 +319,15 @@ class ResourceService:
result.append(dataset_dict)
log.reflect(f"Fetched {len(result)} datasets with status")
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} datasets with status")
return result
# #endregion get_datasets_with_status
# [/DEF:get_datasets_with_status:Function]
# #region get_activity_summary [C:3] [TYPE Function]
# @BRIEF Get summary of active and recent tasks for the activity indicator
# @PRE tasks is a list of Task objects
# @POST Returns summary with active_count and recent_tasks
# [DEF:get_activity_summary:Function]
# @COMPLEXITY: 3
# @PURPOSE: Get summary of active and recent tasks for the activity indicator
# @PRE: tasks is a list of Task objects
# @POST: Returns summary with active_count and recent_tasks
# @PARAM: tasks (List[Task]) - List of tasks to summarize
# @RETURN: Dict - Activity summary
# @RELATION: CALLS ->[_extract_resource_name_from_task]
@@ -353,12 +363,13 @@ class ResourceService:
'active_count': len(active_tasks),
'recent_tasks': recent_tasks_formatted
}
# #endregion get_activity_summary
# [/DEF:get_activity_summary:Function]
# #region _get_git_status_for_dashboard [C:3] [TYPE Function]
# @BRIEF Get Git sync status for a dashboard
# @PRE dashboard_id is a valid integer
# @POST Returns git status or None if no repo exists
# [DEF:_get_git_status_for_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Get Git sync status for a dashboard
# @PRE: dashboard_id is a valid integer
# @POST: Returns git status or None if no repo exists
# @PARAM: dashboard_id (int) - The dashboard ID
# @RETURN: Optional[Dict] - Git status with branch and sync_status
# @RELATION: CALLS ->[get_repo]
@@ -397,7 +408,7 @@ class ResourceService:
'has_changes_for_commit': has_changes_for_commit
}
except Exception:
log.explore(f"Failed to get git status for dashboard {dashboard_id}", error="Git status check failed")
logger.warning(f"[ResourceService][Warning] Failed to get git status for dashboard {dashboard_id}")
return {
'branch': None,
'sync_status': 'ERROR',
@@ -412,12 +423,13 @@ class ResourceService:
'has_repo': False,
'has_changes_for_commit': False
}
# #endregion _get_git_status_for_dashboard
# [/DEF:_get_git_status_for_dashboard:Function]
# #region _get_last_task_for_resource [C:3] [TYPE Function]
# @BRIEF Get the most recent task for a specific resource
# @PRE resource_id is a valid string
# @POST Returns task summary or None if no tasks found
# [DEF:_get_last_task_for_resource:Function]
# @COMPLEXITY: 3
# @PURPOSE: Get the most recent task for a specific resource
# @PRE: resource_id is a valid string
# @POST: Returns task summary or None if no tasks found
# @PARAM: resource_id (str) - The resource identifier (e.g., "dashboard-123")
# @PARAM: tasks (Optional[List[Task]]) - List of tasks to search
# @RETURN: Optional[Dict] - Task summary with task_id and status
@@ -450,29 +462,32 @@ class ResourceService:
'task_id': str(last_task.id),
'status': last_task.status
}
# #endregion _get_last_task_for_resource
# [/DEF:_get_last_task_for_resource:Function]
# #region _extract_resource_name_from_task [C:3] [TYPE Function]
# @BRIEF Extract resource name from task params
# @PRE task is a valid Task object
# @POST Returns resource name or task ID
# [DEF:_extract_resource_name_from_task:Function]
# @COMPLEXITY: 3
# @PURPOSE: Extract resource name from task params
# @PRE: task is a valid Task object
# @POST: Returns resource name or task ID
# @PARAM: task (Task) - The task to extract from
# @RETURN: str - Resource name or fallback
# @RELATION: USED_BY ->[get_activity_summary]
def _extract_resource_name_from_task(self, task: Task) -> str:
params = task.params or {}
return params.get('resource_name', f"Task {task.id}")
# #endregion _extract_resource_name_from_task
# [/DEF:_extract_resource_name_from_task:Function]
# #region _extract_resource_type_from_task [C:3] [TYPE Function]
# @BRIEF Extract resource type from task params
# @PRE task is a valid Task object
# @POST Returns resource type or 'unknown'
# [DEF:_extract_resource_type_from_task:Function]
# @COMPLEXITY: 3
# @PURPOSE: Extract resource type from task params
# @PRE: task is a valid Task object
# @POST: Returns resource type or 'unknown'
# @PARAM: task (Task) - The task to extract from
# @RETURN: str - Resource type
# @RELATION: USED_BY ->[get_activity_summary]
def _extract_resource_type_from_task(self, task: Task) -> str:
params = task.params or {}
return params.get('resource_type', 'unknown')
# #endregion _extract_resource_type_from_task
# [/DEF:_extract_resource_type_from_task:Function]
# #endregion ResourceService
# #endregion ResourceServiceModule