refactor(semantics): close dashboard testing protocol audit

This commit is contained in:
2026-10-02 12:47:12 +03:00
parent aacd1bd039
commit 54bdbcc403
99 changed files with 21443 additions and 7307 deletions

View File

@@ -34,7 +34,6 @@ indexing:
- 'coverage_html_frontend/'
- 'coverage/'
- '*,cover'
- docker/
- html/
- build/
- dist/
@@ -47,6 +46,7 @@ indexing:
- frontend/src
- frontend/tests
doc_dirs:
- scripts/stage6_soak/README.md
- docs
- specs
- .agents

View File

@@ -0,0 +1,120 @@
# #region Api.GitCommitMessage.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
# #region Api.GitCommitMessage.Execution.generate_commit_message [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def generate_commit_message_implementation(authority, dashboard_ref: str, env_id: str | None, purpose: str, language: str, config_manager, db: Session, _):
_gs = authority.get_git_service()
with authority.belief_scope("generate_commit_message"):
from . import _resolve_dashboard_id_from_ref
try:
if purpose not in {"commit", "summary"}:
raise authority.HTTPException(status_code=422, detail="purpose must be 'commit' or 'summary'")
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
staged_diff = await authority._await_service_result(_gs.get_diff(dashboard_id, staged=True))
unstaged_diff = await authority._await_service_result(_gs.get_diff(dashboard_id, staged=False))
if purpose == "summary":
diff = "\n\n".join(part for part in (staged_diff, unstaged_diff) if part)
else:
diff = staged_diff or unstaged_diff
if not diff:
return {"summary": ""} if purpose == "summary" else {"message": "No changes detected"}
history: list[str] = []
if purpose == "commit":
history_objs = await authority._await_service_result(_gs.get_commit_history(dashboard_id, limit=5))
history = [h.message for h in history_objs if hasattr(h, "message")]
from src.plugins.llm_analysis.models import LLMProviderType
from src.plugins.llm_analysis.service import LLMClient
from src.services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
from src.services.llm_provider import LLMProviderService
llm_service = LLMProviderService(db)
provider, llm_settings = _commit_provider(authority, llm_service, config_manager, normalize_llm_settings, resolve_bound_provider_id)
api_key = llm_service.get_decrypted_api_key(provider.id)
client = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model,
)
from src.plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
if purpose == "summary":
from src.services.git_summary_cache import build_git_summary_cache_key, git_summary_cache
summary_prompt = llm_settings["prompts"].get(
"git_change_summary_prompt",
DEFAULT_LLM_PROMPTS["git_change_summary_prompt"],
)
summary_language = language[:40].strip() or "Russian"
cache_key, content_hash = build_git_summary_cache_key(
diff,
language=summary_language,
prompt_template=summary_prompt,
model=client.default_model,
)
# #region Api.GitCommitMessage.Execution.generate_summary [C:2] [TYPE Function]
async def generate_summary() -> str:
return await extension.summarize_changes(
diff,
language=summary_language,
prompt_template=summary_prompt,
)
# #endregion Api.GitCommitMessage.Execution.generate_summary
summary, cache_hit = await git_summary_cache.get_or_create(cache_key, generate_summary)
authority.logger.info(
"Git summary cache lookup",
extra={"content_hash": content_hash, "cache_hit": cache_hit},
)
return {"summary": summary, "content_hash": content_hash, "cache_hit": cache_hit}
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
message = await extension.suggest_commit_message(diff, history, prompt_template=git_prompt)
return {"message": message}
except authority.HTTPException:
raise
except Exception as e:
authority._handle_unexpected_git_route_error("generate_commit_message", e)
# #endregion Api.GitCommitMessage.Execution.generate_commit_message
# #region Api.GitCommitMessage.Execution.Provider [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _commit_provider(authority, llm_service, config_manager, normalize_llm_settings, resolve_bound_provider_id):
providers = llm_service.get_all_providers()
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise authority.HTTPException(status_code=400, detail="No active LLM provider found")
return provider, llm_settings
# #endregion Api.GitCommitMessage.Execution.Provider
# #endregion Api.GitCommitMessage.Execution

View File

@@ -0,0 +1,187 @@
# #region Api.GitDeployment.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from src.models.auth import User
from src.api.routes.git_schemas import DeployRequest
# #region Api.GitDeployment.Execution.deploy_dashboard [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def deploy_dashboard_implementation(authority, dashboard_ref: str, deploy_data: DeployRequest, env_id: str | None, config_manager, db: Session, current_user: User, _):
with authority.belief_scope("deploy_dashboard"):
from . import _resolve_dashboard_id_from_ref
from src.models.deployment import DeploymentRecord
from src.models.git import DeploymentEnvironment
from src.plugins.git_fingerprint import _compute_content_hash
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
target = authority._resolve_stage_environment(deploy_data.stage, db, config_manager)
release_to_publish: authority.DashboardRelease | None = None
if deploy_data.stage == "prod":
release_to_publish, source_branch = await _publication_preconditions(authority, db, dashboard_id, deploy_data, config_manager, dashboard_ref, DeploymentRecord, _compute_content_hash)
else:
source_branch = deploy_data.source_branch
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
result = await plugin.execute(
{
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": target.id,
"commit_hash": deploy_data.commit_hash,
"source_branch": source_branch,
}
)
if deploy_data.stage == "preprod":
# PREPROD is a single shared slot: retain older candidates for audit,
# but make only the newly deployed record eligible for approval/publish.
db.expire_all()
repository = db.query(authority.GitRepository).filter(authority.GitRepository.dashboard_id == dashboard_id).first()
current_candidate = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == target.id,
DeploymentRecord.status == "success",
)
.order_by(DeploymentRecord.deployed_at.desc(), DeploymentRecord.id.desc())
.first()
) if repository else None
if current_candidate:
(
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == target.id,
DeploymentRecord.status == "success",
DeploymentRecord.id != current_candidate.id,
)
.update({"status": "superseded", "validation_status": "superseded"}, synchronize_session=False)
)
(
db.query(authority.DashboardRelease)
.filter(
authority.DashboardRelease.repository_id == repository.id,
authority.DashboardRelease.deployment_id != current_candidate.id,
authority.DashboardRelease.status.in_(["awaiting_approval", "ready_to_publish"]),
)
.update({"status": "superseded"}, synchronize_session=False)
)
db.commit()
elif release_to_publish:
# FR-012: Publish gate — verify immutability before committing
from src.services.dashboard_testing.verification_publish_gate import (
PublishBlockedError,
run_publish_gate_verification,
)
try:
await run_publish_gate_verification(db, release_to_publish.id)
except PublishBlockedError as gate_err:
authority.logger.explore("Publish gate blocked publication", src="deploy_dashboard", payload={"release_id": release_to_publish.id,
"detail": str(gate_err)}, error="PublishBlockedError")
raise authority.HTTPException(
status_code=409,
detail=(
"Cannot publish: immutability verification failed. "
"One or more baseline entries with a closed immutability "
"period have data integrity violations. "
"Detail: " + str(gate_err)
),
) from gate_err
release_to_publish.status = "published"
release_to_publish.published_at = authority.datetime.now(authority.UTC)
release_to_publish.published_by = current_user.username
db.commit()
return result
except authority.HTTPException:
raise
except Exception as e:
authority._handle_unexpected_git_route_error("deploy_dashboard", e)
# #endregion Api.GitDeployment.Execution.deploy_dashboard
# #region Api.GitDeployment.Execution.Preconditions [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
async def _publication_preconditions(authority, db, dashboard_id, deploy_data, config_manager, dashboard_ref, DeploymentRecord, _compute_content_hash):
repository = (
db.query(authority.GitRepository)
.filter(authority.GitRepository.dashboard_id == dashboard_id)
.first()
)
if not repository:
raise authority.HTTPException(status_code=409, detail="Dashboard repository is not initialized")
policy = authority._resolve_repository_policy(repository, config_manager)
if not deploy_data.release_id:
raise authority.HTTPException(status_code=409, detail="Create and publish a named dashboard release before deploying to PROD")
release_to_publish = db.query(authority.DashboardRelease).filter(
authority.DashboardRelease.id == deploy_data.release_id,
authority.DashboardRelease.repository_id == repository.id,
).first()
if not release_to_publish:
raise authority.HTTPException(status_code=404, detail="Dashboard release not found")
if release_to_publish.status != "ready_to_publish":
raise authority.HTTPException(status_code=409, detail="Dashboard release must be approved before publication")
latest_preprod = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == authority._resolve_stage_environment("preprod", db, config_manager).id,
DeploymentRecord.status == "success",
)
.order_by(DeploymentRecord.deployed_at.desc())
.first()
)
if latest_preprod and policy.block_publish_on_drift:
drift_status, _ = await authority._probe_drift(
dashboard_ref, latest_preprod.environment_id, latest_preprod.content_hash, config_manager,
commit_hash=latest_preprod.commit_hash,
)
if drift_status != "in_sync":
raise authority.HTTPException(
status_code=409,
detail="PREPROD version differs from the recorded release candidate; synchronize or redeploy before publishing",
)
from src.services.git_service import GitService
repo = await GitService().get_repo(dashboard_id)
selected_commit = repo.commit(deploy_data.commit_hash).hexsha if deploy_data.commit_hash else None
current_hash = _compute_content_hash(authority.Path(repo.working_dir))
if (
not latest_preprod
or latest_preprod.commit_hash != release_to_publish.commit_hash
or latest_preprod.content_hash != release_to_publish.content_hash
or (selected_commit is not None and selected_commit != release_to_publish.commit_hash)
):
raise authority.HTTPException(status_code=409, detail="PREPROD no longer matches the named release; create a new release")
if (
not latest_preprod
or (policy.require_prod_approval and latest_preprod.validation_status != "validated")
or (
policy.approval_expires_hours > 0
and release_to_publish.approved_at
and (authority.datetime.now(authority.UTC) - release_to_publish.approved_at.replace(tzinfo=authority.UTC)).total_seconds() > policy.approval_expires_hours * 3600
)
or (
latest_preprod.commit_hash != selected_commit
if selected_commit
else latest_preprod.content_hash != current_hash
)
):
raise authority.HTTPException(
status_code=409,
detail="Deploy the current dashboard content to PREPROD and validate it before publishing to PROD",
)
source_branch = (latest_preprod.resources_changed or {}).get("source_branch")
return release_to_publish, source_branch
# #endregion Api.GitDeployment.Execution.Preconditions
# #endregion Api.GitDeployment.Execution

View File

@@ -1,4 +1,5 @@
# #region Api.Helpers.GitHelpers [C:3] [TYPE Module] [SEMANTICS fastapi, git, api]
# @RELATION DEPENDS_ON -> [Api.GitIdentity.Execution]
# @defgroup Api Module group.
# @BRIEF Shared helper functions for Git route modules.
# @LAYER API
@@ -6,6 +7,8 @@
# @RELATION CALLS -> [Core.Init.SupersetClient]
# @RELATION CALLS -> [Models.Profile.UserDashboardPreference]
import sys as _sys
from ._identity_execution import (_resolve_current_user_git_identity_implementation, _resolve_current_user_git_token_implementation, _apply_git_identity_from_profile_implementation)
import inspect
import os
from typing import Any
@@ -324,103 +327,40 @@ def _sanitize_optional_identity_value(value: str | None) -> str | None:
# #region Api.Helpers.ResolveCurrentUserGitIdentity [C:2] [TYPE Function]
# @RELATION CALLS -> [Api.GitIdentity.Execution.resolve_current_user_git_identity]
# @BRIEF Resolve configured Git username/email from current user's profile preferences.
def _resolve_current_user_git_identity(
db: Session,
current_user: User | None,
) -> tuple[str, str] | None:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = db.query(UserDashboardPreference).filter(UserDashboardPreference.user_id == user_id).first()
except Exception as resolve_error:
logger.explore(
"Failed to load profile preference for resolving git identity",
extra={"src": "_resolve_current_user_git_identity", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
git_username = _sanitize_optional_identity_value(getattr(preference, "git_username", None))
git_email = _sanitize_optional_identity_value(getattr(preference, "git_email", None))
if not git_username or not git_email:
return None
return git_username, git_email
return _resolve_current_user_git_identity_implementation(_sys.modules[__name__], db, current_user)
# #endregion Api.Helpers.ResolveCurrentUserGitIdentity
# #region Api.Helpers.ResolveCurrentUserGitToken [C:2] [TYPE Function]
# @RELATION CALLS -> [Api.GitIdentity.Execution.resolve_current_user_git_token]
# @BRIEF Resolve and decrypt the Git personal access token from current user's profile preferences.
def _resolve_current_user_git_token(
db: Session,
current_user: User | None,
) -> str | None:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = db.query(UserDashboardPreference).filter(UserDashboardPreference.user_id == user_id).first()
except Exception as resolve_error:
logger.explore(
"Failed to load profile preference for resolving git PAT",
extra={"src": "_resolve_current_user_git_token", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
encrypted_token = getattr(preference, "git_personal_access_token_encrypted", None)
if not encrypted_token:
return None
try:
encryption = EncryptionManager()
return encryption.decrypt(encrypted_token)
except Exception as decrypt_error:
logger.explore(
"Failed to decrypt git PAT from profile",
extra={"src": "_resolve_current_user_git_token", "user_id": user_id, "error": str(decrypt_error)},
)
return None
return _resolve_current_user_git_token_implementation(_sys.modules[__name__], db, current_user)
# #endregion Api.Helpers.ResolveCurrentUserGitToken
# #region Api.Helpers.ApplyGitIdentityFromProfile [C:2] [TYPE Function]
# @RELATION CALLS -> [Api.GitIdentity.Execution.apply_git_identity_from_profile]
# @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations.
async def _apply_git_identity_from_profile(
dashboard_id: int,
db: Session,
current_user: User | None,
) -> None:
identity = _resolve_current_user_git_identity(db, current_user)
if not identity:
return
git_service = get_git_service()
configure_identity_fn = getattr(git_service, "configure_identity", None)
if not callable(configure_identity_fn):
return
git_username, git_email = identity
result = configure_identity_fn(dashboard_id, git_username, git_email)
if inspect.isawaitable(result):
await result
return await _apply_git_identity_from_profile_implementation(_sys.modules[__name__], dashboard_id, db, current_user)
# #endregion Api.Helpers.ApplyGitIdentityFromProfile

View File

@@ -0,0 +1,101 @@
# #region Api.GitIdentity.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from src.models.auth import User
# #region Api.GitIdentity.Execution.resolve_current_user_git_identity [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _resolve_current_user_git_identity_implementation(authority, db: Session, current_user: User | None):
if db is None or not hasattr(db, "query"):
return None
user_id = authority._sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = db.query(authority.UserDashboardPreference).filter(authority.UserDashboardPreference.user_id == user_id).first()
except Exception as resolve_error:
authority.logger.explore(
"Failed to load profile preference for resolving git identity",
extra={"src": "_resolve_current_user_git_identity", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
git_username = authority._sanitize_optional_identity_value(getattr(preference, "git_username", None))
git_email = authority._sanitize_optional_identity_value(getattr(preference, "git_email", None))
if not git_username or not git_email:
return None
return git_username, git_email
# #endregion Api.GitIdentity.Execution.resolve_current_user_git_identity
# #region Api.GitIdentity.Execution.resolve_current_user_git_token [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _resolve_current_user_git_token_implementation(authority, db: Session, current_user: User | None):
if db is None or not hasattr(db, "query"):
return None
user_id = authority._sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = db.query(authority.UserDashboardPreference).filter(authority.UserDashboardPreference.user_id == user_id).first()
except Exception as resolve_error:
authority.logger.explore(
"Failed to load profile preference for resolving git PAT",
extra={"src": "_resolve_current_user_git_token", "payload": {"user_id": user_id}, "error": str(resolve_error)},
)
return None
if not preference:
return None
encrypted_token = getattr(preference, "git_personal_access_token_encrypted", None)
if not encrypted_token:
return None
try:
encryption = authority.EncryptionManager()
return encryption.decrypt(encrypted_token)
except Exception as decrypt_error:
authority.logger.explore(
"Failed to decrypt git PAT from profile",
extra={"src": "_resolve_current_user_git_token", "user_id": user_id, "error": str(decrypt_error)},
)
return None
# #endregion Api.GitIdentity.Execution.resolve_current_user_git_token
# #region Api.GitIdentity.Execution.apply_git_identity_from_profile [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def _apply_git_identity_from_profile_implementation(authority, dashboard_id: int, db: Session, current_user: User | None):
identity = authority._resolve_current_user_git_identity(db, current_user)
if not identity:
return
git_service = authority.get_git_service()
configure_identity_fn = getattr(git_service, "configure_identity", None)
if not callable(configure_identity_fn):
return
git_username, git_email = identity
result = configure_identity_fn(dashboard_id, git_username, git_email)
if authority.inspect.isawaitable(result):
await result
# #endregion Api.GitIdentity.Execution.apply_git_identity_from_profile
# #endregion Api.GitIdentity.Execution

View File

@@ -0,0 +1,219 @@
# #region Api.GitLifecycle.Observation [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from src.models.auth import User
from src.api.routes.git_schemas import PromoteRequest
# #region Api.GitLifecycle.Observation.promote_dashboard [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def promote_dashboard_implementation(authority, dashboard_ref: str, payload: PromoteRequest, env_id: str | None, config_manager, db: Session, current_user: User, _):
_gs = authority.get_git_service()
with authority.belief_scope("promote_dashboard"):
from . import _resolve_dashboard_id_from_ref
from ._helpers import _handle_unexpected_git_route_error
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
db_repo = db.query(authority.GitRepository).filter(authority.GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
raise authority.HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise authority.HTTPException(status_code=400, detail="from_branch and to_branch are required")
if from_branch == to_branch:
raise authority.HTTPException(status_code=400, detail="from_branch and to_branch must be different")
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
remote_connected = bool(db_repo.config_id and db_repo.remote_url)
reason = (payload.reason or "").strip()
if remote_connected and not reason:
raise authority.HTTPException(status_code=400, detail="Direct promote requires non-empty reason")
if remote_connected:
authority.logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref, from_branch, to_branch, reason,
)
await authority._apply_git_identity_from_profile(dashboard_id, db, current_user)
result = await _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
return authority.PromoteResponse(
mode="direct",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=remote_connected,
)
if not db_repo.config_id or not db_repo.remote_url:
raise authority.HTTPException(status_code=409, detail="Connect a remote repository before creating a merge request")
config = authority._get_git_config_or_404(db, db_repo.config_id)
pr = await _promotion_request(authority, payload, from_branch, to_branch, config, _gs, db_repo)
return authority.PromoteResponse(
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
except authority.HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("promote_dashboard", e)
# #endregion Api.GitLifecycle.Observation.promote_dashboard
# #region Api.GitLifecycle.Observation.get_deployment_status [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def get_deployment_status_implementation(authority, dashboard_ref: str, env_id: str | None, config_manager, _):
with authority.belief_scope("get_deployment_status"):
from . import _resolve_dashboard_id_from_ref
from src.plugins.git_fingerprint import _compute_content_hash
from src.plugins.git_deployment_recorder import _get_last_deployment
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
from src.services.git_service import GitService
gs = GitService()
repo = await gs.get_repo(dashboard_id)
repo_path = authority.Path(repo.working_dir)
# Current content hash (dev branch)
current_hash = _compute_content_hash(repo_path)
# Collect deployment status for each environment
from src.core.database import SessionLocal
from src.models.git import DeploymentEnvironment, GitRepository
db = SessionLocal()
try:
# Get repository_id for scoped deployment lookup (FIX A1)
git_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
repository_id = git_repo.id if git_repo else None
envs = (
db.query(DeploymentEnvironment)
.filter(
DeploymentEnvironment.is_active == True # noqa: E712
)
.all()
)
configured_stages = {
str(environment.id): authority._canonicalize_stage(str(environment.stage))
for environment in config_manager.get_environments()
}
environments = []
for stage in ("dev", "preprod", "prod"):
stage_envs = [environment for environment in envs if configured_stages.get(str(environment.id)) == stage]
deployments = [
_get_last_deployment(repository_id, environment.id, db_session=db)
for environment in stage_envs
] if repository_id else []
last = max(
(deployment for deployment in deployments if deployment),
key=lambda deployment: deployment["deployed_at"],
default=None,
)
drift_status, actual_content_hash = (None, None)
if last and stage in ("preprod", "prod"):
drift_status, actual_content_hash = await authority._probe_drift(
dashboard_ref, last["environment_id"], last["content_hash"], config_manager,
commit_hash=last["commit_hash"],
)
environments.append(
authority.EnvironmentDeploymentStatus(
stage=stage,
commit_hash=last["commit_hash"] if last else None,
content_hash=last["content_hash"] if last else None,
deployed_at=last["deployed_at"] if last else None,
status="deployed" if last else "never_deployed",
is_behind=(last["content_hash"] != current_hash) if last and current_hash else None,
validation_status=last["validation_status"] if last else None,
validated_at=last["validated_at"] if last else None,
drift_status=drift_status,
actual_content_hash=actual_content_hash,
source_branch=last["source_branch"] if last else None,
)
)
return authority.DeploymentStatusResponse(
environments=environments,
current_content_hash=current_hash,
)
finally:
db.close()
except authority.HTTPException:
raise
except Exception as e:
authority._handle_unexpected_git_route_error("get_deployment_status", e)
# #endregion Api.GitLifecycle.Observation.get_deployment_status
# #region Api.GitLifecycle.Observation.PromotionRequest [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
async def _promotion_request(authority, payload, from_branch, to_branch, config, _gs, db_repo):
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == authority.GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == authority.GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == authority.GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise authority.HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return pr
# #endregion Api.GitLifecycle.Observation.PromotionRequest
# #endregion Api.GitLifecycle.Observation

View File

@@ -1,9 +1,15 @@
# #region Api.RepoLifecycleRoutes.GitRepoLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, sync, deploy]
# @RELATION DEPENDS_ON -> [Api.GitDeployment.Execution]
# @RELATION DEPENDS_ON -> [Api.GitLifecycle.Observation]
# @defgroup Api Module group.
# @BRIEF FastAPI endpoints for Git lifecycle operations (sync, promote, deploy).
# @LAYER API
import sys as _sys
from ._lifecycle_observation import (promote_dashboard_implementation, get_deployment_status_implementation)
import sys as _sys
from ._deployment_execution import (deploy_dashboard_implementation)
from pathlib import Path
from datetime import UTC, datetime
import io
@@ -54,6 +60,7 @@ _STAGE_CANONICAL: dict[str, str] = {
}
# #region GitDeployment.canonicalize_stage [C:2] [TYPE Function]
def _canonicalize_stage(name: str) -> str:
"""Normalize environment name to canonical stage key (dev/preprod/prod).
@@ -72,6 +79,7 @@ def _canonicalize_stage(name: str) -> str:
return key
# #endregion GitDeployment.canonicalize_stage
# #region GitDeployment.resolve_stage_environment [C:3] [TYPE Function] [SEMANTICS git,deployment,stage]
# @ingroup Api
# @BRIEF Resolve a canonical release stage to one internal deployment connection.
@@ -221,6 +229,7 @@ async def sync_dashboard(
# #region Api.RepoLifecycleRoutes.PromoteDashboard [C:3] [TYPE Function]
# @RELATION CALLS -> [Api.GitLifecycle.Observation.promote_dashboard]
# @ingroup Api
# @BRIEF Promote changes between branches via MR or direct merge.
# @RELATION CALLS -> [Plugin.GitPlugin]
@@ -234,114 +243,14 @@ async def promote_dashboard(
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("promote_dashboard"):
from . import _resolve_dashboard_id_from_ref
from ._helpers import _handle_unexpected_git_route_error
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
raise HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
if from_branch == to_branch:
raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
remote_connected = bool(db_repo.config_id and db_repo.remote_url)
reason = (payload.reason or "").strip()
if remote_connected and not reason:
raise HTTPException(status_code=400, detail="Direct promote requires non-empty reason")
if remote_connected:
logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref, from_branch, to_branch, reason,
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
result = await _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
return PromoteResponse(
mode="direct",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=remote_connected,
)
if not db_repo.config_id or not db_repo.remote_url:
raise HTTPException(status_code=409, detail="Connect a remote repository before creating a merge request")
config = _get_git_config_or_404(db, db_repo.config_id)
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return PromoteResponse(
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("promote_dashboard", e)
return await promote_dashboard_implementation(_sys.modules[__name__], dashboard_ref, payload, env_id, config_manager, db, current_user, _)
# #endregion Api.RepoLifecycleRoutes.PromoteDashboard
# #region Api.RepoLifecycleRoutes.GetDeploymentStatus [C:3] [TYPE Function] [SEMANTICS deployment, versioning, status]
# @RELATION CALLS -> [Api.GitLifecycle.Observation.get_deployment_status]
# @ingroup Api
# @BRIEF Get per-environment deployment status with content-hash comparison.
# @RELATION CALLS -> [Api.RepoLifecycleRoutes.GitRepoLifecycleRoutes]
@@ -353,89 +262,7 @@ async def get_deployment_status(
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_deployment_status"):
from . import _resolve_dashboard_id_from_ref
from src.plugins.git_fingerprint import _compute_content_hash
from src.plugins.git_deployment_recorder import _get_last_deployment
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
from src.services.git_service import GitService
gs = GitService()
repo = await gs.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
# Current content hash (dev branch)
current_hash = _compute_content_hash(repo_path)
# Collect deployment status for each environment
from src.core.database import SessionLocal
from src.models.git import DeploymentEnvironment, GitRepository
db = SessionLocal()
try:
# Get repository_id for scoped deployment lookup (FIX A1)
git_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
repository_id = git_repo.id if git_repo else None
envs = (
db.query(DeploymentEnvironment)
.filter(
DeploymentEnvironment.is_active == True # noqa: E712
)
.all()
)
configured_stages = {
str(environment.id): _canonicalize_stage(str(environment.stage))
for environment in config_manager.get_environments()
}
environments = []
for stage in ("dev", "preprod", "prod"):
stage_envs = [environment for environment in envs if configured_stages.get(str(environment.id)) == stage]
deployments = [
_get_last_deployment(repository_id, environment.id, db_session=db)
for environment in stage_envs
] if repository_id else []
last = max(
(deployment for deployment in deployments if deployment),
key=lambda deployment: deployment["deployed_at"],
default=None,
)
drift_status, actual_content_hash = (None, None)
if last and stage in ("preprod", "prod"):
drift_status, actual_content_hash = await _probe_drift(
dashboard_ref, last["environment_id"], last["content_hash"], config_manager,
commit_hash=last["commit_hash"],
)
environments.append(
EnvironmentDeploymentStatus(
stage=stage,
commit_hash=last["commit_hash"] if last else None,
content_hash=last["content_hash"] if last else None,
deployed_at=last["deployed_at"] if last else None,
status="deployed" if last else "never_deployed",
is_behind=(last["content_hash"] != current_hash) if last and current_hash else None,
validation_status=last["validation_status"] if last else None,
validated_at=last["validated_at"] if last else None,
drift_status=drift_status,
actual_content_hash=actual_content_hash,
source_branch=last["source_branch"] if last else None,
)
)
return DeploymentStatusResponse(
environments=environments,
current_content_hash=current_hash,
)
finally:
db.close()
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_deployment_status", e)
return await get_deployment_status_implementation(_sys.modules[__name__], dashboard_ref, env_id, config_manager, _)
# #endregion Api.RepoLifecycleRoutes.GetDeploymentStatus
@@ -508,6 +335,7 @@ async def validate_preprod_deployment(
# #region Api.RepoLifecycleRoutes.DeployDashboard [C:4] [TYPE Function] [SEMANTICS deployment, publish, validation]
# @RELATION CALLS -> [Api.GitDeployment.Execution.deploy_dashboard]
# @ingroup Api
# @BRIEF Deploy dashboard from Git to a target environment.
# @POST PROD receives content only when the identical PREPROD deployment is validated.
@@ -524,166 +352,7 @@ async def deploy_dashboard(
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("deploy_dashboard"):
from . import _resolve_dashboard_id_from_ref
from src.models.deployment import DeploymentRecord
from src.models.git import DeploymentEnvironment
from src.plugins.git_fingerprint import _compute_content_hash
try:
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
target = _resolve_stage_environment(deploy_data.stage, db, config_manager)
release_to_publish: DashboardRelease | None = None
if deploy_data.stage == "prod":
repository = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not repository:
raise HTTPException(status_code=409, detail="Dashboard repository is not initialized")
policy = _resolve_repository_policy(repository, config_manager)
if not deploy_data.release_id:
raise HTTPException(status_code=409, detail="Create and publish a named dashboard release before deploying to PROD")
release_to_publish = db.query(DashboardRelease).filter(
DashboardRelease.id == deploy_data.release_id,
DashboardRelease.repository_id == repository.id,
).first()
if not release_to_publish:
raise HTTPException(status_code=404, detail="Dashboard release not found")
if release_to_publish.status != "ready_to_publish":
raise HTTPException(status_code=409, detail="Dashboard release must be approved before publication")
latest_preprod = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == _resolve_stage_environment("preprod", db, config_manager).id,
DeploymentRecord.status == "success",
)
.order_by(DeploymentRecord.deployed_at.desc())
.first()
)
if latest_preprod and policy.block_publish_on_drift:
drift_status, _ = await _probe_drift(
dashboard_ref, latest_preprod.environment_id, latest_preprod.content_hash, config_manager,
commit_hash=latest_preprod.commit_hash,
)
if drift_status != "in_sync":
raise HTTPException(
status_code=409,
detail="PREPROD version differs from the recorded release candidate; synchronize or redeploy before publishing",
)
from src.services.git_service import GitService
repo = await GitService().get_repo(dashboard_id)
selected_commit = repo.commit(deploy_data.commit_hash).hexsha if deploy_data.commit_hash else None
current_hash = _compute_content_hash(Path(repo.working_dir))
if (
not latest_preprod
or latest_preprod.commit_hash != release_to_publish.commit_hash
or latest_preprod.content_hash != release_to_publish.content_hash
or (selected_commit is not None and selected_commit != release_to_publish.commit_hash)
):
raise HTTPException(status_code=409, detail="PREPROD no longer matches the named release; create a new release")
if (
not latest_preprod
or (policy.require_prod_approval and latest_preprod.validation_status != "validated")
or (
policy.approval_expires_hours > 0
and release_to_publish.approved_at
and (datetime.now(UTC) - release_to_publish.approved_at.replace(tzinfo=UTC)).total_seconds() > policy.approval_expires_hours * 3600
)
or (
latest_preprod.commit_hash != selected_commit
if selected_commit
else latest_preprod.content_hash != current_hash
)
):
raise HTTPException(
status_code=409,
detail="Deploy the current dashboard content to PREPROD and validate it before publishing to PROD",
)
source_branch = (latest_preprod.resources_changed or {}).get("source_branch")
else:
source_branch = deploy_data.source_branch
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
result = await plugin.execute(
{
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": target.id,
"commit_hash": deploy_data.commit_hash,
"source_branch": source_branch,
}
)
if deploy_data.stage == "preprod":
# PREPROD is a single shared slot: retain older candidates for audit,
# but make only the newly deployed record eligible for approval/publish.
db.expire_all()
repository = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
current_candidate = (
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == target.id,
DeploymentRecord.status == "success",
)
.order_by(DeploymentRecord.deployed_at.desc(), DeploymentRecord.id.desc())
.first()
) if repository else None
if current_candidate:
(
db.query(DeploymentRecord)
.filter(
DeploymentRecord.repository_id == repository.id,
DeploymentRecord.environment_id == target.id,
DeploymentRecord.status == "success",
DeploymentRecord.id != current_candidate.id,
)
.update({"status": "superseded", "validation_status": "superseded"}, synchronize_session=False)
)
(
db.query(DashboardRelease)
.filter(
DashboardRelease.repository_id == repository.id,
DashboardRelease.deployment_id != current_candidate.id,
DashboardRelease.status.in_(["awaiting_approval", "ready_to_publish"]),
)
.update({"status": "superseded"}, synchronize_session=False)
)
db.commit()
elif release_to_publish:
# FR-012: Publish gate — verify immutability before committing
from src.services.dashboard_testing.verification_publish_gate import (
PublishBlockedError,
run_publish_gate_verification,
)
try:
await run_publish_gate_verification(db, release_to_publish.id)
except PublishBlockedError as gate_err:
logger.explore("Publish gate blocked publication", src="deploy_dashboard", payload={"release_id": release_to_publish.id,
"detail": str(gate_err)}, error="PublishBlockedError")
raise HTTPException(
status_code=409,
detail=(
"Cannot publish: immutability verification failed. "
"One or more baseline entries with a closed immutability "
"period have data integrity violations. "
"Detail: " + str(gate_err)
),
) from gate_err
release_to_publish.status = "published"
release_to_publish.published_at = datetime.now(UTC)
release_to_publish.published_by = current_user.username
db.commit()
return result
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("deploy_dashboard", e)
return await deploy_dashboard_implementation(_sys.modules[__name__], dashboard_ref, deploy_data, env_id, config_manager, db, current_user, _)
# #endregion Api.RepoLifecycleRoutes.DeployDashboard

View File

@@ -1,9 +1,15 @@
# #region Api.RepoOperationsRoutes.GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, history, diff]
# @RELATION DEPENDS_ON -> [Api.GitCommitMessage.Execution]
# @RELATION DEPENDS_ON -> [Api.GitStatusBatch.Execution]
# @defgroup Api Module group.
# @BRIEF FastAPI endpoints for Git repository operations (commit, push, pull, status, diff, history).
# @LAYER API
import sys as _sys
from ._status_batch_execution import (get_repository_status_batch_implementation)
import sys as _sys
from ._commit_message_execution import (generate_commit_message_implementation)
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
@@ -246,6 +252,7 @@ async def get_repository_status(
# #region Api.RepoOperationsRoutes.GetRepositoryStatusBatch [C:2] [TYPE Function]
# @RELATION CALLS -> [Api.GitStatusBatch.Execution.get_repository_status_batch]
# @ingroup Api
# @BRIEF Get Git statuses for multiple dashboard repositories in one request.
@router.post("/repositories/status/batch", response_model=RepoStatusBatchResponse)
@@ -253,33 +260,7 @@ async def get_repository_status_batch(
request: RepoStatusBatchRequest,
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_status_batch"):
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
if len(dashboard_ids) > MAX_REPOSITORY_STATUS_BATCH:
logger.reason(
f"Batch size {len(dashboard_ids)} exceeds limit {MAX_REPOSITORY_STATUS_BATCH}. Truncating request.",
extra={"src": "get_repository_status_batch"},
)
dashboard_ids = dashboard_ids[:MAX_REPOSITORY_STATUS_BATCH]
statuses = {}
for dashboard_id in dashboard_ids:
try:
statuses[str(dashboard_id)] = await _resolve_repository_status(dashboard_id)
except HTTPException:
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
except Exception as e:
logger.explore("Failed for dashboard in batch status", extra={"src": "get_repository_status_batch"}, payload={"dashboard_id": dashboard_id}, error=str(e))
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
return RepoStatusBatchResponse(statuses=statuses)
return await get_repository_status_batch_implementation(_sys.modules[__name__], request, _)
# #endregion Api.RepoOperationsRoutes.GetRepositoryStatusBatch
@@ -389,6 +370,7 @@ async def get_commit_diff(
# #region Api.RepoOperationsRoutes.GenerateCommitMessage [C:3] [TYPE Function]
# @RELATION CALLS -> [Api.GitCommitMessage.Execution.generate_commit_message]
# @ingroup Api
# @BRIEF Generate a suggested commit message or free-form diff summary using LLM.
# @RELATION CALLS -> [Services.Init.GitService]
@@ -405,99 +387,7 @@ async def generate_commit_message(
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("generate_commit_message"):
from . import _resolve_dashboard_id_from_ref
try:
if purpose not in {"commit", "summary"}:
raise HTTPException(status_code=422, detail="purpose must be 'commit' or 'summary'")
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
staged_diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=True))
unstaged_diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=False))
if purpose == "summary":
diff = "\n\n".join(part for part in (staged_diff, unstaged_diff) if part)
else:
diff = staged_diff or unstaged_diff
if not diff:
return {"summary": ""} if purpose == "summary" else {"message": "No changes detected"}
history: list[str] = []
if purpose == "commit":
history_objs = await _await_service_result(_gs.get_commit_history(dashboard_id, limit=5))
history = [h.message for h in history_objs if hasattr(h, "message")]
from src.plugins.llm_analysis.models import LLMProviderType
from src.plugins.llm_analysis.service import LLMClient
from src.services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
from src.services.llm_provider import LLMProviderService
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise HTTPException(status_code=400, detail="No active LLM provider found")
api_key = llm_service.get_decrypted_api_key(provider.id)
client = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model,
)
from src.plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
if purpose == "summary":
from src.services.git_summary_cache import build_git_summary_cache_key, git_summary_cache
summary_prompt = llm_settings["prompts"].get(
"git_change_summary_prompt",
DEFAULT_LLM_PROMPTS["git_change_summary_prompt"],
)
summary_language = language[:40].strip() or "Russian"
cache_key, content_hash = build_git_summary_cache_key(
diff,
language=summary_language,
prompt_template=summary_prompt,
model=client.default_model,
)
async def generate_summary() -> str:
return await extension.summarize_changes(
diff,
language=summary_language,
prompt_template=summary_prompt,
)
summary, cache_hit = await git_summary_cache.get_or_create(cache_key, generate_summary)
logger.info(
"Git summary cache lookup",
extra={"content_hash": content_hash, "cache_hit": cache_hit},
)
return {"summary": summary, "content_hash": content_hash, "cache_hit": cache_hit}
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
message = await extension.suggest_commit_message(diff, history, prompt_template=git_prompt)
return {"message": message}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("generate_commit_message", e)
return await generate_commit_message_implementation(_sys.modules[__name__], dashboard_ref, env_id, purpose, language, config_manager, db, _)
# #endregion Api.RepoOperationsRoutes.GenerateCommitMessage

View File

@@ -1,9 +1,12 @@
# #region Api.RepoRoutes.GitRepoRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, git, api, search]
# @RELATION DEPENDS_ON -> [Api.GitRepository.Initialization]
# @defgroup Api Module group.
# @BRIEF FastAPI endpoints for core Git repository operations (init, binding, branches, checkout).
# @LAYER API
import sys as _sys
from ._repository_initialization import (init_repository_implementation)
import re
from urllib.parse import urlparse
@@ -57,6 +60,7 @@ def _remote_matches_config(remote_url: str, config_url: str) -> bool:
# #region Api.RepoRoutes.InitRepository [C:3] [TYPE Function]
# @RELATION CALLS -> [Api.GitRepository.Initialization.init_repository]
# @ingroup Api
# @BRIEF Link a dashboard to a Git repository and perform initial clone/init.
# @RELATION CALLS -> [Services.Init.GitService]
@@ -71,77 +75,7 @@ async def init_repository(
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("init_repository"):
from . import _resolve_dashboard_id_from_ref, _resolve_repo_key_from_ref
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
repo_key = await _resolve_repo_key_from_ref(dashboard_ref, dashboard_id, config_manager, env_id)
if bool(init_data.config_id) != bool(init_data.remote_url):
raise HTTPException(status_code=422, detail="config_id and remote_url must be supplied together")
config = None
if init_data.config_id:
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
if config and not _remote_matches_config(init_data.remote_url, config.url):
raise HTTPException(
status_code=422,
detail=(
"Repository URL belongs to another Git server. Select the matching "
"server configuration or enter a repository URL from the selected server."
),
)
if not config:
existing_binding = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if existing_binding and (existing_binding.config_id or existing_binding.remote_url):
raise HTTPException(status_code=409, detail="Detach the remote before switching to local mode")
try:
logger.reason(
f"Initializing repo for dashboard {dashboard_id}",
extra={"src": "init_repository"},
)
if config:
await _gs.init_repo(
dashboard_id, init_data.remote_url, config.pat,
repo_key=repo_key, default_branch=config.default_branch,
)
else:
await _gs.init_repo(dashboard_id, repo_key=repo_key)
repo_path = await _gs._get_repo_path(dashboard_id, repo_key=repo_key)
if init_data.fingerprint_version is not None:
from pathlib import Path
from src.core.utils.executors import run_blocking
from src.plugins.git_fingerprint_v2 import configure
await run_blocking('file', configure, Path(repo_path), init_data.fingerprint_version)
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
db_repo = GitRepository(
dashboard_id=dashboard_id,
config_id=config.id if config else None,
remote_url=init_data.remote_url,
local_path=repo_path,
current_branch="dev",
)
db.add(db_repo)
else:
db_repo.config_id = config.id if config else None
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit()
logger.reflect("Repository initialized for dashboard", extra={"src": "init_repository"}, payload={"dashboard_id": dashboard_id})
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
logger.explore("Failed to init repository", extra={"src": "init_repository"}, error=str(e))
if isinstance(e, HTTPException):
raise
_handle_unexpected_git_route_error("init_repository", e)
return await init_repository_implementation(_sys.modules[__name__], dashboard_ref, init_data, env_id, config_manager, db, _)
# #endregion Api.RepoRoutes.InitRepository

View File

@@ -0,0 +1,97 @@
# #region Api.GitRepository.Initialization [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from src.api.routes.git_schemas import RepoInitRequest
# #region Api.GitRepository.Initialization.init_repository [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def init_repository_implementation(authority, dashboard_ref: str, init_data: RepoInitRequest, env_id: str | None, config_manager, db: Session, _):
_gs = authority.get_git_service()
with authority.belief_scope("init_repository"):
from . import _resolve_dashboard_id_from_ref, _resolve_repo_key_from_ref
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
repo_key = await _resolve_repo_key_from_ref(dashboard_ref, dashboard_id, config_manager, env_id)
config = _initialization_binding(authority, init_data, db, dashboard_id)
try:
authority.logger.reason(
f"Initializing repo for dashboard {dashboard_id}",
extra={"src": "init_repository"},
)
if config:
await _gs.init_repo(
dashboard_id, init_data.remote_url, config.pat,
repo_key=repo_key, default_branch=config.default_branch,
)
else:
await _gs.init_repo(dashboard_id, repo_key=repo_key)
repo_path = await _gs._get_repo_path(dashboard_id, repo_key=repo_key)
if init_data.fingerprint_version is not None:
from pathlib import Path
from src.core.utils.executors import run_blocking
from src.plugins.git_fingerprint_v2 import configure
await run_blocking('file', configure, Path(repo_path), init_data.fingerprint_version)
db_repo = db.query(authority.GitRepository).filter(authority.GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
db_repo = authority.GitRepository(
dashboard_id=dashboard_id,
config_id=config.id if config else None,
remote_url=init_data.remote_url,
local_path=repo_path,
current_branch="dev",
)
db.add(db_repo)
else:
db_repo.config_id = config.id if config else None
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit()
authority.logger.reflect("Repository initialized for dashboard", extra={"src": "init_repository"}, payload={"dashboard_id": dashboard_id})
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
authority.logger.explore("Failed to init repository", extra={"src": "init_repository"}, error=str(e))
if isinstance(e, authority.HTTPException):
raise
authority._handle_unexpected_git_route_error("init_repository", e)
# #endregion Api.GitRepository.Initialization.init_repository
# #region Api.GitRepository.Initialization.Binding [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _initialization_binding(authority, init_data, db, dashboard_id):
if bool(init_data.config_id) != bool(init_data.remote_url):
raise authority.HTTPException(status_code=422, detail="config_id and remote_url must be supplied together")
config = None
if init_data.config_id:
config = db.query(authority.GitServerConfig).filter(authority.GitServerConfig.id == init_data.config_id).first()
if not config:
raise authority.HTTPException(status_code=404, detail="Git configuration not found")
if config and not authority._remote_matches_config(init_data.remote_url, config.url):
raise authority.HTTPException(
status_code=422,
detail=(
"Repository URL belongs to another Git server. Select the matching "
"server configuration or enter a repository URL from the selected server."
),
)
if not config:
existing_binding = db.query(authority.GitRepository).filter(authority.GitRepository.dashboard_id == dashboard_id).first()
if existing_binding and (existing_binding.config_id or existing_binding.remote_url):
raise authority.HTTPException(status_code=409, detail="Detach the remote before switching to local mode")
return config
# #endregion Api.GitRepository.Initialization.Binding
# #endregion Api.GitRepository.Initialization

View File

@@ -0,0 +1,44 @@
# #region Api.GitStatusBatch.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.api.routes.git_schemas import RepoStatusBatchRequest
# #region Api.GitStatusBatch.Execution.get_repository_status_batch [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def get_repository_status_batch_implementation(authority, request: RepoStatusBatchRequest, _):
with authority.belief_scope("get_repository_status_batch"):
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
if len(dashboard_ids) > authority.MAX_REPOSITORY_STATUS_BATCH:
authority.logger.reason(
f"Batch size {len(dashboard_ids)} exceeds limit {authority.MAX_REPOSITORY_STATUS_BATCH}. Truncating request.",
extra={"src": "get_repository_status_batch"},
)
dashboard_ids = dashboard_ids[:authority.MAX_REPOSITORY_STATUS_BATCH]
statuses = {}
for dashboard_id in dashboard_ids:
try:
statuses[str(dashboard_id)] = await authority._resolve_repository_status(dashboard_id)
except authority.HTTPException:
statuses[str(dashboard_id)] = {
**authority._build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
except Exception as e:
authority.logger.explore("Failed for dashboard in batch status", extra={"src": "get_repository_status_batch"}, payload={"dashboard_id": dashboard_id}, error=str(e))
statuses[str(dashboard_id)] = {
**authority._build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
return authority.RepoStatusBatchResponse(statuses=statuses)
# #endregion Api.GitStatusBatch.Execution.get_repository_status_batch
# #endregion Api.GitStatusBatch.Execution

View File

@@ -0,0 +1,381 @@
# #region Api.GitLifecycleSchemas [C:2] [TYPE Module] [SEMANTICS git,schema,deployment,release]
# @BRIEF Lifecycle and remote repository DTOs re-exported by the original schema facade.
# @RELATION DEPENDS_ON -> [Api.GitSchemas.BranchSchema]
from datetime import datetime
from enum import StrEnum
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from src.models.git import GitProvider, GitStatus, SyncStatus
from .git_schemas import BranchSchema
# #region Api.GitSchemas.DeploymentEnvironmentSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for representing a target deployment environment.
class DeploymentEnvironmentSchema(BaseModel):
"""Schema for representing a target deployment environment."""
id: str
name: str
superset_url: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
# #endregion Api.GitSchemas.DeploymentEnvironmentSchema
# #region Api.GitSchemas.DeployRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for dashboard deployment requests.
class DeployRequest(BaseModel):
"""Schema for deployment requests."""
stage: str = Field(..., pattern="^(preprod|prod)$", description="Canonical release stage; server connection is resolved internally")
commit_hash: str | None = Field(None, min_length=7, max_length=40, description="Optional immutable commit to deploy instead of current checkout")
source_branch: str | None = Field(None, min_length=1, max_length=255, description="Branch that created a PREPROD candidate; inherited by PROD")
release_id: str | None = Field(None, min_length=1, max_length=36, description="Required named dashboard release when publishing to PROD")
# #endregion Api.GitSchemas.DeployRequest
# #region Api.GitSchemas.RepoInitRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for repository initialization requests.
class RepoInitRequest(BaseModel):
"""Schema for repository initialization requests."""
config_id: str | None = None
remote_url: str | None = None
fingerprint_version: Literal[1, 2] | None = Field(
default=None, description="Explicit future commit fingerprint version; omission preserves existing legacy semantics",
)
# #endregion Api.GitSchemas.RepoInitRequest
# #region Api.GitSchemas.RepositoryBindingSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema describing repository-to-config binding and provider metadata.
class RepositoryBindingSchema(BaseModel):
dashboard_id: int
config_id: str | None
provider: GitProvider | None
remote_url: str | None
remote_connected: bool = False
local_path: str
# #endregion Api.GitSchemas.RepositoryBindingSchema
# #region Api.GitSchemas.RepoRemoteRequest [C:1] [TYPE Class]
# @BRIEF Select a configured Git server and repository for a local workspace.
class RepoRemoteRequest(BaseModel):
config_id: str
remote_url: str
# #endregion Api.GitSchemas.RepoRemoteRequest
# #region Api.GitSchemas.RepoStatusBatchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for requesting repository statuses for multiple dashboards in a single call.
class RepoStatusBatchRequest(BaseModel):
dashboard_ids: list[int] = Field(default_factory=list, description="Dashboard IDs to resolve repository statuses for")
# #endregion Api.GitSchemas.RepoStatusBatchRequest
# #region Api.GitSchemas.RepoStatusBatchResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for returning repository statuses keyed by dashboard ID.
class RepoStatusBatchResponse(BaseModel):
statuses: dict[str, dict[str, Any]]
# #endregion Api.GitSchemas.RepoStatusBatchResponse
# #region Api.GitSchemas.GiteaRepoSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema describing a Gitea repository.
class GiteaRepoSchema(BaseModel):
name: str
full_name: str
private: bool = False
clone_url: str | None = None
html_url: str | None = None
ssh_url: str | None = None
default_branch: str | None = None
# #endregion Api.GitSchemas.GiteaRepoSchema
# #region Api.GitSchemas.GiteaRepoCreateRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for creating a Gitea repository.
class GiteaRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "prod"
# #endregion Api.GitSchemas.GiteaRepoCreateRequest
# #region Api.GitSchemas.RemoteRepoSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Provider-agnostic remote repository payload.
class RemoteRepoSchema(BaseModel):
provider: GitProvider
name: str
full_name: str
private: bool = False
clone_url: str | None = None
html_url: str | None = None
ssh_url: str | None = None
default_branch: str | None = None
# #endregion Api.GitSchemas.RemoteRepoSchema
# #region Api.GitSchemas.RemoteRepoCreateRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Provider-agnostic repository creation request.
class RemoteRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "prod"
# #endregion Api.GitSchemas.RemoteRepoCreateRequest
# #region Api.GitSchemas.PromoteRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for branch promotion workflow.
class PromoteRequest(BaseModel):
from_branch: str = Field(..., min_length=1, max_length=255)
to_branch: str = Field(..., min_length=1, max_length=255)
mode: str = Field(default="mr", pattern="^(mr|direct)$")
title: str | None = None
description: str | None = None
reason: str | None = None
draft: bool = False
remove_source_branch: bool = False
# #endregion Api.GitSchemas.PromoteRequest
# #region Api.GitSchemas.PromoteResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Response schema for promotion operation result.
class PromoteResponse(BaseModel):
mode: str
from_branch: str
to_branch: str
status: str
url: str | None = None
reference_id: str | None = None
policy_violation: bool = False
# #endregion Api.GitSchemas.PromoteResponse
# #region Api.GitSchemas.MergeBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for merging one branch into another.
class MergeBranchRequest(BaseModel):
source_branch: str = Field(..., min_length=1, max_length=255, description="Source branch to merge from")
target_branch: str = Field(..., min_length=1, max_length=255, description="Target branch to merge into")
message: str | None = Field(None, description="Optional merge commit message")
auto_delete_source: bool = Field(False, description="Delete source branch after successful merge")
# #endregion Api.GitSchemas.MergeBranchRequest
# #region Api.GitSchemas.MergeBranchResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Response schema for merge branch operation result.
class MergeBranchResponse(BaseModel):
source_branch: str
target_branch: str
status: str = Field(..., description="success, conflicts, already_merged, or error")
commit_hash: str | None = None
conflicts: list[str] = Field(default_factory=list, description="List of conflicted file paths")
error_message: str | None = None
source_deleted: bool = False
# #endregion Api.GitSchemas.MergeBranchResponse
# #region Api.GitSchemas.DeleteBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for branch deletion.
class DeleteBranchRequest(BaseModel):
force: bool = Field(False, description="Force deletion bypassing protection rules")
# #endregion Api.GitSchemas.DeleteBranchRequest
# #region Api.GitSchemas.BranchTypeEnum [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Enum-like classification for branch types used in grouping.
class BranchTypeEnum(StrEnum):
ENVIRONMENT = "environment"
FEATURE = "feature"
HOTFIX = "hotfix"
BUGFIX = "bugfix"
LEGACY = "legacy"
REMOTE_REF = "remote_ref"
OTHER = "other"
# #endregion Api.GitSchemas.BranchTypeEnum
# #region Api.GitSchemas.BranchSchemaExtended [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Extended branch schema with branch_type for UI grouping.
class BranchSchemaExtended(BranchSchema):
branch_type: BranchTypeEnum = Field(default=BranchTypeEnum.OTHER, description="Classification for UI grouping")
# #endregion Api.GitSchemas.BranchSchemaExtended
# #region Api.GitSchemas.BranchProtectionRule [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema representing a branch protection rule.
class BranchProtectionRule(BaseModel):
branch_name: str
require_mr: bool = False
require_review: bool = False
allow_force_push: bool = False
allow_direct_delete: bool = False
# #endregion Api.GitSchemas.BranchProtectionRule
# #region Api.GitSchemas.EnvironmentDeploymentStatus [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Deployment status for a single environment (DEV/PREPROD/PROD).
class EnvironmentDeploymentStatus(BaseModel):
stage: str = Field(..., description="Environment stage name (dev/preprod/prod)")
commit_hash: str | None = Field(None, description="Immutable Git commit that was deployed")
content_hash: str | None = Field(None, description="SHA256 fingerprint of deployed content")
deployed_at: str | None = Field(None, description="ISO-8601 timestamp of last successful deploy")
status: str = Field("never_deployed", description="deployed | failed | never_deployed")
is_behind: bool | None = Field(None, description="True if this env differs from the primary (dev)")
validation_status: str | None = Field(None, description="pending | validated for the deployed PREPROD version")
validated_at: str | None = Field(None, description="ISO-8601 timestamp of PREPROD validation")
drift_status: str | None = Field(None, description="in_sync | drifted | unknown against the actual Superset export")
actual_content_hash: str | None = Field(None, description="SHA256 fingerprint read from the deployed Superset dashboard")
source_branch: str | None = Field(None, description="Branch from which the immutable release candidate was created")
# #endregion Api.GitSchemas.EnvironmentDeploymentStatus
# #region Api.GitSchemas.DeploymentValidationRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Request to validate the latest deployed PREPROD dashboard version.
class DeploymentValidationRequest(BaseModel):
stage: str = Field("preprod", pattern="^preprod$", description="Canonical stage being approved")
comment: str | None = Field(None, max_length=1000, description="Optional approval note required by release policy")
# #endregion Api.GitSchemas.DeploymentValidationRequest
# #region Api.GitSchemas.DeploymentStatusResponse [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Response for GET /deployment-status — per-environment deployment state.
class DeploymentStatusResponse(BaseModel):
environments: list[EnvironmentDeploymentStatus] = Field(..., description="Deployment status for each configured environment")
current_content_hash: str | None = Field(None, description="Content hash of the current git HEAD (dev branch)")
# #endregion Api.GitSchemas.DeploymentStatusResponse
# #region Api.GitSchemas.ReleasePolicySchema [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Per-dashboard override for Git release approval behavior.
class ReleasePolicySchema(BaseModel):
require_prod_approval: bool = True
approval_roles: list[str] = Field(default_factory=lambda: ["Admin"])
require_approval_comment: bool = False
approval_expires_hours: int = Field(default=0, ge=0, le=720)
block_publish_on_drift: bool = True
is_override: bool = False
# #endregion Api.GitSchemas.ReleasePolicySchema
# #region Api.GitSchemas.ReleaseCreateRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF User-supplied metadata for a named dashboard release.
class ReleaseCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
version: str = Field(min_length=1, max_length=100)
notes: str = Field(min_length=1, max_length=10_000)
# #endregion Api.GitSchemas.ReleaseCreateRequest
# #region Api.GitSchemas.ReleaseApprovalRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Optional approval comment for a dashboard release.
class ReleaseApprovalRequest(BaseModel):
comment: str | None = Field(None, max_length=1000)
# #endregion Api.GitSchemas.ReleaseApprovalRequest
# #region Api.GitSchemas.DashboardReleaseSchema [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Read model for the dashboard release ledger.
class DashboardReleaseSchema(BaseModel):
id: str
name: str
version: str
notes: str
commit_hash: str
content_hash: str
status: str
created_at: datetime
created_by: str
approved_at: datetime | None = None
approved_by: str | None = None
approval_comment: str | None = None
published_at: datetime | None = None
published_by: str | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion Api.GitSchemas.DashboardReleaseSchema
# #endregion Api.GitLifecycleSchemas

View File

@@ -3,6 +3,7 @@
# @BRIEF Defines Pydantic models for the Git integration API layer.
# @LAYER API
# @RELATION DEPENDS_ON -> [Models.Git.GitModels]
# @RELATION DEPENDS_ON -> [Api.GitLifecycleSchemas]
#
# @INVARIANT All schemas must be compatible with the FastAPI router.
@@ -239,374 +240,33 @@ class MergeContinueRequest(BaseModel):
# #endregion Api.GitSchemas.MergeContinueRequest
# #region Api.GitSchemas.DeploymentEnvironmentSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for representing a target deployment environment.
class DeploymentEnvironmentSchema(BaseModel):
"""Schema for representing a target deployment environment."""
id: str
name: str
superset_url: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
# #endregion Api.GitSchemas.DeploymentEnvironmentSchema
# #region Api.GitSchemas.DeployRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for dashboard deployment requests.
class DeployRequest(BaseModel):
"""Schema for deployment requests."""
stage: str = Field(..., pattern="^(preprod|prod)$", description="Canonical release stage; server connection is resolved internally")
commit_hash: str | None = Field(None, min_length=7, max_length=40, description="Optional immutable commit to deploy instead of current checkout")
source_branch: str | None = Field(None, min_length=1, max_length=255, description="Branch that created a PREPROD candidate; inherited by PROD")
release_id: str | None = Field(None, min_length=1, max_length=36, description="Required named dashboard release when publishing to PROD")
# #endregion Api.GitSchemas.DeployRequest
# #region Api.GitSchemas.RepoInitRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for repository initialization requests.
class RepoInitRequest(BaseModel):
"""Schema for repository initialization requests."""
config_id: str | None = None
remote_url: str | None = None
fingerprint_version: Literal[1, 2] | None = Field(
default=None, description="Explicit future commit fingerprint version; omission preserves existing legacy semantics",
)
# #endregion Api.GitSchemas.RepoInitRequest
# #region Api.GitSchemas.RepositoryBindingSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema describing repository-to-config binding and provider metadata.
class RepositoryBindingSchema(BaseModel):
dashboard_id: int
config_id: str | None
provider: GitProvider | None
remote_url: str | None
remote_connected: bool = False
local_path: str
# #endregion Api.GitSchemas.RepositoryBindingSchema
# #region Api.GitSchemas.RepoRemoteRequest [C:1] [TYPE Class]
# @BRIEF Select a configured Git server and repository for a local workspace.
class RepoRemoteRequest(BaseModel):
config_id: str
remote_url: str
# #endregion Api.GitSchemas.RepoRemoteRequest
# #region Api.GitSchemas.RepoStatusBatchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for requesting repository statuses for multiple dashboards in a single call.
class RepoStatusBatchRequest(BaseModel):
dashboard_ids: list[int] = Field(default_factory=list, description="Dashboard IDs to resolve repository statuses for")
# #endregion Api.GitSchemas.RepoStatusBatchRequest
# #region Api.GitSchemas.RepoStatusBatchResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for returning repository statuses keyed by dashboard ID.
class RepoStatusBatchResponse(BaseModel):
statuses: dict[str, dict[str, Any]]
# #endregion Api.GitSchemas.RepoStatusBatchResponse
# #region Api.GitSchemas.GiteaRepoSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema describing a Gitea repository.
class GiteaRepoSchema(BaseModel):
name: str
full_name: str
private: bool = False
clone_url: str | None = None
html_url: str | None = None
ssh_url: str | None = None
default_branch: str | None = None
# #endregion Api.GitSchemas.GiteaRepoSchema
# #region Api.GitSchemas.GiteaRepoCreateRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for creating a Gitea repository.
class GiteaRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "prod"
# #endregion Api.GitSchemas.GiteaRepoCreateRequest
# #region Api.GitSchemas.RemoteRepoSchema [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Provider-agnostic remote repository payload.
class RemoteRepoSchema(BaseModel):
provider: GitProvider
name: str
full_name: str
private: bool = False
clone_url: str | None = None
html_url: str | None = None
ssh_url: str | None = None
default_branch: str | None = None
# #endregion Api.GitSchemas.RemoteRepoSchema
# #region Api.GitSchemas.RemoteRepoCreateRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Provider-agnostic repository creation request.
class RemoteRepoCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "prod"
# #endregion Api.GitSchemas.RemoteRepoCreateRequest
# #region Api.GitSchemas.PromoteRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for branch promotion workflow.
class PromoteRequest(BaseModel):
from_branch: str = Field(..., min_length=1, max_length=255)
to_branch: str = Field(..., min_length=1, max_length=255)
mode: str = Field(default="mr", pattern="^(mr|direct)$")
title: str | None = None
description: str | None = None
reason: str | None = None
draft: bool = False
remove_source_branch: bool = False
# #endregion Api.GitSchemas.PromoteRequest
# #region Api.GitSchemas.PromoteResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Response schema for promotion operation result.
class PromoteResponse(BaseModel):
mode: str
from_branch: str
to_branch: str
status: str
url: str | None = None
reference_id: str | None = None
policy_violation: bool = False
# #endregion Api.GitSchemas.PromoteResponse
# #region Api.GitSchemas.MergeBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for merging one branch into another.
class MergeBranchRequest(BaseModel):
source_branch: str = Field(..., min_length=1, max_length=255, description="Source branch to merge from")
target_branch: str = Field(..., min_length=1, max_length=255, description="Target branch to merge into")
message: str | None = Field(None, description="Optional merge commit message")
auto_delete_source: bool = Field(False, description="Delete source branch after successful merge")
# #endregion Api.GitSchemas.MergeBranchRequest
# #region Api.GitSchemas.MergeBranchResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Response schema for merge branch operation result.
class MergeBranchResponse(BaseModel):
source_branch: str
target_branch: str
status: str = Field(..., description="success, conflicts, already_merged, or error")
commit_hash: str | None = None
conflicts: list[str] = Field(default_factory=list, description="List of conflicted file paths")
error_message: str | None = None
source_deleted: bool = False
# #endregion Api.GitSchemas.MergeBranchResponse
# #region Api.GitSchemas.DeleteBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for branch deletion.
class DeleteBranchRequest(BaseModel):
force: bool = Field(False, description="Force deletion bypassing protection rules")
# #endregion Api.GitSchemas.DeleteBranchRequest
# #region Api.GitSchemas.BranchTypeEnum [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Enum-like classification for branch types used in grouping.
class BranchTypeEnum(StrEnum):
ENVIRONMENT = "environment"
FEATURE = "feature"
HOTFIX = "hotfix"
BUGFIX = "bugfix"
LEGACY = "legacy"
REMOTE_REF = "remote_ref"
OTHER = "other"
# #endregion Api.GitSchemas.BranchTypeEnum
# #region Api.GitSchemas.BranchSchemaExtended [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Extended branch schema with branch_type for UI grouping.
class BranchSchemaExtended(BranchSchema):
branch_type: BranchTypeEnum = Field(default=BranchTypeEnum.OTHER, description="Classification for UI grouping")
# #endregion Api.GitSchemas.BranchSchemaExtended
# #region Api.GitSchemas.BranchProtectionRule [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema representing a branch protection rule.
class BranchProtectionRule(BaseModel):
branch_name: str
require_mr: bool = False
require_review: bool = False
allow_force_push: bool = False
allow_direct_delete: bool = False
# #endregion Api.GitSchemas.BranchProtectionRule
# #region Api.GitSchemas.EnvironmentDeploymentStatus [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Deployment status for a single environment (DEV/PREPROD/PROD).
class EnvironmentDeploymentStatus(BaseModel):
stage: str = Field(..., description="Environment stage name (dev/preprod/prod)")
commit_hash: str | None = Field(None, description="Immutable Git commit that was deployed")
content_hash: str | None = Field(None, description="SHA256 fingerprint of deployed content")
deployed_at: str | None = Field(None, description="ISO-8601 timestamp of last successful deploy")
status: str = Field("never_deployed", description="deployed | failed | never_deployed")
is_behind: bool | None = Field(None, description="True if this env differs from the primary (dev)")
validation_status: str | None = Field(None, description="pending | validated for the deployed PREPROD version")
validated_at: str | None = Field(None, description="ISO-8601 timestamp of PREPROD validation")
drift_status: str | None = Field(None, description="in_sync | drifted | unknown against the actual Superset export")
actual_content_hash: str | None = Field(None, description="SHA256 fingerprint read from the deployed Superset dashboard")
source_branch: str | None = Field(None, description="Branch from which the immutable release candidate was created")
# #endregion Api.GitSchemas.EnvironmentDeploymentStatus
# #region Api.GitSchemas.DeploymentValidationRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Request to validate the latest deployed PREPROD dashboard version.
class DeploymentValidationRequest(BaseModel):
stage: str = Field("preprod", pattern="^preprod$", description="Canonical stage being approved")
comment: str | None = Field(None, max_length=1000, description="Optional approval note required by release policy")
# #endregion Api.GitSchemas.DeploymentValidationRequest
# #region Api.GitSchemas.DeploymentStatusResponse [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Response for GET /deployment-status — per-environment deployment state.
class DeploymentStatusResponse(BaseModel):
environments: list[EnvironmentDeploymentStatus] = Field(..., description="Deployment status for each configured environment")
current_content_hash: str | None = Field(None, description="Content hash of the current git HEAD (dev branch)")
# #endregion Api.GitSchemas.DeploymentStatusResponse
# #region Api.GitSchemas.ReleasePolicySchema [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Per-dashboard override for Git release approval behavior.
class ReleasePolicySchema(BaseModel):
require_prod_approval: bool = True
approval_roles: list[str] = Field(default_factory=lambda: ["Admin"])
require_approval_comment: bool = False
approval_expires_hours: int = Field(default=0, ge=0, le=720)
block_publish_on_drift: bool = True
is_override: bool = False
# #endregion Api.GitSchemas.ReleasePolicySchema
# #region Api.GitSchemas.ReleaseCreateRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF User-supplied metadata for a named dashboard release.
class ReleaseCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
version: str = Field(min_length=1, max_length=100)
notes: str = Field(min_length=1, max_length=10_000)
# #endregion Api.GitSchemas.ReleaseCreateRequest
# #region Api.GitSchemas.ReleaseApprovalRequest [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Optional approval comment for a dashboard release.
class ReleaseApprovalRequest(BaseModel):
comment: str | None = Field(None, max_length=1000)
# #endregion Api.GitSchemas.ReleaseApprovalRequest
# #region Api.GitSchemas.DashboardReleaseSchema [C:1] [TYPE Class]
# @ingroup Api
# @BRIEF Read model for the dashboard release ledger.
class DashboardReleaseSchema(BaseModel):
id: str
name: str
version: str
notes: str
commit_hash: str
content_hash: str
status: str
created_at: datetime
created_by: str
approved_at: datetime | None = None
approved_by: str | None = None
approval_comment: str | None = None
published_at: datetime | None = None
published_by: str | None = None
model_config = ConfigDict(from_attributes=True)
# #endregion Api.GitSchemas.DashboardReleaseSchema
from .git_lifecycle_schemas import (
DeploymentEnvironmentSchema as DeploymentEnvironmentSchema,
DeployRequest as DeployRequest,
RepoInitRequest as RepoInitRequest,
RepositoryBindingSchema as RepositoryBindingSchema,
RepoRemoteRequest as RepoRemoteRequest,
RepoStatusBatchRequest as RepoStatusBatchRequest,
RepoStatusBatchResponse as RepoStatusBatchResponse,
GiteaRepoSchema as GiteaRepoSchema,
GiteaRepoCreateRequest as GiteaRepoCreateRequest,
RemoteRepoSchema as RemoteRepoSchema,
RemoteRepoCreateRequest as RemoteRepoCreateRequest,
PromoteRequest as PromoteRequest,
PromoteResponse as PromoteResponse,
MergeBranchRequest as MergeBranchRequest,
MergeBranchResponse as MergeBranchResponse,
DeleteBranchRequest as DeleteBranchRequest,
BranchTypeEnum as BranchTypeEnum,
BranchSchemaExtended as BranchSchemaExtended,
BranchProtectionRule as BranchProtectionRule,
EnvironmentDeploymentStatus as EnvironmentDeploymentStatus,
DeploymentValidationRequest as DeploymentValidationRequest,
DeploymentStatusResponse as DeploymentStatusResponse,
ReleasePolicySchema as ReleasePolicySchema,
ReleaseCreateRequest as ReleaseCreateRequest,
ReleaseApprovalRequest as ReleaseApprovalRequest,
DashboardReleaseSchema as DashboardReleaseSchema,
)
# #endregion Api.GitSchemas

View File

@@ -0,0 +1,221 @@
# #region SharedLlmHttpClient.CompletionExecution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
# #region SharedLlmHttpClient.CompletionExecution.call_openai_compatible [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def call_openai_compatible_implementation(authority, base_url: str, api_key: str, model: str, prompt: str, provider_type: str, max_tokens: int, disable_reasoning: bool, context_window: int | None, timeout: float, reasoning_control: str | None, supports_json_object: bool | None, server_system_content: str | None, log_error_body: bool, max_requests: int | None, usage_callback: Any):
"""Call OpenAI-compatible API for LLM requests (async)."""
if not base_url:
raise ValueError("LLM provider has no base_url configured")
# Normalise base_url: strip trailing /v1 to avoid double /v1
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
url = f"{base}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = _completion_payload(authority, server_system_content, prompt, max_tokens, context_window, model, provider_type, supports_json_object, disable_reasoning, reasoning_control)
client = authority.get_shared_http_client(timeout=timeout)
budget = authority.LlmRequestBudget(max_requests)
try:
response, response_text = await authority._do_http_request(client, url, headers, payload, budget=budget)
response, response_text = await authority._handle_response_format_fallback(
client, response, response_text, payload, url, headers, budget=budget,
)
except authority.httpx.TimeoutException as exc:
# httpx often stringifies to "" — always include type + timeout budget.
detail = str(exc).strip() or repr(exc)
raise TimeoutError(
f"LLM HTTP timeout after {timeout}s ({type(exc).__name__}: {detail})"
) from exc
except authority.httpx.HTTPError as exc:
detail = str(exc).strip() or repr(exc)
raise RuntimeError(f"LLM HTTP error ({type(exc).__name__}: {detail})") from exc
if not response.is_success:
authority.logger.explore(
f"LLM API error status={response.status_code} model={payload.get('model')}"
+ (f" body={response_text[:2000]}" if log_error_body else ""),
extra={"src": "SharedLlmHttpClient"},
)
response.raise_for_status()
try:
data = authority._parse_chat_completion_body(response_text, status_code=response.status_code)
except ValueError:
if not log_error_body:
raise ValueError("LLM provider response invalid") from None
raise
content, finish_reason = _completion_content(authority, data, log_error_body)
if usage_callback is not None:
usage = data.get("usage")
safe_usage = {key: usage[key] for key in ("prompt_tokens", "completion_tokens", "total_tokens")
if isinstance(usage, dict) and type(usage.get(key)) is int and usage[key] >= 0}
usage_callback(safe_usage)
return content, finish_reason
# #endregion SharedLlmHttpClient.CompletionExecution.call_openai_compatible
# #region SharedLlmHttpClient.CompletionExecution.Payload [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _completion_payload(authority, server_system_content, prompt, max_tokens, context_window, model, provider_type, supports_json_object, disable_reasoning, reasoning_control):
system_content = server_system_content if server_system_content is not None else (
"You are a database content translation assistant. "
"Translate the provided text accurately, preserving data semantics. "
"Respond directly with ONLY the JSON result. "
"Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. "
"Output ONLY valid JSON."
)
prompt_tokens = (
authority._estimate_msg_tokens(system_content)
+ authority._estimate_msg_tokens(prompt)
+ 16 # role/message framing overhead
)
effective_max_tokens = authority._clamp_max_tokens(max_tokens, prompt_tokens, context_window)
if effective_max_tokens != max_tokens:
authority.logger.explore(
"Clamped max_tokens to fit context / hard cap",
extra={
"src": "SharedLlmHttpClient",
"requested_max_tokens": max_tokens,
"effective_max_tokens": effective_max_tokens,
"prompt_tokens_est": prompt_tokens,
"context_window": context_window,
},
)
payload: dict[str, authority.Any] = {
"model": model,
"messages": [
{"role": "system", "content": system_content},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
"max_tokens": effective_max_tokens,
# Explicit non-stream — some proxies (cliproxy) default to SSE chunks otherwise.
"stream": False,
}
# Prefer strict JSON when capability allows (NULL = true for openai-compatible family).
use_json = supports_json_object if supports_json_object is not None else (
provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm")
)
if use_json:
payload["response_format"] = {"type": "json_object"}
authority._apply_reasoning_control(
payload,
disable_reasoning=disable_reasoning,
reasoning_control=reasoning_control,
)
return payload
# #endregion SharedLlmHttpClient.CompletionExecution.Payload
# #region SharedLlmHttpClient.CompletionExecution.Content [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _completion_content(authority, data, log_error_body):
choices = data.get("choices", [])
if not choices:
authority.logger.explore(
"LLM returned no choices",
extra={
"src": "SharedLlmHttpClient",
"response_keys": list(data.keys()) if log_error_body else len(data),
"response_preview": str(data)[:2000] if log_error_body else "suppressed",
},
)
raise ValueError("LLM returned no choices")
try:
finish_reason = choices[0].get("finish_reason") or "none"
msg = choices[0].get("message") or {}
except (TypeError, AttributeError) as e:
authority.logger.explore(
"TypeError processing LLM response choices",
extra={
"src": "SharedLlmHttpClient",
"error": str(e),
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
"choices_0_repr": repr(choices[0])[:2000] if choices and log_error_body else "suppressed",
"data_type": type(data).__name__,
"data_preview": str(data)[:2000] if log_error_body else "suppressed",
},
)
raise ValueError(f"LLM response processing failed: {e}")
if not isinstance(msg, dict):
raise ValueError("LLM response message is not an object")
refusal = msg.get("refusal")
if refusal:
authority.logger.explore(
"LLM refused to respond",
extra={
"src": "SharedLlmHttpClient",
"refusal": str(refusal)[:500] if log_error_body else "suppressed",
"finish_reason": finish_reason,
},
)
raise ValueError(f"LLM refused to respond: {refusal}" if log_error_body else "LLM refused to respond")
content, fallback_field = authority._resolve_message_content(msg)
if fallback_field:
authority.logger.explore(
"Recovered LLM content from reasoning/thinking field",
extra={
"src": "SharedLlmHttpClient",
"field": fallback_field,
"content_len": len(content),
"finish_reason": finish_reason,
},
)
if not content:
_empty_completion_diagnostics(authority, data, msg, finish_reason, log_error_body)
raise ValueError("LLM returned empty content")
return content, finish_reason
# #endregion SharedLlmHttpClient.CompletionExecution.Content
# #region SharedLlmHttpClient.CompletionExecution.EmptyDiagnostics [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _empty_completion_diagnostics(authority, data, msg, finish_reason, log_error_body):
# Surface diagnostic fields both as structured extras and in the message so
# they survive log formatters that drop nested `extra` keys.
usage = data.get("usage") if isinstance(data, dict) else None
reasoning_len = 0
for rk in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
alt = msg.get(rk)
if isinstance(alt, str) and alt:
reasoning_len = max(reasoning_len, len(alt))
authority.logger.explore(
"LLM returned empty content",
extra={
"src": "SharedLlmHttpClient",
"payload": {
"finish_reason": finish_reason,
"msg_keys": list(msg.keys()) if log_error_body else len(msg),
"reasoning_len": reasoning_len,
"usage": usage if log_error_body else None,
"response_preview": str(data)[:1500] if log_error_body else "suppressed",
},
},
)
# #endregion SharedLlmHttpClient.CompletionExecution.EmptyDiagnostics
# #endregion SharedLlmHttpClient.CompletionExecution

View File

@@ -1,4 +1,13 @@
# #region Shared.LlmHttp.SharedLlmHttpClient [C:3] [TYPE Module] [SEMANTICS shared,http,client,ssl,llm]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.CompletionExecution]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.ReasoningPayload]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.ResponseBody]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.ResponseParsing]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.TokenLimits]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.CompletionExecution]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.ReasoningPayload]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.ResponseParsing]
# @RELATION DEPENDS_ON -> [SharedLlmHttpClient.TokenLimits]
# @defgroup Shared Shared lightweight utilities for backend and agent.
# @BRIEF Singleton httpx.AsyncClient with system SSL context for all LLM/API HTTP calls.
# Provides connection pooling, proper SSL verification (capath), configurable timeout.
@@ -19,6 +28,16 @@
# but silently fails with corporate intermediate CAs installed in /etc/ssl/certs.
# Only capath-based ssl.create_default_context() works with OpenSSL 3.x intermediates.
import sys as _sys
from .llm_response_body import (_parse_chat_completion_body_implementation)
import sys as _sys
from .llm_token_limits import (_clamp_max_tokens_implementation)
import sys as _sys
from .llm_reasoning_payload import (_apply_reasoning_control_implementation)
import sys as _sys
from .llm_completion_execution import (call_openai_compatible_implementation)
import sys as _sys
from .llm_response_parsing import (_aggregate_sse_chat_completion_implementation, _extract_json_blob_implementation, _resolve_message_content_implementation)
import asyncio
import json
import ssl
@@ -136,187 +155,38 @@ def _estimate_msg_tokens(text: str) -> int:
# #region SharedLlmHttpClient.ParseChatCompletionBody [C:3] [TYPE Function] [SEMANTICS shared,llm,sse,parse]
# @RELATION CALLS -> [SharedLlmHttpClient.ResponseBody.parse_chat_completion_body]
# @BRIEF Parse non-stream JSON or SSE chat.completion.chunk stream into a completion dict.
def _parse_chat_completion_body(response_text: str, status_code: int = 200) -> dict[str, Any]:
"""Parse OpenAI chat completion body; aggregate SSE if the proxy streamed anyway."""
text = response_text or ""
stripped = text.strip()
if not stripped:
raise ValueError(
f"LLM provider returned an empty body (status={status_code})"
)
# Fast path: normal non-stream JSON object
if stripped.startswith("{"):
try:
data = json.loads(stripped)
if isinstance(data, dict):
return data
except (json.JSONDecodeError, ValueError):
pass
# SSE path: "data: {...}\n\ndata: {...}\n\ndata: [DONE]"
if "data:" in stripped[:64] or stripped.startswith("data:") or "\ndata:" in stripped:
aggregated = _aggregate_sse_chat_completion(stripped)
if aggregated is not None:
logger.explore(
"Aggregated SSE chat.completion stream into non-stream payload",
extra={
"src": "SharedLlmHttpClient",
"content_len": len((aggregated.get("choices") or [{}])[0].get("message", {}).get("content") or ""),
"finish_reason": (aggregated.get("choices") or [{}])[0].get("finish_reason"),
},
)
return aggregated
preview = stripped[:500]
raise ValueError(
f"LLM provider returned an invalid JSON response "
f"(status={status_code}, body_len={len(text)}, preview={preview!r})"
)
return _parse_chat_completion_body_implementation(_sys.modules[__name__], response_text, status_code)
# #endregion SharedLlmHttpClient.ParseChatCompletionBody
# #region SharedLlmHttpClient.AggregateSseChatCompletion [C:3] [TYPE Function] [SEMANTICS shared,llm,sse,aggregate]
# @RELATION CALLS -> [SharedLlmHttpClient.ResponseParsing.aggregate_sse_chat_completion]
# @BRIEF Merge OpenAI SSE chat.completion.chunk frames into one chat.completion-like dict.
def _aggregate_sse_chat_completion(sse_text: str) -> dict[str, Any] | None:
content_parts: list[str] = []
reasoning_parts: list[str] = []
finish_reason: str | None = None
model: str | None = None
completion_id: str | None = None
saw_chunk = False
for raw_line in sse_text.splitlines():
line = raw_line.strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if not line or line == "[DONE]":
continue
try:
chunk = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(chunk, dict):
continue
# Some proxies wrap non-stream JSON as a single data: line
if chunk.get("object") == "chat.completion" and chunk.get("choices"):
return chunk
saw_chunk = True
model = model or chunk.get("model")
completion_id = completion_id or chunk.get("id")
for choice in chunk.get("choices") or []:
if not isinstance(choice, dict):
continue
if choice.get("finish_reason"):
finish_reason = choice.get("finish_reason")
delta = choice.get("delta") or {}
if not isinstance(delta, dict):
# Non-delta message (rare in streams)
msg = choice.get("message") or {}
if isinstance(msg, dict):
if msg.get("content"):
content_parts.append(str(msg["content"]))
for rk in ("reasoning_content", "reasoning", "thinking"):
if msg.get(rk):
reasoning_parts.append(str(msg[rk]))
continue
if delta.get("content"):
content_parts.append(str(delta["content"]))
for rk in ("reasoning_content", "reasoning", "thinking"):
if delta.get(rk):
reasoning_parts.append(str(delta[rk]))
if not saw_chunk and not content_parts and not reasoning_parts:
return None
message: dict[str, Any] = {
"role": "assistant",
"content": "".join(content_parts),
}
if reasoning_parts:
message["reasoning_content"] = "".join(reasoning_parts)
return {
"id": completion_id or "sse-aggregated",
"object": "chat.completion",
"model": model or "",
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason or "stop",
}
],
}
return _aggregate_sse_chat_completion_implementation(_sys.modules[__name__], sse_text)
# #endregion SharedLlmHttpClient.AggregateSseChatCompletion
# #region SharedLlmHttpClient.ClampMaxTokens [C:2] [TYPE Function] [SEMANTICS shared,llm,token,clamp]
# @RELATION CALLS -> [SharedLlmHttpClient.TokenLimits.clamp_max_tokens]
# @BRIEF Cap completion tokens so prompt + output fits context and avoids multi-minute gens.
def _clamp_max_tokens(
requested: int,
prompt_tokens: int,
context_window: int | None,
) -> int:
capped = max(1, int(requested))
capped = min(capped, MAX_COMPLETION_TOKENS_HARD_CAP)
if context_window and context_window > 0:
free = context_window - prompt_tokens - CONTEXT_OUTPUT_MARGIN
if free < 64:
# Still request a tiny completion so the API call is valid; caller should
# have reduced batch size — this is a last-resort guard.
free = 64
capped = min(capped, free)
return max(1, capped)
return _clamp_max_tokens_implementation(_sys.modules[__name__], requested, prompt_tokens, context_window)
# #endregion SharedLlmHttpClient.ClampMaxTokens
# #region SharedLlmHttpClient.ExtractJsonBlob [C:2] [TYPE Function] [SEMANTICS shared,llm,json,extract]
# @RELATION CALLS -> [SharedLlmHttpClient.ResponseParsing.extract_json_blob]
# @BRIEF Extract a parseable JSON object/array from free-form model text (e.g. reasoning).
def _extract_json_blob(text: str) -> str | None:
if not text or not isinstance(text, str):
return None
stripped = text.strip()
if stripped.startswith(("{", "[")):
try:
json.loads(stripped)
return stripped
except (json.JSONDecodeError, ValueError):
pass
for start_char, end_char in (("{", "}"), ("[", "]")):
start = text.find(start_char)
if start < 0:
continue
depth = 0
in_str = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == start_char:
depth += 1
elif ch == end_char:
depth -= 1
if depth == 0:
candidate = text[start : i + 1]
try:
json.loads(candidate)
return candidate
except (json.JSONDecodeError, ValueError):
break
return None
return _extract_json_blob_implementation(_sys.modules[__name__], text)
# #endregion SharedLlmHttpClient.ExtractJsonBlob
@@ -336,49 +206,17 @@ def _looks_like_complete_json(text: str) -> bool:
# #region SharedLlmHttpClient.ResolveMessageContent [C:2] [TYPE Function] [SEMANTICS shared,llm,content,reasoning]
# @RELATION CALLS -> [SharedLlmHttpClient.ResponseParsing.resolve_message_content]
# @BRIEF Prefer message.content; fall back to JSON inside reasoning_content / thinking fields.
# Also used when content is truncated mid-JSON (finish_reason=length) but reasoning holds
# a fuller answer — common for DeepSeek/Gemma thinking models.
def _resolve_message_content(msg: dict) -> tuple[str, str | None]:
content = msg.get("content")
if isinstance(content, list):
# Multimodal-style content blocks
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
elif isinstance(block, str):
parts.append(block)
content = "".join(parts)
if content is None:
content = ""
content = str(content).strip() if content else ""
if content and _looks_like_complete_json(content):
return content, None
# Incomplete / non-JSON content: try extract JSON blob first, then reasoning fields.
if content:
extracted = _extract_json_blob(content)
if extracted and _looks_like_complete_json(extracted):
return extracted, "content_extracted"
for key in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
alt = msg.get(key)
if not alt or not isinstance(alt, str):
continue
extracted = _extract_json_blob(alt)
if extracted and _looks_like_complete_json(extracted):
return extracted, key
alt_stripped = alt.strip()
if _looks_like_complete_json(alt_stripped):
return alt_stripped, key
# Last resort: return partial content (caller may still recover truncated rows).
if content:
return content, None
return "", None
return _resolve_message_content_implementation(_sys.modules[__name__], msg)
# #endregion SharedLlmHttpClient.ResolveMessageContent
# #region SharedLlmHttpClient.ApplyReasoningControl [C:2] [TYPE Function] [SEMANTICS shared,llm,reasoning,capabilities]
# @RELATION CALLS -> [SharedLlmHttpClient.ReasoningPayload.apply_reasoning_control]
# @ingroup Shared
# @BRIEF Mutate payload with anti-think wire fields from explicit reasoning_control capability.
# @RATIONALE Wire format is a provider capability stored in DB — not inferred from model name.
@@ -389,32 +227,12 @@ def _apply_reasoning_control(
disable_reasoning: bool,
reasoning_control: str | None,
) -> None:
"""Apply anti-reasoning payload fields when job requests disable_reasoning.
reasoning_control values (provider capability):
off | generic_none | openai_effort | deepseek_thinking | llamacpp_think | auto|None
Unsupported fields are stripped on HTTP 400 by _handle_response_format_fallback.
"""
if not disable_reasoning:
return
mode = (reasoning_control or "generic_none").strip().lower()
if mode in ("", "auto", "none"):
# Safe default when capability unset: send generic thinking-disable;
# strip-on-400 recovers for providers that reject the field.
mode = "generic_none"
if mode == "off":
return
if mode in ("generic_none", "deepseek_thinking"):
payload["thinking"] = {"type": "disabled"}
if mode == "openai_effort":
payload["reasoning_effort"] = "none"
if mode == "llamacpp_think":
payload["chat_template_kwargs"] = {"enable_thinking": False}
payload["think"] = False
return _apply_reasoning_control_implementation(_sys.modules[__name__], payload, disable_reasoning=disable_reasoning, reasoning_control=reasoning_control)
# #endregion SharedLlmHttpClient.ApplyReasoningControl
# #region SharedLlmHttpClient.CallOpenaiCompatible [C:3] [TYPE Function] [SEMANTICS shared,llm,http,openai,async]
# @RELATION CALLS -> [SharedLlmHttpClient.CompletionExecution.call_openai_compatible]
# @ingroup Shared
# @BRIEF Call OpenAI-compatible API asynchronously with rate-limit handling and structured output fallback.
# @PRE Valid API endpoint, key, model, and prompt.
@@ -450,187 +268,7 @@ async def call_openai_compatible(
max_requests: int | None = None,
usage_callback: Any = None,
) -> tuple[str, str | None]:
"""Call OpenAI-compatible API for LLM requests (async)."""
if not base_url:
raise ValueError("LLM provider has no base_url configured")
# Normalise base_url: strip trailing /v1 to avoid double /v1
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
url = f"{base}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
system_content = server_system_content if server_system_content is not None else (
"You are a database content translation assistant. "
"Translate the provided text accurately, preserving data semantics. "
"Respond directly with ONLY the JSON result. "
"Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. "
"Output ONLY valid JSON."
)
prompt_tokens = (
_estimate_msg_tokens(system_content)
+ _estimate_msg_tokens(prompt)
+ 16 # role/message framing overhead
)
effective_max_tokens = _clamp_max_tokens(max_tokens, prompt_tokens, context_window)
if effective_max_tokens != max_tokens:
logger.explore(
"Clamped max_tokens to fit context / hard cap",
extra={
"src": "SharedLlmHttpClient",
"requested_max_tokens": max_tokens,
"effective_max_tokens": effective_max_tokens,
"prompt_tokens_est": prompt_tokens,
"context_window": context_window,
},
)
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system_content},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
"max_tokens": effective_max_tokens,
# Explicit non-stream — some proxies (cliproxy) default to SSE chunks otherwise.
"stream": False,
}
# Prefer strict JSON when capability allows (NULL = true for openai-compatible family).
use_json = supports_json_object if supports_json_object is not None else (
provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm")
)
if use_json:
payload["response_format"] = {"type": "json_object"}
_apply_reasoning_control(
payload,
disable_reasoning=disable_reasoning,
reasoning_control=reasoning_control,
)
client = get_shared_http_client(timeout=timeout)
budget = LlmRequestBudget(max_requests)
try:
response, response_text = await _do_http_request(client, url, headers, payload, budget=budget)
response, response_text = await _handle_response_format_fallback(
client, response, response_text, payload, url, headers, budget=budget,
)
except httpx.TimeoutException as exc:
# httpx often stringifies to "" — always include type + timeout budget.
detail = str(exc).strip() or repr(exc)
raise TimeoutError(
f"LLM HTTP timeout after {timeout}s ({type(exc).__name__}: {detail})"
) from exc
except httpx.HTTPError as exc:
detail = str(exc).strip() or repr(exc)
raise RuntimeError(f"LLM HTTP error ({type(exc).__name__}: {detail})") from exc
if not response.is_success:
logger.explore(
f"LLM API error status={response.status_code} model={payload.get('model')}"
+ (f" body={response_text[:2000]}" if log_error_body else ""),
extra={"src": "SharedLlmHttpClient"},
)
response.raise_for_status()
try:
data = _parse_chat_completion_body(response_text, status_code=response.status_code)
except ValueError:
if not log_error_body:
raise ValueError("LLM provider response invalid") from None
raise
choices = data.get("choices", [])
if not choices:
logger.explore(
"LLM returned no choices",
extra={
"src": "SharedLlmHttpClient",
"response_keys": list(data.keys()) if log_error_body else len(data),
"response_preview": str(data)[:2000] if log_error_body else "suppressed",
},
)
raise ValueError("LLM returned no choices")
try:
finish_reason = choices[0].get("finish_reason") or "none"
msg = choices[0].get("message") or {}
except (TypeError, AttributeError) as e:
logger.explore(
"TypeError processing LLM response choices",
extra={
"src": "SharedLlmHttpClient",
"error": str(e),
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
"choices_0_repr": repr(choices[0])[:2000] if choices and log_error_body else "suppressed",
"data_type": type(data).__name__,
"data_preview": str(data)[:2000] if log_error_body else "suppressed",
},
)
raise ValueError(f"LLM response processing failed: {e}")
if not isinstance(msg, dict):
raise ValueError("LLM response message is not an object")
refusal = msg.get("refusal")
if refusal:
logger.explore(
"LLM refused to respond",
extra={
"src": "SharedLlmHttpClient",
"refusal": str(refusal)[:500] if log_error_body else "suppressed",
"finish_reason": finish_reason,
},
)
raise ValueError(f"LLM refused to respond: {refusal}" if log_error_body else "LLM refused to respond")
content, fallback_field = _resolve_message_content(msg)
if fallback_field:
logger.explore(
"Recovered LLM content from reasoning/thinking field",
extra={
"src": "SharedLlmHttpClient",
"field": fallback_field,
"content_len": len(content),
"finish_reason": finish_reason,
},
)
if not content:
# Surface diagnostic fields both as structured extras and in the message so
# they survive log formatters that drop nested `extra` keys.
usage = data.get("usage") if isinstance(data, dict) else None
reasoning_len = 0
for rk in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
alt = msg.get(rk)
if isinstance(alt, str) and alt:
reasoning_len = max(reasoning_len, len(alt))
logger.explore(
"LLM returned empty content",
extra={
"src": "SharedLlmHttpClient",
"payload": {
"finish_reason": finish_reason,
"msg_keys": list(msg.keys()) if log_error_body else len(msg),
"reasoning_len": reasoning_len,
"usage": usage if log_error_body else None,
"response_preview": str(data)[:1500] if log_error_body else "suppressed",
},
},
)
raise ValueError("LLM returned empty content")
if usage_callback is not None:
usage = data.get("usage")
safe_usage = {key: usage[key] for key in ("prompt_tokens", "completion_tokens", "total_tokens")
if isinstance(usage, dict) and type(usage.get(key)) is int and usage[key] >= 0}
usage_callback(safe_usage)
return content, finish_reason
return await call_openai_compatible_implementation(_sys.modules[__name__], base_url, api_key, model, prompt, provider_type, max_tokens, disable_reasoning, context_window, timeout, reasoning_control, supports_json_object, server_system_content, log_error_body, max_requests, usage_callback)
# #endregion SharedLlmHttpClient.CallOpenaiCompatible

View File

@@ -0,0 +1,39 @@
# #region SharedLlmHttpClient.ReasoningPayload [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
# #region SharedLlmHttpClient.ReasoningPayload.apply_reasoning_control [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _apply_reasoning_control_implementation(authority, payload: dict[str, Any], *, disable_reasoning: bool, reasoning_control: str | None):
"""Apply anti-reasoning payload fields when job requests disable_reasoning.
reasoning_control values (provider capability):
off | generic_none | openai_effort | deepseek_thinking | llamacpp_think | auto|None
Unsupported fields are stripped on HTTP 400 by _handle_response_format_fallback.
"""
if not disable_reasoning:
return
mode = (reasoning_control or "generic_none").strip().lower()
if mode in ("", "auto", "none"):
# Safe default when capability unset: send generic thinking-disable;
# strip-on-400 recovers for providers that reject the field.
mode = "generic_none"
if mode == "off":
return
if mode in ("generic_none", "deepseek_thinking"):
payload["thinking"] = {"type": "disabled"}
if mode == "openai_effort":
payload["reasoning_effort"] = "none"
if mode == "llamacpp_think":
payload["chat_template_kwargs"] = {"enable_thinking": False}
payload["think"] = False
# #endregion SharedLlmHttpClient.ReasoningPayload.apply_reasoning_control
# #endregion SharedLlmHttpClient.ReasoningPayload

View File

@@ -0,0 +1,50 @@
# #region SharedLlmHttpClient.ResponseBody [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
# #region SharedLlmHttpClient.ResponseBody.parse_chat_completion_body [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _parse_chat_completion_body_implementation(authority, response_text: str, status_code: int):
"""Parse OpenAI chat completion body; aggregate SSE if the proxy streamed anyway."""
text = response_text or ""
stripped = text.strip()
if not stripped:
raise ValueError(
f"LLM provider returned an empty body (status={status_code})"
)
# Fast path: normal non-stream JSON object
if stripped.startswith("{"):
try:
data = authority.json.loads(stripped)
if isinstance(data, dict):
return data
except (authority.json.JSONDecodeError, ValueError):
pass
# SSE path: "data: {...}\n\ndata: {...}\n\ndata: [DONE]"
if "data:" in stripped[:64] or stripped.startswith("data:") or "\ndata:" in stripped:
aggregated = authority._aggregate_sse_chat_completion(stripped)
if aggregated is not None:
authority.logger.explore(
"Aggregated SSE chat.completion stream into non-stream payload",
extra={
"src": "SharedLlmHttpClient",
"content_len": len((aggregated.get("choices") or [{}])[0].get("message", {}).get("content") or ""),
"finish_reason": (aggregated.get("choices") or [{}])[0].get("finish_reason"),
},
)
return aggregated
preview = stripped[:500]
raise ValueError(
f"LLM provider returned an invalid JSON response "
f"(status={status_code}, body_len={len(text)}, preview={preview!r})"
)
# #endregion SharedLlmHttpClient.ResponseBody.parse_chat_completion_body
# #endregion SharedLlmHttpClient.ResponseBody

View File

@@ -0,0 +1,218 @@
# #region SharedLlmHttpClient.ResponseParsing [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
# #region SharedLlmHttpClient.ResponseParsing.aggregate_sse_chat_completion [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _aggregate_sse_chat_completion_implementation(authority, sse_text: str):
content_parts: list[str] = []
reasoning_parts: list[str] = []
finish_reason: str | None = None
model: str | None = None
completion_id: str | None = None
saw_chunk = False
for chunk in _sse_chunks(authority, sse_text):
# Some proxies wrap non-stream JSON as a single data: line
if chunk.get("object") == "chat.completion" and chunk.get("choices"):
return chunk
saw_chunk = True
model = model or chunk.get("model")
completion_id = completion_id or chunk.get("id")
finish_reason = _sse_choices(chunk, content_parts, reasoning_parts, finish_reason)
if not saw_chunk and not content_parts and not reasoning_parts:
return None
message: dict[str, authority.Any] = {
"role": "assistant",
"content": "".join(content_parts),
}
if reasoning_parts:
message["reasoning_content"] = "".join(reasoning_parts)
return {
"id": completion_id or "sse-aggregated",
"object": "chat.completion",
"model": model or "",
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason or "stop",
}
],
}
# #endregion SharedLlmHttpClient.ResponseParsing.aggregate_sse_chat_completion
# #region SharedLlmHttpClient.ResponseParsing.extract_json_blob [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _extract_json_blob_implementation(authority, text: str):
if not text or not isinstance(text, str):
return None
stripped = text.strip()
if stripped.startswith(("{", "[")):
try:
authority.json.loads(stripped)
return stripped
except (authority.json.JSONDecodeError, ValueError):
pass
for start_char, end_char in (("{", "}"), ("[", "]")):
start = text.find(start_char)
if start < 0:
continue
candidate = _balanced_candidate(text, start, start_char, end_char)
if candidate is not None:
try:
authority.json.loads(candidate)
return candidate
except (authority.json.JSONDecodeError, ValueError):
pass
return None
# #endregion SharedLlmHttpClient.ResponseParsing.extract_json_blob
# #region SharedLlmHttpClient.ResponseParsing.resolve_message_content [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _resolve_message_content_implementation(authority, msg: dict):
content = msg.get("content")
if isinstance(content, list):
content = _content_blocks(content)
if content is None:
content = ""
content = str(content).strip() if content else ""
if content and authority._looks_like_complete_json(content):
return content, None
# Incomplete / non-JSON content: try extract JSON blob first, then reasoning fields.
if content:
extracted = authority._extract_json_blob(content)
if extracted and authority._looks_like_complete_json(extracted):
return extracted, "content_extracted"
recovered, field = _reasoning_content(authority, msg)
if recovered:
return recovered, field
# Last resort: return partial content (caller may still recover truncated rows).
if content:
return content, None
return "", None
# #endregion SharedLlmHttpClient.ResponseParsing.resolve_message_content
# #region SharedLlmHttpClient.ResponseParsing.sse_chunks [C:3] [TYPE Function]
# @BRIEF Preserve the ordered parsing phase without changing accepted wire formats.
def _sse_chunks(authority, sse_text):
for raw_line in sse_text.splitlines():
line = raw_line.strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if not line or line == "[DONE]":
continue
try:
chunk = authority.json.loads(line)
except (authority.json.JSONDecodeError, ValueError):
continue
if not isinstance(chunk, dict):
continue
yield chunk
# #endregion SharedLlmHttpClient.ResponseParsing.sse_chunks
# #region SharedLlmHttpClient.ResponseParsing.sse_choices [C:3] [TYPE Function]
# @BRIEF Preserve the ordered parsing phase without changing accepted wire formats.
def _sse_choices(chunk, content_parts, reasoning_parts, finish_reason):
for choice in chunk.get("choices") or []:
if not isinstance(choice, dict):
continue
if choice.get("finish_reason"):
finish_reason = choice.get("finish_reason")
delta = choice.get("delta") or {}
if not isinstance(delta, dict):
# Non-delta message (rare in streams)
msg = choice.get("message") or {}
if isinstance(msg, dict):
_append_sse_parts(msg, content_parts, reasoning_parts)
continue
_append_sse_parts(delta, content_parts, reasoning_parts)
return finish_reason
# #endregion SharedLlmHttpClient.ResponseParsing.sse_choices
# #region SharedLlmHttpClient.ResponseParsing.balanced_candidate [C:3] [TYPE Function]
# @BRIEF Preserve the ordered parsing phase without changing accepted wire formats.
def _balanced_candidate(text, start, start_char, end_char):
depth = 0
in_str = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == start_char:
depth += 1
elif ch == end_char:
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
# #endregion SharedLlmHttpClient.ResponseParsing.balanced_candidate
# #region SharedLlmHttpClient.ResponseParsing.content_blocks [C:3] [TYPE Function]
# @BRIEF Preserve the ordered parsing phase without changing accepted wire formats.
def _content_blocks(content):
# Multimodal-style content blocks
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
elif isinstance(block, str):
parts.append(block)
return "".join(parts)
# #endregion SharedLlmHttpClient.ResponseParsing.content_blocks
# #region SharedLlmHttpClient.ResponseParsing.reasoning_content [C:3] [TYPE Function]
# @BRIEF Preserve the ordered parsing phase without changing accepted wire formats.
def _reasoning_content(authority, msg):
for key in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
alt = msg.get(key)
if not alt or not isinstance(alt, str):
continue
extracted = authority._extract_json_blob(alt)
if extracted and authority._looks_like_complete_json(extracted):
return extracted, key
alt_stripped = alt.strip()
if authority._looks_like_complete_json(alt_stripped):
return alt_stripped, key
return None, None
# #endregion SharedLlmHttpClient.ResponseParsing.reasoning_content
# #region SharedLlmHttpClient.ResponseParsing.AppendParts [C:2] [TYPE Function]
# @BRIEF Append content and reasoning fields in their original field order.
def _append_sse_parts(message, content_parts, reasoning_parts):
if message.get("content"):
content_parts.append(str(message["content"]))
for key in ("reasoning_content", "reasoning", "thinking"):
if message.get(key):
reasoning_parts.append(str(message[key]))
# #endregion SharedLlmHttpClient.ResponseParsing.AppendParts
# #endregion SharedLlmHttpClient.ResponseParsing

View File

@@ -0,0 +1,24 @@
# #region SharedLlmHttpClient.TokenLimits [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
# #region SharedLlmHttpClient.TokenLimits.clamp_max_tokens [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _clamp_max_tokens_implementation(authority, requested: int, prompt_tokens: int, context_window: int | None):
capped = max(1, int(requested))
capped = min(capped, authority.MAX_COMPLETION_TOKENS_HARD_CAP)
if context_window and context_window > 0:
free = context_window - prompt_tokens - authority.CONTEXT_OUTPUT_MARGIN
if free < 64:
# Still request a tiny completion so the API call is valid; caller should
# have reduced batch size — this is a last-resort guard.
free = 64
capped = min(capped, free)
return max(1, capped)
# #endregion SharedLlmHttpClient.TokenLimits.clamp_max_tokens
# #endregion SharedLlmHttpClient.TokenLimits

View File

@@ -0,0 +1,96 @@
# #region McpServer.Provenance [C:4] [TYPE Module] [SEMANTICS mcp,provenance,approval]
# @defgroup McpServer.Provenance Durable invocation and bounded response methods.
from __future__ import annotations
from src.mcp_server import rbac_server as api
# #region McpServer.ProvenanceMixin [C:4] [TYPE Class]
# @BRIEF Durable invocation recording, bounded replies and approval retry lookup.
class RbacProvenanceMixin:
# #region McpServer.Provenance.RbacProvenanceMixin._record [C:3] [TYPE Function]
def _record(self, *, operation: str, tool_name: str | None, arguments: dict[str, api.Any] | None, outcome: str, error_code: str | None = None, continuation_payload: dict[str, api.Any] | None = None) -> str | None:
"""Persist request provenance when dispatch is running inside MCP context."""
access = api._access_token_context.get()
if access is None:
return None
try:
request_id = str(self.get_context().request_id)
except (RuntimeError, ValueError):
request_id = None
try:
with api.SessionLocal() as db:
return api.record_invocation(
db,
request_id=request_id,
client_id=access.client_id,
subject=access.subject,
principal_type="service" if access.claims and access.claims.get("principal_type") == "service" else "user",
operation=operation,
tool_name=tool_name,
arguments=arguments,
outcome=outcome,
error_code=error_code,
continuation_payload=continuation_payload,
)
except Exception:
# Provenance is security-critical: callers must not receive a successful
# mutation/decision when its durable audit write failed. The exception is
# re-raised to let the MCP protocol return an error envelope.
raise
# #endregion McpServer.Provenance.RbacProvenanceMixin._record
# #region McpServer.Provenance.RbacProvenanceMixin._oversized [C:3] [TYPE Function]
def _oversized(self, result: api.Any) -> dict[str, api.Any] | None:
"""Fail closed when a structured tool reply exceeds the server response limit."""
import json as _json
payload = result[1] if isinstance(result, tuple) else result
try:
encoded = _json.dumps(payload, ensure_ascii=False, default=str).encode()
except Exception:
encoded = str(payload).encode()
if len(encoded) <= self.config.response_limit:
return None
return {
"status": "rejected",
"error": "response_too_large",
"limit": self.config.response_limit,
}
# #endregion McpServer.Provenance.RbacProvenanceMixin._oversized
# #region McpServer.Provenance.RbacProvenanceMixin._find_retryable_approval [C:3] [TYPE Function]
@staticmethod
def _find_retryable_approval(subject: str, tool_name: str, request_hash: str) -> tuple[api.Any, api.Any] | None:
"""Reuse one live approval invocation for an identical client retry."""
with api.SessionLocal() as db:
record = (
db.query(api.McpToolInvocationRecord)
.filter(
api.McpToolInvocationRecord.subject == subject,
api.McpToolInvocationRecord.tool_name == tool_name,
api.McpToolInvocationRecord.arguments_sha256 == request_hash,
api.McpToolInvocationRecord.approval_status.in_(["pending", "approved"]),
api.McpToolInvocationRecord.dispatch_status.in_(["not_queued", "queued"]),
)
.order_by(api.McpToolInvocationRecord.created_at.desc(), api.McpToolInvocationRecord.id.desc())
.first()
)
if record is None:
return None
gate = (
db.query(api.ActionApprovalGate)
.filter(
api.ActionApprovalGate.owner_type == "mcp_invocation",
api.ActionApprovalGate.owner_id == record.id,
api.ActionApprovalGate.operation == "mcp_tool_call",
)
.order_by(api.ActionApprovalGate.created_at.desc())
.first()
)
if gate is None:
return None
return record.id, gate.id, gate.status
# #endregion McpServer.Provenance.RbacProvenanceMixin._find_retryable_approval
# #endregion McpServer.ProvenanceMixin
# #endregion McpServer.Provenance

View File

@@ -0,0 +1,122 @@
# #region McpServer.ToolCatalog [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolCatalog Explicit catalog definitions and permission metadata.
from __future__ import annotations
from dataclasses import dataclass
# #region McpServer.Catalog [C:3] [TYPE Class] [SEMANTICS mcp,catalog,tools,permissions]
# @ingroup McpServer
# @BRIEF Explicit MCP tool metadata; permission policy is declared beside each tool, never inferred from route names.
# @POST Deprecated entries stay registered/listed/callable for one minor catalog cycle with a
# [DEPRECATED] marker applied at the tools/list choke point (MCPX-FR-010).
# #region McpServer.ToolCatalog.McpToolDefinition [C:3] [TYPE Class]
@dataclass(frozen=True)
class McpToolDefinition:
name: str
permission: tuple[str, str] | None
service_allowed: bool = True
risk_level: str = "safe"
requires_approval: bool = False
deprecated: bool = False
deprecation_note: str | None = None
additional_permissions: tuple[tuple[str, str], ...] = ()
# #endregion McpServer.ToolCatalog.McpToolDefinition
_MCP_CATALOG = (
McpToolDefinition("list_environments", None),
McpToolDefinition("get_health_summary", ("plugin:migration", "READ")),
McpToolDefinition("search_dashboards", None),
McpToolDefinition("list_llm_providers", None),
McpToolDefinition("get_llm_status", None),
McpToolDefinition("get_task_status", ("tasks", "READ")),
McpToolDefinition("list_pending_approvals", ("scenario", "RUN_PROD"), service_allowed=False),
McpToolDefinition("decide_approval", ("scenario", "RUN_PROD"), service_allowed=False),
McpToolDefinition("list_maintenance_events", ("maintenance", "READ"), service_allowed=False),
McpToolDefinition("create_branch", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("commit_changes", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("deploy_dashboard", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("execute_migration", ("plugin:migration", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("run_backup", ("plugin:backup", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("run_llm_documentation", ("plugin:llm_documentation", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_list_databases", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_explore_database", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_format_sql", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_audit_permissions", ("plugin:superset_proxy", "EXECUTE")),
# SQL execution is its own risk class: a dedicated permission plus terminal PROD denial.
McpToolDefinition("superset_execute_sql", ("plugin:superset_sql", "EXECUTE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("superset_create_dashboard", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_copy_dashboard", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_create_dataset", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("capture_baseline_candidate", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("preview_reference_dashboard", ("dashboard:testing", "READ"), service_allowed=False,
additional_permissions=(("dashboard:testing", "EXECUTE"),)),
McpToolDefinition("capture_reference_selection", ("dashboard:testing", "READ"), service_allowed=False,
risk_level="guarded", additional_permissions=(("dashboard:testing", "EXECUTE"), ("dashboard:testing", "WRITE"))),
McpToolDefinition("request_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("decide_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("consume_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded", requires_approval=True),
# 037 publication worker (050 T045 / MCPX-FR-030): explicit authorized publish with branch-head
# CAS. REST parity: POST /api/catalog-publications (scenario RUN_PROD); human-only, gated.
McpToolDefinition("publish_baseline_catalog", ("scenario", "RUN_PROD"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("create_verification_run", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
# Human observation checkpoints (044) — automation/service principals have no path to them (050 FR-019).
McpToolDefinition("list_checkpoints", ("scenario", "RUN"), service_allowed=False),
McpToolDefinition("decide_checkpoint", ("scenario", "RUN"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("start_maintenance", ("maintenance", "WRITE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("end_maintenance", ("maintenance", "WRITE"), service_allowed=False, risk_level="guarded", requires_approval=True),
# AgentRun ownership anchor (ADR-0024 / MCPX-FR-027): register_draft_pack binds to a principal-owned
# AgentRun; these two tools make that prerequisite mintable/readable from the catalog itself. REST
# parity: POST/GET /api/agent/runs (EXECUTE create / ownership-scoped read); human-only.
McpToolDefinition("create_agent_run", ("dashboard:testing", "EXECUTE"), service_allowed=False),
McpToolDefinition("get_agent_run", ("dashboard:testing", "READ"), service_allowed=False),
# 047 investigation loop (SCAN-FR-016..019 / MCPX-FR-031, design 2026-09-17): reads require the
# scenario-result view grant and are zero-side-effect; append-only writes require triage and are
# human-only, and a proposal never decides the case (the human CAS disposition stays authoritative).
McpToolDefinition("list_investigation_queue", ("scenario:result", "VIEW")),
McpToolDefinition("get_investigation_case", ("scenario:result", "VIEW")),
McpToolDefinition("record_case_note", ("scenario:result", "TRIAGE"), service_allowed=False),
McpToolDefinition("propose_case_disposition", ("scenario:result", "TRIAGE"), service_allowed=False),
McpToolDefinition("inspect_dashboard_context", None),
McpToolDefinition("propose_test_pack_profile", None, service_allowed=False),
McpToolDefinition("resolve_test_pack_profile", None, service_allowed=False),
McpToolDefinition("propose_metric_baseline_profile", ("dashboard:testing", "READ"), service_allowed=False),
McpToolDefinition("resolve_metric_baseline_profile", ("dashboard:testing", "WRITE"), service_allowed=False),
McpToolDefinition("register_metric_baseline_pack", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("inspect_scenario", None),
McpToolDefinition("validate_scenario", None),
McpToolDefinition("scenario_resolve", None),
McpToolDefinition("generate_draft_pack", None),
McpToolDefinition("register_draft_pack", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("start_scenario_run", ("scenario", "RUN"), service_allowed=False, risk_level="guarded"),
# No canonical workspace RBAC permission exists; authenticated human MCP access is
# the narrow existing MCP-only pattern, while service principals remain denied.
McpToolDefinition("create_authoring_session", None, service_allowed=False),
McpToolDefinition("bootstrap_authoring_scenario", None, service_allowed=False),
McpToolDefinition("propose_test_plan", None, service_allowed=False),
McpToolDefinition("start_exploration", None, service_allowed=False),
McpToolDefinition("get_exploration_result", None, service_allowed=False),
McpToolDefinition("propose_graph_revision", None, service_allowed=False),
McpToolDefinition("get_graph_diff", None, service_allowed=False),
McpToolDefinition("promote_to_scenario", None, service_allowed=False),
McpToolDefinition("request_save", ("scenario", "EDIT"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("activate_revision", ("scenario", "EDIT"), service_allowed=False, risk_level="guarded"),
# DG-2 (046 T019): automation reads admit any authenticated principal type — humans with
# scenario:automation READ (live DB RBAC) and service principals with the mcp:read scope —
# with per-object scenario-ownership ACL enforced inside the tool bodies. Mutations stay
# human-only below.
McpToolDefinition("list_scenario_schedules", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_schedule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("delete_scenario_schedule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("list_scenario_trigger_rules", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_trigger_rule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("delete_scenario_trigger_rule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("get_scenario_automation_policy", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_automation_policy", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("get_scenario_automation_metrics", ("scenario:automation", "READ")),
)
_MCP_CATALOG_BY_NAME = {definition.name: definition for definition in _MCP_CATALOG}
# #endregion McpServer.Catalog
# #endregion McpServer.ToolCatalog

View File

@@ -0,0 +1,94 @@
# #region McpServer.ToolsLeaf.authoring.promote_to_scenario [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.authoring.promote_to_scenario Review graph proposals, save candidates and explicitly activate revisions.
from __future__ import annotations
from src.mcp_server import tools_authoring as api
# #region McpServer.PromoteToScenarioTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,promotion,validation,cas]
# @ingroup McpServer
# @BRIEF Promote an owner-reviewed graph proposal toward save readiness without activating it.
# @PRE Authenticated human principal, strict bounded request, and a workspace in proposal_ready.
# @POST Returns the server-recomputed digest, validation, diff, and promotion status.
# @SIDE_EFFECT Commits the workspace CAS/promotion advance and operation receipt; never creates a revision.
# @REJECTED Save, activation, registry mutation, and raw caller digests are not part of this tool.
async def promote_to_scenario_tool(request: api.PromoteScenarioInput) -> dict[str, api.Any]:
"""Promote a graph proposal toward save readiness without activating it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Promotion requires a human MCP principal", src="McpServer.PromoteToScenarioTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
result = api.promote_to_scenario(
db, request.workspace_id, access.subject, request.expected_cas_version,
idempotency_key=request.idempotency_key, actor_principal=access.subject,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.PromoteToScenarioTool
# #region McpServer.RequestSaveTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,save,candidate,cas]
# @ingroup McpServer
# @BRIEF Save an owner-reviewed proposal into one candidate revision through the guarded editor path.
# @PRE Authenticated human principal with scenario:edit, strict bounded request, and a workspace in awaiting_user_review.
# @POST Returns the new candidate revision identity; current_revision is never advanced.
# @SIDE_EFFECT Commits one candidate ScenarioRevision, the workspace candidate advance, and an operation receipt.
# @REJECTED Activation, raw caller digests, and implicit current targets are not part of this tool.
async def request_save_tool(request: api.RequestSaveInput) -> dict[str, api.Any]:
"""Save a reviewed proposal into one candidate revision without activating it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Save requires a human MCP principal", src="McpServer.RequestSaveTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
result = api.request_save(
db, request.workspace_id, access.subject, request.expected_cas_version,
idempotency_key=request.idempotency_key, actor_principal=access.subject,
agent_action_id=request.agent_action_id,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.RequestSaveTool
# #region McpServer.ActivateRevisionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,activation,current,cas]
# @ingroup McpServer
# @BRIEF Activate an explicit candidate revision as current through the guarded registry CAS.
# @PRE Authenticated human principal with scenario:edit, strict bounded request, and a workspace in candidate state.
# @POST Returns the activated revision identity and advances the workspace to current.
# @SIDE_EFFECT Promotes one candidate revision to current via activate_current_revision and records a receipt.
# @REJECTED Implicit latest-revision activation, save-during-activation, and raw caller digests are not part of this tool.
async def activate_revision_tool(request: api.ActivateRevisionInput) -> dict[str, api.Any]:
"""Activate an explicit candidate revision as current without saving or running it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Activation requires a human MCP principal", src="McpServer.ActivateRevisionTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
result = api.activate_revision(
db, request.workspace_id, access.subject, request.revision_id,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject, agent_action_id=request.agent_action_id,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.ActivateRevisionTool
# #region McpServer.Registration.authoring.promote_to_scenario [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="promote_to_scenario", structured_output=True)(promote_to_scenario_tool)
server.tool(name="request_save", structured_output=True)(request_save_tool)
server.tool(name="activate_revision", structured_output=True)(activate_revision_tool)
# #endregion McpServer.Registration.authoring.promote_to_scenario
# #endregion McpServer.ToolsLeaf.authoring.promote_to_scenario

View File

@@ -0,0 +1,301 @@
# #region McpServer.ToolsLeaf.authoring.bootstrap_authoring_scenario [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.authoring.bootstrap_authoring_scenario Bootstrap owner workspaces, propose plans/explorations and read or propose graph edits.
from __future__ import annotations
from src.mcp_server import tools_authoring as api
# #region McpServer.BootstrapAuthoringScenario [C:5] [TYPE Function] [SEMANTICS mcp,bootstrap,registry,workspace]
# @ingroup McpServer
# @BRIEF Atomically bootstrap a server-owned registry chain and bound authoring workspace.
async def bootstrap_authoring_scenario(request: api.InitialScenarioIntent) -> dict[str, api.Any]:
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
request_hash = api.hashlib.sha256(api.json.dumps(request.model_dump(), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
# The operation table's workspace_id is String(36); use a stable
# principal-scoped sentinel rather than storing an unbounded username.
receipt_scope = "boot-" + api.hashlib.sha256(access.subject.encode()).hexdigest()[:31]
receipt = db.query(api.AgentAuthoringWorkspaceOperation).filter(
api.AgentAuthoringWorkspaceOperation.workspace_id == receipt_scope,
api.AgentAuthoringWorkspaceOperation.operation == "bootstrap_authoring_scenario",
api.AgentAuthoringWorkspaceOperation.idempotency_key == request.idempotency_key,
api.AgentAuthoringWorkspaceOperation.actor_principal == access.subject,
).first()
if receipt is not None:
if receipt.request_hash != request_hash:
raise api.WorkspaceIdempotencyConflict("idempotency key conflicts with a different bootstrap request")
workspace = db.get(api.AgentAuthoringWorkspace, receipt.result_reference)
revision = db.get(api.ScenarioRevision, workspace.base_revision_id) if workspace else None
entry = db.get(api.ScenarioRegistryEntry, workspace.scenario_id) if workspace else None
if workspace is None or revision is None or entry is None:
raise ValueError("bootstrap replay receipt is incomplete")
return {"scenario_id": entry.scenario_id, "revision_id": revision.revision_id,
"workspace_id": workspace.workspace_id, "content_hash": revision.content_hash,
"cas_version": workspace.cas_version, "activation_status": revision.activation_status,
"replayed": True}
# Metric save admission re-inspects via the provider's application loop.
# Release that loop while the synchronous registry boundary waits for it.
from src.models.scenario_handles import CompiledScenarioHandle
compiled = db.get(CompiledScenarioHandle, request.compiled_handle_id)
if compiled is not None and compiled.schema_version == 2:
import asyncio
entry, revision = await asyncio.to_thread(
api.create_initial, db, intent=request, user_id=access.subject, owner_username=access.subject,
)
else:
entry, revision = api.create_initial(db, intent=request, user_id=access.subject, owner_username=access.subject)
workspace = api.create_workspace(db, access.subject, expires_in=api.timedelta(hours=1),
scenario_id=entry.scenario_id, base_revision_id=revision.revision_id,
base_content_hash=revision.content_hash, idempotency_key=request.idempotency_key,
actor_principal=access.subject)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=receipt_scope, operation="bootstrap_authoring_scenario",
idempotency_key=request.idempotency_key, request_hash=request_hash,
result_reference=workspace.workspace_id, result_status="current",
result_cas_version=workspace.cas_version, actor_principal=access.subject,
))
db.commit()
return {"scenario_id": entry.scenario_id, "revision_id": revision.revision_id,
"workspace_id": workspace.workspace_id, "content_hash": revision.content_hash,
"cas_version": workspace.cas_version, "activation_status": revision.activation_status}
except Exception:
db.rollback()
raise
# #endregion McpServer.BootstrapAuthoringScenario
# #region McpServer.AuthoringSessionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,workspace,create,idempotency]
# @ingroup McpServer
# @BRIEF Create or replay a server-owned authoring workspace for the authenticated MCP user.
# @PRE Input is strict and bounded; owner is resolved from authenticated MCP context.
# @POST Returns only server-owned workspace metadata; identical retries return the same workspace.
# @SIDE_EFFECT Commits one workspace and immutable operation receipt, or rolls back on failure.
# @RELATION CALLS -> [Services.AgentAuthoringWorkspace.Service]
# @RATIONALE No canonical workspace RBAC permission exists, so this uses the existing MCP-only
# authenticated-user pattern and explicitly denies service principals in the catalog.
# @REJECTED Raw content, Playwright code, sandbox execution, and invented broad RBAC were rejected
# because this first authoring operation must persist metadata only.
async def create_authoring_session(request: api.AuthoringSessionInput) -> dict[str, api.Any]:
"""Create a metadata-only authoring session owned by the authenticated MCP user."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore(
"Authoring session requires a human MCP principal", src="McpServer.AuthoringSessionTool.create",
error="human_principal_required",
)
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
workspace = api.create_workspace(
db,
access.subject,
expires_in=api.timedelta(seconds=request.expires_in_seconds),
agent_principal=request.agent_principal,
scenario_id=request.scenario_id,
base_revision_id=request.base_revision_id,
base_content_hash=request.base_content_hash,
idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
api.logger.reflect(
"Authoring session projection returned", src="McpServer.AuthoringSessionTool.create",
payload={"workspace_id": workspace.workspace_id},
)
return {
"workspace_id": workspace.workspace_id,
"session_status": workspace.session_status,
"owner_principal": workspace.owner_principal,
"agent_principal": workspace.agent_principal,
"scenario_id": workspace.scenario_id,
"base_revision_id": workspace.base_revision_id,
"base_content_hash": workspace.base_content_hash,
"cas_version": workspace.cas_version,
"expires_at": workspace.expires_at.isoformat(),
}
except Exception:
db.rollback()
raise
# #endregion McpServer.AuthoringSessionTool
# #region McpServer.ProposeTestPlanTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,test-plan,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a bounded user-facing test-plan intent for the authenticated MCP user.
# @PRE Authenticated human principal and strict bounded request; owner is derived from token.
# @POST Returns plan identity, server digest, and resulting CAS version without executable content.
# @SIDE_EFFECT Commits the plan, workspace CAS update, and operation receipt together.
# @REJECTED Sandbox, exploration, promotion, ScenarioRun execution, and registry mutation are not part of this tool.
async def propose_test_plan_tool(request: api.TestPlanInput) -> dict[str, api.Any]:
"""Persist one bounded checklist proposal without executing it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Test-plan proposal requires a human MCP principal", src="McpServer.ProposeTestPlanTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
plan = api.propose_test_plan(
db, request.workspace_id, access.subject, request.plan_content.model_dump(),
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
workspace = db.get(api.AgentAuthoringWorkspace, request.workspace_id)
return {
"status": "ok", "plan_id": plan.plan_id, "workspace_id": plan.workspace_id,
"content_digest": plan.content_digest, "plan_content": plan.plan_content,
"cas_version": workspace.cas_version if workspace else None,
"created_at": plan.created_at.isoformat(),
}
except Exception:
db.rollback()
raise
# #endregion McpServer.ProposeTestPlanTool
# #region McpServer.StartExplorationTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,exploration,sandbox,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a bounded exploration request and report sandbox readiness without executing.
# @PRE Authenticated human principal and strict bounded request; owner is derived from token.
# @POST Returns request status sandbox_unavailable when no provider registry is available; workspace remains draft.
# @SIDE_EFFECT Commits request, CAS, and receipt together, or rolls back on failure.
# @REJECTED Raw browser/code execution and provider I/O are not exposed by this boundary.
async def start_exploration_tool(request: api.ExplorationInput) -> dict[str, api.Any]:
"""Persist an exploration request without sandbox execution."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Exploration requires a human MCP principal", src="McpServer.StartExplorationTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
exploration = api.start_exploration(
db, request.workspace_id, access.subject, request.exploration_spec,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject, provider_available=api.get_registered_runner() is not None,
)
db.commit()
workspace = db.get(api.AgentAuthoringWorkspace, request.workspace_id)
return {
"status": exploration.request_status,
"request_id": exploration.request_id,
"operation_id": exploration.operation_id,
"workspace_id": exploration.workspace_id,
"session_status": workspace.session_status if workspace else None,
"cas_version": workspace.cas_version if workspace else None,
}
except Exception:
db.rollback()
raise
# #endregion McpServer.StartExplorationTool
# #region McpServer.GetExplorationResultTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,exploration,read,owner,bounded]
# @ingroup McpServer
# @BRIEF Read bounded metadata for an owner-authorized exploration request without execution.
# @PRE Authenticated human principal and strict workspace/request identifiers; owner comes from token.
# @POST Returns only request status, identifiers, creation time, and opaque receipt reference.
# @SIDE_EFFECT None; no provider I/O, sandbox execution, CAS/status, or domain-row mutation.
# @REJECTED Raw source, code, result payloads, synthesized outcomes, and artifact disclosure are not exposed.
async def get_exploration_result_tool(request: api.ExplorationResultInput) -> dict[str, api.Any]:
"""Read a bounded exploration request projection without running or mutating it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore(
"Exploration result requires a human MCP principal", src="McpServer.GetExplorationResultTool",
error="human_principal_required",
)
return {"status": "permission_denied", "error": "permission_denied"}
with api.SessionLocal() as db:
try:
projection = api.get_exploration_result(
db, request.workspace_id, request.request_id, access.subject
)
except api.WorkspaceAccessError:
api.logger.explore(
"Exploration result owner check denied", src="McpServer.GetExplorationResultTool",
error="permission_denied",
)
return {"status": "permission_denied", "error": "permission_denied"}
except api.WorkspaceNotFound:
api.logger.explore(
"Exploration request was not found", src="McpServer.GetExplorationResultTool",
error="not_found",
)
return {
"status": "not_found",
"request_id": request.request_id,
"workspace_id": request.workspace_id,
}
return {
"status": projection.status,
"request_id": projection.request_id,
"workspace_id": projection.workspace_id,
"created_at": projection.created_at.isoformat(),
"receipt_reference": projection.receipt_reference,
}
# #endregion McpServer.GetExplorationResultTool
# #region McpServer.ProposeGraphRevisionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,proposal,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a server-derived graph proposal from typed edit operations for the authenticated user.
# @PRE Authenticated human principal, strict bounded request, and a workspace bound to a scenario revision.
# @POST Returns proposal identity, server digest, deterministic diff, and resulting CAS version.
# @SIDE_EFFECT Commits one ScenarioEditProposal, the workspace CAS update, and an operation receipt together.
# @REJECTED Client graph payloads, revision activation, sandbox execution, and registry save are not part of this tool.
async def propose_graph_revision_tool(request: api.GraphRevisionInput) -> dict[str, api.Any]:
"""Derive and persist one graph proposal from typed edit operations without saving or activating."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Graph revision proposal requires a human MCP principal", src="McpServer.ProposeGraphRevisionTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with api.SessionLocal() as db:
try:
result = api.propose_graph_revision(
db, request.workspace_id, access.subject, request.request_text, request.operations,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.ProposeGraphRevisionTool
# #region McpServer.GetGraphDiffTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,diff,read,bounded]
# @ingroup McpServer
# @BRIEF Read the deterministic diff for an owner-authorized graph proposal without execution.
# @PRE Authenticated human principal and strict workspace identifier; owner comes from the token.
# @POST Returns proposal identity, digest, status, and the computed diff only.
# @SIDE_EFFECT None; no provider I/O, sandbox execution, CAS/status, or domain-row mutation.
# @REJECTED Raw graph snapshots, source operations, and synthesized outcomes are not exposed.
async def get_graph_diff_tool(request: api.GraphDiffInput) -> dict[str, api.Any]:
"""Read a bounded graph-proposal diff without running or mutating it."""
access = api._access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
api.logger.explore("Graph diff requires a human MCP principal", src="McpServer.GetGraphDiffTool", error="human_principal_required")
return {"status": "permission_denied", "error": "permission_denied"}
with api.SessionLocal() as db:
try:
result = api.get_graph_diff(db, request.workspace_id, access.subject)
except api.WorkspaceAccessError:
api.logger.explore("Graph diff owner check denied", src="McpServer.GetGraphDiffTool", error="permission_denied")
return {"status": "permission_denied", "error": "permission_denied"}
except api.WorkspaceNotFound:
api.logger.explore("Graph proposal was not found", src="McpServer.GetGraphDiffTool", error="not_found")
return {"status": "not_found", "workspace_id": request.workspace_id}
return {"status": "ok", **result}
# #endregion McpServer.GetGraphDiffTool
# #region McpServer.Registration.authoring.bootstrap_authoring_scenario [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="bootstrap_authoring_scenario", structured_output=True)(bootstrap_authoring_scenario)
server.tool(name="create_authoring_session", structured_output=True)(create_authoring_session)
server.tool(name="propose_test_plan", structured_output=True)(propose_test_plan_tool)
server.tool(name="start_exploration", structured_output=True)(start_exploration_tool)
server.tool(name="get_exploration_result", structured_output=True)(get_exploration_result_tool)
server.tool(name="propose_graph_revision", structured_output=True)(propose_graph_revision_tool)
server.tool(name="get_graph_diff", structured_output=True)(get_graph_diff_tool)
# #endregion McpServer.Registration.authoring.bootstrap_authoring_scenario
# #endregion McpServer.ToolsLeaf.authoring.bootstrap_authoring_scenario

View File

@@ -0,0 +1,336 @@
# #region McpServer.ToolsLeaf.scenario.inspect_dashboard_context [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.scenario.inspect_dashboard_context Inspect dashboards, preview/resolve profile choices and compile/validate read-only scenarios.
from __future__ import annotations
from src.mcp_server import tools_scenario as api
# #region McpServer.ScenarioTools.InspectDashboardContext [C:4] [TYPE Function] [SEMANTICS mcp,scenario,inspect,context,stage1]
# @ingroup McpServer
# @BRIEF T029h stage 1: resolve live authoritative dashboard context through the existing
# BaselineEngine inspect service and return the model an agent must echo into compile.
# @RELATION CALLS -> [McpServer.TraversalGuidance.Build]
# @PRE environment_id resolves via get_config_manager; dashboard_id is a positive integer.
# @POST Returns status ok with the full DashboardQueryModel dump and fingerprint, degraded when
# upstream inspection returned a sentinel model, or blocked on typed failures.
# @SIDE_EFFECT Async upstream Superset reads through the shared client registry.
# @REJECTED Returning only a bounded projection was rejected — the client must echo the exact
# authoritative model into compile's query_model or the register-boundary fingerprint
# recomputation can never match (ScenarioGraph.ContextAuthority).
async def inspect_dashboard_context_tool(request: api.InspectContextInput) -> dict[str, api.Any]:
environment = api.get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await api.get_superset_client(environment)
model = await api.inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
except Exception as exc:
api.logger.explore("Dashboard context inspection failed", src="McpServer.ScenarioTools.InspectDashboardContext",
error_code="INSPECTION_FAILED", error=str(exc)[:300],
payload={"environment_id": request.environment_id, "dashboard_id": request.dashboard_id})
return {"status": "blocked", "error": "INSPECTION_FAILED"}
fingerprint = model.query_model_fingerprint
degraded = (not fingerprint) or fingerprint == "sha256:error"
# T029k (MCPX-FR-028): the same authoritative model yields the truthful capability facts, so the
# agent sees the derived classification inputs before echoing the model into compile.
derivation = api.derive_capabilities(model, browser_available=api.resolve_browser_availability())
api.logger.reflect("Dashboard context inspected", src="McpServer.ScenarioTools.InspectDashboardContext",
payload={"environment_id": request.environment_id, "dashboard_id": request.dashboard_id,
"degraded": degraded, "charts": len(model.charts),
"derived_facts": sorted(k for k, v in derivation.capabilities.items() if v)})
return {
"status": "degraded" if degraded else "ok",
"query_model": model.model_dump(mode="json"),
"query_model_fingerprint": fingerprint,
"warning_codes": [w.code for w in model.warnings],
"browser_traversal_guidance": api.browser_traversal_guidance(),
"derived_capabilities": {
"capabilities": dict(derivation.capabilities),
"has_dataset_fields": derivation.has_dataset_fields,
"undetermined": list(derivation.undetermined),
},
}
# #endregion McpServer.ScenarioTools.InspectDashboardContext
# #region McpServer.ScenarioTools.ProposeTestPackProfile [C:4] [TYPE Function] [SEMANTICS mcp,scenario,profile,preview]
# @ingroup McpServer
# @BRIEF Build a typed test-pack proposal from a fresh server inspection and expose unresolved questions.
# @PRE Environment access is authorized and selected case IDs exist in the pinned checklist catalog.
# @POST Returns complete per-case coverage; only a fully resolvable profile may be save_eligible.
# @SIDE_EFFECT Performs bounded upstream Superset reads and emits profile/compile molecular-CoT events.
# @INVARIANT Caller-supplied capabilities, query models and expected values are not accepted.
# @REJECTED Reusing inspect_scenario's caller-carried query model was rejected — T029h verifies such
# claims only at registration; this proposal must classify from fresh server inspection.
# #region McpServer.ScenarioTools.ProposeTestPackProfile.Call [C:4] [TYPE Function] [SEMANTICS mcp,scenario,profile,inspection]
async def propose_test_pack_profile(request: api.TestPackProfileInput) -> dict[str, api.Any]:
environment = api.get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await api.get_superset_client(environment)
query_model = await api.inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
if not query_model.query_model_fingerprint or query_model.query_model_fingerprint == "sha256:error":
return {"status": "blocked", "error": "CONTEXT_INSPECTION_DEGRADED"}
if (query_model.environment_id != request.environment_id
or int(query_model.dashboard_id) != request.dashboard_id):
return {"status": "blocked", "error": "CONTEXT_IDENTITY_MISMATCH"}
profile, scenario, pack = api.build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids,
browser_available=api.resolve_browser_availability(),
)
except (KeyError, ValueError) as exc:
api.logger.explore("Test-pack profile proposal rejected", src="McpServer.ScenarioTools.ProposeTestPackProfile",
error_code=str(exc), payload={"dashboard_id": request.dashboard_id}, error=str(exc))
return {"status": "blocked", "error": str(exc)}
except Exception as exc:
api.logger.explore("Test-pack profile inspection failed", src="McpServer.ScenarioTools.ProposeTestPackProfile",
error_code="INSPECTION_FAILED", payload={"dashboard_id": request.dashboard_id}, error=str(exc)[:300])
return {"status": "blocked", "error": "INSPECTION_FAILED"}
return {
"status": profile.status,
"profile": profile.model_dump(mode="json"),
"profile_handle_id": api._persist_profile_preview(profile, request, scenario),
"cas_version": 0,
"preview": {
"step_count": len(scenario.steps),
"artifacts": pack.get("artifacts", []),
"validation": pack.get("validation_summary", {}),
},
}
# #endregion McpServer.ScenarioTools.ProposeTestPackProfile.Call
# #endregion McpServer.ScenarioTools.ProposeTestPackProfile
# #region McpServer.ScenarioTools.ResolveTestPackProfile [C:4] [TYPE Function] [SEMANTICS mcp,profile,resolve,cas]
# @ingroup McpServer
# @BRIEF Re-inspect context, CAS-check a durable owner profile, and apply reviewed typed answers.
# @PRE Every resolution ID names a current unresolved item; the owner and CAS match.
# @POST Legacy answers and URL-free versioned baseline selections commit with the replay receipt under CAS.
# @SIDE_EFFECT Performs bounded Superset reads and one profile/receipt database transaction.
# @INVARIANT Unsupported targets, stale profile digests/catalog/compiler versions and caller
# graph/value claims fail closed before any profile or receipt write.
# @INVARIANT Baseline locator URLs contribute only to the one-way request hash, never durable profile or response bytes.
# @RATIONALE Each accepted answer changes the durable profile digest; the next CAS compares the
# caller's digest to that stored version after checking fresh context identity.
# @REJECTED Comparing later requests to the unresolved baseline digest rejects valid sequential
# answers even though their owner-scoped CAS and stored profile digest agree.
# #region McpServer.ScenarioTools.ResolveTestPackProfile.Call [C:4] [TYPE Function] [SEMANTICS mcp,profile,resolve,inspection]
# @RELATION CALLS -> [McpServer.TestPackProfile.LoadResolutionSession]
# @RELATION CALLS -> [McpServer.TestPackProfile.ApplyResolutions]
# @RELATION CALLS -> [McpServer.TestPackProfile.CommitResolution]
async def resolve_test_pack_profile(request: api.ResolveTestPackProfileInput) -> dict[str, api.Any]:
access = api._access_token_context.get()
if access is None or not access.subject or not request.profile_handle_id or not request.idempotency_key:
return {"status": "blocked", "error": "PROFILE_OWNER_OR_IDEMPOTENCY_REQUIRED"}
owner = str(access.subject)
request_body = request.model_dump(mode="json", exclude={"idempotency_key"})
request_hash = api.hashlib.sha256(api.json.dumps(request_body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
session, early_response = _load_resolution_session(request, owner, request_hash)
if early_response is not None:
return early_response
environment = api.get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await api.get_superset_client(environment)
query_model = await api.inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
if (not query_model.query_model_fingerprint or query_model.query_model_fingerprint == "sha256:error"
or query_model.environment_id != request.environment_id
or int(query_model.dashboard_id) != request.dashboard_id):
return {"status": "blocked", "error": "CONTEXT_IDENTITY_MISMATCH"}
baseline, scenario, _ = api.build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids,
browser_available=api.resolve_browser_availability(),
)
snapshot = session.profile_snapshot
if (not isinstance(snapshot, dict)
or baseline.query_model_fingerprint != session.context_fingerprint):
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
stored_profile = api.require_profile_snapshot(snapshot, session.profile_digest, baseline)
if session.profile_digest != request.expected_profile_digest:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": session.profile_digest, "cas_version": session.cas_version}
resolved = await _apply_resolutions(request, session, baseline, scenario, query_model, stored_profile, client)
if isinstance(resolved, dict):
return resolved
profile, updated, pack, accumulated = resolved
except (KeyError, ValueError) as exc:
api.logger.explore("Test-pack profile resolution rejected", src="McpServer.ScenarioTools.ResolveTestPackProfile",
error_code=str(exc), payload={"dashboard_id": request.dashboard_id}, error=str(exc))
return {"status": "blocked", "error": str(exc)}
except Exception as exc:
api.logger.explore("Test-pack profile resolution inspection failed", src="McpServer.ScenarioTools.ResolveTestPackProfile",
error_code="INSPECTION_FAILED", payload={"dashboard_id": request.dashboard_id}, error=str(exc)[:300])
return {"status": "blocked", "error": "INSPECTION_FAILED"}
response = {
"status": profile.status, "profile": profile.model_dump(mode="json"),
"profile_handle_id": session.profile_handle_id,
"cas_version": session.cas_version + 1,
"preview": {"step_count": len(updated.steps), "artifacts": pack.get("artifacts", []),
"validation": pack.get("validation_summary", {})},
}
return _commit_resolution(request, session, owner, request_hash, profile, accumulated, response)
# #endregion McpServer.ScenarioTools.ResolveTestPackProfile.Call
# #endregion McpServer.ScenarioTools.ResolveTestPackProfile
# #region McpServer.Tool.inspect_scenario [C:4] [TYPE Function]
async def inspect_scenario(request: api.ScenarioCompileInput) -> dict[str, api.Any]:
"""Compile a scenario graph without registering or persisting it.
T029k (MCPX-FR-028): when the supplied query_model is authoritative-shape, the server derives
truthful capability facts from it and they win over conflicting caller declarations in both
directions; the additive `capability_authority` section reports the derived facts, undetermined
keys, and overridden declarations. Legacy/non-authoritative payloads keep caller-declared
capabilities (register-time context_authority remains the hard gate).
"""
api.logger.reason("Compile MCP scenario inspection", src="McpServer.ScenarioTools.inspect_scenario", payload={"dashboard_id": request.dashboard_id})
capability_authority = api.build_capability_authority(
request.query_model, request.capabilities, request.has_dataset_fields,
)
compile_fields = request.model_dump()
compile_fields["capabilities"] = capability_authority["effective_capabilities"]
compile_fields["has_dataset_fields"] = capability_authority["effective_has_dataset_fields"]
compiled = api.compile_scenario(api.CompileScenarioRequest(**compile_fields))
result = {
"status": "ok",
"scenario": compiled.scenario.model_dump(mode="json"),
"warnings": [item.model_dump(mode="json") for item in compiled.warnings],
"blockers": [item.model_dump(mode="json") for item in compiled.blockers],
"capability_authority": capability_authority["section"],
}
api.logger.reflect("Inspection graph returned", src="McpServer.ScenarioTools.inspect_scenario", payload={"steps": len(compiled.scenario.steps)})
return result
# #endregion McpServer.Tool.inspect_scenario
# #region McpServer.Tool.validate_scenario_tool [C:4] [TYPE Function]
async def validate_scenario_tool(scenario: api.DashboardTestScenario) -> dict[str, api.Any]:
"""Validate a supplied graph without persisting it."""
api.logger.reason("Validate MCP scenario graph", src="McpServer.ScenarioTools.validate_scenario", payload={"scenario_id": scenario.scenario_id})
result = api.validate_scenario(scenario)
api.logger.reflect("Validation result returned", src="McpServer.ScenarioTools.validate_scenario", payload={"valid": result.valid})
return {
"status": "valid" if result.valid else "needs_context",
"valid": result.valid,
"errors": [item.model_dump(mode="json") for item in result.errors],
"warnings": [item.model_dump(mode="json") for item in result.warnings],
"blockers": [item.model_dump(mode="json") for item in result.blockers],
"coverage": result.coverage,
"topological_order": result.topological_order,
"unresolved_parameters": result.unresolved_parameters,
"unresolved_selectors": result.unresolved_selectors,
"unresolved_baselines": result.unresolved_baselines,
"graph_hash": result.graph_hash,
}
# #endregion McpServer.Tool.validate_scenario_tool
# #region McpServer.Registration.scenario.inspect_dashboard_context [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="inspect_dashboard_context", structured_output=True,
description=api.BROWSER_TRAVERSAL_MCP_DESCRIPTION)(inspect_dashboard_context_tool)
server.tool(name="propose_test_pack_profile", structured_output=True)(propose_test_pack_profile)
server.tool(name="resolve_test_pack_profile", structured_output=True)(resolve_test_pack_profile)
server.tool(name="inspect_scenario", structured_output=True)(inspect_scenario)
server.tool(name="validate_scenario", structured_output=True)(validate_scenario_tool)
# #endregion McpServer.Registration.scenario.inspect_dashboard_context
# #region McpServer.TestPackProfile.LoadResolutionSession [C:3] [TYPE Function]
# @BRIEF Load the owner profile and replay receipt before the live inspection, preserving CAS conflict ordering.
def _load_resolution_session(request, owner, request_hash):
with api.SessionLocal() as db:
session = db.query(api.TestPackProfileSession).filter(
api.TestPackProfileSession.profile_handle_id == request.profile_handle_id,
api.TestPackProfileSession.owner_principal == owner,
).with_for_update().first()
if session is None:
return None, {"status": "blocked", "error": "PROFILE_ACCESS_DENIED"}
receipt = db.query(api.TestPackProfileReceipt).filter_by(
profile_handle_id=session.profile_handle_id, owner_principal=owner,
idempotency_key=request.idempotency_key,
).first()
if receipt is not None:
if receipt.request_hash != request_hash:
return None, {"status": "conflict", "error": "IDEMPOTENCY_CONFLICT"}
return None, {**receipt.response, "replayed": True}
if (session.cas_version != request.expected_cas_version
or session.environment_id != request.environment_id
or session.dashboard_id != request.dashboard_id
or session.objective != request.objective
or session.selected_case_ids != request.selected_case_ids):
return None, {"status": "conflict", "error": "PROFILE_CAS_CONFLICT",
"current_profile_digest": session.profile_digest, "cas_version": session.cas_version}
return session, None
# #endregion McpServer.TestPackProfile.LoadResolutionSession
# #region McpServer.TestPackProfile.ApplyResolutions [C:3] [TYPE Function]
# @BRIEF Validate current and accumulated answers, rebuild selector coordinates, then apply baseline answers.
async def _apply_resolutions(request, session, baseline, scenario, query_model, stored_profile, client):
baseline_resolutions = [item for item in request.resolutions if isinstance(item, api.BaselineProfileResolution)]
selector_resolutions = [item for item in request.resolutions
if isinstance(item, api.TestPackProfileResolution) and item.selector_hint is not None]
coordinate_resolutions = [item for item in request.resolutions
if isinstance(item, api.TestPackProfileResolution) and item.coordinate_id is not None]
has_selector_items = any(item.kind == "needs_selector" for item in baseline.unresolved)
selector_changes = api._selector_profile_changes(baseline, selector_resolutions, scenario) if selector_resolutions else []
coordinate_choices = api._coordinate_profile_choices(baseline, coordinate_resolutions) if coordinate_resolutions else []
has_metric_items = any(item.kind == "needs_metric" for item in baseline.unresolved)
invalid_resolution = api._invalid_profile_resolution(
has_selector_items, has_metric_items or bool(baseline_resolutions), selector_resolutions,
selector_changes, coordinate_resolutions, coordinate_choices,
)
if (invalid_resolution or (selector_resolutions and not selector_changes)
or (coordinate_resolutions and not coordinate_choices)):
return {"status": "blocked", "error": "PROFILE_RESOLUTION_INVALID"}
accumulated = list(session.resolutions or [])
accumulated.extend(item.model_dump(mode="json") for item in request.resolutions
if isinstance(item, api.TestPackProfileResolution))
all_selector_changes = api._selector_profile_changes(baseline, [
api.TestPackProfileResolution.model_validate(item) for item in accumulated
if item.get("selector_hint") is not None
], scenario)
if accumulated and all_selector_changes is None:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
parameters = api._selector_parameters(all_selector_changes or [])
profile, updated, pack = api.build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids, parameters=parameters,
browser_available=api.resolve_browser_availability(),
)
all_coordinates = [api.TestPackProfileResolution.model_validate(item) for item in accumulated
if item.get("coordinate_id") is not None]
all_coordinate_choices = api._coordinate_profile_choices(baseline, all_coordinates) if all_coordinates else []
if all_coordinate_choices is None:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
if all_coordinate_choices:
selected_coordinates = {item["unresolved_id"]: item["coordinate_id"] for item in all_coordinate_choices}
profile = api.apply_coordinate_choices(profile, selected_coordinates)
profile = await api.apply_baseline_answers(profile, stored_profile, baseline_resolutions, client)
return profile, updated, pack, accumulated
# #endregion McpServer.TestPackProfile.ApplyResolutions
# #region McpServer.TestPackProfile.CommitResolution [C:3] [TYPE Function]
# @BRIEF Commit profile digest, snapshot, accumulated answers and receipt under a fresh owner CAS lock.
def _commit_resolution(request, session, owner, request_hash, profile, accumulated, response):
with api.SessionLocal() as db:
current = db.query(api.TestPackProfileSession).filter(
api.TestPackProfileSession.profile_handle_id == session.profile_handle_id,
api.TestPackProfileSession.owner_principal == owner,
api.TestPackProfileSession.cas_version == request.expected_cas_version,
).with_for_update().first()
if current is None:
return {"status": "conflict", "error": "PROFILE_CAS_CONFLICT"}
current.resolutions = accumulated
current.profile_digest = profile.profile_digest
current.profile_snapshot = profile.model_dump(mode="json")
current.cas_version += 1
db.add(api.TestPackProfileReceipt(
profile_handle_id=current.profile_handle_id, owner_principal=owner,
idempotency_key=request.idempotency_key, request_hash=request_hash, response=response,
))
db.commit()
return response
# #endregion McpServer.TestPackProfile.CommitResolution
# #endregion McpServer.ToolsLeaf.scenario.inspect_dashboard_context

View File

@@ -0,0 +1,104 @@
# #region McpServer.ToolsLeaf.scenario.maintenance_approval [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.scenario.maintenance_approval Read and request maintenance events and inspect/decide pending approvals.
from __future__ import annotations
from src.mcp_server import tools_scenario as api
# #region McpServer.Tool.list_maintenance_events [C:4] [TYPE Function]
async def list_maintenance_events() -> dict[str, api.Any]:
"""Read maintenance events without auto-expiry or task enqueueing."""
with api.SessionLocal() as db:
events = (
db.query(api.MaintenanceEvent)
.order_by(api.MaintenanceEvent.created_at.desc())
.limit(100)
.all()
)
return {
"events": [
{
"id": event.id,
"environment_id": event.environment_id,
"tables": event.tables,
"start_time": event.start_time.isoformat() if event.start_time else None,
"end_time": event.end_time.isoformat() if event.end_time else None,
"status": event.status.value if hasattr(event.status, "value") else str(event.status),
"task_id": event.task_id,
"message": event.message,
}
for event in events
]
}
# #endregion McpServer.Tool.list_maintenance_events
# #region McpServer.Tool.start_maintenance [C:4] [TYPE Function]
async def start_maintenance(
tables: list[str],
start_time: str,
environment_id: str,
end_time: str | None = None,
auto_end: bool = False,
message: str | None = None,
) -> dict[str, api.Any]:
"""Request a maintenance window; execution requires ApprovalGate."""
return {
"status": "approval_required",
"tool": "start_maintenance",
"payload_validated": bool(tables and start_time and environment_id),
"message": message,
"end_time": end_time,
"auto_end": auto_end,
}
# #endregion McpServer.Tool.start_maintenance
# #region McpServer.Tool.end_maintenance [C:4] [TYPE Function]
async def end_maintenance(event_id: str | None = None, end_all: bool = False) -> dict[str, api.Any]:
"""Request one maintenance event or all active events to end; approval is required."""
return {
"status": "approval_required",
"tool": "end_maintenance",
"payload_validated": bool(event_id or end_all),
"event_id": event_id,
"end_all": end_all,
}
# #endregion McpServer.Tool.end_maintenance
# #region McpServer.Tool.list_pending_approvals [C:4] [TYPE Function]
async def list_pending_approvals() -> dict[str, api.Any]:
"""List pending MCP approval requests for the authenticated human user."""
access = api._access_token_context.get()
if access is None or access.claims and access.claims.get("principal_type") == "service":
return {"status": "permission_denied", "approvals": []}
with api.SessionLocal() as db:
user = api.AuthRepository(db).get_user_by_username(access.subject or "")
if user is None:
return {"status": "permission_denied", "approvals": []}
return {"status": "ok", "approvals": api.list_pending_mcp_approvals(db, user)}
# #endregion McpServer.Tool.list_pending_approvals
# #region McpServer.Tool.decide_approval [C:4] [TYPE Function]
async def decide_approval(gate_id: str, decision: str, comment: str = "") -> dict[str, api.Any]:
"""Approve or deny one pending MCP gate; no provider is dispatched here."""
access = api._access_token_context.get()
if access is None or access.claims and access.claims.get("principal_type") == "service":
return {"status": "permission_denied", "error": "human_principal_required"}
with api.SessionLocal() as db:
user = api.AuthRepository(db).get_user_by_username(access.subject or "")
if user is None:
return {"status": "permission_denied", "error": "user_not_found"}
try:
return api.decide_mcp_approval(db, gate_id, user, decision, comment)
except ValueError as exc:
return {"status": "rejected", "error": str(exc)}
# #endregion McpServer.Tool.decide_approval
# #region McpServer.Registration.scenario.maintenance_approval [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="list_maintenance_events", structured_output=True)(list_maintenance_events)
server.tool(name="start_maintenance", structured_output=True)(start_maintenance)
server.tool(name="end_maintenance", structured_output=True)(end_maintenance)
server.tool(name="list_pending_approvals", structured_output=True)(list_pending_approvals)
server.tool(name="decide_approval", structured_output=True)(decide_approval)
# #endregion McpServer.Registration.scenario.maintenance_approval
# #endregion McpServer.ToolsLeaf.scenario.maintenance_approval

View File

@@ -0,0 +1,141 @@
# #region McpServer.ToolsLeaf.scenario.probe_read [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.scenario.probe_read Read environments, health, dashboard search, LLM configuration and task status.
from __future__ import annotations
from src.mcp_server import tools_scenario as api
# #region McpServer.Tool.list_environments [C:4] [TYPE Function]
async def list_environments() -> dict[str, api.Any]:
"""List configured Superset environments without credentials."""
environments = api.get_config_manager().get_environments()
return {
"environments": [
{
"id": environment.id,
"name": environment.name,
"stage": environment.stage,
"is_production": environment.is_production,
}
for environment in environments
]
}
# #endregion McpServer.Tool.list_environments
# #region McpServer.Tool.get_health_summary [C:4] [TYPE Function]
async def get_health_summary(ctx: api.Context) -> dict[str, api.Any]:
"""Return the persisted dashboard health aggregate."""
with api.SessionLocal() as db:
summary = await api.HealthService(db, config_manager=api.get_config_manager()).get_health_summary()
return {
"items": [item.model_dump(mode="json") for item in summary.items[:100]],
"pass_count": summary.pass_count,
"warn_count": summary.warn_count,
"fail_count": summary.fail_count,
"unknown_count": summary.unknown_count,
"request_id": ctx.request_id,
}
# #endregion McpServer.Tool.get_health_summary
# #region McpServer.Tool.search_dashboards [C:4] [TYPE Function]
async def search_dashboards(environment_id: str, query: str = "", limit: int = 20) -> dict[str, api.Any]:
"""Search dashboards in one configured Superset environment."""
bounded_limit = max(1, min(limit, 100))
config_manager = api.get_config_manager()
environment = next(
(item for item in config_manager.get_environments() if item.id == environment_id),
None,
)
if environment is None:
return {"environment_id": environment_id, "query": query[:200], "limit": bounded_limit, "items": [], "error": "environment_not_found"}
dashboards = await api.SupersetClient(environment).get_dashboards_summary(require_slug=True)
needle = query.strip().lower()
if needle:
dashboards = [
item for item in dashboards
if needle in str(item.get("id", "")).lower()
or needle in str(item.get("title", "")).lower()
or needle in str(item.get("slug", "")).lower()
]
return {
"environment_id": environment_id,
"query": query[:200],
"limit": bounded_limit,
"total": len(dashboards),
"items": dashboards[:bounded_limit],
}
# #endregion McpServer.Tool.search_dashboards
# #region McpServer.Tool.list_llm_providers [C:4] [TYPE Function]
async def list_llm_providers() -> dict[str, api.Any]:
"""List local LLM providers without returning API keys or credentials."""
with api.SessionLocal() as db:
providers = api.LLMProviderService(db).get_all_providers()
return {
"providers": [
{
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"default_model": provider.default_model,
"is_active": provider.is_active,
}
for provider in providers[:100]
]
}
# #endregion McpServer.Tool.list_llm_providers
# #region McpServer.Tool.get_llm_status [C:4] [TYPE Function]
async def get_llm_status() -> dict[str, api.Any]:
"""Return local LLM readiness without exposing provider secrets."""
with api.SessionLocal() as db:
providers = api.LLMProviderService(db).get_all_providers()
active = [provider for provider in providers if provider.is_active]
return {
"configured": bool(providers),
"provider_count": len(providers),
"active_count": len(active),
"ready": bool(active),
"active_provider": active[0].name if active else None,
}
# #endregion McpServer.Tool.get_llm_status
# #region McpServer.Tool.get_task_status [C:4] [TYPE Function]
async def get_task_status(task_id: str | None = None) -> dict[str, api.Any]:
"""Return a bounded status for one task owned by the authenticated user."""
access = api._access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "permission_denied"}
task_manager: api.TaskManager = api.get_task_manager()
task = task_manager.get_task(task_id) if task_id else None
if task is None:
tasks = [
item
for item in task_manager.get_tasks(limit=20, offset=0)
if str(getattr(item, "user_id", "")) == access.subject
]
task = tasks[0] if tasks else None
if task is None:
return {"status": "not_found", "task_id": task_id}
with api.SessionLocal() as db:
user = api.AuthRepository(db).get_user_by_username(access.subject)
if user is None or str(getattr(task, "user_id", "")) != str(user.id):
return {"status": "not_found", "task_id": task_id}
return {
"status": "available",
"task_id": str(task.id),
"task_status": str(task.status),
"plugin_id": str(getattr(task, "plugin_id", "")),
}
# #endregion McpServer.Tool.get_task_status
# #region McpServer.Registration.scenario.probe_read [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="list_environments", structured_output=True)(list_environments)
server.tool(name="get_health_summary", structured_output=True)(get_health_summary)
server.tool(name="search_dashboards", structured_output=True)(search_dashboards)
server.tool(name="list_llm_providers", structured_output=True)(list_llm_providers)
server.tool(name="get_llm_status", structured_output=True)(get_llm_status)
server.tool(name="get_task_status", structured_output=True)(get_task_status)
# #endregion McpServer.Registration.scenario.probe_read
# #endregion McpServer.ToolsLeaf.scenario.probe_read

View File

@@ -0,0 +1,259 @@
# #region McpServer.ToolsLeaf.scenario.scenario_resolve [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup McpServer.ToolsLeaf.scenario.scenario_resolve Resolve drafts, register owner profile packs and launch immutable scenario revisions.
from __future__ import annotations
from src.mcp_server import tools_scenario as api
# #region McpServer.Tool.scenario_resolve [C:4] [TYPE Function]
async def scenario_resolve(request: api.ScenarioResolveInput) -> dict[str, api.Any]:
"""Apply typed resolutions to a supplied graph without persisting it."""
api.logger.reason("Resolve MCP scenario graph", src="McpServer.ScenarioTools.scenario_resolve", payload={"changes": len(request.changes)})
parsed = [api.ResolveChange(kind=c.kind, target=c.target, value=c.value, reason=c.reason) for c in request.changes]
resolved = api.resolve_scenario(request.scenario, parsed, base_revision_hash=request.base_revision_hash)
validation = api.validate_scenario(resolved)
api.logger.reflect("Resolution result returned", src="McpServer.ScenarioTools.scenario_resolve", payload={"revision_hash": resolved.revision_hash[:16], "valid": validation.valid})
return {
"status": "ok",
"scenario": resolved.model_dump(mode="json"),
"revision_hash": resolved.revision_hash,
"parent_revision_hash": resolved.parent_revision_hash,
"validation": {
"valid": validation.valid,
"errors": [item.model_dump(mode="json") for item in validation.errors],
"warnings": [item.model_dump(mode="json") for item in validation.warnings],
"blockers": [item.model_dump(mode="json") for item in validation.blockers],
},
}
# #endregion McpServer.Tool.scenario_resolve
# #region McpServer.Tool.generate_draft_pack_tool [C:4] [TYPE Function]
async def generate_draft_pack_tool(request: api.DraftPackInput) -> dict[str, api.Any]:
"""Generate a server-owned draft pack manifest for a supplied graph without persisting it."""
api.logger.reason("Generate MCP draft pack", src="McpServer.ScenarioTools.generate_draft_pack", payload={"scenario_id": request.scenario.scenario_id})
pack = api.generate_draft_pack(request.scenario)
api.logger.reflect("Draft pack manifest returned", src="McpServer.ScenarioTools.generate_draft_pack", payload={"status": pack["status"]})
return {
"status": pack["status"],
"scenario_revision_hash": pack["scenario_revision_hash"],
"template_version": pack["template_version"],
"manifest": pack["manifest"],
"validation_summary": pack["validation_summary"],
"warnings": pack["warnings"],
}
# #endregion McpServer.Tool.generate_draft_pack_tool
# #region McpServer.Tool.register_draft_pack_tool [C:4] [TYPE Function]
# @RELATION CALLS -> [McpServer.DraftPack.RebuildProfile]
# @RELATION CALLS -> [McpServer.DraftPack.LegacyScenario]
# @RATIONALE Profile-path receipts must come from a durable owner session and a freshly
# recompiled graph; the optional ID preserves the established T029i legacy tool.
# @REJECTED Treating a caller graph's digest or its self-derived receipt as proof of a
# resolved profile would let unresolved profile decisions authorize bootstrap.
async def register_draft_pack_tool(request: api.RegisterDraftPackInput) -> dict[str, api.Any]:
"""Build profile graphs server-side; preserve explicit legacy graph registration."""
access = api._access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "principal_required"}
with api.SessionLocal() as db:
try:
run = db.query(api.AgentRun).filter(api.AgentRun.id == request.agent_run_id).first()
if run is None or str(run.user_id) != str(access.subject):
return {"status": "blocked", "error": "DRAFT_PACK_ACCESS_DENIED"}
owner = str(access.subject)
profile_session = db.query(api.TestPackProfileSession).filter(
api.TestPackProfileSession.profile_handle_id == request.profile_handle_id,
api.TestPackProfileSession.owner_principal == owner,
).with_for_update().first() if request.profile_handle_id else None
if request.profile_handle_id and profile_session is None:
raise ValueError("PROFILE_ACCESS_DENIED")
if profile_session is not None:
scenario = await _rebuild_profile(profile_session, request, run)
else:
scenario = _legacy_scenario(request)
# T029h (option C): evaluate the context authority FIRST — a falsifiable
# client-context claim that fails against the live dashboard rejects the whole
# registration with zero handle/artifact rows.
context_authority = await api.evaluate_context_authority(scenario)
if profile_session is not None and context_authority != "verified":
raise ValueError("PROFILE_CONTEXT_UNVERIFIED")
pack = api.generate_draft_pack(scenario)
validation = api.validate_scenario(scenario)
if profile_session is not None and (pack["status"] != "save_eligible" or not validation.valid):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
compiled = api.mint_compiled_handle(
db, scenario, owner_principal=owner,
dashboard_id=int(scenario.dashboard_context.get("dashboard_id") or run.dashboard_id),
agent_run_id=request.agent_run_id,
)
validation_handle = api.mint_validation_result(db, compiled, validation)
refs: list[dict[str, str]] = []
if pack["status"] == "save_eligible":
refs = api.register_pack_drafts(
db, request.agent_run_id, owner, api.render_pack_artifacts(scenario),
scenario.scenario_id, scenario.revision_hash,
)
pack_handle = api.mint_draft_pack_handle(
db, compiled, owner_principal=owner, agent_run_id=request.agent_run_id,
scenario_key=scenario.scenario_id, status=pack["status"],
template_version=pack.get("template_version", scenario.template_version),
artifact_refs=refs,
context_authority=context_authority,
profile_session=profile_session,
)
db.commit()
return {
"status": pack["status"],
"compiled_handle_id": compiled.handle_id,
"validation_result_id": validation_handle.result_id,
"draft_pack_handle_id": pack_handle.draft_pack_id,
"draft_pack_digest": pack_handle.digest,
"profile_receipt": pack_handle.profile_receipt,
"context_authority": context_authority,
"manifest": pack["manifest"],
"artifacts": refs,
"validation_summary": pack["validation_summary"],
"warnings": pack["warnings"],
}
except (ValueError, KeyError) as exc:
db.rollback()
api.logger.explore("Draft pack registration rejected", src="McpServer.ScenarioTools.register_draft_pack", error=str(exc))
return {"status": "blocked", "error": str(exc)}
# #endregion McpServer.Tool.register_draft_pack_tool
# #region McpServer.Tool.start_scenario_run [C:4] [TYPE Function]
async def start_scenario_run(request: api.ScenarioStartInput) -> dict[str, api.Any]:
"""Start a server-resolved scenario revision through start_run's persistence boundary."""
access = api._access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "principal_required"}
with api.SessionLocal() as db:
try:
import asyncio
run = await asyncio.to_thread(api.start_run,
db, request.scenario_id, request.revision_id, request.params, request.environment_id,
actor=access.subject, idempotency_key=request.idempotency_key,
config_manager=api.get_config_manager(), auto_advance=False,
dashboard_release_id=request.dashboard_release_id, baseline_set=request.baseline_set,
baseline_set_version=request.baseline_set_version,
execution_toggles=request.execution_toggles, trigger_source="manual",
)
db.commit()
return {"status": run.status, "run_id": run.id, "scenario_id": run.scenario_id, "revision_id": run.scenario_revision_id, "environment_id": run.environment_id, "idempotency_key": run.idempotency_key}
except api.BaselinePeriodStale as exc:
db.rollback()
# 050 T044 parity: the same durable receipt the REST transport emits, on a separate
# committed session; the blocked answer below stays identical to the pre-receipt contract.
api.emit_launch_period_stale_receipt(
exc,
scenario_id=request.scenario_id,
revision_id=request.revision_id,
requested_period=api.requested_period_from(request.params, None),
)
api.logger.explore("Scenario start rejected", src="McpServer.ScenarioTools.start_scenario_run", error=str(exc))
return {"status": "blocked", "error": api.classify_start_error(exc), "detail": str(exc)}
except (ValueError, PermissionError) as exc:
db.rollback()
api.logger.explore("Scenario start rejected", src="McpServer.ScenarioTools.start_scenario_run", error=str(exc))
# 050 T044 residual: the same classify_start_error used by REST keeps the typed code
# identical across transports; detail preserves the raw cause for operators.
return {"status": "blocked", "error": api.classify_start_error(exc), "detail": str(exc)}
# #endregion McpServer.Tool.start_scenario_run
# #region McpServer.Registration.scenario.scenario_resolve [C:3] [TYPE Function]
# @BRIEF Register this tool cohort in its established catalog order.
def register(server):
server.tool(name="scenario_resolve", structured_output=True)(scenario_resolve)
server.tool(name="generate_draft_pack", structured_output=True)(generate_draft_pack_tool)
server.tool(name="register_draft_pack", structured_output=True)(register_draft_pack_tool)
server.tool(name="start_scenario_run", structured_output=True)(start_scenario_run)
# #endregion McpServer.Registration.scenario.scenario_resolve
# #region McpServer.DraftPack.InspectProfile [C:3] [TYPE Function]
# @BRIEF Check the saved profile snapshot against a fresh dashboard inspection and current compiler metadata.
async def _inspect_profile(profile_session):
snapshot = profile_session.profile_snapshot
if not isinstance(snapshot, dict):
raise ValueError("PROFILE_STALE_CONTEXT")
if snapshot.get("status") != "save_eligible" or not snapshot.get("eligible"):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
environment = api.get_config_manager().get_environment(profile_session.environment_id)
if environment is None:
raise ValueError("ENV_NOT_FOUND")
client = await api.get_superset_client(environment)
query_model = await api.inspect_dashboard_query_model(
client, profile_session.environment_id, profile_session.dashboard_id,
)
if (not query_model.query_model_fingerprint
or query_model.query_model_fingerprint == "sha256:error"
or query_model.query_model_fingerprint != profile_session.context_fingerprint
or query_model.environment_id != profile_session.environment_id
or int(query_model.dashboard_id) != profile_session.dashboard_id):
raise ValueError("PROFILE_STALE_CONTEXT")
baseline, baseline_scenario, _ = api.build_test_pack_profile(
query_model=query_model, objective=profile_session.objective,
selected_case_ids=profile_session.selected_case_ids,
browser_available=api.resolve_browser_availability(),
)
if (snapshot.get("profile_digest") != profile_session.profile_digest
or snapshot.get("query_model_fingerprint") != profile_session.context_fingerprint
or baseline.query_model_fingerprint != profile_session.context_fingerprint
or any(snapshot.get(field) != getattr(baseline, field) for field in (
"profile_version", "checklist_catalog_version", "compiler_version",
"dashboard_id", "environment_id", "selected_case_ids",
))):
raise ValueError("PROFILE_STALE_CONTEXT")
return query_model, baseline, baseline_scenario, snapshot
# #endregion McpServer.DraftPack.InspectProfile
# #region McpServer.DraftPack.RebuildProfile [C:3] [TYPE Function]
# @RELATION CALLS -> [McpServer.DraftPack.InspectProfile]
# @BRIEF Rebuild selectors and coordinates from stored resolutions and enforce caller graph/run binding.
async def _rebuild_profile(profile_session, request, run):
query_model, baseline, baseline_scenario, snapshot = await _inspect_profile(profile_session)
resolutions = [api.TestPackProfileResolution.model_validate(item)
for item in (profile_session.resolutions or [])]
selectors = [item for item in resolutions if item.selector_hint is not None]
coordinates = [item for item in resolutions if item.coordinate_id is not None]
selector_changes = api._selector_profile_changes(baseline, selectors, baseline_scenario) if selectors else []
coordinate_choices = api._coordinate_profile_choices(baseline, coordinates) if coordinates else []
if selector_changes is None or coordinate_choices is None:
raise ValueError("PROFILE_RESOLUTION_INVALID")
fresh_profile, fresh_scenario, _ = api.build_test_pack_profile(
query_model=query_model, objective=profile_session.objective,
selected_case_ids=profile_session.selected_case_ids,
parameters=api._selector_parameters(selector_changes),
browser_available=api.resolve_browser_availability(),
)
if coordinate_choices:
fresh_profile = api.apply_coordinate_choices(fresh_profile, {
item["unresolved_id"]: item["coordinate_id"] for item in coordinate_choices
})
if (fresh_profile.profile_digest != profile_session.profile_digest
or fresh_profile.model_dump(mode="json") != snapshot):
raise ValueError("PROFILE_STALE_CONTEXT")
if fresh_profile.status != "save_eligible" or not fresh_profile.eligible:
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
if request.scenario is not None and fresh_scenario.canonical_bytes() != request.scenario.canonical_bytes():
raise ValueError("PROFILE_GRAPH_MISMATCH")
scenario = fresh_scenario
if (int(run.dashboard_id) != profile_session.dashboard_id
or str(run.environment_id) != profile_session.environment_id):
raise ValueError("PROFILE_RUN_MISMATCH")
return scenario
# #endregion McpServer.DraftPack.RebuildProfile
# #region McpServer.DraftPack.LegacyScenario [C:3] [TYPE Function]
# @BRIEF Require an explicit legacy graph without unresolved authority or metric profile claims.
def _legacy_scenario(request):
scenario = request.scenario
if scenario is None:
raise ValueError("LEGACY_SCENARIO_REQUIRED")
if scenario.schema_version == 2:
raise ValueError("METRIC_SERVER_PROFILE_REQUIRED")
if any(step.automation_status in {"needs_baseline", "needs_selector", "needs_context"}
for step in scenario.steps):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
return scenario
# #endregion McpServer.DraftPack.LegacyScenario
# #endregion McpServer.ToolsLeaf.scenario.scenario_resolve

View File

@@ -1,6 +1,5 @@
"""MCP RBAC layer: explicit tool catalog, scenario start gate, and the catalog-filtering guarded server."""
# #region McpServer.RbacLayer [C:5] [TYPE Module] [SEMANTICS mcp,rbac,catalog,gate,guard,provenance]
# @RATIONALE The provenance mixin resolves SessionLocal and record_invocation through rbac_server, preserving the mounted server audit-write patch seam.
# @ingroup McpServer
# @BRIEF Tool-surface governance moved verbatim from server.py (decomposition gate Phase C):
# envelope unwrapping (GateArguments), the server-owned scenario start gate (ScenarioGate),
@@ -13,6 +12,8 @@
# @REJECTED Renaming or re-tiering during the move was rejected — behavior-neutral relocation only
# (specs/050-mcp-interface/plans/server-decomposition-gate.md).
"""MCP RBAC layer: explicit tool catalog, scenario start gate, and the catalog-filtering guarded server."""
from __future__ import annotations
from dataclasses import dataclass
@@ -57,6 +58,7 @@ def _gate_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
# mcp_invocation gate or route an approved continuation into a new pending run.
# @REJECTED An MCP-level approval gate for PROD scenario starts was rejected because it duplicates
# start_run's authoritative gate and can complete the invocation while the run is pending.
# #region McpServer.RbacLayer.ScenarioStartPolicyUnavailable [C:3] [TYPE Class]
class ScenarioStartPolicyUnavailable(ValueError):
"""Start-gate classification failure carrying the typed code REST would return.
@@ -64,11 +66,15 @@ class ScenarioStartPolicyUnavailable(ValueError):
scenario_start_policy_unavailable when the request body itself is invalid.
"""
# #region McpServer.RbacLayer.ScenarioStartPolicyUnavailable.__init__ [C:3] [TYPE Function]
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
# #endregion McpServer.RbacLayer.ScenarioStartPolicyUnavailable.__init__
# #endregion McpServer.RbacLayer.ScenarioStartPolicyUnavailable
# #region McpServer.RbacLayer._scenario_start_permission [C:3] [TYPE Function]
def _scenario_start_permission(arguments: dict[str, Any]) -> tuple[str, str]:
try:
request = ScenarioStartInput.model_validate(_gate_arguments(arguments))
@@ -83,6 +89,7 @@ def _scenario_start_permission(arguments: dict[str, Any]) -> tuple[str, str]:
# envelope can match the REST 422 instead of collapsing to an opaque permission_denied.
raise ScenarioStartPolicyUnavailable(str(exc)) from exc
return ("scenario", "RUN_PROD" if is_prod else "RUN")
# #endregion McpServer.RbacLayer._scenario_start_permission
# #endregion McpServer.ScenarioGate
@@ -97,156 +104,19 @@ MCP_CATALOG_VERSION = "2.8.0"
# #endregion McpServer.CatalogVersion
# #region McpServer.Catalog [C:3] [TYPE Class] [SEMANTICS mcp,catalog,tools,permissions]
# @ingroup McpServer
# @BRIEF Explicit MCP tool metadata; permission policy is declared beside each tool, never inferred from route names.
# @POST Deprecated entries stay registered/listed/callable for one minor catalog cycle with a
# [DEPRECATED] marker applied at the tools/list choke point (MCPX-FR-010).
@dataclass(frozen=True)
class McpToolDefinition:
name: str
permission: tuple[str, str] | None
service_allowed: bool = True
risk_level: str = "safe"
requires_approval: bool = False
deprecated: bool = False
deprecation_note: str | None = None
additional_permissions: tuple[tuple[str, str], ...] = ()
from ._tool_catalog import McpToolDefinition, _MCP_CATALOG, _MCP_CATALOG_BY_NAME
_MCP_CATALOG = (
McpToolDefinition("list_environments", None),
McpToolDefinition("get_health_summary", ("plugin:migration", "READ")),
McpToolDefinition("search_dashboards", None),
McpToolDefinition("list_llm_providers", None),
McpToolDefinition("get_llm_status", None),
McpToolDefinition("get_task_status", ("tasks", "READ")),
McpToolDefinition("list_pending_approvals", ("scenario", "RUN_PROD"), service_allowed=False),
McpToolDefinition("decide_approval", ("scenario", "RUN_PROD"), service_allowed=False),
McpToolDefinition("list_maintenance_events", ("maintenance", "READ"), service_allowed=False),
McpToolDefinition("create_branch", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("commit_changes", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("deploy_dashboard", ("plugin:git", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("execute_migration", ("plugin:migration", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("run_backup", ("plugin:backup", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("run_llm_documentation", ("plugin:llm_documentation", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_list_databases", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_explore_database", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_format_sql", ("plugin:superset_proxy", "EXECUTE")),
McpToolDefinition("superset_audit_permissions", ("plugin:superset_proxy", "EXECUTE")),
# SQL execution is its own risk class: a dedicated permission plus terminal PROD denial.
McpToolDefinition("superset_execute_sql", ("plugin:superset_sql", "EXECUTE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("superset_create_dashboard", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_copy_dashboard", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("superset_create_dataset", ("plugin:superset_proxy", "EXECUTE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("capture_baseline_candidate", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("preview_reference_dashboard", ("dashboard:testing", "READ"), service_allowed=False,
additional_permissions=(("dashboard:testing", "EXECUTE"),)),
McpToolDefinition("capture_reference_selection", ("dashboard:testing", "READ"), service_allowed=False,
risk_level="guarded", additional_permissions=(("dashboard:testing", "EXECUTE"), ("dashboard:testing", "WRITE"))),
McpToolDefinition("request_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("decide_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("consume_baseline_approval", ("dashboard:testing", "APPROVE"), service_allowed=False, risk_level="guarded", requires_approval=True),
# 037 publication worker (050 T045 / MCPX-FR-030): explicit authorized publish with branch-head
# CAS. REST parity: POST /api/catalog-publications (scenario RUN_PROD); human-only, gated.
McpToolDefinition("publish_baseline_catalog", ("scenario", "RUN_PROD"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("create_verification_run", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
# Human observation checkpoints (044) — automation/service principals have no path to them (050 FR-019).
McpToolDefinition("list_checkpoints", ("scenario", "RUN"), service_allowed=False),
McpToolDefinition("decide_checkpoint", ("scenario", "RUN"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("start_maintenance", ("maintenance", "WRITE"), service_allowed=False, risk_level="guarded", requires_approval=True),
McpToolDefinition("end_maintenance", ("maintenance", "WRITE"), service_allowed=False, risk_level="guarded", requires_approval=True),
# AgentRun ownership anchor (ADR-0024 / MCPX-FR-027): register_draft_pack binds to a principal-owned
# AgentRun; these two tools make that prerequisite mintable/readable from the catalog itself. REST
# parity: POST/GET /api/agent/runs (EXECUTE create / ownership-scoped read); human-only.
McpToolDefinition("create_agent_run", ("dashboard:testing", "EXECUTE"), service_allowed=False),
McpToolDefinition("get_agent_run", ("dashboard:testing", "READ"), service_allowed=False),
# 047 investigation loop (SCAN-FR-016..019 / MCPX-FR-031, design 2026-09-17): reads require the
# scenario-result view grant and are zero-side-effect; append-only writes require triage and are
# human-only, and a proposal never decides the case (the human CAS disposition stays authoritative).
McpToolDefinition("list_investigation_queue", ("scenario:result", "VIEW")),
McpToolDefinition("get_investigation_case", ("scenario:result", "VIEW")),
McpToolDefinition("record_case_note", ("scenario:result", "TRIAGE"), service_allowed=False),
McpToolDefinition("propose_case_disposition", ("scenario:result", "TRIAGE"), service_allowed=False),
McpToolDefinition("inspect_dashboard_context", None),
McpToolDefinition("propose_test_pack_profile", None, service_allowed=False),
McpToolDefinition("resolve_test_pack_profile", None, service_allowed=False),
McpToolDefinition("propose_metric_baseline_profile", ("dashboard:testing", "READ"), service_allowed=False),
McpToolDefinition("resolve_metric_baseline_profile", ("dashboard:testing", "WRITE"), service_allowed=False),
McpToolDefinition("register_metric_baseline_pack", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("inspect_scenario", None),
McpToolDefinition("validate_scenario", None),
McpToolDefinition("scenario_resolve", None),
McpToolDefinition("generate_draft_pack", None),
McpToolDefinition("register_draft_pack", ("dashboard:testing", "WRITE"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("start_scenario_run", ("scenario", "RUN"), service_allowed=False, risk_level="guarded"),
# No canonical workspace RBAC permission exists; authenticated human MCP access is
# the narrow existing MCP-only pattern, while service principals remain denied.
McpToolDefinition("create_authoring_session", None, service_allowed=False),
McpToolDefinition("bootstrap_authoring_scenario", None, service_allowed=False),
McpToolDefinition("propose_test_plan", None, service_allowed=False),
McpToolDefinition("start_exploration", None, service_allowed=False),
McpToolDefinition("get_exploration_result", None, service_allowed=False),
McpToolDefinition("propose_graph_revision", None, service_allowed=False),
McpToolDefinition("get_graph_diff", None, service_allowed=False),
McpToolDefinition("promote_to_scenario", None, service_allowed=False),
McpToolDefinition("request_save", ("scenario", "EDIT"), service_allowed=False, risk_level="guarded"),
McpToolDefinition("activate_revision", ("scenario", "EDIT"), service_allowed=False, risk_level="guarded"),
# DG-2 (046 T019): automation reads admit any authenticated principal type — humans with
# scenario:automation READ (live DB RBAC) and service principals with the mcp:read scope —
# with per-object scenario-ownership ACL enforced inside the tool bodies. Mutations stay
# human-only below.
McpToolDefinition("list_scenario_schedules", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_schedule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("delete_scenario_schedule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("list_scenario_trigger_rules", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_trigger_rule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("delete_scenario_trigger_rule", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("get_scenario_automation_policy", ("scenario:automation", "READ")),
McpToolDefinition("upsert_scenario_automation_policy", ("scenario:automation", "MANAGE"), service_allowed=False),
McpToolDefinition("get_scenario_automation_metrics", ("scenario:automation", "READ")),
)
_MCP_CATALOG_BY_NAME = {definition.name: definition for definition in _MCP_CATALOG}
# #endregion McpServer.Catalog
from ._rbac_provenance import RbacProvenanceMixin
# #region McpServer.RbacServer [C:5] [TYPE Class] [SEMANTICS mcp,tools,rbac,catalog]
# @ingroup McpServer
# @BRIEF Filters the tool catalog and guards invocation through live database RBAC.
# @INVARIANT A tool hidden from tools/list remains denied when called by name.
class RbacFastMCP(FastMCP):
def _record(self, *, operation: str, tool_name: str | None, arguments: dict[str, Any] | None, outcome: str, error_code: str | None = None, continuation_payload: dict[str, Any] | None = None) -> str | None:
"""Persist request provenance when dispatch is running inside MCP context."""
access = _access_token_context.get()
if access is None:
return None
try:
request_id = str(self.get_context().request_id)
except (RuntimeError, ValueError):
request_id = None
try:
with SessionLocal() as db:
return record_invocation(
db,
request_id=request_id,
client_id=access.client_id,
subject=access.subject,
principal_type="service" if access.claims and access.claims.get("principal_type") == "service" else "user",
operation=operation,
tool_name=tool_name,
arguments=arguments,
outcome=outcome,
error_code=error_code,
continuation_payload=continuation_payload,
)
except Exception:
# Provenance is security-critical: callers must not receive a successful
# mutation/decision when its durable audit write failed. The exception is
# re-raised to let the MCP protocol return an error envelope.
raise
# #region McpServer.RbacLayer.RbacFastMCP [C:3] [TYPE Class]
class RbacFastMCP(RbacProvenanceMixin, FastMCP):
# #region McpServer.RbacLayer.RbacFastMCP._has_permission [C:3] [TYPE Function]
def _has_permission(self, required_permission: tuple[str, str]) -> bool:
access = _access_token_context.get()
if access is None or not access.subject:
@@ -254,7 +124,9 @@ class RbacFastMCP(FastMCP):
with SessionLocal() as db:
user = AuthRepository(db).get_user_by_username(access.subject)
return bool(user and user.is_active and user_has_permission(user, *required_permission))
# #endregion McpServer.RbacLayer.RbacFastMCP._has_permission
# #region McpServer.RbacLayer.RbacFastMCP._can_use_tool [C:3] [TYPE Function]
def _can_use_tool(self, name: str) -> bool:
access = _access_token_context.get()
definition = _MCP_CATALOG_BY_NAME.get(name)
@@ -270,62 +142,19 @@ class RbacFastMCP(FastMCP):
# scenario permission can authorize a later typed call.
return self._has_permission(("scenario", "RUN")) or self._has_permission(("scenario", "RUN_PROD"))
return all(self._has_permission(permission) for permission in (definition.permission, *definition.additional_permissions))
# #endregion McpServer.RbacLayer.RbacFastMCP._can_use_tool
def _oversized(self, result: Any) -> dict[str, Any] | None:
"""Fail closed when a structured tool reply exceeds the server response limit."""
import json as _json
payload = result[1] if isinstance(result, tuple) else result
try:
encoded = _json.dumps(payload, ensure_ascii=False, default=str).encode()
except Exception:
encoded = str(payload).encode()
if len(encoded) <= self.config.response_limit:
return None
return {
"status": "rejected",
"error": "response_too_large",
"limit": self.config.response_limit,
}
@staticmethod
def _find_retryable_approval(subject: str, tool_name: str, request_hash: str) -> tuple[Any, Any] | None:
"""Reuse one live approval invocation for an identical client retry."""
with SessionLocal() as db:
record = (
db.query(McpToolInvocationRecord)
.filter(
McpToolInvocationRecord.subject == subject,
McpToolInvocationRecord.tool_name == tool_name,
McpToolInvocationRecord.arguments_sha256 == request_hash,
McpToolInvocationRecord.approval_status.in_(["pending", "approved"]),
McpToolInvocationRecord.dispatch_status.in_(["not_queued", "queued"]),
)
.order_by(McpToolInvocationRecord.created_at.desc(), McpToolInvocationRecord.id.desc())
.first()
)
if record is None:
return None
gate = (
db.query(ActionApprovalGate)
.filter(
ActionApprovalGate.owner_type == "mcp_invocation",
ActionApprovalGate.owner_id == record.id,
ActionApprovalGate.operation == "mcp_tool_call",
)
.order_by(ActionApprovalGate.created_at.desc())
.first()
)
if gate is None:
return None
return record.id, gate.id, gate.status
# #region McpServer.RbacLayer.RbacFastMCP.list_tools [C:3] [TYPE Function]
async def list_tools(self):
visible = [tool for tool in await super().list_tools() if self._can_use_tool(tool.name)]
marked = [self._with_deprecation_marker(tool) for tool in visible]
self._record(operation="tools/list", tool_name=None, arguments=None, outcome="allowed")
return marked
# #endregion McpServer.RbacLayer.RbacFastMCP.list_tools
# #region McpServer.RbacLayer.RbacFastMCP._with_deprecation_marker [C:3] [TYPE Function]
@staticmethod
def _with_deprecation_marker(tool: Any) -> Any:
"""MCPX-FR-010: a deprecated catalog entry stays listed with a visible marker for one
@@ -336,8 +165,12 @@ class RbacFastMCP(FastMCP):
note = f": {definition.deprecation_note}" if definition.deprecation_note else ""
marker = f" [DEPRECATED{note} — scheduled for removal in the next minor catalog cycle.]"
return tool.model_copy(update={"description": (tool.description or "") + marker})
# #endregion McpServer.RbacLayer.RbacFastMCP._with_deprecation_marker
async def call_tool(self, name: str, arguments: dict[str, Any]):
# #region McpServer.RbacServer.DispatchPolicy [C:4] [TYPE Function]
# @RELATION CALLS -> [McpServer.RbacServer.ScenarioLaunchPolicy]
# @BRIEF Deny invisible tools, classify scenario launch permission, and terminally reject PROD SQL.
def _dispatch_policy(self, name, arguments):
definition = _MCP_CATALOG_BY_NAME.get(name)
required_permission = definition.permission if definition is not None else None
policy_error: str | None = None
@@ -357,36 +190,9 @@ class RbacFastMCP(FastMCP):
)
raise PermissionError("permission_denied")
if name == "start_scenario_run":
if required_permission is None:
# 050 T044 residual: an unclassifiable target (unknown/unconfigured environment)
# keeps the fail-closed gate, but surfaces the same typed code as the REST 422
# instead of an opaque permission_denied. Invalid bodies stay permission-denied.
if policy_error == "ENVIRONMENT_NOT_CONFIGURED":
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="environment_not_configured",
)
return {"status": "blocked", "error": "ENVIRONMENT_NOT_CONFIGURED"}
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="permission_denied",
)
raise PermissionError("permission_denied")
if not self._has_permission(required_permission):
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="permission_denied",
)
raise PermissionError("permission_denied")
response = self._scenario_launch_policy(name, arguments, required_permission, policy_error)
if response is not None:
return response
definition = _MCP_CATALOG_BY_NAME[name]
# start_run creates the sole approval-gated PROD path. MCP approval dispatch is
# reserved for tools whose catalog definition explicitly requires an MCP gate.
@@ -416,6 +222,52 @@ class RbacFastMCP(FastMCP):
"error": "production_sql_execution_rejected",
"message": "SQL execution against PROD-classified environments is denied; export the query and run it through the reviewed release path.",
}
return None
# #endregion McpServer.RbacServer.DispatchPolicy
# #region McpServer.RbacServer.ScenarioLaunchPolicy [C:4] [TYPE Function]
# @BRIEF Return the typed unavailable-environment block or enforce the resolved launch permission.
def _scenario_launch_policy(self, name, arguments, required_permission, policy_error):
if required_permission is None:
# 050 T044 residual: an unclassifiable target (unknown/unconfigured environment)
# keeps the fail-closed gate, but surfaces the same typed code as the REST 422
# instead of an opaque permission_denied. Invalid bodies stay permission-denied.
if policy_error == "ENVIRONMENT_NOT_CONFIGURED":
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="environment_not_configured",
)
return {"status": "blocked", "error": "ENVIRONMENT_NOT_CONFIGURED"}
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="permission_denied",
)
raise PermissionError("permission_denied")
if not self._has_permission(required_permission):
self._record(
operation="tools/call",
tool_name=name,
arguments=arguments,
outcome="denied",
error_code="permission_denied",
)
raise PermissionError("permission_denied")
return None
# #endregion McpServer.RbacServer.ScenarioLaunchPolicy
# #region McpServer.RbacLayer.RbacFastMCP.call_tool [C:3] [TYPE Function]
# @RELATION CALLS -> [McpServer.RbacServer.DispatchPolicy]
async def call_tool(self, name: str, arguments: dict[str, Any]):
policy_response = self._dispatch_policy(name, arguments)
if policy_response is not None:
return policy_response
definition = _MCP_CATALOG_BY_NAME[name]
requires_approval = definition.requires_approval
if requires_approval:
import hashlib
@@ -498,6 +350,8 @@ class RbacFastMCP(FastMCP):
outcome="allowed",
)
return result
# #endregion McpServer.RbacLayer.RbacFastMCP.call_tool
# #endregion McpServer.RbacLayer.RbacFastMCP
# #endregion McpServer.RbacServer

View File

@@ -1,6 +1,5 @@
"""MCP authoring-chain tool registrations (workspace session -> plan -> exploration -> graph -> save/activate)."""
# #region McpServer.ToolsAuthoring [C:4] [TYPE Module] [SEMANTICS mcp,authoring,tools,registration,workspace]
# @RATIONALE Workspace service calls and SessionLocal remain resolved through tools_authoring after registration; importing their values into leaves would bypass existing caller patches.
# @ingroup McpServer
# @BRIEF Authoring-chain tool bodies moved verbatim from server.py (_build_probe_server closure) into a
# registration seam (decomposition gate Phase D); contract IDs of the nested tool regions are frozen.
@@ -12,6 +11,8 @@
# ordering is frozen by the gate (specs/050-mcp-interface/plans/server-decomposition-gate.md).
# @REJECTED Rewriting the tool bodies during the move was rejected — behavior-neutral relocation only.
"""MCP authoring-chain tool registrations (workspace session -> plan -> exploration -> graph -> save/activate)."""
from __future__ import annotations
from datetime import timedelta
@@ -59,378 +60,10 @@ import json
# @ingroup McpServer
# @BRIEF Registration seam: decorate the nine authoring-chain tools onto the guarded server instance.
def register_authoring_tools(server) -> None:
# #region McpServer.BootstrapAuthoringScenario [C:5] [TYPE Function] [SEMANTICS mcp,bootstrap,registry,workspace]
# @ingroup McpServer
# @BRIEF Atomically bootstrap a server-owned registry chain and bound authoring workspace.
@server.tool(name="bootstrap_authoring_scenario", structured_output=True)
async def bootstrap_authoring_scenario(request: InitialScenarioIntent) -> dict[str, Any]:
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
request_hash = hashlib.sha256(json.dumps(request.model_dump(), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
# The operation table's workspace_id is String(36); use a stable
# principal-scoped sentinel rather than storing an unbounded username.
receipt_scope = "boot-" + hashlib.sha256(access.subject.encode()).hexdigest()[:31]
receipt = db.query(AgentAuthoringWorkspaceOperation).filter(
AgentAuthoringWorkspaceOperation.workspace_id == receipt_scope,
AgentAuthoringWorkspaceOperation.operation == "bootstrap_authoring_scenario",
AgentAuthoringWorkspaceOperation.idempotency_key == request.idempotency_key,
AgentAuthoringWorkspaceOperation.actor_principal == access.subject,
).first()
if receipt is not None:
if receipt.request_hash != request_hash:
raise WorkspaceIdempotencyConflict("idempotency key conflicts with a different bootstrap request")
workspace = db.get(AgentAuthoringWorkspace, receipt.result_reference)
revision = db.get(ScenarioRevision, workspace.base_revision_id) if workspace else None
entry = db.get(ScenarioRegistryEntry, workspace.scenario_id) if workspace else None
if workspace is None or revision is None or entry is None:
raise ValueError("bootstrap replay receipt is incomplete")
return {"scenario_id": entry.scenario_id, "revision_id": revision.revision_id,
"workspace_id": workspace.workspace_id, "content_hash": revision.content_hash,
"cas_version": workspace.cas_version, "activation_status": revision.activation_status,
"replayed": True}
# Metric save admission re-inspects via the provider's application loop.
# Release that loop while the synchronous registry boundary waits for it.
from src.models.scenario_handles import CompiledScenarioHandle
compiled = db.get(CompiledScenarioHandle, request.compiled_handle_id)
if compiled is not None and compiled.schema_version == 2:
import asyncio
entry, revision = await asyncio.to_thread(
create_initial, db, intent=request, user_id=access.subject, owner_username=access.subject,
)
else:
entry, revision = create_initial(db, intent=request, user_id=access.subject, owner_username=access.subject)
workspace = create_workspace(db, access.subject, expires_in=timedelta(hours=1),
scenario_id=entry.scenario_id, base_revision_id=revision.revision_id,
base_content_hash=revision.content_hash, idempotency_key=request.idempotency_key,
actor_principal=access.subject)
db.add(AgentAuthoringWorkspaceOperation(
workspace_id=receipt_scope, operation="bootstrap_authoring_scenario",
idempotency_key=request.idempotency_key, request_hash=request_hash,
result_reference=workspace.workspace_id, result_status="current",
result_cas_version=workspace.cas_version, actor_principal=access.subject,
))
db.commit()
return {"scenario_id": entry.scenario_id, "revision_id": revision.revision_id,
"workspace_id": workspace.workspace_id, "content_hash": revision.content_hash,
"cas_version": workspace.cas_version, "activation_status": revision.activation_status}
except Exception:
db.rollback()
raise
# #endregion McpServer.BootstrapAuthoringScenario
# #region McpServer.AuthoringSessionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,workspace,create,idempotency]
# @ingroup McpServer
# @BRIEF Create or replay a server-owned authoring workspace for the authenticated MCP user.
# @PRE Input is strict and bounded; owner is resolved from authenticated MCP context.
# @POST Returns only server-owned workspace metadata; identical retries return the same workspace.
# @SIDE_EFFECT Commits one workspace and immutable operation receipt, or rolls back on failure.
# @RELATION CALLS -> [Services.AgentAuthoringWorkspace.Service]
# @RATIONALE No canonical workspace RBAC permission exists, so this uses the existing MCP-only
# authenticated-user pattern and explicitly denies service principals in the catalog.
# @REJECTED Raw content, Playwright code, sandbox execution, and invented broad RBAC were rejected
# because this first authoring operation must persist metadata only.
@server.tool(name="create_authoring_session", structured_output=True)
async def create_authoring_session(request: AuthoringSessionInput) -> dict[str, Any]:
"""Create a metadata-only authoring session owned by the authenticated MCP user."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore(
"Authoring session requires a human MCP principal", src="McpServer.AuthoringSessionTool.create",
error="human_principal_required",
)
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
workspace = create_workspace(
db,
access.subject,
expires_in=timedelta(seconds=request.expires_in_seconds),
agent_principal=request.agent_principal,
scenario_id=request.scenario_id,
base_revision_id=request.base_revision_id,
base_content_hash=request.base_content_hash,
idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
logger.reflect(
"Authoring session projection returned", src="McpServer.AuthoringSessionTool.create",
payload={"workspace_id": workspace.workspace_id},
)
return {
"workspace_id": workspace.workspace_id,
"session_status": workspace.session_status,
"owner_principal": workspace.owner_principal,
"agent_principal": workspace.agent_principal,
"scenario_id": workspace.scenario_id,
"base_revision_id": workspace.base_revision_id,
"base_content_hash": workspace.base_content_hash,
"cas_version": workspace.cas_version,
"expires_at": workspace.expires_at.isoformat(),
}
except Exception:
db.rollback()
raise
# #endregion McpServer.AuthoringSessionTool
# #region McpServer.ProposeTestPlanTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,test-plan,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a bounded user-facing test-plan intent for the authenticated MCP user.
# @PRE Authenticated human principal and strict bounded request; owner is derived from token.
# @POST Returns plan identity, server digest, and resulting CAS version without executable content.
# @SIDE_EFFECT Commits the plan, workspace CAS update, and operation receipt together.
# @REJECTED Sandbox, exploration, promotion, ScenarioRun execution, and registry mutation are not part of this tool.
@server.tool(name="propose_test_plan", structured_output=True)
async def propose_test_plan_tool(request: TestPlanInput) -> dict[str, Any]:
"""Persist one bounded checklist proposal without executing it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Test-plan proposal requires a human MCP principal", src="McpServer.ProposeTestPlanTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
plan = propose_test_plan(
db, request.workspace_id, access.subject, request.plan_content.model_dump(),
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
workspace = db.get(AgentAuthoringWorkspace, request.workspace_id)
return {
"status": "ok", "plan_id": plan.plan_id, "workspace_id": plan.workspace_id,
"content_digest": plan.content_digest, "plan_content": plan.plan_content,
"cas_version": workspace.cas_version if workspace else None,
"created_at": plan.created_at.isoformat(),
}
except Exception:
db.rollback()
raise
# #endregion McpServer.ProposeTestPlanTool
# #region McpServer.StartExplorationTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,exploration,sandbox,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a bounded exploration request and report sandbox readiness without executing.
# @PRE Authenticated human principal and strict bounded request; owner is derived from token.
# @POST Returns request status sandbox_unavailable when no provider registry is available; workspace remains draft.
# @SIDE_EFFECT Commits request, CAS, and receipt together, or rolls back on failure.
# @REJECTED Raw browser/code execution and provider I/O are not exposed by this boundary.
@server.tool(name="start_exploration", structured_output=True)
async def start_exploration_tool(request: ExplorationInput) -> dict[str, Any]:
"""Persist an exploration request without sandbox execution."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Exploration requires a human MCP principal", src="McpServer.StartExplorationTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
exploration = start_exploration(
db, request.workspace_id, access.subject, request.exploration_spec,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject, provider_available=get_registered_runner() is not None,
)
db.commit()
workspace = db.get(AgentAuthoringWorkspace, request.workspace_id)
return {
"status": exploration.request_status,
"request_id": exploration.request_id,
"operation_id": exploration.operation_id,
"workspace_id": exploration.workspace_id,
"session_status": workspace.session_status if workspace else None,
"cas_version": workspace.cas_version if workspace else None,
}
except Exception:
db.rollback()
raise
# #endregion McpServer.StartExplorationTool
# #region McpServer.GetExplorationResultTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,exploration,read,owner,bounded]
# @ingroup McpServer
# @BRIEF Read bounded metadata for an owner-authorized exploration request without execution.
# @PRE Authenticated human principal and strict workspace/request identifiers; owner comes from token.
# @POST Returns only request status, identifiers, creation time, and opaque receipt reference.
# @SIDE_EFFECT None; no provider I/O, sandbox execution, CAS/status, or domain-row mutation.
# @REJECTED Raw source, code, result payloads, synthesized outcomes, and artifact disclosure are not exposed.
@server.tool(name="get_exploration_result", structured_output=True)
async def get_exploration_result_tool(request: ExplorationResultInput) -> dict[str, Any]:
"""Read a bounded exploration request projection without running or mutating it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore(
"Exploration result requires a human MCP principal", src="McpServer.GetExplorationResultTool",
error="human_principal_required",
)
return {"status": "permission_denied", "error": "permission_denied"}
with SessionLocal() as db:
try:
projection = get_exploration_result(
db, request.workspace_id, request.request_id, access.subject
)
except WorkspaceAccessError:
logger.explore(
"Exploration result owner check denied", src="McpServer.GetExplorationResultTool",
error="permission_denied",
)
return {"status": "permission_denied", "error": "permission_denied"}
except WorkspaceNotFound:
logger.explore(
"Exploration request was not found", src="McpServer.GetExplorationResultTool",
error="not_found",
)
return {
"status": "not_found",
"request_id": request.request_id,
"workspace_id": request.workspace_id,
}
return {
"status": projection.status,
"request_id": projection.request_id,
"workspace_id": projection.workspace_id,
"created_at": projection.created_at.isoformat(),
"receipt_reference": projection.receipt_reference,
}
# #endregion McpServer.GetExplorationResultTool
# #region McpServer.ProposeGraphRevisionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,proposal,cas,idempotency]
# @ingroup McpServer
# @BRIEF Persist a server-derived graph proposal from typed edit operations for the authenticated user.
# @PRE Authenticated human principal, strict bounded request, and a workspace bound to a scenario revision.
# @POST Returns proposal identity, server digest, deterministic diff, and resulting CAS version.
# @SIDE_EFFECT Commits one ScenarioEditProposal, the workspace CAS update, and an operation receipt together.
# @REJECTED Client graph payloads, revision activation, sandbox execution, and registry save are not part of this tool.
@server.tool(name="propose_graph_revision", structured_output=True)
async def propose_graph_revision_tool(request: GraphRevisionInput) -> dict[str, Any]:
"""Derive and persist one graph proposal from typed edit operations without saving or activating."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Graph revision proposal requires a human MCP principal", src="McpServer.ProposeGraphRevisionTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
result = propose_graph_revision(
db, request.workspace_id, access.subject, request.request_text, request.operations,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.ProposeGraphRevisionTool
# #region McpServer.GetGraphDiffTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,diff,read,bounded]
# @ingroup McpServer
# @BRIEF Read the deterministic diff for an owner-authorized graph proposal without execution.
# @PRE Authenticated human principal and strict workspace identifier; owner comes from the token.
# @POST Returns proposal identity, digest, status, and the computed diff only.
# @SIDE_EFFECT None; no provider I/O, sandbox execution, CAS/status, or domain-row mutation.
# @REJECTED Raw graph snapshots, source operations, and synthesized outcomes are not exposed.
@server.tool(name="get_graph_diff", structured_output=True)
async def get_graph_diff_tool(request: GraphDiffInput) -> dict[str, Any]:
"""Read a bounded graph-proposal diff without running or mutating it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Graph diff requires a human MCP principal", src="McpServer.GetGraphDiffTool", error="human_principal_required")
return {"status": "permission_denied", "error": "permission_denied"}
with SessionLocal() as db:
try:
result = get_graph_diff(db, request.workspace_id, access.subject)
except WorkspaceAccessError:
logger.explore("Graph diff owner check denied", src="McpServer.GetGraphDiffTool", error="permission_denied")
return {"status": "permission_denied", "error": "permission_denied"}
except WorkspaceNotFound:
logger.explore("Graph proposal was not found", src="McpServer.GetGraphDiffTool", error="not_found")
return {"status": "not_found", "workspace_id": request.workspace_id}
return {"status": "ok", **result}
# #endregion McpServer.GetGraphDiffTool
# #region McpServer.PromoteToScenarioTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,promotion,validation,cas]
# @ingroup McpServer
# @BRIEF Promote an owner-reviewed graph proposal toward save readiness without activating it.
# @PRE Authenticated human principal, strict bounded request, and a workspace in proposal_ready.
# @POST Returns the server-recomputed digest, validation, diff, and promotion status.
# @SIDE_EFFECT Commits the workspace CAS/promotion advance and operation receipt; never creates a revision.
# @REJECTED Save, activation, registry mutation, and raw caller digests are not part of this tool.
@server.tool(name="promote_to_scenario", structured_output=True)
async def promote_to_scenario_tool(request: PromoteScenarioInput) -> dict[str, Any]:
"""Promote a graph proposal toward save readiness without activating it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Promotion requires a human MCP principal", src="McpServer.PromoteToScenarioTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
result = promote_to_scenario(
db, request.workspace_id, access.subject, request.expected_cas_version,
idempotency_key=request.idempotency_key, actor_principal=access.subject,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.PromoteToScenarioTool
# #region McpServer.RequestSaveTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,graph,save,candidate,cas]
# @ingroup McpServer
# @BRIEF Save an owner-reviewed proposal into one candidate revision through the guarded editor path.
# @PRE Authenticated human principal with scenario:edit, strict bounded request, and a workspace in awaiting_user_review.
# @POST Returns the new candidate revision identity; current_revision is never advanced.
# @SIDE_EFFECT Commits one candidate ScenarioRevision, the workspace candidate advance, and an operation receipt.
# @REJECTED Activation, raw caller digests, and implicit current targets are not part of this tool.
@server.tool(name="request_save", structured_output=True)
async def request_save_tool(request: RequestSaveInput) -> dict[str, Any]:
"""Save a reviewed proposal into one candidate revision without activating it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Save requires a human MCP principal", src="McpServer.RequestSaveTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
result = request_save(
db, request.workspace_id, access.subject, request.expected_cas_version,
idempotency_key=request.idempotency_key, actor_principal=access.subject,
agent_action_id=request.agent_action_id,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.RequestSaveTool
# #region McpServer.ActivateRevisionTool [C:5] [TYPE Function] [SEMANTICS mcp,authoring,activation,current,cas]
# @ingroup McpServer
# @BRIEF Activate an explicit candidate revision as current through the guarded registry CAS.
# @PRE Authenticated human principal with scenario:edit, strict bounded request, and a workspace in candidate state.
# @POST Returns the activated revision identity and advances the workspace to current.
# @SIDE_EFFECT Promotes one candidate revision to current via activate_current_revision and records a receipt.
# @REJECTED Implicit latest-revision activation, save-during-activation, and raw caller digests are not part of this tool.
@server.tool(name="activate_revision", structured_output=True)
async def activate_revision_tool(request: ActivateRevisionInput) -> dict[str, Any]:
"""Activate an explicit candidate revision as current without saving or running it."""
access = _access_token_context.get()
if access is None or not access.subject or access.claims and access.claims.get("principal_type") == "service":
logger.explore("Activation requires a human MCP principal", src="McpServer.ActivateRevisionTool", error="human_principal_required")
raise PermissionError("human_principal_required")
with SessionLocal() as db:
try:
result = activate_revision(
db, request.workspace_id, access.subject, request.revision_id,
request.expected_cas_version, idempotency_key=request.idempotency_key,
actor_principal=access.subject, agent_action_id=request.agent_action_id,
)
db.commit()
return {"status": "ok", **result}
except Exception:
db.rollback()
raise
# #endregion McpServer.ActivateRevisionTool
from ._tools_authoring_workspace import register as register_1
register_1(server)
from ._tools_authoring_revisions import register as register_2
register_2(server)
# #endregion McpServer.ToolsAuthoring.Register
# #endregion McpServer.ToolsAuthoring

View File

@@ -1,6 +1,5 @@
"""MCP probe, scenario-chain, maintenance and approval tool registrations."""
# #region McpServer.ToolsScenario [C:4] [TYPE Module] [SEMANTICS mcp,scenario,tools,probe,maintenance,approval,registration]
# @RATIONALE SessionLocal and live dashboard inspection are resolved through tools_scenario by the scenario tool leaves; patches to this existing seam must affect registered tools.
# @ingroup McpServer
# @BRIEF Probe/read tools, the scenario chain (ScenarioTools block), and the maintenance/approval tools
# moved verbatim from server.py (_build_probe_server closure) into registration seams
@@ -16,6 +15,8 @@
# @REJECTED Merging the three seams into one register function was rejected — it would blur the
# probe-read / scenario-chain / maintenance-approval contract boundaries.
"""MCP probe, scenario-chain, maintenance and approval tool registrations."""
from __future__ import annotations
import re
@@ -201,124 +202,8 @@ def _selector_parameters(changes: list[dict[str, str]]) -> dict[str, Any]:
# @ingroup McpServer
# @BRIEF Registration seam: the six read-only probe tools (environments/health/dashboards/llm/task).
def register_probe_read_tools(server) -> None:
@server.tool(name="list_environments", structured_output=True)
async def list_environments() -> dict[str, Any]:
"""List configured Superset environments without credentials."""
environments = get_config_manager().get_environments()
return {
"environments": [
{
"id": environment.id,
"name": environment.name,
"stage": environment.stage,
"is_production": environment.is_production,
}
for environment in environments
]
}
@server.tool(name="get_health_summary", structured_output=True)
async def get_health_summary(ctx: Context) -> dict[str, Any]:
"""Return the persisted dashboard health aggregate."""
with SessionLocal() as db:
summary = await HealthService(db, config_manager=get_config_manager()).get_health_summary()
return {
"items": [item.model_dump(mode="json") for item in summary.items[:100]],
"pass_count": summary.pass_count,
"warn_count": summary.warn_count,
"fail_count": summary.fail_count,
"unknown_count": summary.unknown_count,
"request_id": ctx.request_id,
}
@server.tool(name="search_dashboards", structured_output=True)
async def search_dashboards(environment_id: str, query: str = "", limit: int = 20) -> dict[str, Any]:
"""Search dashboards in one configured Superset environment."""
bounded_limit = max(1, min(limit, 100))
config_manager = get_config_manager()
environment = next(
(item for item in config_manager.get_environments() if item.id == environment_id),
None,
)
if environment is None:
return {"environment_id": environment_id, "query": query[:200], "limit": bounded_limit, "items": [], "error": "environment_not_found"}
dashboards = await SupersetClient(environment).get_dashboards_summary(require_slug=True)
needle = query.strip().lower()
if needle:
dashboards = [
item for item in dashboards
if needle in str(item.get("id", "")).lower()
or needle in str(item.get("title", "")).lower()
or needle in str(item.get("slug", "")).lower()
]
return {
"environment_id": environment_id,
"query": query[:200],
"limit": bounded_limit,
"total": len(dashboards),
"items": dashboards[:bounded_limit],
}
@server.tool(name="list_llm_providers", structured_output=True)
async def list_llm_providers() -> dict[str, Any]:
"""List local LLM providers without returning API keys or credentials."""
with SessionLocal() as db:
providers = LLMProviderService(db).get_all_providers()
return {
"providers": [
{
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"default_model": provider.default_model,
"is_active": provider.is_active,
}
for provider in providers[:100]
]
}
@server.tool(name="get_llm_status", structured_output=True)
async def get_llm_status() -> dict[str, Any]:
"""Return local LLM readiness without exposing provider secrets."""
with SessionLocal() as db:
providers = LLMProviderService(db).get_all_providers()
active = [provider for provider in providers if provider.is_active]
return {
"configured": bool(providers),
"provider_count": len(providers),
"active_count": len(active),
"ready": bool(active),
"active_provider": active[0].name if active else None,
}
@server.tool(name="get_task_status", structured_output=True)
async def get_task_status(task_id: str | None = None) -> dict[str, Any]:
"""Return a bounded status for one task owned by the authenticated user."""
access = _access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "permission_denied"}
task_manager: TaskManager = get_task_manager()
task = task_manager.get_task(task_id) if task_id else None
if task is None:
tasks = [
item
for item in task_manager.get_tasks(limit=20, offset=0)
if str(getattr(item, "user_id", "")) == access.subject
]
task = tasks[0] if tasks else None
if task is None:
return {"status": "not_found", "task_id": task_id}
with SessionLocal() as db:
user = AuthRepository(db).get_user_by_username(access.subject)
if user is None or str(getattr(task, "user_id", "")) != str(user.id):
return {"status": "not_found", "task_id": task_id}
return {
"status": "available",
"task_id": str(task.id),
"task_status": str(task.status),
"plugin_id": str(getattr(task, "plugin_id", "")),
}
from ._tools_scenario_probe_read import register as register_1
register_1(server)
# #endregion McpServer.ToolsScenario.RegisterProbeRead
@@ -337,517 +222,10 @@ def register_scenario_tools(server) -> None:
# @RELATION CALLS -> [ScenarioExecution.Runner.Start]
# @RELATION CALLS -> [BaselineEngine.QueryModel.Inspect]
# #region McpServer.ScenarioTools.InspectDashboardContext [C:4] [TYPE Function] [SEMANTICS mcp,scenario,inspect,context,stage1]
# @ingroup McpServer
# @BRIEF T029h stage 1: resolve live authoritative dashboard context through the existing
# BaselineEngine inspect service and return the model an agent must echo into compile.
# @RELATION CALLS -> [McpServer.TraversalGuidance.Build]
# @PRE environment_id resolves via get_config_manager; dashboard_id is a positive integer.
# @POST Returns status ok with the full DashboardQueryModel dump and fingerprint, degraded when
# upstream inspection returned a sentinel model, or blocked on typed failures.
# @SIDE_EFFECT Async upstream Superset reads through the shared client registry.
# @REJECTED Returning only a bounded projection was rejected — the client must echo the exact
# authoritative model into compile's query_model or the register-boundary fingerprint
# recomputation can never match (ScenarioGraph.ContextAuthority).
@server.tool(name="inspect_dashboard_context", structured_output=True,
description=BROWSER_TRAVERSAL_MCP_DESCRIPTION)
async def inspect_dashboard_context_tool(request: InspectContextInput) -> dict[str, Any]:
environment = get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await get_superset_client(environment)
model = await inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
except Exception as exc:
logger.explore("Dashboard context inspection failed", src="McpServer.ScenarioTools.InspectDashboardContext",
error_code="INSPECTION_FAILED", error=str(exc)[:300],
payload={"environment_id": request.environment_id, "dashboard_id": request.dashboard_id})
return {"status": "blocked", "error": "INSPECTION_FAILED"}
fingerprint = model.query_model_fingerprint
degraded = (not fingerprint) or fingerprint == "sha256:error"
# T029k (MCPX-FR-028): the same authoritative model yields the truthful capability facts, so the
# agent sees the derived classification inputs before echoing the model into compile.
derivation = derive_capabilities(model, browser_available=resolve_browser_availability())
logger.reflect("Dashboard context inspected", src="McpServer.ScenarioTools.InspectDashboardContext",
payload={"environment_id": request.environment_id, "dashboard_id": request.dashboard_id,
"degraded": degraded, "charts": len(model.charts),
"derived_facts": sorted(k for k, v in derivation.capabilities.items() if v)})
return {
"status": "degraded" if degraded else "ok",
"query_model": model.model_dump(mode="json"),
"query_model_fingerprint": fingerprint,
"warning_codes": [w.code for w in model.warnings],
"browser_traversal_guidance": browser_traversal_guidance(),
"derived_capabilities": {
"capabilities": dict(derivation.capabilities),
"has_dataset_fields": derivation.has_dataset_fields,
"undetermined": list(derivation.undetermined),
},
}
# #endregion McpServer.ScenarioTools.InspectDashboardContext
# #region McpServer.ScenarioTools.ProposeTestPackProfile [C:4] [TYPE Function] [SEMANTICS mcp,scenario,profile,preview]
# @ingroup McpServer
# @BRIEF Build a typed test-pack proposal from a fresh server inspection and expose unresolved questions.
# @PRE Environment access is authorized and selected case IDs exist in the pinned checklist catalog.
# @POST Returns complete per-case coverage; only a fully resolvable profile may be save_eligible.
# @SIDE_EFFECT Performs bounded upstream Superset reads and emits profile/compile molecular-CoT events.
# @INVARIANT Caller-supplied capabilities, query models and expected values are not accepted.
# @REJECTED Reusing inspect_scenario's caller-carried query model was rejected — T029h verifies such
# claims only at registration; this proposal must classify from fresh server inspection.
# #region McpServer.ScenarioTools.ProposeTestPackProfile.Call [C:4] [TYPE Function] [SEMANTICS mcp,scenario,profile,inspection]
@server.tool(name="propose_test_pack_profile", structured_output=True)
async def propose_test_pack_profile(request: TestPackProfileInput) -> dict[str, Any]:
environment = get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await get_superset_client(environment)
query_model = await inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
if not query_model.query_model_fingerprint or query_model.query_model_fingerprint == "sha256:error":
return {"status": "blocked", "error": "CONTEXT_INSPECTION_DEGRADED"}
if (query_model.environment_id != request.environment_id
or int(query_model.dashboard_id) != request.dashboard_id):
return {"status": "blocked", "error": "CONTEXT_IDENTITY_MISMATCH"}
profile, scenario, pack = build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids,
browser_available=resolve_browser_availability(),
)
except (KeyError, ValueError) as exc:
logger.explore("Test-pack profile proposal rejected", src="McpServer.ScenarioTools.ProposeTestPackProfile",
error_code=str(exc), payload={"dashboard_id": request.dashboard_id}, error=str(exc))
return {"status": "blocked", "error": str(exc)}
except Exception as exc:
logger.explore("Test-pack profile inspection failed", src="McpServer.ScenarioTools.ProposeTestPackProfile",
error_code="INSPECTION_FAILED", payload={"dashboard_id": request.dashboard_id}, error=str(exc)[:300])
return {"status": "blocked", "error": "INSPECTION_FAILED"}
return {
"status": profile.status,
"profile": profile.model_dump(mode="json"),
"profile_handle_id": _persist_profile_preview(profile, request, scenario),
"cas_version": 0,
"preview": {
"step_count": len(scenario.steps),
"artifacts": pack.get("artifacts", []),
"validation": pack.get("validation_summary", {}),
},
}
# #endregion McpServer.ScenarioTools.ProposeTestPackProfile.Call
# #endregion McpServer.ScenarioTools.ProposeTestPackProfile
# #region McpServer.ScenarioTools.ResolveTestPackProfile [C:4] [TYPE Function] [SEMANTICS mcp,profile,resolve,cas]
# @ingroup McpServer
# @BRIEF Re-inspect context, CAS-check a durable owner profile, and apply reviewed typed answers.
# @PRE Every resolution ID names a current unresolved item; the owner and CAS match.
# @POST Legacy answers and URL-free versioned baseline selections commit with the replay receipt under CAS.
# @SIDE_EFFECT Performs bounded Superset reads and one profile/receipt database transaction.
# @INVARIANT Unsupported targets, stale profile digests/catalog/compiler versions and caller
# graph/value claims fail closed before any profile or receipt write.
# @INVARIANT Baseline locator URLs contribute only to the one-way request hash, never durable profile or response bytes.
# @RATIONALE Each accepted answer changes the durable profile digest; the next CAS compares the
# caller's digest to that stored version after checking fresh context identity.
# @REJECTED Comparing later requests to the unresolved baseline digest rejects valid sequential
# answers even though their owner-scoped CAS and stored profile digest agree.
# #region McpServer.ScenarioTools.ResolveTestPackProfile.Call [C:4] [TYPE Function] [SEMANTICS mcp,profile,resolve,inspection]
@server.tool(name="resolve_test_pack_profile", structured_output=True)
async def resolve_test_pack_profile(request: ResolveTestPackProfileInput) -> dict[str, Any]:
access = _access_token_context.get()
if access is None or not access.subject or not request.profile_handle_id or not request.idempotency_key:
return {"status": "blocked", "error": "PROFILE_OWNER_OR_IDEMPOTENCY_REQUIRED"}
owner = str(access.subject)
request_body = request.model_dump(mode="json", exclude={"idempotency_key"})
request_hash = hashlib.sha256(json.dumps(request_body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
with SessionLocal() as db:
session = db.query(TestPackProfileSession).filter(
TestPackProfileSession.profile_handle_id == request.profile_handle_id,
TestPackProfileSession.owner_principal == owner,
).with_for_update().first()
if session is None:
return {"status": "blocked", "error": "PROFILE_ACCESS_DENIED"}
receipt = db.query(TestPackProfileReceipt).filter_by(
profile_handle_id=session.profile_handle_id, owner_principal=owner,
idempotency_key=request.idempotency_key,
).first()
if receipt is not None:
if receipt.request_hash != request_hash:
return {"status": "conflict", "error": "IDEMPOTENCY_CONFLICT"}
return {**receipt.response, "replayed": True}
if (session.cas_version != request.expected_cas_version
or session.environment_id != request.environment_id
or session.dashboard_id != request.dashboard_id
or session.objective != request.objective
or session.selected_case_ids != request.selected_case_ids):
return {"status": "conflict", "error": "PROFILE_CAS_CONFLICT",
"current_profile_digest": session.profile_digest, "cas_version": session.cas_version}
environment = get_config_manager().get_environment(request.environment_id)
if environment is None:
return {"status": "blocked", "error": "ENV_NOT_FOUND"}
try:
client = await get_superset_client(environment)
query_model = await inspect_dashboard_query_model(client, request.environment_id, request.dashboard_id)
if (not query_model.query_model_fingerprint or query_model.query_model_fingerprint == "sha256:error"
or query_model.environment_id != request.environment_id
or int(query_model.dashboard_id) != request.dashboard_id):
return {"status": "blocked", "error": "CONTEXT_IDENTITY_MISMATCH"}
baseline, scenario, _ = build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids,
browser_available=resolve_browser_availability(),
)
snapshot = session.profile_snapshot
if (not isinstance(snapshot, dict)
or baseline.query_model_fingerprint != session.context_fingerprint):
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
stored_profile = require_profile_snapshot(snapshot, session.profile_digest, baseline)
if session.profile_digest != request.expected_profile_digest:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": session.profile_digest, "cas_version": session.cas_version}
baseline_resolutions = [item for item in request.resolutions if isinstance(item, BaselineProfileResolution)]
selector_resolutions = [item for item in request.resolutions
if isinstance(item, TestPackProfileResolution) and item.selector_hint is not None]
coordinate_resolutions = [item for item in request.resolutions
if isinstance(item, TestPackProfileResolution) and item.coordinate_id is not None]
has_selector_items = any(item.kind == "needs_selector" for item in baseline.unresolved)
selector_changes = _selector_profile_changes(baseline, selector_resolutions, scenario) if selector_resolutions else []
coordinate_choices = _coordinate_profile_choices(baseline, coordinate_resolutions) if coordinate_resolutions else []
has_metric_items = any(item.kind == "needs_metric" for item in baseline.unresolved)
invalid_resolution = _invalid_profile_resolution(
has_selector_items, has_metric_items or bool(baseline_resolutions), selector_resolutions,
selector_changes, coordinate_resolutions, coordinate_choices,
)
if (invalid_resolution or (selector_resolutions and not selector_changes)
or (coordinate_resolutions and not coordinate_choices)):
return {"status": "blocked", "error": "PROFILE_RESOLUTION_INVALID"}
accumulated = list(session.resolutions or [])
accumulated.extend(item.model_dump(mode="json") for item in request.resolutions
if isinstance(item, TestPackProfileResolution))
all_selector_changes = _selector_profile_changes(baseline, [
TestPackProfileResolution.model_validate(item) for item in accumulated
if item.get("selector_hint") is not None
], scenario)
if accumulated and all_selector_changes is None:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
parameters = _selector_parameters(all_selector_changes or [])
profile, updated, pack = build_test_pack_profile(
query_model=query_model, objective=request.objective,
selected_case_ids=request.selected_case_ids, parameters=parameters,
browser_available=resolve_browser_availability(),
)
all_coordinates = [TestPackProfileResolution.model_validate(item) for item in accumulated
if item.get("coordinate_id") is not None]
all_coordinate_choices = _coordinate_profile_choices(baseline, all_coordinates) if all_coordinates else []
if all_coordinate_choices is None:
return {"status": "conflict", "error": "PROFILE_STALE_CONTEXT",
"current_profile_digest": baseline.profile_digest, "cas_version": session.cas_version}
if all_coordinate_choices:
selected_coordinates = {item["unresolved_id"]: item["coordinate_id"] for item in all_coordinate_choices}
profile = apply_coordinate_choices(profile, selected_coordinates)
profile = await apply_baseline_answers(profile, stored_profile, baseline_resolutions, client)
except (KeyError, ValueError) as exc:
logger.explore("Test-pack profile resolution rejected", src="McpServer.ScenarioTools.ResolveTestPackProfile",
error_code=str(exc), payload={"dashboard_id": request.dashboard_id}, error=str(exc))
return {"status": "blocked", "error": str(exc)}
except Exception as exc:
logger.explore("Test-pack profile resolution inspection failed", src="McpServer.ScenarioTools.ResolveTestPackProfile",
error_code="INSPECTION_FAILED", payload={"dashboard_id": request.dashboard_id}, error=str(exc)[:300])
return {"status": "blocked", "error": "INSPECTION_FAILED"}
response = {
"status": profile.status, "profile": profile.model_dump(mode="json"),
"profile_handle_id": session.profile_handle_id,
"cas_version": session.cas_version + 1,
"preview": {"step_count": len(updated.steps), "artifacts": pack.get("artifacts", []),
"validation": pack.get("validation_summary", {})},
}
with SessionLocal() as db:
current = db.query(TestPackProfileSession).filter(
TestPackProfileSession.profile_handle_id == session.profile_handle_id,
TestPackProfileSession.owner_principal == owner,
TestPackProfileSession.cas_version == request.expected_cas_version,
).with_for_update().first()
if current is None:
return {"status": "conflict", "error": "PROFILE_CAS_CONFLICT"}
current.resolutions = accumulated
current.profile_digest = profile.profile_digest
current.profile_snapshot = profile.model_dump(mode="json")
current.cas_version += 1
db.add(TestPackProfileReceipt(
profile_handle_id=current.profile_handle_id, owner_principal=owner,
idempotency_key=request.idempotency_key, request_hash=request_hash, response=response,
))
db.commit()
return response
# #endregion McpServer.ScenarioTools.ResolveTestPackProfile.Call
# #endregion McpServer.ScenarioTools.ResolveTestPackProfile
@server.tool(name="inspect_scenario", structured_output=True)
async def inspect_scenario(request: ScenarioCompileInput) -> dict[str, Any]:
"""Compile a scenario graph without registering or persisting it.
T029k (MCPX-FR-028): when the supplied query_model is authoritative-shape, the server derives
truthful capability facts from it and they win over conflicting caller declarations in both
directions; the additive `capability_authority` section reports the derived facts, undetermined
keys, and overridden declarations. Legacy/non-authoritative payloads keep caller-declared
capabilities (register-time context_authority remains the hard gate).
"""
logger.reason("Compile MCP scenario inspection", src="McpServer.ScenarioTools.inspect_scenario", payload={"dashboard_id": request.dashboard_id})
capability_authority = build_capability_authority(
request.query_model, request.capabilities, request.has_dataset_fields,
)
compile_fields = request.model_dump()
compile_fields["capabilities"] = capability_authority["effective_capabilities"]
compile_fields["has_dataset_fields"] = capability_authority["effective_has_dataset_fields"]
compiled = compile_scenario(CompileScenarioRequest(**compile_fields))
result = {
"status": "ok",
"scenario": compiled.scenario.model_dump(mode="json"),
"warnings": [item.model_dump(mode="json") for item in compiled.warnings],
"blockers": [item.model_dump(mode="json") for item in compiled.blockers],
"capability_authority": capability_authority["section"],
}
logger.reflect("Inspection graph returned", src="McpServer.ScenarioTools.inspect_scenario", payload={"steps": len(compiled.scenario.steps)})
return result
@server.tool(name="validate_scenario", structured_output=True)
async def validate_scenario_tool(scenario: DashboardTestScenario) -> dict[str, Any]:
"""Validate a supplied graph without persisting it."""
logger.reason("Validate MCP scenario graph", src="McpServer.ScenarioTools.validate_scenario", payload={"scenario_id": scenario.scenario_id})
result = validate_scenario(scenario)
logger.reflect("Validation result returned", src="McpServer.ScenarioTools.validate_scenario", payload={"valid": result.valid})
return {
"status": "valid" if result.valid else "needs_context",
"valid": result.valid,
"errors": [item.model_dump(mode="json") for item in result.errors],
"warnings": [item.model_dump(mode="json") for item in result.warnings],
"blockers": [item.model_dump(mode="json") for item in result.blockers],
"coverage": result.coverage,
"topological_order": result.topological_order,
"unresolved_parameters": result.unresolved_parameters,
"unresolved_selectors": result.unresolved_selectors,
"unresolved_baselines": result.unresolved_baselines,
"graph_hash": result.graph_hash,
}
@server.tool(name="scenario_resolve", structured_output=True)
async def scenario_resolve(request: ScenarioResolveInput) -> dict[str, Any]:
"""Apply typed resolutions to a supplied graph without persisting it."""
logger.reason("Resolve MCP scenario graph", src="McpServer.ScenarioTools.scenario_resolve", payload={"changes": len(request.changes)})
parsed = [ResolveChange(kind=c.kind, target=c.target, value=c.value, reason=c.reason) for c in request.changes]
resolved = resolve_scenario(request.scenario, parsed, base_revision_hash=request.base_revision_hash)
validation = validate_scenario(resolved)
logger.reflect("Resolution result returned", src="McpServer.ScenarioTools.scenario_resolve", payload={"revision_hash": resolved.revision_hash[:16], "valid": validation.valid})
return {
"status": "ok",
"scenario": resolved.model_dump(mode="json"),
"revision_hash": resolved.revision_hash,
"parent_revision_hash": resolved.parent_revision_hash,
"validation": {
"valid": validation.valid,
"errors": [item.model_dump(mode="json") for item in validation.errors],
"warnings": [item.model_dump(mode="json") for item in validation.warnings],
"blockers": [item.model_dump(mode="json") for item in validation.blockers],
},
}
@server.tool(name="generate_draft_pack", structured_output=True)
async def generate_draft_pack_tool(request: DraftPackInput) -> dict[str, Any]:
"""Generate a server-owned draft pack manifest for a supplied graph without persisting it."""
logger.reason("Generate MCP draft pack", src="McpServer.ScenarioTools.generate_draft_pack", payload={"scenario_id": request.scenario.scenario_id})
pack = generate_draft_pack(request.scenario)
logger.reflect("Draft pack manifest returned", src="McpServer.ScenarioTools.generate_draft_pack", payload={"status": pack["status"]})
return {
"status": pack["status"],
"scenario_revision_hash": pack["scenario_revision_hash"],
"template_version": pack["template_version"],
"manifest": pack["manifest"],
"validation_summary": pack["validation_summary"],
"warnings": pack["warnings"],
}
# @RATIONALE Profile-path receipts must come from a durable owner session and a freshly
# recompiled graph; the optional ID preserves the established T029i legacy tool.
# @REJECTED Treating a caller graph's digest or its self-derived receipt as proof of a
# resolved profile would let unresolved profile decisions authorize bootstrap.
@server.tool(name="register_draft_pack", structured_output=True)
async def register_draft_pack_tool(request: RegisterDraftPackInput) -> dict[str, Any]:
"""Build profile graphs server-side; preserve explicit legacy graph registration."""
access = _access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "principal_required"}
with SessionLocal() as db:
try:
run = db.query(AgentRun).filter(AgentRun.id == request.agent_run_id).first()
if run is None or str(run.user_id) != str(access.subject):
return {"status": "blocked", "error": "DRAFT_PACK_ACCESS_DENIED"}
owner = str(access.subject)
profile_session = db.query(TestPackProfileSession).filter(
TestPackProfileSession.profile_handle_id == request.profile_handle_id,
TestPackProfileSession.owner_principal == owner,
).with_for_update().first() if request.profile_handle_id else None
if request.profile_handle_id and profile_session is None:
raise ValueError("PROFILE_ACCESS_DENIED")
if profile_session is not None:
snapshot = profile_session.profile_snapshot
if not isinstance(snapshot, dict):
raise ValueError("PROFILE_STALE_CONTEXT")
if snapshot.get("status") != "save_eligible" or not snapshot.get("eligible"):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
environment = get_config_manager().get_environment(profile_session.environment_id)
if environment is None:
raise ValueError("ENV_NOT_FOUND")
client = await get_superset_client(environment)
query_model = await inspect_dashboard_query_model(
client, profile_session.environment_id, profile_session.dashboard_id,
)
if (not query_model.query_model_fingerprint
or query_model.query_model_fingerprint == "sha256:error"
or query_model.query_model_fingerprint != profile_session.context_fingerprint
or query_model.environment_id != profile_session.environment_id
or int(query_model.dashboard_id) != profile_session.dashboard_id):
raise ValueError("PROFILE_STALE_CONTEXT")
baseline, baseline_scenario, _ = build_test_pack_profile(
query_model=query_model, objective=profile_session.objective,
selected_case_ids=profile_session.selected_case_ids,
browser_available=resolve_browser_availability(),
)
if (snapshot.get("profile_digest") != profile_session.profile_digest
or snapshot.get("query_model_fingerprint") != profile_session.context_fingerprint
or baseline.query_model_fingerprint != profile_session.context_fingerprint
or any(snapshot.get(field) != getattr(baseline, field) for field in (
"profile_version", "checklist_catalog_version", "compiler_version",
"dashboard_id", "environment_id", "selected_case_ids",
))):
raise ValueError("PROFILE_STALE_CONTEXT")
resolutions = [TestPackProfileResolution.model_validate(item)
for item in (profile_session.resolutions or [])]
selectors = [item for item in resolutions if item.selector_hint is not None]
coordinates = [item for item in resolutions if item.coordinate_id is not None]
selector_changes = _selector_profile_changes(baseline, selectors, baseline_scenario) if selectors else []
coordinate_choices = _coordinate_profile_choices(baseline, coordinates) if coordinates else []
if selector_changes is None or coordinate_choices is None:
raise ValueError("PROFILE_RESOLUTION_INVALID")
fresh_profile, fresh_scenario, _ = build_test_pack_profile(
query_model=query_model, objective=profile_session.objective,
selected_case_ids=profile_session.selected_case_ids,
parameters=_selector_parameters(selector_changes),
browser_available=resolve_browser_availability(),
)
if coordinate_choices:
fresh_profile = apply_coordinate_choices(fresh_profile, {
item["unresolved_id"]: item["coordinate_id"] for item in coordinate_choices
})
if (fresh_profile.profile_digest != profile_session.profile_digest
or fresh_profile.model_dump(mode="json") != snapshot):
raise ValueError("PROFILE_STALE_CONTEXT")
if fresh_profile.status != "save_eligible" or not fresh_profile.eligible:
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
if request.scenario is not None and fresh_scenario.canonical_bytes() != request.scenario.canonical_bytes():
raise ValueError("PROFILE_GRAPH_MISMATCH")
scenario = fresh_scenario
if (int(run.dashboard_id) != profile_session.dashboard_id
or str(run.environment_id) != profile_session.environment_id):
raise ValueError("PROFILE_RUN_MISMATCH")
else:
scenario = request.scenario
if scenario is None:
raise ValueError("LEGACY_SCENARIO_REQUIRED")
if scenario.schema_version == 2:
raise ValueError("METRIC_SERVER_PROFILE_REQUIRED")
if any(step.automation_status in {"needs_baseline", "needs_selector", "needs_context"}
for step in scenario.steps):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
# T029h (option C): evaluate the context authority FIRST — a falsifiable
# client-context claim that fails against the live dashboard rejects the whole
# registration with zero handle/artifact rows.
context_authority = await evaluate_context_authority(scenario)
if profile_session is not None and context_authority != "verified":
raise ValueError("PROFILE_CONTEXT_UNVERIFIED")
pack = generate_draft_pack(scenario)
validation = validate_scenario(scenario)
if profile_session is not None and (pack["status"] != "save_eligible" or not validation.valid):
raise ValueError("PROFILE_NOT_SAVE_ELIGIBLE")
compiled = mint_compiled_handle(
db, scenario, owner_principal=owner,
dashboard_id=int(scenario.dashboard_context.get("dashboard_id") or run.dashboard_id),
agent_run_id=request.agent_run_id,
)
validation_handle = mint_validation_result(db, compiled, validation)
refs: list[dict[str, str]] = []
if pack["status"] == "save_eligible":
refs = register_pack_drafts(
db, request.agent_run_id, owner, render_pack_artifacts(scenario),
scenario.scenario_id, scenario.revision_hash,
)
pack_handle = mint_draft_pack_handle(
db, compiled, owner_principal=owner, agent_run_id=request.agent_run_id,
scenario_key=scenario.scenario_id, status=pack["status"],
template_version=pack.get("template_version", scenario.template_version),
artifact_refs=refs,
context_authority=context_authority,
profile_session=profile_session,
)
db.commit()
return {
"status": pack["status"],
"compiled_handle_id": compiled.handle_id,
"validation_result_id": validation_handle.result_id,
"draft_pack_handle_id": pack_handle.draft_pack_id,
"draft_pack_digest": pack_handle.digest,
"profile_receipt": pack_handle.profile_receipt,
"context_authority": context_authority,
"manifest": pack["manifest"],
"artifacts": refs,
"validation_summary": pack["validation_summary"],
"warnings": pack["warnings"],
}
except (ValueError, KeyError) as exc:
db.rollback()
logger.explore("Draft pack registration rejected", src="McpServer.ScenarioTools.register_draft_pack", error=str(exc))
return {"status": "blocked", "error": str(exc)}
@server.tool(name="start_scenario_run", structured_output=True)
async def start_scenario_run(request: ScenarioStartInput) -> dict[str, Any]:
"""Start a server-resolved scenario revision through start_run's persistence boundary."""
access = _access_token_context.get()
if access is None or not access.subject:
return {"status": "permission_denied", "error": "principal_required"}
with SessionLocal() as db:
try:
import asyncio
run = await asyncio.to_thread(start_run,
db, request.scenario_id, request.revision_id, request.params, request.environment_id,
actor=access.subject, idempotency_key=request.idempotency_key,
config_manager=get_config_manager(), auto_advance=False,
dashboard_release_id=request.dashboard_release_id, baseline_set=request.baseline_set,
baseline_set_version=request.baseline_set_version,
execution_toggles=request.execution_toggles, trigger_source="manual",
)
db.commit()
return {"status": run.status, "run_id": run.id, "scenario_id": run.scenario_id, "revision_id": run.scenario_revision_id, "environment_id": run.environment_id, "idempotency_key": run.idempotency_key}
except BaselinePeriodStale as exc:
db.rollback()
# 050 T044 parity: the same durable receipt the REST transport emits, on a separate
# committed session; the blocked answer below stays identical to the pre-receipt contract.
emit_launch_period_stale_receipt(
exc,
scenario_id=request.scenario_id,
revision_id=request.revision_id,
requested_period=requested_period_from(request.params, None),
)
logger.explore("Scenario start rejected", src="McpServer.ScenarioTools.start_scenario_run", error=str(exc))
return {"status": "blocked", "error": classify_start_error(exc), "detail": str(exc)}
except (ValueError, PermissionError) as exc:
db.rollback()
logger.explore("Scenario start rejected", src="McpServer.ScenarioTools.start_scenario_run", error=str(exc))
# 050 T044 residual: the same classify_start_error used by REST keeps the typed code
# identical across transports; detail preserves the raw cause for operators.
return {"status": "blocked", "error": classify_start_error(exc), "detail": str(exc)}
from ._tools_scenario_inspection import register as register_1
register_1(server)
from ._tools_scenario_registration import register as register_2
register_2(server)
# #endregion McpServer.ScenarioTools
# #endregion McpServer.ToolsScenario.RegisterScenario
@@ -857,89 +235,8 @@ def register_scenario_tools(server) -> None:
# @ingroup McpServer
# @BRIEF Registration seam: maintenance event reads plus the approval list/decide tools.
def register_maintenance_approval_tools(server) -> None:
@server.tool(name="list_maintenance_events", structured_output=True)
async def list_maintenance_events() -> dict[str, Any]:
"""Read maintenance events without auto-expiry or task enqueueing."""
with SessionLocal() as db:
events = (
db.query(MaintenanceEvent)
.order_by(MaintenanceEvent.created_at.desc())
.limit(100)
.all()
)
return {
"events": [
{
"id": event.id,
"environment_id": event.environment_id,
"tables": event.tables,
"start_time": event.start_time.isoformat() if event.start_time else None,
"end_time": event.end_time.isoformat() if event.end_time else None,
"status": event.status.value if hasattr(event.status, "value") else str(event.status),
"task_id": event.task_id,
"message": event.message,
}
for event in events
]
}
@server.tool(name="start_maintenance", structured_output=True)
async def start_maintenance(
tables: list[str],
start_time: str,
environment_id: str,
end_time: str | None = None,
auto_end: bool = False,
message: str | None = None,
) -> dict[str, Any]:
"""Request a maintenance window; execution requires ApprovalGate."""
return {
"status": "approval_required",
"tool": "start_maintenance",
"payload_validated": bool(tables and start_time and environment_id),
"message": message,
"end_time": end_time,
"auto_end": auto_end,
}
@server.tool(name="end_maintenance", structured_output=True)
async def end_maintenance(event_id: str | None = None, end_all: bool = False) -> dict[str, Any]:
"""Request one maintenance event or all active events to end; approval is required."""
return {
"status": "approval_required",
"tool": "end_maintenance",
"payload_validated": bool(event_id or end_all),
"event_id": event_id,
"end_all": end_all,
}
@server.tool(name="list_pending_approvals", structured_output=True)
async def list_pending_approvals() -> dict[str, Any]:
"""List pending MCP approval requests for the authenticated human user."""
access = _access_token_context.get()
if access is None or access.claims and access.claims.get("principal_type") == "service":
return {"status": "permission_denied", "approvals": []}
with SessionLocal() as db:
user = AuthRepository(db).get_user_by_username(access.subject or "")
if user is None:
return {"status": "permission_denied", "approvals": []}
return {"status": "ok", "approvals": list_pending_mcp_approvals(db, user)}
@server.tool(name="decide_approval", structured_output=True)
async def decide_approval(gate_id: str, decision: str, comment: str = "") -> dict[str, Any]:
"""Approve or deny one pending MCP gate; no provider is dispatched here."""
access = _access_token_context.get()
if access is None or access.claims and access.claims.get("principal_type") == "service":
return {"status": "permission_denied", "error": "human_principal_required"}
with SessionLocal() as db:
user = AuthRepository(db).get_user_by_username(access.subject or "")
if user is None:
return {"status": "permission_denied", "error": "user_not_found"}
try:
return decide_mcp_approval(db, gate_id, user, decision, comment)
except ValueError as exc:
return {"status": "rejected", "error": str(exc)}
from ._tools_scenario_maintenance_approval import register as register_1
register_1(server)
# #endregion McpServer.ToolsScenario.RegisterMaintenanceApproval
# #endregion McpServer.ToolsScenario

View File

@@ -46,32 +46,7 @@ def _compute_content_hash(repo_path: Path, logger=None, *, fingerprint_version:
raise ValueError('FINGERPRINT_VERSION_UNSUPPORTED')
if selected == 2:
return compute(repo_path)
hasher = hashlib.sha256()
total_files = 0
for dir_name in ["dashboards", "charts", "datasets"]:
dir_path = repo_path / dir_name
if not dir_path.exists():
continue
for ext in ["*.yaml", "*.yml"]:
for yaml_file in sorted(dir_path.glob(ext)):
total_files += 1
try:
data = yaml.safe_load(yaml_file.read_bytes())
if data is None:
continue # empty YAML file
hasher.update(yaml.dump(data, sort_keys=True).encode("utf-8"))
except yaml.YAMLError as exc:
# Edge A3: corrupted YAML — skip file, don't crash sync
if logger:
logger.warning("Skipping corrupted YAML %s: %s", yaml_file, exc)
continue
if total_files == 0:
return None # Edge A1/A2: no YAML content at all
return hasher.hexdigest()
return _compute_legacy_hash(repo_path, logger)
# #endregion Plugin.GitFingerprint.ComputeContentHash
@@ -107,9 +82,9 @@ def _write_fingerprint(repo_path: Path, content_hash: str) -> None:
# @SIDE_EFFECT Writes .superset-tools-fingerprint; logs comparison result.
# @PRE repo_path must contain managed YAML directories (dashboards/, charts/, datasets/).
# @POST Fingerprint file written if valid YAML content exists.
# @RELATION CALLS -> [_compute_content_hash]
# @RELATION CALLS -> [_read_fingerprint]
# @RELATION CALLS -> [_write_fingerprint]
# @RELATION CALLS -> [Plugin.GitFingerprint.ComputeContentHash]
# @RELATION CALLS -> [Plugin.GitFingerprint.ReadFingerprint]
# @RELATION CALLS -> [Plugin.GitFingerprint.WriteFingerprint]
def _compute_and_store_fingerprint(repo_path: Path, logger) -> None:
"""Thread-safe wrapper for run_blocking. Compares with old hash (if any) and logs diff."""
new_hash = _compute_content_hash(repo_path, logger=logger)
@@ -133,4 +108,36 @@ def _compute_and_store_fingerprint(repo_path: Path, logger) -> None:
# #endregion Plugin.GitFingerprint.ComputeAndStoreFingerprint
# #region Plugin.GitFingerprint.ComputeLegacyHash [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _compute_legacy_hash(repo_path, logger):
hasher = hashlib.sha256()
total_files = 0
for dir_name in ["dashboards", "charts", "datasets"]:
dir_path = repo_path / dir_name
if not dir_path.exists():
continue
for ext in ["*.yaml", "*.yml"]:
for yaml_file in sorted(dir_path.glob(ext)):
total_files += 1
try:
data = yaml.safe_load(yaml_file.read_bytes())
if data is None:
continue # empty YAML file
hasher.update(yaml.dump(data, sort_keys=True).encode("utf-8"))
except yaml.YAMLError as exc:
# Edge A3: corrupted YAML — skip file, don't crash sync
if logger:
logger.warning("Skipping corrupted YAML %s: %s", yaml_file, exc)
continue
if total_files == 0:
return None # Edge A1/A2: no YAML content at all
return hasher.hexdigest()
# #endregion Plugin.GitFingerprint.ComputeLegacyHash
# #endregion Plugin.GitFingerprint.GitFingerprintModule

View File

@@ -0,0 +1,198 @@
# #region Services.AgentAuthoringWorkspace.GraphProposals [C:4] [TYPE Module] [SEMANTICS authoring,workspace,cas]
# @defgroup Services.AgentAuthoringWorkspace.GraphProposals Graph revision proposals, validation and bounded diff projections.
from __future__ import annotations
from src.services.agent_authoring_workspace import service as api
# #region Services.AgentAuthoringWorkspace.ProposeGraphRevision [C:5] [TYPE Function] [SEMANTICS agent,authoring,graph,proposal,cas,idempotency]
# @ingroup Services
# @BRIEF Persist a server-derived graph proposal from typed edit operations and attach it to the workspace.
# @PRE Caller owns an active draft workspace bound to scenario_id and base_revision_id; expected CAS and idempotency key supplied.
# @POST Returns the proposal digest and deterministic diff; workspace advances draft -> proposal_ready; no candidate or current revision is created or activated.
# @SIDE_EFFECT Inserts one ScenarioEditProposal, one operation receipt, and advances workspace proposal_id, proposal_ready status and CAS.
# @INVARIANT The proposed graph is derived server-side from typed ops; the client never supplies a graph.
# @RELATION CALLS -> [ScenarioEditor.Agent.Propose]
# @REJECTED Accepting a client graph payload or activating a revision was rejected — this boundary proposes only.
def propose_graph_revision(
db: api.Session,
workspace_id: str,
owner_principal: str,
request_text: str,
operations: list[dict[str, api.Any]],
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
) -> dict[str, api.Any]:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
operation = "propose_graph_revision"
if not isinstance(request_text, str) or not request_text.strip() or len(request_text) > 2000:
raise api.WorkspaceGraphRevisionError("request_text must be a non-empty string of at most 2000 characters")
if not isinstance(operations, list) or not 1 <= len(operations) <= 20 or any(not isinstance(item, dict) for item in operations):
raise api.WorkspaceGraphRevisionError("operations must contain between 1 and 20 typed edit operations")
request_hash = api._request_hash({"request_text": request_text.strip(), "operations": operations})
workspace = api._owned(db, workspace_id, owner)
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
proposal = db.get(api.ScenarioEditProposal, receipt.result_reference)
if proposal is None:
raise api.WorkspaceError("graph proposal receipt has no durable proposal")
return api._graph_revision_projection(db, proposal, receipt.result_cas_version)
workspace = api._owned_active(db, workspace_id, owner)
if workspace.scenario_id is None or workspace.base_revision_id is None:
raise api.WorkspaceGraphRevisionError("workspace is not bound to a scenario revision")
if workspace.session_status != "draft":
raise api.WorkspaceGraphRevisionError("graph revision can only be proposed from draft")
if not isinstance(expected_cas_version, int) or isinstance(expected_cas_version, bool) or workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
result = api.agent_propose(
db, workspace.scenario_id, workspace.base_revision_id, request_text.strip(), operations,
created_by=owner, agent_action_id=None,
)
proposal_id = result["proposal_id"]
updated = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "draft",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
)
.values(
proposal_id=proposal_id,
session_status="proposal_ready",
cas_version=expected_cas_version + 1,
updated_at=api._now(),
)
.execution_options(synchronize_session=False)
)
if updated.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=proposal_id,
result_status="proposal_ready", result_cas_version=expected_cas_version + 1,
actor_principal=actor,
))
db.flush()
proposal = db.get(api.ScenarioEditProposal, proposal_id)
if proposal is None:
raise api.WorkspaceError("graph proposal was not persisted")
api.logger.reflect(
"Graph proposal attached to workspace", src="AgentAuthoringWorkspace.ProposeGraphRevision",
payload={"workspace_id": workspace_id, "proposal_id": proposal_id},
)
return api._graph_revision_projection(db, proposal, expected_cas_version + 1)
# #endregion Services.AgentAuthoringWorkspace.ProposeGraphRevision
# #region Services.AgentAuthoringWorkspace.GetGraphDiff [C:4] [TYPE Function] [SEMANTICS agent,authoring,graph,diff,read,bounded]
# @ingroup Services
# @BRIEF Read the deterministic diff between the base revision and the attached graph proposal.
# @PRE workspace_id and owner_principal identify an authenticated read boundary.
# @POST Returns proposal identity, digest, status, and the computed graph diff only.
# @SIDE_EFFECT None; this function performs no mutation, CAS change, or provider I/O.
# @INVARIANT Missing proposals and mismatched ownership never disclose proposal metadata.
def get_graph_diff(db: api.Session, workspace_id: str, owner_principal: str) -> dict[str, api.Any]:
owner = api._identity(owner_principal, "owner_principal")
workspace = api._owned_active(db, workspace_id, owner)
proposal_id = workspace.proposal_id
if proposal_id is None:
raise api.WorkspaceNotFound(f"workspace has no graph proposal: {workspace_id}")
proposal = db.get(api.ScenarioEditProposal, proposal_id)
if proposal is None:
raise api.WorkspaceNotFound(f"graph proposal not found: {proposal_id}")
return api._graph_revision_projection(db, proposal, workspace.cas_version)
# #endregion Services.AgentAuthoringWorkspace.GetGraphDiff
# #region Services.AgentAuthoringWorkspace.GraphProposals._graph_digest [C:3] [TYPE Function]
def _graph_digest(graph: dict[str, api.Any]) -> str:
payload = api.json.dumps(graph, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return api.hashlib.sha256(payload.encode("utf-8")).hexdigest()
# #endregion Services.AgentAuthoringWorkspace.GraphProposals._graph_digest
# #region Services.AgentAuthoringWorkspace.PromotionGraphValidation [C:3] [TYPE Function] [SEMANTICS agent,authoring,promotion,safety,validation]
# @ingroup Services
# @BRIEF Structure-first promotion gate: canonical graphs are validated by the 038 validator; legacy snapshots fall back to a free-text-only scan.
# @RATIONALE DEF-01 (ss-prod E2E 2026-09-08): naive whole-graph token scan false-rejected a server-derived proposal; closed-schema structure is the authority, and promotion gates safety/cycle codes only — NEEDS_SELECTOR/NEEDS_BASELINE resolution errors belong to the request_save boundary, not the review boundary.
# @REJECTED Whole-graph substring scanning ("select ", "--", "\\") — false positives on legitimate server strings; rejecting every 038 validation error at promotion — blocks review of merely save-blocked graphs.
_PROMOTION_GATE_ERROR_CODES = frozenset({"FORBIDDEN_SQL", "FORBIDDEN_QUERY_CONTEXT", "PATH_TRAVERSAL", "CYCLE"})
_FREE_TEXT_KEYS = frozenset({"title", "description", "label", "goal", "rationale", "notes", "message", "request_text"})
# #region Services.AgentAuthoringWorkspace.GraphProposals._has_unsafe_free_text [C:3] [TYPE Function]
def _has_unsafe_free_text(node: api.Any, key: str | None = None) -> bool:
if isinstance(node, str):
return key in api._FREE_TEXT_KEYS and api.contains_unsafe_free_text(node)
if isinstance(node, dict):
return any(api._has_unsafe_free_text(value, str(k).lower()) for k, value in node.items())
if isinstance(node, list):
return any(api._has_unsafe_free_text(item, key) for item in node)
return False
# #endregion Services.AgentAuthoringWorkspace.GraphProposals._has_unsafe_free_text
# #region Services.AgentAuthoringWorkspace.GraphProposals._validate_proposal_graph [C:3] [TYPE Function]
def _validate_proposal_graph(graph: api.Any) -> dict[str, api.Any]:
if not isinstance(graph, dict):
return {"status": "invalid", "findings": ["proposed graph is not an object"]}
if not graph:
return {"status": "invalid", "findings": ["proposed graph is empty"]}
try:
from src.services.dashboard_testing.editor.registered_snapshot import canonical_registered_snapshot, has_canonical_identity
scenario = api.DashboardTestScenario.model_validate(canonical_registered_snapshot(graph))
except api.ValidationError:
if has_canonical_identity(graph):
return {"status": "invalid", "findings": ["canonical graph does not satisfy its declared schema"]}
# Persisted legacy snapshot: typed model cannot adjudicate; scan free-text fields only.
if api._has_unsafe_free_text(graph):
return {"status": "invalid", "findings": ["proposed graph contains unsafe SQL, executable, or path content"]}
return {"status": "valid", "findings": []}
findings = [
f"{finding.code}: {finding.message}"
for finding in api.validate_scenario(scenario).errors
if finding.code in api._PROMOTION_GATE_ERROR_CODES
]
return {"status": "invalid" if findings else "valid", "findings": findings}
# #endregion Services.AgentAuthoringWorkspace.GraphProposals._validate_proposal_graph
# #endregion Services.AgentAuthoringWorkspace.PromotionGraphValidation
# #region Services.AgentAuthoringWorkspace.GraphProposals._promotion_projection [C:3] [TYPE Function]
def _promotion_projection(db: api.Session, proposal: api.ScenarioEditProposal, new_status: str, cas_version: int) -> dict[str, api.Any]:
base_revision = db.get(api.ScenarioRevision, proposal.base_revision_id)
before = base_revision.graph_snapshot if base_revision is not None else {}
return {
"proposal_id": proposal.proposal_id,
"base_revision_id": proposal.base_revision_id,
"digest": api._graph_digest(proposal.proposed_graph),
"validation": api._validate_proposal_graph(proposal.proposed_graph),
"diff": api.graph_diff(before, proposal.proposed_graph),
"session_status": new_status,
"cas_version": cas_version,
}
# #endregion Services.AgentAuthoringWorkspace.GraphProposals._promotion_projection
# #region Services.AgentAuthoringWorkspace.GraphProposals._graph_revision_projection [C:3] [TYPE Function]
def _graph_revision_projection(db: api.Session, proposal: api.ScenarioEditProposal, cas_version: int) -> dict[str, api.Any]:
base_revision = db.get(api.ScenarioRevision, proposal.base_revision_id)
before = base_revision.graph_snapshot if base_revision is not None else {}
return {
"proposal_id": proposal.proposal_id,
"base_revision_id": proposal.base_revision_id,
"digest": proposal.digest,
"diff": api.graph_diff(before, proposal.proposed_graph),
"validation": {"status": "valid", "findings": []},
"proposal_status": proposal.status,
"cas_version": cas_version,
}
# #endregion Services.AgentAuthoringWorkspace.GraphProposals._graph_revision_projection
# #endregion Services.AgentAuthoringWorkspace.GraphProposals

View File

@@ -0,0 +1,249 @@
# #region Services.AgentAuthoringWorkspace.Intents [C:4] [TYPE Module] [SEMANTICS authoring,workspace,cas]
# @defgroup Services.AgentAuthoringWorkspace.Intents Bounded test-plan and exploration intents.
from __future__ import annotations
from src.services.agent_authoring_workspace import service as api
# #region Services.AgentAuthoringWorkspace.Intents._now [C:3] [TYPE Function]
def _now() -> api.datetime:
return api.datetime.now(api.UTC)
# #endregion Services.AgentAuthoringWorkspace.Intents._now
# #region Services.AgentAuthoringWorkspace.Intents._request_hash [C:3] [TYPE Function]
def _request_hash(payload: dict[str, api.Any]) -> str:
return api.hashlib.sha256(api.json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
# #endregion Services.AgentAuthoringWorkspace.Intents._request_hash
# #region Services.AgentAuthoringWorkspace.TestPlanValidation [C:4] [TYPE Function] [SEMANTICS agent,authoring,test-plan,validation,bounds]
# @RELATION CALLS -> [Services.AgentAuthoringWorkspace.ValidatePlanList]
# @ingroup Services
# @BRIEF Canonicalize and reject executable or unbounded test-plan intent.
# @PRE content is a JSON object supplied at an authenticated service boundary.
# @POST Returns a bounded allow-listed JSON object suitable for durable user-facing storage.
def _bounded_test_plan(content: dict[str, api.Any]) -> dict[str, api.Any]:
if not isinstance(content, dict) or set(content) - {"title", "objective", "steps", "checks", "notes"}:
raise api.WorkspaceTestPlanValidationError("plan_content must contain only title, objective, steps, checks, notes")
if not content or not isinstance(content.get("title"), str) or not content["title"].strip():
raise api.WorkspaceTestPlanValidationError("plan title is required")
if len(content["title"]) > 200:
raise api.WorkspaceTestPlanValidationError("plan title is too long")
result: dict[str, api.Any] = {"title": content["title"].strip()}
for field in ("objective", "notes"):
value = content.get(field, "")
if not isinstance(value, str) or len(value) > 2000:
raise api.WorkspaceTestPlanValidationError(f"{field} must be a string of at most 2000 characters")
if value:
result[field] = value.strip()
for field in ("steps", "checks"):
values = content.get(field, [])
_validate_plan_list(field, values)
result[field] = [item.strip() for item in values]
encoded = api.json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
dangerous = ("javascript:", "http://", "https://", "cookie", "password", "secret", "token", "shell", "bash", "powershell", "sql", "select ", "insert ", "update ", "delete ", "drop ", "playwright", "python", "../", "\\")
if any(term in encoded.lower() for term in dangerous):
raise api.WorkspaceTestPlanValidationError("plan contains a forbidden executable, network, secret, SQL, or path reference")
if len(encoded.encode("utf-8")) > 32 * 1024:
raise api.WorkspaceTestPlanValidationError("plan content exceeds 32768 bytes")
return result
# #endregion Services.AgentAuthoringWorkspace.TestPlanValidation
# #region Services.AgentAuthoringWorkspace.ExplorationValidation [C:5] [TYPE Function] [SEMANTICS agent,authoring,exploration,validation,bounds]
# @ingroup Services
# @BRIEF Canonicalize reviewed exploration intent and reject execution escape inputs.
# @PRE exploration_spec is a JSON object supplied at the authenticated service boundary.
# @POST Returns only allowlisted, bounded intent with named server-reviewed actions.
# @REJECTED Raw Playwright/Python/code, URLs/origins, credentials, paths, and arbitrary tool names are not request authority.
def _bounded_exploration_spec(exploration_spec: dict[str, api.Any]) -> dict[str, api.Any]:
allowed = {"objective", "actions", "limits"}
if not isinstance(exploration_spec, dict) or set(exploration_spec) - allowed:
raise api.WorkspaceExplorationValidationError("exploration_spec contains unsupported fields")
objective = exploration_spec.get("objective", "")
actions = exploration_spec.get("actions", [])
limits = exploration_spec.get("limits", {})
if not isinstance(objective, str) or not objective.strip() or len(objective) > 2000:
raise api.WorkspaceExplorationValidationError("objective must be a non-empty string of at most 2000 characters")
if not isinstance(actions, list) or not 1 <= len(actions) <= 20:
raise api.WorkspaceExplorationValidationError("actions must contain between 1 and 20 reviewed action names")
if any(not isinstance(action, str) or not action.strip() or len(action) > 64 for action in actions):
raise api.WorkspaceExplorationValidationError("actions must contain short strings")
normalized_actions = [action.strip() for action in actions]
if any(action not in api._REVIEWED_EXPLORATION_ACTIONS for action in normalized_actions):
raise api.WorkspaceExplorationValidationError("actions must be registered 038 ActionRegistry names")
if not isinstance(limits, dict) or set(limits) - {"timeout_seconds", "max_bytes", "max_steps"}:
raise api.WorkspaceExplorationValidationError("limits contain unsupported fields")
normalized_limits = {
"timeout_seconds": limits.get("timeout_seconds", 60),
"max_bytes": limits.get("max_bytes", 1024 * 1024),
"max_steps": limits.get("max_steps", 50),
}
if any(not isinstance(value, int) or isinstance(value, bool) for value in normalized_limits.values()):
raise api.WorkspaceExplorationValidationError("limits must contain integer budgets")
if not 1 <= normalized_limits["timeout_seconds"] <= 600 or not 1024 <= normalized_limits["max_bytes"] <= 10 * 1024 * 1024 or not 1 <= normalized_limits["max_steps"] <= 100:
raise api.WorkspaceExplorationValidationError("exploration limits are outside the server bounds")
result = {"objective": objective.strip(), "actions": normalized_actions, "limits": normalized_limits}
encoded = api.json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
forbidden = ("playwright", "python", "javascript", "shell", "bash", "powershell", "http://", "https://", "origin", "cookie", "secret", "password", "credential", "token", "../", "\\", "/")
if any(term in encoded.lower() for term in forbidden) or len(encoded.encode("utf-8")) > 16 * 1024:
raise api.WorkspaceExplorationValidationError("exploration_spec contains forbidden execution, network, secret, or path content")
return result
# #endregion Services.AgentAuthoringWorkspace.ExplorationValidation
# #region Services.AgentAuthoringWorkspace.ProposeTestPlan [C:5] [TYPE Function] [SEMANTICS agent,authoring,test-plan,cas,idempotency]
# @ingroup Services
# @BRIEF Persist one immutable bounded test-plan proposal and advance its workspace by CAS.
# @PRE Caller is the workspace owner; workspace is active and draft; expected_cas_version matches unless replaying.
# @POST Returns the same immutable plan on an identical retry; new plans atomically update workspace.test_plan_id and receipt.
# @SIDE_EFFECT Inserts one plan and operation receipt and updates one workspace CAS version in the caller transaction.
# @RELATION DEPENDS_ON -> [Models.AgentAuthoringWorkspace.TestPlan]
def propose_test_plan(
db: api.Session,
workspace_id: str,
owner_principal: str,
plan_content: dict[str, api.Any],
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
) -> api.AgentAuthoringWorkspaceTestPlan:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
operation = "propose_test_plan"
workspace = api._owned(db, workspace_id, owner)
bounded = api._bounded_test_plan(plan_content)
request_hash = api._request_hash({"plan_content": bounded})
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
plan = db.get(api.AgentAuthoringWorkspaceTestPlan, receipt.result_reference)
if plan is None:
raise api.WorkspaceError("test-plan receipt has no durable plan")
return plan
workspace = api._owned_active(db, workspace_id, owner)
if workspace.session_status != "draft":
raise api.WorkspaceTransitionError("test plan can only be proposed from draft")
if workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
plan = api.AgentAuthoringWorkspaceTestPlan(
workspace_id=workspace_id,
plan_content=bounded,
content_digest=api.hashlib.sha256(api.json.dumps(bounded, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest(),
actor_principal=actor,
)
db.add(plan)
db.flush()
result = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "draft",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
)
.values(test_plan_id=plan.plan_id, cas_version=expected_cas_version + 1, updated_at=api._now())
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=plan.plan_id,
result_status="draft", result_cas_version=expected_cas_version + 1,
actor_principal=actor,
))
db.flush()
api.logger.reflect("Bounded test plan persisted", src="AgentAuthoringWorkspace.ProposeTestPlan", payload={"workspace_id": workspace_id, "plan_id": plan.plan_id})
return plan
# #endregion Services.AgentAuthoringWorkspace.ProposeTestPlan
# #region Services.AgentAuthoringWorkspace.StartExploration [C:5] [TYPE Function] [SEMANTICS agent,authoring,exploration,sandbox,cas,idempotency]
# @ingroup Services
# @BRIEF Persist a bounded exploration request and fail closed when no sandbox provider is registered.
# @PRE Caller owns an active draft workspace; expected CAS and idempotency key are supplied.
# @POST Returns the durable request with status sandbox_unavailable or queued only when a provider is explicitly available.
# @SIDE_EFFECT Inserts one request and operation receipt and advances workspace CAS without changing lifecycle status.
# @INVARIANT No provider I/O, subprocess, registry, revision, run, artifact, or exploration-id mutation occurs here.
# @RELATION DEPENDS_ON -> [Models.AgentAuthoringWorkspace.ExplorationRequest]
def start_exploration(
db: api.Session,
workspace_id: str,
owner_principal: str,
exploration_spec: dict[str, api.Any],
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
provider_available: bool = False,
) -> api.AuthoringExplorationRequest:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
workspace = api._owned(db, workspace_id, owner)
bounded = api._bounded_exploration_spec(exploration_spec)
operation = "start_exploration"
request_hash = api._request_hash({"exploration_spec": bounded})
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
request = db.get(api.AuthoringExplorationRequest, receipt.result_reference)
if request is None:
raise api.WorkspaceError("exploration receipt has no durable request")
return request
workspace = api._owned_active(db, workspace_id, owner)
if workspace.session_status != "draft":
raise api.WorkspaceTransitionError("exploration can only be requested from draft")
if not isinstance(expected_cas_version, int) or isinstance(expected_cas_version, bool) or workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
operation_id = str(api.uuid.uuid4())
request = api.AuthoringExplorationRequest(
workspace_id=workspace_id,
exploration_spec=bounded,
request_status="queued" if provider_available else "sandbox_unavailable",
operation_id=operation_id,
actor_principal=actor,
)
db.add(request)
db.flush()
result = db.execute(api.update(api.AgentAuthoringWorkspace).where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "draft",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
).values(cas_version=expected_cas_version + 1, updated_at=api._now()).execution_options(synchronize_session=False))
if result.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
updated = api._get(db, workspace_id)
if updated is None:
raise api.WorkspaceNotFound(f"workspace not found: {workspace_id}")
receipt = api.AgentAuthoringWorkspaceOperation(
operation_id=operation_id,
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=request.request_id,
result_status=request.request_status, result_cas_version=updated.cas_version,
actor_principal=actor,
)
db.add(receipt)
db.flush()
api.logger.reflect(
"Bounded exploration request persisted without provider admission", src="AgentAuthoringWorkspace.StartExploration",
payload={"workspace_id": workspace_id, "request_id": request.request_id, "status": request.request_status},
)
return request
# #endregion Services.AgentAuthoringWorkspace.StartExploration
# #region Services.AgentAuthoringWorkspace.ValidatePlanList [C:3] [TYPE Function]
# @BRIEF Reject checklist fields exceeding fifty strings or five hundred characters per string.
def _validate_plan_list(field, values):
if not isinstance(values, list) or len(values) > 50 or any(not isinstance(item, str) or len(item) > 500 for item in values):
raise api.WorkspaceTestPlanValidationError(f"{field} must contain at most 50 short strings")
# #endregion Services.AgentAuthoringWorkspace.ValidatePlanList
# #endregion Services.AgentAuthoringWorkspace.Intents

View File

@@ -0,0 +1,265 @@
# #region Services.AgentAuthoringWorkspace.Lifecycle [C:4] [TYPE Module] [SEMANTICS authoring,workspace,cas]
# @defgroup Services.AgentAuthoringWorkspace.Lifecycle Owner-scoped lifecycle, CAS mutation and idempotency receipts.
from __future__ import annotations
from src.services.agent_authoring_workspace import service as api
# #region Services.AgentAuthoringWorkspace.Lifecycle._identity [C:3] [TYPE Function]
def _identity(principal: str | None, field: str) -> str:
"""Validate an already-authenticated/delegated principal identity supplied at this boundary."""
if not isinstance(principal, str) or not principal.strip():
raise api.WorkspaceAccessError(f"{field} must be an authenticated principal identity")
return principal.strip()
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._identity
# #region Services.AgentAuthoringWorkspace.Lifecycle._key [C:3] [TYPE Function]
def _key(key: str | None) -> str:
if not isinstance(key, str) or not key.strip():
raise api.WorkspaceError("idempotency_key is required")
return key.strip()
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._key
# #region Services.AgentAuthoringWorkspace.Lifecycle._is_expired [C:3] [TYPE Function]
def _is_expired(expires_at: api.datetime) -> bool:
return expires_at.replace(tzinfo=api.UTC) <= api._now() if expires_at.tzinfo is None else expires_at <= api._now()
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._is_expired
# #region Services.AgentAuthoringWorkspace.Lifecycle._get [C:3] [TYPE Function]
def _get(db: api.Session, workspace_id: str) -> api.AgentAuthoringWorkspace | None:
return db.scalar(api.select(api.AgentAuthoringWorkspace).where(api.AgentAuthoringWorkspace.workspace_id == workspace_id))
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._get
# #region Services.AgentAuthoringWorkspace.Lifecycle._owned [C:3] [TYPE Function]
def _owned(db: api.Session, workspace_id: str, owner_principal: str) -> api.AgentAuthoringWorkspace:
workspace = api._get(db, workspace_id)
if workspace is None:
raise api.WorkspaceNotFound(f"workspace not found: {workspace_id}")
if workspace.owner_principal != api._identity(owner_principal, "owner_principal"):
raise api.WorkspaceAccessError("workspace owner mismatch")
return workspace
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._owned
# #region Services.AgentAuthoringWorkspace.Lifecycle._owned_active [C:3] [TYPE Function]
def _owned_active(db: api.Session, workspace_id: str, owner_principal: str) -> api.AgentAuthoringWorkspace:
workspace = api._owned(db, workspace_id, owner_principal)
if api._is_expired(workspace.expires_at):
raise api.WorkspaceExpired(f"workspace expired: {workspace_id}")
return workspace
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._owned_active
# #region Services.AgentAuthoringWorkspace.Lifecycle._receipt [C:3] [TYPE Function]
def _receipt(db: api.Session, workspace_id: str, operation: str, key: str, request_hash: str) -> api.AgentAuthoringWorkspaceOperation | None:
receipt = db.scalar(api.select(api.AgentAuthoringWorkspaceOperation).where(
api.AgentAuthoringWorkspaceOperation.workspace_id == workspace_id,
api.AgentAuthoringWorkspaceOperation.operation == operation,
api.AgentAuthoringWorkspaceOperation.idempotency_key == key,
))
if receipt is not None and receipt.request_hash != request_hash:
raise api.WorkspaceIdempotencyConflict("idempotency key conflicts with a different request")
return receipt
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._receipt
# #region Services.AgentAuthoringWorkspace.Lifecycle._replay_or_none [C:3] [TYPE Function]
def _replay_or_none(db: api.Session, workspace_id: str, operation: str, key: str, request_hash: str) -> api.AgentAuthoringWorkspace | None:
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
return api._get(db, receipt.result_reference) if receipt is not None else None
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._replay_or_none
# #region Services.AgentAuthoringWorkspace.Lifecycle._record [C:3] [TYPE Function]
def _record(db: api.Session, workspace_id: str, operation: str, key: str, request_hash: str, workspace: api.AgentAuthoringWorkspace, actor: str) -> None:
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key, request_hash=request_hash,
result_reference=workspace.workspace_id, result_status=workspace.session_status,
result_cas_version=workspace.cas_version, actor_principal=actor,
))
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._record
# #region Services.AgentAuthoringWorkspace.Lifecycle._mutate [C:3] [TYPE Function]
def _mutate(db: api.Session, workspace_id: str, owner: str, expected_cas_version: int, values: dict[str, api.Any], *, operation: str, key: str, request_hash: str, actor: str, require_active: bool = True) -> api.AgentAuthoringWorkspace:
workspace = api._owned_active(db, workspace_id, owner) if require_active else api._owned(db, workspace_id, owner)
replay = api._replay_or_none(db, workspace_id, operation, key, request_hash)
if replay is not None:
return replay
criteria = [api.AgentAuthoringWorkspace.workspace_id == workspace_id, api.AgentAuthoringWorkspace.owner_principal == owner, api.AgentAuthoringWorkspace.cas_version == expected_cas_version]
if require_active:
criteria.append(api.AgentAuthoringWorkspace.expires_at > api._now())
result = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(*criteria)
.values(**values, cas_version=expected_cas_version + 1, updated_at=api._now())
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
updated = api._get(db, workspace_id)
if updated is None:
raise api.WorkspaceNotFound(f"workspace not found: {workspace_id}")
api._record(db, workspace_id, operation, key, request_hash, updated, actor)
db.flush()
return updated
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._mutate
# #region Services.AgentAuthoringWorkspace.Lifecycle.create_workspace [C:3] [TYPE Function]
def create_workspace(db: api.Session, owner_principal: str, *, expires_in: api.timedelta = api.timedelta(hours=1), agent_principal: str | None = None, scenario_id: str | None = None, base_revision_id: str | None = None, base_content_hash: str | None = None, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
"""Create a draft workspace; principal inputs are authenticated/delegated identities from the caller boundary."""
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
if expires_in <= api.timedelta(0):
raise ValueError("expires_in must be positive")
request_hash = api._request_hash({"owner": owner, "expires_in": expires_in.total_seconds(), "agent": agent_principal, "scenario": scenario_id, "base_revision": base_revision_id, "base_hash": base_content_hash})
existing = db.scalar(api.select(api.AgentAuthoringWorkspaceOperation).where(
api.AgentAuthoringWorkspaceOperation.operation == "create",
api.AgentAuthoringWorkspaceOperation.idempotency_key == key,
api.AgentAuthoringWorkspaceOperation.actor_principal == actor,
))
if existing is not None:
if existing.request_hash != request_hash:
raise api.WorkspaceIdempotencyConflict("idempotency key conflicts with a different request")
replay = api._get(db, existing.result_reference)
if replay is not None:
return replay
workspace = api.AgentAuthoringWorkspace(owner_principal=owner, agent_principal=agent_principal, scenario_id=scenario_id, base_revision_id=base_revision_id, base_content_hash=base_content_hash, expires_at=api._now() + expires_in)
db.add(workspace)
db.flush()
api._record(db, workspace.workspace_id, "create", key, request_hash, workspace, actor)
db.flush()
api.logger.reflect("Agent authoring workspace created", payload={"workspace_id": workspace.workspace_id})
return workspace
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.create_workspace
# #region Services.AgentAuthoringWorkspace.Lifecycle.load_workspace [C:3] [TYPE Function]
def load_workspace(db: api.Session, workspace_id: str, owner_principal: str) -> api.AgentAuthoringWorkspace:
"""Read workspace without mutation; an expired row raises WorkspaceExpired fail-closed."""
return api._owned_active(db, workspace_id, owner_principal)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.load_workspace
# #region Services.AgentAuthoringWorkspace.GetExplorationResult [C:4] [TYPE Function] [SEMANTICS agent,authoring,exploration,read,owner,bounded]
# @ingroup Services
# @BRIEF Read one owner-authorized exploration request as a bounded metadata projection.
# @PRE workspace_id, request_id, and owner_principal identify an authenticated read boundary.
# @POST Returns status, identities, creation time, and an opaque receipt reference only.
# @SIDE_EFFECT None; this function performs no mutation, CAS change, status transition, or provider I/O.
# @INVARIANT Missing requests and mismatched workspace ownership never disclose request metadata.
def get_exploration_result(
db: api.Session, workspace_id: str, request_id: str, owner_principal: str
) -> api.ExplorationResultProjection:
owner = api._identity(owner_principal, "owner_principal")
request = db.scalar(
api.select(api.AuthoringExplorationRequest)
.join(
api.AgentAuthoringWorkspace,
api.AgentAuthoringWorkspace.workspace_id == api.AuthoringExplorationRequest.workspace_id,
)
.where(
api.AuthoringExplorationRequest.workspace_id == workspace_id,
api.AuthoringExplorationRequest.request_id == request_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
)
)
if request is not None:
api.logger.reflect(
"Bounded exploration result projection loaded", src="AgentAuthoringWorkspace.GetExplorationResult",
payload={"workspace_id": workspace_id, "request_id": request_id, "status": request.request_status},
)
return api.ExplorationResultProjection(request)
workspace = api._get(db, workspace_id)
if workspace is not None and workspace.owner_principal != owner:
raise api.WorkspaceAccessError("workspace owner mismatch")
raise api.WorkspaceNotFound(f"exploration request not found: {request_id}")
# #endregion Services.AgentAuthoringWorkspace.GetExplorationResult
# #region Services.AgentAuthoringWorkspace.Lifecycle.transition_workspace [C:3] [TYPE Function]
def transition_workspace(db: api.Session, workspace_id: str, owner_principal: str, to_status: str, expected_cas_version: int, *, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
owner, actor, key = api._identity(owner_principal, "owner_principal"), api._identity(actor_principal or owner_principal, "actor_principal"), api._key(idempotency_key)
request_hash = api._request_hash({"to": to_status})
workspace = api._owned(db, workspace_id, owner)
replay = api._replay_or_none(db, workspace_id, "transition", key, request_hash)
if replay is not None:
return replay
workspace = api._owned_active(db, workspace_id, owner)
if to_status not in api.WORKSPACE_STATES or to_status not in api._TRANSITIONS.get(workspace.session_status, frozenset()):
raise api.WorkspaceTransitionError(f"invalid transition {workspace.session_status} -> {to_status}")
return api._mutate(db, workspace_id, owner, expected_cas_version, {"session_status": to_status}, operation="transition", key=key, request_hash=request_hash, actor=actor)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.transition_workspace
# #region Services.AgentAuthoringWorkspace.Lifecycle._attach [C:3] [TYPE Function]
def _attach(db: api.Session, workspace_id: str, owner_principal: str, expected_cas_version: int, field: str, value: str, *, idempotency_key: str | None, actor_principal: str | None) -> api.AgentAuthoringWorkspace:
owner, actor, key = api._identity(owner_principal, "owner_principal"), api._identity(actor_principal or owner_principal, "actor_principal"), api._key(idempotency_key)
request_hash = api._request_hash({"field": field, "value": value})
operation = f"attach_{field}"
workspace = api._owned(db, workspace_id, owner)
replay = api._replay_or_none(db, workspace_id, operation, key, request_hash)
if replay is not None:
return replay
workspace = api._owned_active(db, workspace_id, owner)
values = list(getattr(workspace, field) or [])
if value in values:
if workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
api._record(db, workspace_id, operation, key, request_hash, workspace, actor)
db.flush()
return workspace
return api._mutate(db, workspace_id, owner, expected_cas_version, {field: values + [value]}, operation=operation, key=key, request_hash=request_hash, actor=actor)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle._attach
# #region Services.AgentAuthoringWorkspace.Lifecycle.attach_proposal [C:3] [TYPE Function]
def attach_proposal(db: api.Session, workspace_id: str, owner_principal: str, proposal_id: str, expected_cas_version: int, *, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
owner, actor, key = api._identity(owner_principal, "owner_principal"), api._identity(actor_principal or owner_principal, "actor_principal"), api._key(idempotency_key)
request_hash = api._request_hash({"proposal_id": proposal_id})
workspace = api._owned(db, workspace_id, owner)
replay = api._replay_or_none(db, workspace_id, "attach_proposal", key, request_hash)
if replay is not None:
return replay
workspace = api._owned_active(db, workspace_id, owner)
if workspace.proposal_id is not None:
raise api.WorkspaceTransitionError("workspace already has a proposal")
return api._mutate(db, workspace_id, owner, expected_cas_version, {"proposal_id": proposal_id}, operation="attach_proposal", key=key, request_hash=request_hash, actor=actor)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.attach_proposal
# #region Services.AgentAuthoringWorkspace.Lifecycle.attach_exploration [C:3] [TYPE Function]
def attach_exploration(db: api.Session, workspace_id: str, owner_principal: str, exploration_id: str, expected_cas_version: int, *, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
return api._attach(db, workspace_id, owner_principal, expected_cas_version, "exploration_ids", exploration_id, idempotency_key=idempotency_key, actor_principal=actor_principal)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.attach_exploration
# #region Services.AgentAuthoringWorkspace.Lifecycle.attach_artifact [C:3] [TYPE Function]
def attach_artifact(db: api.Session, workspace_id: str, owner_principal: str, artifact_id: str, expected_cas_version: int, *, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
return api._attach(db, workspace_id, owner_principal, expected_cas_version, "artifact_ids", artifact_id, idempotency_key=idempotency_key, actor_principal=actor_principal)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.attach_artifact
# #region Services.AgentAuthoringWorkspace.Lifecycle.expire_workspace [C:3] [TYPE Function]
def expire_workspace(db: api.Session, workspace_id: str, owner_principal: str, expected_cas_version: int, *, idempotency_key: str | None = None, actor_principal: str | None = None) -> api.AgentAuthoringWorkspace:
"""Explicit CAS-protected maintenance mutation; expiry remains represented by expires_at, not status."""
owner, actor, key = api._identity(owner_principal, "owner_principal"), api._identity(actor_principal or owner_principal, "actor_principal"), api._key(idempotency_key)
request_hash = api._request_hash({"expiry": "terminal"})
workspace = api._owned(db, workspace_id, owner)
replay = api._replay_or_none(db, workspace_id, "expire", key, request_hash)
if replay is not None:
return replay
if workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
return api._mutate(db, workspace_id, owner, expected_cas_version, {"expires_at": api._now()}, operation="expire", key=key, request_hash=request_hash, actor=actor, require_active=False)
# #endregion Services.AgentAuthoringWorkspace.Lifecycle.expire_workspace
# #endregion Services.AgentAuthoringWorkspace.Lifecycle

View File

@@ -0,0 +1,262 @@
# #region Services.AgentAuthoringWorkspace.RevisionLifecycle [C:4] [TYPE Module] [SEMANTICS authoring,workspace,cas]
# @defgroup Services.AgentAuthoringWorkspace.RevisionLifecycle Review, candidate save and explicit activation transactions.
from __future__ import annotations
from src.services.agent_authoring_workspace import service as api
# #region Services.AgentAuthoringWorkspace.PromoteToScenario [C:5] [TYPE Function] [SEMANTICS agent,authoring,graph,promotion,validation,cas,idempotency]
# @ingroup Services
# @BRIEF Deterministically promote a graph proposal toward save readiness without activating it.
# @PRE Caller owns an active workspace in proposal_ready with an attached open proposal; expected CAS and idempotency key supplied.
# @POST Returns the server-recomputed digest, validation, diff, and awaiting_user_review or validation_blocked status.
# @SIDE_EFFECT Advances workspace CAS and promotion status and records an operation receipt; no candidate or current revision is created.
# @INVARIANT The proposal digest is recomputed server-side; caller digests never establish authority.
# @RELATION CALLS -> [ScenarioEditor.Agent.Propose]
# @REJECTED Saving or activating a revision during promotion was rejected — promotion only moves the review boundary.
def promote_to_scenario(
db: api.Session,
workspace_id: str,
owner_principal: str,
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
) -> dict[str, api.Any]:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
operation = "promote_to_scenario"
request_hash = api._request_hash({"promote": True})
workspace = api._owned(db, workspace_id, owner)
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
proposal = db.get(api.ScenarioEditProposal, receipt.result_reference)
if proposal is None:
raise api.WorkspaceError("promotion receipt has no durable proposal")
return api._promotion_projection(db, proposal, receipt.result_status, receipt.result_cas_version)
workspace = api._owned_active(db, workspace_id, owner)
proposal_id = workspace.proposal_id
if proposal_id is None:
raise api.WorkspaceGraphRevisionError("workspace has no graph proposal to promote")
if workspace.session_status != "proposal_ready":
raise api.WorkspaceTransitionError(f"promotion requires proposal_ready, got {workspace.session_status}")
if not isinstance(expected_cas_version, int) or isinstance(expected_cas_version, bool) or workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
proposal = db.get(api.ScenarioEditProposal, proposal_id)
if proposal is None or proposal.status != "open":
raise api.WorkspaceGraphRevisionError("promotion requires an open proposal")
validation = api._validate_proposal_graph(proposal.proposed_graph)
new_status = "awaiting_user_review" if validation["status"] == "valid" else "validation_blocked"
updated = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "proposal_ready",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
)
.values(session_status=new_status, cas_version=expected_cas_version + 1, updated_at=api._now())
.execution_options(synchronize_session=False)
)
if updated.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=proposal_id,
result_status=new_status, result_cas_version=expected_cas_version + 1,
actor_principal=actor,
))
db.flush()
api.logger.reflect(
"Graph proposal promotion boundary evaluated", src="AgentAuthoringWorkspace.PromoteToScenario",
payload={"workspace_id": workspace_id, "proposal_id": proposal_id, "status": new_status},
)
return api._promotion_projection(db, proposal, new_status, expected_cas_version + 1)
# #endregion Services.AgentAuthoringWorkspace.PromoteToScenario
# #region Services.AgentAuthoringWorkspace.RequestSave [C:5] [TYPE Function] [SEMANTICS agent,authoring,graph,save,candidate,cas,idempotency]
# @ingroup Services
# @BRIEF Save a reviewed proposal into one immutable candidate revision through the guarded editor path.
# @PRE Caller owns an active workspace in awaiting_user_review with an open proposal; expected CAS and idempotency key supplied.
# @POST Returns the new candidate revision identity; current_revision is never advanced.
# @SIDE_EFFECT Creates one candidate ScenarioRevision, marks the proposal saved, advances workspace to candidate status, and records a receipt.
# @INVARIANT The proposal save is authorized by the reviewed owner boundary; caller digests are never accepted.
# @RELATION CALLS -> [ScenarioEditor.Agent.SaveProposal]
# @REJECTED Activating a revision during save was rejected — save creates a candidate only; activation is a separate CAS operation.
def request_save(
db: api.Session,
workspace_id: str,
owner_principal: str,
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
agent_action_id: str | None = None,
) -> dict[str, api.Any]:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
operation = "request_save"
request_hash = api._request_hash({"save": True})
workspace = api._owned(db, workspace_id, owner)
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
revision = db.get(api.ScenarioRevision, receipt.result_reference)
if revision is None:
raise api.WorkspaceError("save receipt has no durable revision")
return api._save_projection(revision, receipt.result_cas_version)
workspace = api._owned_active(db, workspace_id, owner)
proposal_id = workspace.proposal_id
if proposal_id is None:
raise api.WorkspaceGraphRevisionError("workspace has no graph proposal to save")
if workspace.session_status != "awaiting_user_review":
raise api.WorkspaceTransitionError(f"save requires awaiting_user_review, got {workspace.session_status}")
if not isinstance(expected_cas_version, int) or isinstance(expected_cas_version, bool) or workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
proposal = db.get(api.ScenarioEditProposal, proposal_id)
if proposal is None or proposal.status != "open":
raise api.WorkspaceGraphRevisionError("save requires an open proposal")
digest = api._graph_digest(proposal.proposed_graph)
result = api.save_proposal(
db, proposal_id, digest, scenario_id=workspace.scenario_id,
actor=owner, authorized=True, agent_action_id=agent_action_id,
)
revision_id = result["revision_id"]
updated = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "awaiting_user_review",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
)
.values(session_status="candidate", cas_version=expected_cas_version + 1, updated_at=api._now())
.execution_options(synchronize_session=False)
)
if updated.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=revision_id,
result_status="candidate", result_cas_version=expected_cas_version + 1,
actor_principal=actor,
))
db.flush()
revision = db.get(api.ScenarioRevision, revision_id)
if revision is None:
raise api.WorkspaceError("saved revision was not persisted")
api.logger.reflect(
"Candidate revision saved from reviewed proposal", src="AgentAuthoringWorkspace.RequestSave",
payload={"workspace_id": workspace_id, "revision_id": revision_id},
)
return api._save_projection(revision, expected_cas_version + 1)
# #endregion Services.AgentAuthoringWorkspace.RequestSave
# #region Services.AgentAuthoringWorkspace.ActivateRevision [C:5] [TYPE Function] [SEMANTICS agent,authoring,activation,current,cas,idempotency]
# @ingroup Services
# @BRIEF Activate an explicit saved candidate revision into current through the guarded registry CAS.
# @PRE Caller owns an active workspace in candidate state; revision_id is a candidate of the bound scenario; expected CAS and idempotency key supplied.
# @POST Returns the activated revision identity and advances the workspace to current.
# @SIDE_EFFECT Promotes one candidate revision to current via activate_current_revision and records a receipt.
# @INVARIANT Only the separate activation CAS may advance current_revision; save never activates.
# @RELATION CALLS -> [ScenarioRegistry.Revisions.Activate]
# @REJECTED Implicit latest-revision activation was rejected — the client must name an explicit revision_id.
def activate_revision(
db: api.Session,
workspace_id: str,
owner_principal: str,
revision_id: str,
expected_cas_version: int,
*,
idempotency_key: str,
actor_principal: str | None = None,
agent_action_id: str | None = None,
) -> dict[str, api.Any]:
owner = api._identity(owner_principal, "owner_principal")
actor = api._identity(actor_principal or owner, "actor_principal")
key = api._key(idempotency_key)
operation = "activate_revision"
request_hash = api._request_hash({"revision_id": revision_id})
workspace = api._owned(db, workspace_id, owner)
receipt = api._receipt(db, workspace_id, operation, key, request_hash)
if receipt is not None:
revision = db.get(api.ScenarioRevision, receipt.result_reference)
if revision is None:
raise api.WorkspaceError("activation receipt has no durable revision")
return api._activation_projection(revision, receipt.result_cas_version)
workspace = api._owned_active(db, workspace_id, owner)
if workspace.scenario_id is None:
raise api.WorkspaceGraphRevisionError("workspace is not bound to a scenario")
if workspace.session_status != "candidate":
raise api.WorkspaceTransitionError(f"activation requires candidate, got {workspace.session_status}")
if not isinstance(expected_cas_version, int) or isinstance(expected_cas_version, bool) or workspace.cas_version != expected_cas_version:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
revision = api.activate_current_revision(
db, workspace.scenario_id, revision_id,
actor=owner, agent_action_id=agent_action_id,
)
updated = db.execute(
api.update(api.AgentAuthoringWorkspace)
.where(
api.AgentAuthoringWorkspace.workspace_id == workspace_id,
api.AgentAuthoringWorkspace.owner_principal == owner,
api.AgentAuthoringWorkspace.session_status == "candidate",
api.AgentAuthoringWorkspace.cas_version == expected_cas_version,
api.AgentAuthoringWorkspace.expires_at > api._now(),
)
.values(session_status="current", cas_version=expected_cas_version + 1, updated_at=api._now())
.execution_options(synchronize_session=False)
)
if updated.rowcount != 1:
raise api.WorkspaceCASConflict(f"workspace CAS conflict: {workspace_id}")
db.expire(workspace)
db.add(api.AgentAuthoringWorkspaceOperation(
workspace_id=workspace_id, operation=operation, idempotency_key=key,
request_hash=request_hash, result_reference=revision_id,
result_status="current", result_cas_version=expected_cas_version + 1,
actor_principal=actor,
))
db.flush()
revision = db.get(api.ScenarioRevision, revision_id)
if revision is None:
raise api.WorkspaceError("activated revision was not persisted")
api.logger.reflect(
"Candidate revision activated as current", src="AgentAuthoringWorkspace.ActivateRevision",
payload={"workspace_id": workspace_id, "revision_id": revision_id},
)
return api._activation_projection(revision, expected_cas_version + 1)
# #endregion Services.AgentAuthoringWorkspace.ActivateRevision
# #region Services.AgentAuthoringWorkspace.RevisionLifecycle._activation_projection [C:3] [TYPE Function]
def _activation_projection(revision: api.ScenarioRevision, cas_version: int) -> dict[str, api.Any]:
return {
"revision_id": revision.revision_id,
"scenario_id": revision.scenario_id,
"activation_status": revision.activation_status,
"cas_version": cas_version,
}
# #endregion Services.AgentAuthoringWorkspace.RevisionLifecycle._activation_projection
# #region Services.AgentAuthoringWorkspace.RevisionLifecycle._save_projection [C:3] [TYPE Function]
def _save_projection(revision: api.ScenarioRevision, cas_version: int) -> dict[str, api.Any]:
return {
"revision_id": revision.revision_id,
"scenario_id": revision.scenario_id,
"parent_revision_id": revision.parent_revision_id,
"content_hash": revision.content_hash,
"activation_status": revision.activation_status,
"cas_version": cas_version,
}
# #endregion Services.AgentAuthoringWorkspace.RevisionLifecycle._save_projection
# #endregion Services.AgentAuthoringWorkspace.RevisionLifecycle

File diff suppressed because it is too large Load Diff

View File

@@ -36,16 +36,20 @@ from src.services.dashboard_testing.scenario.sql_guard import contains_unsafe_fr
from .registered_snapshot import canonical_registered_snapshot, has_canonical_identity, restore_registered_snapshot
# #region SemanticRepair.apply.contains_unsafe_text [C:3] [TYPE Function]
def _contains_unsafe_text(value: Any) -> bool:
return contains_unsafe_free_text(value)
# #endregion SemanticRepair.apply.contains_unsafe_text
# #region SemanticRepair.apply.contains_unsafe_inputs [C:3] [TYPE Function]
def _contains_unsafe_inputs(value: Any) -> bool:
if isinstance(value, dict):
return any(_contains_unsafe_inputs(item) for item in value.values())
if isinstance(value, list):
return any(_contains_unsafe_inputs(item) for item in value)
return _contains_unsafe_text(value)
# #endregion SemanticRepair.apply.contains_unsafe_inputs
# AGSCN-FR-034: prompt/description text inside evaluation specs is natural-language content,
@@ -53,12 +57,14 @@ def _contains_unsafe_inputs(value: Any) -> bool:
_EVALUATION_CODE_TOKENS = ("import os", "subprocess", "shell=True", "__import__", "eval(", "exec(")
# #region SemanticRepair.apply.contains_evaluation_code_tokens [C:3] [TYPE Function]
def _contains_evaluation_code_tokens(value: Any) -> bool:
if isinstance(value, dict):
return any(_contains_evaluation_code_tokens(item) for item in value.values())
if isinstance(value, list):
return any(_contains_evaluation_code_tokens(item) for item in value)
return isinstance(value, str) and any(token in value.lower() for token in _EVALUATION_CODE_TOKENS)
# #endregion SemanticRepair.apply.contains_evaluation_code_tokens
# #region ScenarioEditor.Apply.ValidateAssertion [C:3] [TYPE Function] [SEMANTICS scenario,editor,assertion,constrain]
@@ -77,12 +83,11 @@ def validate_assertion(edit: SetAssertionOp) -> dict[str, Any]:
# #endregion ScenarioEditor.Apply.ValidateAssertion
# #region ScenarioEditor.Apply.Ops [C:4] [TYPE Function] [SEMANTICS scenario,editor,ops,apply,graph]
# @ingroup ScenarioEditor
# @BRIEF Apply typed operations to a graph snapshot and validate dependency acyclicity.
# @PRE base_graph is a server-loaded revision snapshot; ops are the closed EditOperation union.
# @POST Returns a new graph plus normalized validation findings; input graph is not mutated.
def apply_ops(base_graph: dict[str, Any], ops: list[EditOperation]) -> dict[str, Any]:
# #region SemanticRepair.apply.guard_operations [C:3] [TYPE Function]
# @BRIEF Reject unsafe typed editor payloads before graph interpretation.
def _guard_operations(ops):
for operation in ops:
if isinstance(operation, SetStepInputsOp):
if _contains_unsafe_inputs(operation.action_inputs):
@@ -94,6 +99,15 @@ def apply_ops(base_graph: dict[str, Any], ops: list[EditOperation]) -> dict[str,
raise ValueError("unsafe edit operation")
elif any(contains_unsafe_free_text(getattr(operation, field, None)) for field in ("baseline_ref", "value")):
raise ValueError("unsafe edit operation")
# #endregion SemanticRepair.apply.guard_operations
# #region ScenarioEditor.Apply.Ops [C:4] [TYPE Function] [SEMANTICS scenario,editor,ops,apply,graph]
# @ingroup ScenarioEditor
# @BRIEF Apply typed operations to a graph snapshot and validate dependency acyclicity.
# @PRE base_graph is a server-loaded revision snapshot; ops are the closed EditOperation union.
# @POST Returns a new graph plus normalized validation findings; input graph is not mutated.
def apply_ops(base_graph: dict[str, Any], ops: list[EditOperation]) -> dict[str, Any]:
_guard_operations(ops)
try:
scenario = DashboardTestScenario.model_validate(canonical_registered_snapshot(base_graph))
except ValidationError:
@@ -126,117 +140,46 @@ def _apply_legacy_ops(base_graph: dict[str, Any], ops: list[EditOperation]) -> d
if "dependencies" in base_graph:
graph["dependencies"] = [dict(edge) for edge in (base_graph.get("dependencies") or [])]
for operation in ops:
if isinstance(operation, SetParameterDefinitionOp):
if not isinstance(graph["parameters"], dict):
raise ValueError("legacy graph parameters are not a mapping")
graph["parameters"][operation.param_name] = operation.value
elif isinstance(operation, SetAssertionOp):
if "assertions" not in graph:
graph["assertions"] = {}
graph["assertions"][operation.logical_step_id] = validate_assertion(operation)
elif isinstance(operation, AddStepOp):
if "dependencies" not in graph:
raise ValueError("legacy graph has no dependencies collection")
if any(step.get("template") == operation.template for step in graph["steps"]):
raise ValueError("duplicate step template")
step_id = f"new:{operation.template}"
graph["steps"].append({"template": operation.template, "logical_step_id": step_id})
if operation.after_logical_step_id:
graph["dependencies"].append({"source": operation.after_logical_step_id, "target": step_id})
elif isinstance(operation, RemoveStepOp):
graph["steps"] = [
step for step in graph["steps"]
if step.get("logical_step_id", step.get("id")) != operation.logical_step_id
]
if "dependencies" in graph:
graph["dependencies"] = [
edge for edge in graph["dependencies"]
if operation.logical_step_id not in {edge.get("source"), edge.get("target")}
]
elif isinstance(operation, SetStepInputsOp):
step = next((item for item in graph["steps"]
if item.get("logical_step_id", item.get("id")) == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["action_inputs"] = assert_step_inputs(step.get("action"), operation.action_inputs)
elif isinstance(operation, SetStepEvaluationOp):
step = next((item for item in graph["steps"]
if item.get("logical_step_id", item.get("id")) == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["agent_evaluation_spec"] = AgentEvaluationSpec.model_validate(
operation.agent_evaluation_spec
).model_dump(mode="json")
elif isinstance(operation, SetDependencyOp):
if "dependencies" not in graph:
raise ValueError("legacy graph has no dependencies collection")
edge = {"source": operation.logical_step_id, "target": operation.target_logical_step_id}
if operation.action == "add" and edge not in graph["dependencies"]:
graph["dependencies"].append(edge)
elif operation.action == "remove":
graph["dependencies"] = [item for item in graph["dependencies"] if item != edge]
handlers = [
(SetParameterDefinitionOp, _legacy_SetParameterDefinitionOp),
(SetAssertionOp, _legacy_SetAssertionOp),
(AddStepOp, _legacy_AddStepOp),
(RemoveStepOp, _legacy_RemoveStepOp),
(SetStepInputsOp, _legacy_SetStepInputsOp),
(SetStepEvaluationOp, _legacy_SetStepEvaluationOp),
(SetDependencyOp, _legacy_SetDependencyOp),
]
for kind, handler in handlers:
if isinstance(operation,kind):
handler(graph,operation)
break
if "dependencies" in graph and _has_cycle(graph["dependencies"]):
raise ValueError("dependency cycle rejected")
return graph
# #endregion ScenarioEditor.Apply.LegacyOps
# #region SemanticRepair.apply.apply_operation [C:3] [TYPE Function]
def _apply_operation(graph: dict[str, Any], operation: EditOperation) -> None:
steps = graph["steps"]
ids = {step["id"] for step in steps}
if isinstance(operation, SetParameterDefinitionOp):
parameter = next((p for p in graph["parameters"] if p["name"] == operation.param_name), None)
if parameter is None:
raise ValueError("parameter not found")
parameter["value"] = operation.value
parameter["status"] = "resolved"
elif isinstance(operation, SetAssertionOp):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["expected"] = {"kind": "baseline_ref", "ref": operation.baseline_ref, "predicate": operation.comparison}
if operation.threshold is not None:
step["expected"]["description"] = f"threshold {operation.threshold}"
elif isinstance(operation, SetStepInputsOp):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["action_inputs"] = assert_step_inputs(step.get("action"), operation.action_inputs)
elif isinstance(operation, SetStepEvaluationOp):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["agent_evaluation_spec"] = AgentEvaluationSpec.model_validate(
operation.agent_evaluation_spec
).model_dump(mode="json")
elif isinstance(operation, AddStepOp):
template = STEP_TEMPLATES.get(operation.template)
if template is None:
raise ValueError("unknown step template")
action, tool, phase = template
step_id = f"step-{operation.template.replace('_', '-')}-{len(steps) + 1}"
if step_id in ids:
raise ValueError("duplicate step id")
depends = [operation.after_logical_step_id] if operation.after_logical_step_id else []
steps.append(ScenarioStep(id=step_id, phase=phase, title=operation.template, tool=tool, action=action,
expected=Expected(kind="structural", description=f"{action} completes"),
depends_on=depends, automation_status="ready", risk=REGISTERED_ACTIONS[action]["risk"]).model_dump(mode="json"))
elif isinstance(operation, RemoveStepOp):
if operation.logical_step_id not in ids:
raise ValueError("step not found")
graph["steps"] = [step for step in steps if step["id"] != operation.logical_step_id]
for step in graph["steps"]:
step["depends_on"] = [dep for dep in step.get("depends_on", []) if dep != operation.logical_step_id]
elif isinstance(operation, SetDependencyOp):
if operation.logical_step_id not in ids or operation.target_logical_step_id not in ids:
raise ValueError("step not found")
step = next(s for s in steps if s["id"] == operation.logical_step_id)
if operation.action == "add" and operation.target_logical_step_id not in step["depends_on"]:
step["depends_on"].append(operation.target_logical_step_id)
elif operation.action == "remove":
step["depends_on"] = [d for d in step["depends_on"] if d != operation.target_logical_step_id]
handlers = [
(SetParameterDefinitionOp, _canonical_SetParameterDefinitionOp),
(SetAssertionOp, _canonical_SetAssertionOp),
(SetStepInputsOp, _canonical_SetStepInputsOp),
(SetStepEvaluationOp, _canonical_SetStepEvaluationOp),
(AddStepOp, _canonical_AddStepOp),
(RemoveStepOp, _canonical_RemoveStepOp),
(SetDependencyOp, _canonical_SetDependencyOp),
]
for kind, handler in handlers:
if isinstance(operation,kind):
handler(graph,operation,steps,ids)
break
# #endregion SemanticRepair.apply.apply_operation
# #region SemanticRepair.apply.has_cycle [C:3] [TYPE Function]
def _has_cycle(edges: list[dict[str, Any]]) -> bool:
adjacency: dict[str, set[str]] = {}
for edge in edges:
@@ -244,6 +187,7 @@ def _has_cycle(edges: list[dict[str, Any]]) -> bool:
visiting: set[str] = set()
visited: set[str] = set()
# #region SemanticRepair.apply.visit [C:3] [TYPE Function]
def visit(node: str) -> bool:
if node in visiting:
return True
@@ -255,7 +199,38 @@ def _has_cycle(edges: list[dict[str, Any]]) -> bool:
visiting.remove(node)
visited.add(node)
return False
# #endregion SemanticRepair.apply.visit
return any(visit(node) for node in adjacency)
# #endregion SemanticRepair.apply.has_cycle
from .apply_operation_handlers import _legacy_SetParameterDefinitionOp # noqa: F401
from .apply_operation_handlers import _legacy_SetAssertionOp # noqa: F401
from .apply_operation_handlers import _legacy_AddStepOp # noqa: F401
from .apply_operation_handlers import _legacy_RemoveStepOp # noqa: F401
from .apply_operation_handlers import _legacy_SetStepInputsOp # noqa: F401
from .apply_operation_handlers import _legacy_SetStepEvaluationOp # noqa: F401
from .apply_operation_handlers import _legacy_SetDependencyOp # noqa: F401
from .apply_operation_handlers import _canonical_SetParameterDefinitionOp # noqa: F401
from .apply_operation_handlers import _canonical_SetAssertionOp # noqa: F401
from .apply_operation_handlers import _canonical_SetStepInputsOp # noqa: F401
from .apply_operation_handlers import _canonical_SetStepEvaluationOp # noqa: F401
from .apply_operation_handlers import _canonical_AddStepOp # noqa: F401
from .apply_operation_handlers import _canonical_RemoveStepOp # noqa: F401
from .apply_operation_handlers import _canonical_SetDependencyOp # noqa: F401
# #endregion ScenarioEditor.Apply

View File

@@ -0,0 +1,149 @@
# #region SemanticRepair.apply_operation_handlers [C:4] [TYPE Module]
# @BRIEF Apply typed canonical and historical editor operations without rewriting graph identity.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import apply as seam
# #region ScenarioEditor.Apply.legacy_SetParameterDefinitionOp [C:3] [TYPE Function]
def _legacy_SetParameterDefinitionOp(graph, operation):
if not isinstance(graph["parameters"], dict):
raise ValueError("legacy graph parameters are not a mapping")
graph["parameters"][operation.param_name] = operation.value
# #endregion ScenarioEditor.Apply.legacy_SetParameterDefinitionOp
# #region ScenarioEditor.Apply.legacy_SetAssertionOp [C:3] [TYPE Function]
def _legacy_SetAssertionOp(graph, operation):
if "assertions" not in graph:
graph["assertions"] = {}
graph["assertions"][operation.logical_step_id] = seam.validate_assertion(operation)
# #endregion ScenarioEditor.Apply.legacy_SetAssertionOp
# #region ScenarioEditor.Apply.legacy_AddStepOp [C:3] [TYPE Function]
def _legacy_AddStepOp(graph, operation):
if "dependencies" not in graph:
raise ValueError("legacy graph has no dependencies collection")
if any(step.get("template") == operation.template for step in graph["steps"]):
raise ValueError("duplicate step template")
step_id = f"new:{operation.template}"
graph["steps"].append({"template": operation.template, "logical_step_id": step_id})
if operation.after_logical_step_id:
graph["dependencies"].append({"source": operation.after_logical_step_id, "target": step_id})
# #endregion ScenarioEditor.Apply.legacy_AddStepOp
# #region ScenarioEditor.Apply.legacy_RemoveStepOp [C:3] [TYPE Function]
def _legacy_RemoveStepOp(graph, operation):
graph["steps"] = [
step for step in graph["steps"]
if step.get("logical_step_id", step.get("id")) != operation.logical_step_id
]
if "dependencies" in graph:
graph["dependencies"] = [
edge for edge in graph["dependencies"]
if operation.logical_step_id not in {edge.get("source"), edge.get("target")}
]
# #endregion ScenarioEditor.Apply.legacy_RemoveStepOp
# #region ScenarioEditor.Apply.legacy_SetStepInputsOp [C:3] [TYPE Function]
def _legacy_SetStepInputsOp(graph, operation):
step = next((item for item in graph["steps"]
if item.get("logical_step_id", item.get("id")) == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["action_inputs"] = seam.assert_step_inputs(step.get("action"), operation.action_inputs)
# #endregion ScenarioEditor.Apply.legacy_SetStepInputsOp
# #region ScenarioEditor.Apply.legacy_SetStepEvaluationOp [C:3] [TYPE Function]
def _legacy_SetStepEvaluationOp(graph, operation):
step = next((item for item in graph["steps"]
if item.get("logical_step_id", item.get("id")) == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["agent_evaluation_spec"] = seam.AgentEvaluationSpec.model_validate(
operation.agent_evaluation_spec
).model_dump(mode="json")
# #endregion ScenarioEditor.Apply.legacy_SetStepEvaluationOp
# #region ScenarioEditor.Apply.legacy_SetDependencyOp [C:3] [TYPE Function]
def _legacy_SetDependencyOp(graph, operation):
if "dependencies" not in graph:
raise ValueError("legacy graph has no dependencies collection")
edge = {"source": operation.logical_step_id, "target": operation.target_logical_step_id}
if operation.action == "add" and edge not in graph["dependencies"]:
graph["dependencies"].append(edge)
elif operation.action == "remove":
graph["dependencies"] = [item for item in graph["dependencies"] if item != edge]
# #endregion ScenarioEditor.Apply.legacy_SetDependencyOp
# #region ScenarioEditor.Apply.canonical_SetParameterDefinitionOp [C:3] [TYPE Function]
def _canonical_SetParameterDefinitionOp(graph, operation, steps, ids):
parameter = next((p for p in graph["parameters"] if p["name"] == operation.param_name), None)
if parameter is None:
raise ValueError("parameter not found")
parameter["value"] = operation.value
parameter["status"] = "resolved"
# #endregion ScenarioEditor.Apply.canonical_SetParameterDefinitionOp
# #region ScenarioEditor.Apply.canonical_SetAssertionOp [C:3] [TYPE Function]
def _canonical_SetAssertionOp(graph, operation, steps, ids):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["expected"] = {"kind": "baseline_ref", "ref": operation.baseline_ref, "predicate": operation.comparison}
if operation.threshold is not None:
step["expected"]["description"] = f"threshold {operation.threshold}"
# #endregion ScenarioEditor.Apply.canonical_SetAssertionOp
# #region ScenarioEditor.Apply.canonical_SetStepInputsOp [C:3] [TYPE Function]
def _canonical_SetStepInputsOp(graph, operation, steps, ids):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["action_inputs"] = seam.assert_step_inputs(step.get("action"), operation.action_inputs)
# #endregion ScenarioEditor.Apply.canonical_SetStepInputsOp
# #region ScenarioEditor.Apply.canonical_SetStepEvaluationOp [C:3] [TYPE Function]
def _canonical_SetStepEvaluationOp(graph, operation, steps, ids):
step = next((s for s in steps if s["id"] == operation.logical_step_id), None)
if step is None:
raise ValueError("step not found")
step["agent_evaluation_spec"] = seam.AgentEvaluationSpec.model_validate(
operation.agent_evaluation_spec
).model_dump(mode="json")
# #endregion ScenarioEditor.Apply.canonical_SetStepEvaluationOp
# #region ScenarioEditor.Apply.canonical_AddStepOp [C:3] [TYPE Function]
def _canonical_AddStepOp(graph, operation, steps, ids):
template = seam.STEP_TEMPLATES.get(operation.template)
if template is None:
raise ValueError("unknown step template")
action, tool, phase = template
step_id = f"step-{operation.template.replace('_', '-')}-{len(steps) + 1}"
if step_id in ids:
raise ValueError("duplicate step id")
depends = [operation.after_logical_step_id] if operation.after_logical_step_id else []
steps.append(seam.ScenarioStep(id=step_id, phase=phase, title=operation.template, tool=tool, action=action,
expected=seam.Expected(kind="structural", description=f"{action} completes"),
depends_on=depends, automation_status="ready", risk=seam.REGISTERED_ACTIONS[action]["risk"]).model_dump(mode="json"))
# #endregion ScenarioEditor.Apply.canonical_AddStepOp
# #region ScenarioEditor.Apply.canonical_RemoveStepOp [C:3] [TYPE Function]
def _canonical_RemoveStepOp(graph, operation, steps, ids):
if operation.logical_step_id not in ids:
raise ValueError("step not found")
graph["steps"] = [step for step in steps if step["id"] != operation.logical_step_id]
for step in graph["steps"]:
step["depends_on"] = [dep for dep in step.get("depends_on", []) if dep != operation.logical_step_id]
# #endregion ScenarioEditor.Apply.canonical_RemoveStepOp
# #region ScenarioEditor.Apply.canonical_SetDependencyOp [C:3] [TYPE Function]
def _canonical_SetDependencyOp(graph, operation, steps, ids):
if operation.logical_step_id not in ids or operation.target_logical_step_id not in ids:
raise ValueError("step not found")
step = next(s for s in steps if s["id"] == operation.logical_step_id)
if operation.action == "add" and operation.target_logical_step_id not in step["depends_on"]:
step["depends_on"].append(operation.target_logical_step_id)
elif operation.action == "remove":
step["depends_on"] = [d for d in step["depends_on"] if d != operation.target_logical_step_id]
# #endregion ScenarioEditor.Apply.canonical_SetDependencyOp
# #endregion SemanticRepair.apply_operation_handlers

View File

@@ -5,6 +5,30 @@ from hashlib import sha256
import json
# #region SemanticRepair.evaluation_browser_scope.validate_native_scope [C:3] [TYPE Function]
# @BRIEF Prove each requested native filter has latest durable readback and agrees with retained table rows.
def _validate_native_scope(db, run_id, recipe, completed, columns, rows):
for index, directive in enumerate(recipe.browser_filter_directives, 1):
if columns.count(directive.column) != 1:
raise RuntimeError("EVALUATION_TEXT_SCOPE_COLUMN_MISMATCH")
position = columns.index(directive.column)
if any(row[position] not in directive.values for row in rows):
raise RuntimeError("EVALUATION_TEXT_SCOPE_ROWS_MISMATCH")
native_id = f"phase-4a-M01-apply_native_filter-{index}"
native = db.query(ScenarioStepRun).filter_by(run_id=run_id, logical_step_id=native_id).order_by(
ScenarioStepRun.attempt.desc()).first()
if native is None or native.status != "passed" or completed.get(native_id) != native.step_outcome:
raise RuntimeError("EVALUATION_TEXT_SCOPE_NATIVE_UNAVAILABLE")
details = native.step_outcome.get("step_outcome") if isinstance(native.step_outcome, dict) else None
if (not isinstance(details, dict) or details.get("filter_scope_observed") is not True
or type(details.get("chart_id")) is not int or details.get("filter_id") != directive.filter_id
or details.get("observed_values") != directive.values or details.get("chart_id") != directive.target_chart_id
or details.get("filters_hash") != recipe.browser_filter_scope.filters_hash):
raise RuntimeError("EVALUATION_TEXT_SCOPE_NATIVE_UNPROVED")
# #endregion SemanticRepair.evaluation_browser_scope.validate_native_scope
# #region ScenarioExecution.EvaluationBrowserScope.Validate [C:4] [TYPE Function] [SEMANTICS scope,filter,observed,retained]
# @PRE Owned JSON payloads and the server recipe have already passed runtime/artifact authority checks.
# @POST Requested values alone, stale attempts and contradictory retained table rows cannot reach the provider.
@@ -42,23 +66,7 @@ def validate_retained_browser_scope(db, *, step, recipe, completed, payloads, st
or not isinstance(rows, list) or any(not isinstance(row, list) or len(row) != len(columns)
or any(not isinstance(cell, str) for cell in row) for row in rows)):
raise RuntimeError("EVALUATION_TEXT_SCOPE_TABLE_INVALID")
for index, directive in enumerate(recipe.browser_filter_directives, 1):
if columns.count(directive.column) != 1:
raise RuntimeError("EVALUATION_TEXT_SCOPE_COLUMN_MISMATCH")
position = columns.index(directive.column)
if any(row[position] not in directive.values for row in rows):
raise RuntimeError("EVALUATION_TEXT_SCOPE_ROWS_MISMATCH")
native_id = f"phase-4a-M01-apply_native_filter-{index}"
native = db.query(ScenarioStepRun).filter_by(run_id=run_id, logical_step_id=native_id).order_by(
ScenarioStepRun.attempt.desc()).first()
if native is None or native.status != "passed" or completed.get(native_id) != native.step_outcome:
raise RuntimeError("EVALUATION_TEXT_SCOPE_NATIVE_UNAVAILABLE")
details = native.step_outcome.get("step_outcome") if isinstance(native.step_outcome, dict) else None
if (not isinstance(details, dict) or details.get("filter_scope_observed") is not True
or type(details.get("chart_id")) is not int or details.get("filter_id") != directive.filter_id
or details.get("observed_values") != directive.values or details.get("chart_id") != directive.target_chart_id
or details.get("filters_hash") != recipe.browser_filter_scope.filters_hash):
raise RuntimeError("EVALUATION_TEXT_SCOPE_NATIVE_UNPROVED")
_validate_native_scope(db, run_id, recipe, completed, columns, rows)
# A proven SHA identity is provenance, not a secret-looking token. Restore
# only this metadata label; original row/filter values remain redacted.
payload["content"]["scope_observation"]["filters_hash"] = expected["filters_hash"]

View File

@@ -21,6 +21,29 @@ MAX_TEXT_ITEMS = 8
MAX_TEXT_BYTES = 256 * 1024
# #region SemanticRepair.evaluation_text.validate_owned_producer [C:3] [TYPE Function]
# @BRIEF Require latest passed producer and exact durable MIME, digest and byte-length receipt.
def _validate_owned_producer(db, run_id, artifact, ref, digest, length):
producer = db.query(ScenarioStepRun).filter(
ScenarioStepRun.run_id == run_id, ScenarioStepRun.logical_step_id == artifact.logical_step_id,
).order_by(ScenarioStepRun.attempt.desc()).first()
outcome = producer.step_outcome if producer is not None else None
if (producer is None or producer.status != "passed" or producer.attempt != artifact.attempt
or not isinstance(outcome, dict) or not isinstance(outcome.get("artifact_refs"), list)
or ref not in outcome["artifact_refs"]):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_NOT_DURABLE")
nested = outcome.get("step_outcome")
if not isinstance(nested, dict) or any(not isinstance(nested.get(key), dict) for key in (
"artifact_digests", "artifact_content_types", "artifact_byte_lengths")):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_RECEIPT_INVALID")
if (nested.get("artifact_digests", {}).get(ref) != digest
or nested.get("artifact_content_types", {}).get(ref) != "application/json"
or nested.get("artifact_byte_lengths", {}).get(ref) != length):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_RECEIPT_INVALID")
# #endregion SemanticRepair.evaluation_text.validate_owned_producer
# #region ScenarioExecution.EvaluationText.Receipts [C:4] [TYPE Function] [SEMANTICS evidence,owner,attempt,receipt]
# @PRE Runtime and pinned spec identity were proved; manifest itself is untrusted until matched to durable rows.
# @POST All selected JSON receipts have latest passed producer and active same-run artifact authority before storage access.
@@ -50,22 +73,7 @@ def _owned_receipts(db, run_id, producer_ids, manifest):
if (artifact.logical_step_id not in producer_ids or artifact.sha256 != digest
or artifact.content_type != "application/json" or artifact.byte_length != length):
raise RuntimeError("EVALUATION_TEXT_ARTIFACT_NOT_OWNED")
producer = db.query(ScenarioStepRun).filter(
ScenarioStepRun.run_id == run_id, ScenarioStepRun.logical_step_id == artifact.logical_step_id,
).order_by(ScenarioStepRun.attempt.desc()).first()
outcome = producer.step_outcome if producer is not None else None
if (producer is None or producer.status != "passed" or producer.attempt != artifact.attempt
or not isinstance(outcome, dict) or not isinstance(outcome.get("artifact_refs"), list)
or ref not in outcome["artifact_refs"]):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_NOT_DURABLE")
nested = outcome.get("step_outcome")
if not isinstance(nested, dict) or any(not isinstance(nested.get(key), dict) for key in (
"artifact_digests", "artifact_content_types", "artifact_byte_lengths")):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_RECEIPT_INVALID")
if (nested.get("artifact_digests", {}).get(ref) != digest
or nested.get("artifact_content_types", {}).get(ref) != "application/json"
or nested.get("artifact_byte_lengths", {}).get(ref) != length):
raise RuntimeError("EVALUATION_TEXT_PRODUCER_RECEIPT_INVALID")
_validate_owned_producer(db, run_id, artifact, ref, digest, length)
seen.add(ref)
covered.add(artifact.logical_step_id)
owned.append(item)

View File

@@ -18,6 +18,24 @@ JUDGE_SYSTEM = (
)
# #region SemanticRepair.evaluation_text_transport.decode_judge_response [C:3] [TYPE Function]
# @BRIEF Reject truncated/nonobject judge JSON and replace model-supplied usage with transport telemetry.
def _decode_judge_response(content, finish, usage):
if finish == "length":
raise RuntimeError("EVALUATION_TEXT_OUTPUT_TRUNCATED")
result = json.loads(content)
if not isinstance(result, dict):
raise RuntimeError("EVALUATION_TEXT_RESPONSE_INVALID")
# A judge's JSON usage is not transport telemetry or billing evidence.
result.pop("usage", None)
if usage:
result["usage"] = {"input_tokens": usage.get("prompt_tokens"),
"output_tokens": usage.get("completion_tokens")}
return result
# #endregion SemanticRepair.evaluation_text_transport.decode_judge_response
# #region ScenarioExecution.EvaluationTextTransport.Submit [C:4] [TYPE Function] [SEMANTICS recipe,judge,budget,wire]
# @PRE Exact persisted recipe/runtime and public provider pin precede capacity and credential access.
# @POST One physical HTTP POST at most; whole operation deadline; no images, tools or model-generated billing authority.
@@ -54,17 +72,7 @@ async def submit_recipe_text(db, *, spec, prompt, images, environment_id, enviro
server_system_content=JUDGE_SYSTEM, log_error_body=False,
max_requests=spec.limits.max_requests, usage_callback=usage.update,
), timeout=spec.limits.timeout_ms / 1000)
if finish == "length":
raise RuntimeError("EVALUATION_TEXT_OUTPUT_TRUNCATED")
result = json.loads(content)
if not isinstance(result, dict):
raise RuntimeError("EVALUATION_TEXT_RESPONSE_INVALID")
# A judge's JSON usage is not transport telemetry or billing evidence.
result.pop("usage", None)
if usage:
result["usage"] = {"input_tokens": usage.get("prompt_tokens"),
"output_tokens": usage.get("completion_tokens")}
return result
return _decode_judge_response(content, finish, usage)
except TimeoutError as exc:
raise RuntimeError("EVALUATION_TIMED_OUT") from exc
except CapacityUnavailable:

View File

@@ -19,6 +19,31 @@ from .executor_helpers import _outcome
from .live_adapter import dispatch_live_adapter
# #region SemanticRepair.metric_actual.canonical_scalar [C:3] [TYPE Function]
# @BRIEF Reject type mismatches and normalize finite scalar values using the inspected metric kind.
def _canonical_scalar(value, column_type, kind):
if kind in {"decimal", "integer"}:
if column_type != 0 or isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
decimal = Decimal(str(value))
if not decimal.is_finite() or (kind == "integer" and decimal != decimal.to_integral_value()):
raise ValueError("METRIC_SCALAR_NONFINITE_OR_FRACTIONAL")
with localcontext() as context:
context.prec = max(context.prec, len(decimal.as_tuple().digits))
canonical = str(int(decimal)) if kind == "integer" else str(decimal.normalize())
elif kind == "string":
if column_type != 1 or not isinstance(value, str):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
canonical = value
else:
if column_type != 3 or not isinstance(value, bool):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
canonical = str(value).lower()
return canonical
# #endregion SemanticRepair.metric_actual.canonical_scalar
# #region ScenarioExecution.MetricActual.Scalar [C:4] [TYPE Function] [SEMANTICS scalar,type,schema,ambiguity]
# @BRIEF Extract exactly one named scalar whose Superset column metadata agrees with the inspected type.
# @POST Missing/null/nonfinite values, duplicate columns and multiple result rows raise ValueError.
@@ -37,26 +62,10 @@ def scalar_from_wire(raw: bytes, coordinate: MetricProducerCoordinate) -> Normal
or not isinstance(types, list) or len(types) != len(columns) or key not in rows[0]):
raise ValueError("METRIC_SCALAR_SCHEMA_INVALID")
value, column_type = rows[0][key], types[columns.index(key)]
kind = coordinate.value_type
if type(column_type) is not int:
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
kind = coordinate.value_type
if kind in {"decimal", "integer"}:
if column_type != 0 or isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
decimal = Decimal(str(value))
if not decimal.is_finite() or (kind == "integer" and decimal != decimal.to_integral_value()):
raise ValueError("METRIC_SCALAR_NONFINITE_OR_FRACTIONAL")
with localcontext() as context:
context.prec = max(context.prec, len(decimal.as_tuple().digits))
canonical = str(int(decimal)) if kind == "integer" else str(decimal.normalize())
elif kind == "string":
if column_type != 1 or not isinstance(value, str):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
canonical = value
else:
if column_type != 3 or not isinstance(value, bool):
raise ValueError("METRIC_SCALAR_TYPE_MISMATCH")
canonical = str(value).lower()
canonical = _canonical_scalar(value, column_type, kind)
return NormalizedValue(kind=ValueKind(kind), raw_value=value, canonical_value=canonical)
# #endregion ScenarioExecution.MetricActual.Scalar

View File

@@ -82,6 +82,10 @@ _DEFAULT_MAX_SCREENSHOT_BYTES = 10485760
_DEFAULT_MAX_DOWNLOAD_BYTES = 26214400 # 25 MiB (T034 spec)
from .browser_provider_evidence import _store_browser_result # noqa: F401
# #region ScenarioExecution.BrowserProvider.Factory [C:5] [TYPE Function] [SEMANTICS provider,browser,factory,capacity,evidence,mutation,session]
# @ingroup ScenarioExecution
# @RELATION CALLS -> [ScenarioExecution.BrowserProvider.TableEvidence.Observation]
@@ -111,289 +115,34 @@ def build_browser_provider(
if session_manager is None and is_session_capable_transport(transport):
session_manager = BrowserSessionManager(transport=transport, event_loop=event_loop)
def provider(context: Any) -> LiveAdapterResult:
rejection, admission = admit_browser_action(event_loop, context.step)
if rejection is not None or admission is None:
return rejection or LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ADMISSION_INVALID")
binding = admission["binding"]
metadata = admission["metadata"]
run_id: str = admission["run_id"]
action: str = admission["action"]
mutating: bool = admission["mutating"]
descriptor = admission["descriptor"]
logger.reason(
"Browser action admitted for run", src=_SRC,
payload={"run_id": run_id, "dashboard_id": binding.dashboard_id, "action": action, "environment_class": admission["environment_class"], "mutating": mutating},
)
lease_id: str | None = None
operation_id: str | None = None
try:
try:
with SessionLocal() as db:
lease = claim_capacity(
db,
environment_id=binding.environment_id,
environment_class=admission["environment_class"],
workload_class="browser",
provider_id="browser",
run_id=run_id,
logical_step_id=metadata.get("logical_step_id"),
)
if mutating:
receipt = open_provider_operation(
db,
run_id=run_id,
logical_step_id=str(metadata.get("logical_step_id")),
attempt=int(metadata.get("attempt") or 1),
provider_id="browser",
provider_version=str(descriptor.get("provider_version") or "unset"),
action=action,
descriptor_fingerprint=descriptor_fingerprint(descriptor),
binding_ref=binding.binding_ref,
execution_principal_fingerprint=binding.execution_principal_fingerprint,
idempotency_key=f"{run_id}:{metadata.get('logical_step_id')}:{int(metadata.get('attempt') or 1)}",
capacity_lease_id=lease["lease_id"],
effect_state="unknown",
summary=mutation_receipt_summary(binding=binding, descriptor=descriptor, metadata=metadata),
)
operation_id = receipt["operation_id"]
db.commit()
lease_id = lease["lease_id"]
# T032: heartbeat refreshes the lease TTL across any pre-I/O admission work so the
# provider loop submission window stays covered; an already-expired/lost lease is a
# typed capacity refusal (walker parks the run), never I/O without a lease.
try:
with SessionLocal() as db:
heartbeat_capacity(db, lease_id)
db.commit()
except CapacityUnavailable as exc:
logger.explore("Browser lease lost before I/O", src=_SRC, payload={"run_id": run_id}, error=str(exc))
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_CAPACITY_UNAVAILABLE")
except CapacityUnavailable as exc:
logger.explore("Browser capacity unavailable", src=_SRC, payload={"run_id": run_id}, error=str(exc))
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_CAPACITY_UNAVAILABLE")
if action in {'pagination', 'navigate_tabs'}:
from .browser_traversal_runtime import execute_traversal
return execute_traversal(step=context.step,admission=admission,storage=storage,
capacity_lease_id=lease_id,event_loop=event_loop,transport=transport,session_manager=session_manager)
session_plan_box: list[Any] = [None]
transport_factory = build_transport_factory(
session_manager=session_manager, session_plan_box=session_plan_box, transport=transport,
binding=binding, admission=admission, metadata=metadata, descriptor=descriptor,
action=action, mutating=mutating, action_timeout_seconds=action_timeout_seconds,
)
session_checkpoint: dict[str, Any] | None = None
try:
if session_manager is not None:
with session_manager.run_guard(run_id):
session_plan_box[0] = session_manager.prepare_step(
run_id=run_id,
lease_id=lease_id,
dashboard_id=binding.dashboard_id,
)
outcome, session_checkpoint = event_loop.submit(transport_factory, timeout=action_timeout_seconds * 3)
else:
outcome = event_loop.submit(transport_factory, timeout=action_timeout_seconds * 3)
except BrowserCheckpointMissing:
logger.explore(
"Browser recovery blocked: no declared checkpoint", src=_SRC,
payload={"run_id": run_id, "action": action},
claim="PRE: declared checkpoint for recovery",
error_code="BROWSER_CHECKPOINT_MISSING",
)
if mutating:
finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "checkpoint_missing"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_CHECKPOINT_MISSING",
details={"action": action, "retry_disposition": "manual_only"},
)
except TimeoutError:
if session_manager is not None:
session_manager.close(run_id, reason="step_timeout")
if mutating:
logger.explore("Mutating action deadline expired with unknown effect", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_RECONCILE_REQUIRED")
finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "timeout"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
logger.explore("Browser action exceeded the deadline", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_ACTION_TIMEOUT")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_TIMEOUT")
except ProviderSubmissionOverflow:
logger.explore("Browser submission overflowed the bounded queue", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_LOOP_OVERFLOW")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_LOOP_OVERFLOW")
except BrowserTransportPreconditionMismatch:
logger.explore("Mutation precondition mismatch; nothing mutated", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_PRECONDITION_MISMATCH")
finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "precondition_mismatch"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_PRECONDITION_MISMATCH",
details={"effect_state": "not_started", "retry_disposition": "manual_only", "operation_id": operation_id},
)
except BrowserTransportCleanupFailed:
# T034 round 4: the mutation completed (known effect) but the fixture was not
# restored — the environment is dirty; reconciliation must confirm/restore state
# before any retry, and the run reports inconclusive, never pass.
logger.explore(
"Mutation completed but fixture restore failed", src=_SRC,
payload={"run_id": run_id}, error_code="BROWSER_MUTATION_CLEANUP_FAILED",
)
finalize_provider_receipt(operation_id, "reconciliation_required", "completed", summary={"phase": "cleanup_failed"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_CLEANUP_FAILED",
details={"effect_state": "completed", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
except BrowserTransportReadbackMismatch:
# SCEX-FR-038: the same-session independent SELECT diverges from the expected
# post-mutation state. The effect state is unknown-but-real — never a PASS, never
# retryable before reconciliation; the readback evidence stays in the receipt.
logger.explore(
"Independent readback diverges from the expected mutation state", src=_SRC,
payload={"run_id": run_id}, error_code="BROWSER_MUTATION_READBACK_MISMATCH",
)
finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "readback_mismatch"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_READBACK_MISMATCH",
details={
"effect_state": "unknown",
"reconciliation_required": True,
"retry_disposition": "after_reconciliation",
"operation_id": operation_id,
},
)
except BrowserTransportUnsupported:
logger.explore("Transport cannot execute the action; nothing started", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "unsupported"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_ACTION_NOT_SUPPORTED",
details={"effect_state": "not_started", "retry_disposition": "manual_only", "operation_id": operation_id},
)
except BrowserTransportSelectorNotFound:
if mutating:
raise
logger.explore(
"Filter-bar locator miss; typed inconclusive without retry", src=_SRC,
payload={"run_id": run_id, "action": action}, error_code="BROWSER_SELECTOR_NOT_FOUND",
)
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_SELECTOR_NOT_FOUND",
details={"action": action, "retry_disposition": "manual_only"},
)
except ValueError as exc:
code = str(exc)
if mutating or not code.startswith("BROWSER_"):
raise
logger.explore(
"Browser action input rejected by the transport", src=_SRC,
payload={"run_id": run_id, "action": action, "code": code}, error_code=code,
)
return LiveAdapterResult(status="inconclusive", reason_code=code)
except RuntimeError as exc:
if str(exc) == "PROVIDER_LOOP_NOT_RUNNING":
if session_manager is not None:
session_manager.close(run_id, reason="loop_unavailable")
logger.explore("Provider loop stopped mid-dispatch", src=_SRC, error_code="BROWSER_LOOP_UNAVAILABLE")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_LOOP_UNAVAILABLE")
if mutating:
if session_manager is not None:
session_manager.close(run_id, reason="step_crashed")
logger.explore("Mutating action failed with unknown effect", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_RECONCILE_REQUIRED")
finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "runtime_error"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
raise
evidence = outcome.evidence_png
if not evidence:
logger.explore("Transport produced no evidence", src=_SRC, payload={"run_id": run_id}, error_code="BROWSER_EVIDENCE_REQUIRED")
finalize_provider_receipt(operation_id, "reconciliation_required" if mutating else "failed", "unknown" if mutating else "not_started", summary={"phase": "evidence_missing"})
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_EVIDENCE_REQUIRED")
rejection, artifact_refs, artifact_digests, ref_bytes, ref_types, table_ref = store_browser_observation(
action, outcome, storage, run_id, max_screenshot_bytes=max_screenshot_bytes,
)
if rejection is not None:
finalize_provider_receipt(operation_id, "reconciliation_required" if mutating else "failed", "unknown" if mutating else "not_started", summary={"phase": "evidence_store"})
return rejection
download_bytes = getattr(outcome, "download_bytes", None)
download_ref: str | None = None
if download_bytes is not None:
# Round-2 download action: the captured bytes become a second content-addressed
# artifact ref beside the screenshot evidence (helper enforces the 25 MiB bound
# at the storage gate; read-only, never a mutation receipt).
rejection, download_ref, artifact_refs, artifact_digests, ref_bytes, ref_types = store_download_side_artifact(
download_bytes=download_bytes, storage=storage, run_id=run_id,
artifact_refs=artifact_refs, artifact_digests=artifact_digests,
ref_bytes=ref_bytes, ref_types=ref_types, evidence=evidence,
max_download_bytes=max_download_bytes, operation_id=operation_id,
)
if rejection is not None:
finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "download_store"})
return rejection
effect_state = outcome.effect_state if mutating else "none"
if mutating:
# SCEX-FR-038: the receipt carries the independent readback proof next to the
# mutation flow's own post-rows.
finalize_provider_receipt(operation_id, "completed", effect_state, summary=mutation_readback_summary(outcome))
logger.reflect(
"Browser action completed with evidence", src=_SRC,
payload={"run_id": run_id, "checkpoints": list(outcome.checkpoints), "bytes": len(evidence), "effect_state": effect_state, "operation_id": operation_id},
)
return LiveAdapterResult(
status="passed",
reason_code="BROWSER_ACTION_EXECUTED",
details={
"sha256": artifact_digests[artifact_refs[0]],
"checkpoints": list(outcome.checkpoints),
"page_url": outcome.page_url,
"action": action,
"effect_state": effect_state,
**({"operation_id": operation_id} if operation_id else {}),
# Evaluation manifest inputs: per-ref byte length + sniffed MIME let
# ScenarioExecution.EvaluationAdapter.Manifest admit browser/download evidence.
"artifact_byte_lengths": ref_bytes,
"artifact_content_types": ref_types,
**({"download_artifact_ref": download_ref} if download_ref else {}),
**outcome.details,
**({"table_artifact_ref": table_ref} if table_ref else {}),
# DG-1 browser-safe checkpoint: reconstructible filter/tab/wait state slice.
**({"browser_checkpoint": session_checkpoint} if session_checkpoint else {}),
},
artifact_refs=artifact_refs,
artifact_digests=artifact_digests,
)
except Exception as exc:
if session_manager is not None:
session_manager.close(run_id, reason="step_error")
logger.explore("Browser provider failed", src=_SRC, payload={"run_id": run_id if isinstance(run_id, str) else None}, error=repr(exc))
if mutating:
finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "provider_error"})
return LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_FAILED")
finally:
if lease_id is not None:
try:
with SessionLocal() as db:
release_capacity(db, lease_id)
db.commit()
except Exception:
logger.explore("Capacity release failed after browser action", src=_SRC, payload={"lease_id": lease_id}, error_code="CAPACITY_RELEASE_FAILED")
return provider
from functools import partial
return partial(_browser_action_provider,transport=transport,storage=storage,event_loop=event_loop,action_timeout_seconds=action_timeout_seconds,max_screenshot_bytes=max_screenshot_bytes,max_download_bytes=max_download_bytes,session_manager=session_manager)
# #endregion ScenarioExecution.BrowserProvider.Factory
from .browser_provider_errors import _transport_BrowserCheckpointMissing # noqa: F401
from .browser_provider_errors import _transport_TimeoutError # noqa: F401
from .browser_provider_errors import _transport_ProviderSubmissionOverflow # noqa: F401
from .browser_provider_errors import _transport_BrowserTransportPreconditionMismatch # noqa: F401
from .browser_provider_errors import _transport_BrowserTransportCleanupFailed # noqa: F401
from .browser_provider_errors import _transport_BrowserTransportReadbackMismatch # noqa: F401
from .browser_provider_errors import _transport_BrowserTransportUnsupported # noqa: F401
from .browser_provider_errors import _transport_BrowserTransportSelectorNotFound # noqa: F401
from .browser_provider_errors import _transport_ValueError # noqa: F401
from .browser_provider_errors import _transport_RuntimeError # noqa: F401
from .browser_provider_errors import _transport_error # noqa: F401
from .browser_provider_action import _browser_action_provider # noqa: F401
# #endregion ScenarioExecution.BrowserProvider

View File

@@ -60,16 +60,59 @@ def environment_class_from_step(step: dict[str, Any]) -> str:
# #endregion ScenarioExecution.BrowserProvider.Admission.EnvironmentClass
# #region ScenarioExecution.BrowserProvider.Admission.Gate [C:5] [TYPE Function] [SEMANTICS provider,browser,admission,descriptor]
# @ingroup ScenarioExecution
# @BRIEF Fail-closed admission: loop, binding, run identity, target identity and descriptor gating.
# @POST Returns (None, admission_payload) when the step may proceed, or (typed_rejection, None);
# no branch performs external I/O.
# @RELATION CALLS -> [ScenarioExecution.MetricBrowserInputs.Resolve]
def admit_browser_action(
event_loop: Any,
step: dict[str, Any],
) -> tuple[LiveAdapterResult | None, dict[str, Any] | None]:
# #region SemanticRepair.browser_admission.admit_mutation [C:3] [TYPE Function]
# @BRIEF Require supported mutation action, contract and typed inputs before browser I/O.
def _admit_mutation(mutating, action, metadata, descriptor):
if mutating or action not in _READ_ONLY_ACTIONS:
if not mutating:
logger.explore("Unsupported browser action rejected before I/O", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_NOT_SUPPORTED"), None
contract_error = validate_mutation_contract(metadata)
if contract_error is not None:
return LiveAdapterResult(status="inconclusive", reason_code=contract_error), None
if action not in _MUTATION_ACTIONS:
logger.explore("Mutating action outside the supported catalog", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_NOT_SUPPORTED"), None
contract = metadata.get("mutation_contract") or {}
inputs_error = validate_mutation_inputs(descriptor.get("inputs"), contract)
if inputs_error is not None:
return LiveAdapterResult(status="inconclusive", reason_code=inputs_error), None
# #endregion SemanticRepair.browser_admission.admit_mutation
# #region SemanticRepair.browser_admission.admit_readonly_inputs [C:3] [TYPE Function]
# @BRIEF Validate native-filter or registered read-only inputs with typed refusals.
def _admit_readonly_inputs(mutating, action, metadata, action_inputs):
filter_input: dict[str, Any] | None = None
if not mutating and action == "apply_native_filter":
filter_input = resolve_native_filter_input(metadata, action_inputs)
filter_error = validate_native_filter_input(filter_input)
if filter_error is not None:
logger.explore(
"Native filter input rejected before I/O", src=_SRC,
payload={"action": action}, error_code=filter_error,
)
return LiveAdapterResult(status="inconclusive", reason_code=filter_error), None
if not mutating and action in _TYPED_READONLY_ACTIONS:
readonly_inputs = action_inputs if isinstance(action_inputs, dict) else {}
readonly_error = validate_readonly_action_input(action, readonly_inputs)
if readonly_error is not None:
logger.explore(
"Read-only action input rejected before I/O", src=_SRC,
payload={"action": action}, error_code=readonly_error,
)
return LiveAdapterResult(status="inconclusive", reason_code=readonly_error), None
return filter_input
# #endregion SemanticRepair.browser_admission.admit_readonly_inputs
# #region SemanticRepair.browser_admission.admit_context [C:3] [TYPE Function]
# @BRIEF Prove provider-loop availability and exact persisted browser run/binding identity.
def _admit_context(event_loop, step):
if not event_loop.is_running:
logger.explore("Provider loop is not running; no I/O admitted", src=_SRC, error_code="BROWSER_LOOP_UNAVAILABLE")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_LOOP_UNAVAILABLE"), None
@@ -97,6 +140,23 @@ def admit_browser_action(
if str(descriptor.get("tool") or "browser") != "browser":
logger.explore("Browser descriptor tool mismatch", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_TOOL_MISMATCH")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_TOOL_MISMATCH"), None
return None,(binding, metadata, run_id, descriptor, action)
# #endregion SemanticRepair.browser_admission.admit_context
# #region ScenarioExecution.BrowserProvider.Admission.Gate [C:5] [TYPE Function] [SEMANTICS provider,browser,admission,descriptor]
# @ingroup ScenarioExecution
# @BRIEF Fail-closed admission: loop, binding, run identity, target identity and descriptor gating.
# @POST Returns (None, admission_payload) when the step may proceed, or (typed_rejection, None);
# no branch performs external I/O.
# @RELATION CALLS -> [ScenarioExecution.MetricBrowserInputs.Resolve]
def admit_browser_action(
event_loop: Any,
step: dict[str, Any],
) -> tuple[LiveAdapterResult | None, dict[str, Any] | None]:
rejected, context = _admit_context(event_loop, step)
if rejected is not None:
return rejected,None
binding, metadata, run_id, descriptor, action = context
mutating = bool(descriptor.get("mutating"))
from .metric_browser_inputs import resolve_metric_browser_inputs
from .browser_pinned_inputs import resolve_pinned_browser_inputs
@@ -107,39 +167,13 @@ def admit_browser_action(
except ValueError as exc:
return LiveAdapterResult(status="inconclusive", reason_code=str(exc)), None
action_inputs = recipe_inputs if recipe_inputs is not None else descriptor.get("inputs")
if mutating or action not in _READ_ONLY_ACTIONS:
if not mutating:
logger.explore("Unsupported browser action rejected before I/O", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_NOT_SUPPORTED"), None
contract_error = validate_mutation_contract(metadata)
if contract_error is not None:
return LiveAdapterResult(status="inconclusive", reason_code=contract_error), None
if action not in _MUTATION_ACTIONS:
logger.explore("Mutating action outside the supported catalog", src=_SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
return LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_NOT_SUPPORTED"), None
contract = metadata.get("mutation_contract") or {}
inputs_error = validate_mutation_inputs(descriptor.get("inputs"), contract)
if inputs_error is not None:
return LiveAdapterResult(status="inconclusive", reason_code=inputs_error), None
filter_input: dict[str, Any] | None = None
if not mutating and action == "apply_native_filter":
filter_input = resolve_native_filter_input(metadata, action_inputs)
filter_error = validate_native_filter_input(filter_input)
if filter_error is not None:
logger.explore(
"Native filter input rejected before I/O", src=_SRC,
payload={"action": action}, error_code=filter_error,
)
return LiveAdapterResult(status="inconclusive", reason_code=filter_error), None
if not mutating and action in _TYPED_READONLY_ACTIONS:
readonly_inputs = action_inputs if isinstance(action_inputs, dict) else {}
readonly_error = validate_readonly_action_input(action, readonly_inputs)
if readonly_error is not None:
logger.explore(
"Read-only action input rejected before I/O", src=_SRC,
payload={"action": action}, error_code=readonly_error,
)
return LiveAdapterResult(status="inconclusive", reason_code=readonly_error), None
rejected = _admit_mutation(mutating, action, metadata, descriptor)
if rejected is not None:
return rejected
readonly = _admit_readonly_inputs(mutating, action, metadata, action_inputs)
if isinstance(readonly,tuple):
return readonly
filter_input = readonly
registry_fingerprint = metadata.get("action_registry_fingerprint")
if registry_fingerprint is not None:
from src.services.dashboard_testing.scenario.templates import action_registry_fingerprint

View File

@@ -104,8 +104,10 @@ _DATE_OK_BUTTON_SELECTORS = (
)
# #region SemanticRepair.browser_native_filter.BrowserTransportSelectorNotFound [C:3] [TYPE Class]
class BrowserTransportSelectorNotFound(RuntimeError):
"""Raised when the filter bar/control/option/apply control cannot be located; no retry."""
# #endregion SemanticRepair.browser_native_filter.BrowserTransportSelectorNotFound
# #region ScenarioExecution.BrowserProvider.NativeFilter.Resolve [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,input]
@@ -139,19 +141,11 @@ def resolve_native_filter_input(metadata: dict[str, Any], descriptor_inputs: Any
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.Resolve
# #region ScenarioExecution.BrowserProvider.NativeFilter.Validate [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,validate]
# @ingroup ScenarioExecution
# @BRIEF Provider-side typed validation of the merged filter input before any browser I/O.
# @POST Returns None for a well-formed input (empty dict = current-state apply mode), otherwise a
# stable typed rejection code.
def validate_native_filter_input(filter_input: dict[str, Any]) -> str | None:
for key in ("filter_id", "filter_name", "column"):
value = filter_input.get(key)
if value is not None and (
not isinstance(value, str) or not value.strip() or len(value) > _MAX_IDENTITY_LENGTH
):
logger.explore("Native filter identity invalid", src=_SRC, payload={"key": key}, error_code="BROWSER_FILTER_INPUT_INVALID")
return "BROWSER_FILTER_INPUT_INVALID"
# #region SemanticRepair.browser_native_filter.validate_filter_options [C:3] [TYPE Function]
# @BRIEF Check selector, search, mode, date, values and wait-state bounds in original precedence.
def _validate_filter_options(filter_input):
selector_hint = filter_input.get("selector_hint")
if selector_hint is not None and (not isinstance(selector_hint, str) or not selector_hint.strip()):
logger.explore("Native filter selector hint invalid", src=_SRC, error_code="BROWSER_FILTER_INPUT_INVALID")
@@ -183,6 +177,26 @@ def validate_native_filter_input(filter_input: dict[str, Any]) -> str | None:
if wait_state is not None and str(wait_state) not in _ALLOWED_WAIT_STATES:
logger.explore("Native filter wait state invalid", src=_SRC, payload={"wait_state": str(wait_state)}, error_code="BROWSER_WAIT_STATE_INVALID")
return "BROWSER_WAIT_STATE_INVALID"
return None
# #endregion SemanticRepair.browser_native_filter.validate_filter_options
# #region ScenarioExecution.BrowserProvider.NativeFilter.Validate [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,validate]
# @ingroup ScenarioExecution
# @BRIEF Provider-side typed validation of the merged filter input before any browser I/O.
# @POST Returns None for a well-formed input (empty dict = current-state apply mode), otherwise a
# stable typed rejection code.
def validate_native_filter_input(filter_input: dict[str, Any]) -> str | None:
for key in ("filter_id", "filter_name", "column"):
value = filter_input.get(key)
if value is not None and (
not isinstance(value, str) or not value.strip() or len(value) > _MAX_IDENTITY_LENGTH
):
logger.explore("Native filter identity invalid", src=_SRC, payload={"key": key}, error_code="BROWSER_FILTER_INPUT_INVALID")
return "BROWSER_FILTER_INPUT_INVALID"
options_error = _validate_filter_options(filter_input)
if options_error is not None:
return options_error
mode = filter_input.get("mode")
# clear semantics: values/search_text/date are mutually exclusive with clear mode.
if mode == "clear":
conflicting = [key for key in ("values", "search_text", "date") if filter_input.get(key)]
@@ -246,237 +260,28 @@ async def _resolve_filter_control(service: Any, page: Any, bar: Any, filter_inpu
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ResolveControl
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyValues [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,values]
# @ingroup ScenarioExecution
# @BRIEF Open the control, select each typed value option and click the filter bar Apply control.
# @POST Returns the applied values; a missing option or Apply control raises
# BrowserTransportSelectorNotFound (typed, no retry).
async def _settle_dropdown(page: Any) -> None:
"""Bounded one-animation-cycle settle after opening a dropdown (antd slide-up). Test
doubles without wait_for_timeout just yield control — the settle is timing-only."""
wait_for_timeout = getattr(page, "wait_for_timeout", None)
if wait_for_timeout is not None:
await wait_for_timeout(_DROPDOWN_SETTLE_MS)
else:
import asyncio
await asyncio.sleep(0)
from .browser_native_filter_ui import _settle_dropdown # noqa: F401
async def _apply_filter_values(service: Any, page: Any, control: Any, values: list[str], timeout_ms: int) -> list[str]:
await control.click(timeout=timeout_ms)
for value in values:
option_candidates = [page.get_by_text(value, exact=True)]
if _ATTRIBUTE_IDENTITY_RE.fullmatch(value):
option_candidates.insert(0, page.locator(f'.ant-select-item-option[title="{value}"]'))
# The dropdown panel mounts asynchronously after the control click; synchronize on the
# first bounded render before the fail-closed visibility snapshot (a genuine miss still
# types BROWSER_SELECTOR_NOT_FOUND after the bound — this wait is not a retry loop).
# The settle pause lets the antd slide-up animation finish: Playwright visibility can
# flip between animation frames and a same-frame snapshot races the opening overlay
# (live flake on ss-prod dashboard 11, 2026-09-18).
try:
await page.locator(".ant-select-dropdown, [role=listbox]").first.wait_for(
state="visible", timeout=min(timeout_ms, _DROPDOWN_MOUNT_TIMEOUT_MS),
)
except Exception:
pass
await _settle_dropdown(page)
option = await service._find_first_visible_locator(option_candidates)
if option is None:
logger.explore("Native filter option not found", src=_SRC, payload={"value": value}, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
return list(values)
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyValues
from .browser_native_filter_ui import _apply_filter_values # noqa: F401
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyWithSearch [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,search]
# @ingroup ScenarioExecution
# @BRIEF B02 search flow: open the control, type into the dropdown search input, click the first
# bounded matching option (B02 checklists: search → «Применить фильтр» on the result).
# @POST Returns the applied value; a missing search input or matching option raises
# BrowserTransportSelectorNotFound (typed, no retry). No Apply click here — the caller
# clicks the shared filter-bar Apply control once per step.
async def _apply_filter_search(service: Any, page: Any, control: Any, search_text: str, timeout_ms: int) -> str:
await control.click(timeout=timeout_ms)
search_candidates = [page.locator(selector) for selector in _DROPDOWN_SEARCH_INPUT_SELECTORS]
try:
await search_candidates[0].first.wait_for(state="visible", timeout=min(timeout_ms, _DROPDOWN_MOUNT_TIMEOUT_MS))
except Exception:
pass
search_input = await service._find_first_visible_locator(search_candidates)
if search_input is None:
logger.explore("Native filter search input not found", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await search_input.fill(search_text, timeout=timeout_ms)
option = await service._find_first_visible_locator([page.locator(f'.ant-select-item-option[title*="{search_text}"]'), page.get_by_text(search_text, exact=False)])
if option is None:
logger.explore("Native filter search option not found", src=_SRC, payload={"search_text": search_text}, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
return search_text
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyWithSearch
from .browser_native_filter_ui import _apply_filter_search # noqa: F401
# #region ScenarioExecution.BrowserProvider.NativeFilter.ClearValues [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,clear]
# @ingroup ScenarioExecution
# @BRIEF Clear mode: remove the control's selected values (chip removes first, then a bounded
# clear-all inside the dropdown), then Apply. Values must be empty (admission enforces).
# @POST Returns the cleared control's previously selected labels (bounded); a clear affordance
# miss raises BrowserTransportSelectorNotFound when nothing could be cleared.
# @RATIONALE Chip-remove before clear-all: prod Superset builds differ on whether the filter bar
# exposes a global Clear button; the per-chip remove icon is the always-present floor.
async def _clear_filter_values(service: Any, page: Any, control: Any, timeout_ms: int) -> list[str]:
prior = await _observe_current_selection(control)
chip_remove = await service._find_first_visible_locator(
[control.locator(selector) for selector in _CHIP_REMOVE_SELECTORS]
)
if chip_remove is not None:
remove_count = min(await chip_remove.count(), _MAX_FILTER_VALUES)
for index in range(remove_count):
await chip_remove.first.click(timeout=timeout_ms)
else:
await control.click(timeout=timeout_ms)
# Same bounded settle as the values flow: the dropdown's slide-up animation must
# finish before the fail-closed clear-affordance snapshot.
await _settle_dropdown(page)
clear_button = await service._find_first_visible_locator(
[page.locator(selector) for selector in _CLEAR_ALL_SELECTORS]
)
if clear_button is None:
logger.explore("Native filter clear affordance not found", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await clear_button.click(timeout=timeout_ms)
return prior
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ClearValues
from .browser_native_filter_ui import _clear_filter_values # noqa: F401
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyDate [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,date-picker]
# @ingroup ScenarioExecution
# @BRIEF Date mode: the control is an antd DatePicker, not an ant-select — type the ISO date into
# the picker input, confirm via the calendar cell or OK, then let the caller Apply.
# @POST Returns the typed date string; a missing picker input/confirmation raises
# BrowserTransportSelectorNotFound (typed, no retry).
async def _apply_filter_date(service: Any, page: Any, control: Any, date: str, timeout_ms: int) -> str:
date_value = date.strip()
picker_input = await service._find_first_visible_locator(
[control.locator(selector) for selector in _DATE_PICKER_INPUT_SELECTORS]
)
if picker_input is None:
logger.explore("Native filter date picker input not found", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await picker_input.fill(date_value, timeout=timeout_ms)
await picker_input.press("Enter", timeout=timeout_ms)
cell = await service._find_first_visible_locator(
[page.locator(selector.format(date=date_value)) for selector in _DATE_PICKER_CELL_SELECTORS]
)
if cell is not None:
await cell.click(timeout=timeout_ms)
else:
ok_button = await service._find_first_visible_locator(
[page.locator(selector) for selector in _DATE_OK_BUTTON_SELECTORS]
)
if ok_button is not None:
await ok_button.click(timeout=timeout_ms)
return date_value
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyDate
from .browser_native_filter_ui import _apply_filter_date # noqa: F401
# #region ScenarioExecution.BrowserProvider.NativeFilter.ObserveCurrent [C:2] [TYPE Function] [SEMANTICS provider,browser,native-filter,observe]
# @ingroup ScenarioExecution
# @BRIEF Current-state mode: open the control and read its bounded rendered selection without changes.
# @POST Returns up to _MAX_FILTER_VALUES selected labels; no filter state is modified.
async def _observe_current_selection(control: Any) -> list[str]:
selected = control.locator(_SELECTED_VALUE_SELECTOR)
count = min(await selected.count(), _MAX_FILTER_VALUES)
labels: list[str] = []
for index in range(count):
labels.append(str(await selected.nth(index).text_content() or "").strip())
return [label for label in labels if label]
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ObserveCurrent
from .browser_native_filter_ui import _observe_current_selection # noqa: F401
# #region ScenarioExecution.BrowserProvider.NativeFilter.Apply [C:4] [TYPE Function] [SEMANTICS provider,browser,native-filter,apply,settle]
# @ingroup ScenarioExecution
# @BRIEF Apply the native filter through the filter bar UI and wait for bounded chart settle.
# @PRE filter_input is normalized by parse_native_filter_input; the dashboard page is open.
# @POST Returns typed details for the transport outcome; raises BrowserTransportSelectorNotFound on
# any locator miss (bar, control, option, apply control) before evidence exists.
# @SIDE_EFFECT Filter bar clicks; optional wait_state load-state wait; chart settle polling.
# @RELATION CALLS -> [ScenarioExecution.BrowserScopedFilter.Apply]
async def apply_native_filter_via_ui(
service: Any,
page: Any,
filter_input: dict[str, Any],
*,
timeout_seconds: float,
) -> dict[str, Any]:
if filter_input.get("required_filter_identity") is True:
from .browser_scoped_filter import apply_scoped_native_filter
return await apply_scoped_native_filter(service, page, filter_input, timeout_seconds=timeout_seconds)
timeout_ms = int(timeout_seconds * 1000)
# Reused run-scoped sessions may hold a dropdown left open by a previous step; a control
# click would TOGGLE it closed and the option search below would miss. Escape closes any
# open antd overlay without mutating filter state (SCEX filter-canary finding 2026-09-18).
# Test doubles without a keyboard cannot hold an open overlay — skip the press there.
keyboard = getattr(page, "keyboard", None)
if keyboard is not None:
await keyboard.press("Escape")
bar = await service._find_first_visible_locator([page.locator(selector) for selector in _FILTER_BAR_SELECTORS])
if bar is None:
logger.explore("Dashboard filter bar not found", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
control = await _resolve_filter_control(service, page, bar, filter_input)
if control is None:
logger.explore(
"Native filter control not found", src=_SRC,
payload={"identity": filter_input.get("filter_id") or filter_input.get("filter_name") or filter_input.get("column")},
error_code="BROWSER_SELECTOR_NOT_FOUND",
)
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
values = list(filter_input.get("values") or [])
mode = str(filter_input.get("mode") or "set")
search_text = filter_input.get("search_text")
date = filter_input.get("date")
if mode == "clear":
applied_values = await _clear_filter_values(service, page, control, timeout_ms)
applied_mode = "clear"
elif values:
applied_values = await _apply_filter_values(service, page, control, values, timeout_ms)
applied_mode = "values"
elif search_text:
applied_values = [await _apply_filter_search(service, page, control, str(search_text), timeout_ms)]
applied_mode = "search"
elif date:
applied_values = [await _apply_filter_date(service, page, control, str(date), timeout_ms)]
applied_mode = "date"
else:
applied_values = await _observe_current_selection(control)
applied_mode = "current_state"
if applied_mode != "current_state":
apply_button = await service._find_first_visible_locator([page.locator(selector) for selector in _APPLY_BUTTON_SELECTORS])
if apply_button is None:
logger.explore("Native filter apply control not found", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await apply_button.click(timeout=timeout_ms)
wait_state = filter_input.get("wait_state")
if wait_state is not None:
await page.wait_for_load_state(str(wait_state), timeout=timeout_ms)
await service._wait_for_charts_stabilized(page, timeout_ms=min(timeout_ms, _CHART_SETTLE_TIMEOUT_MS))
rendered_charts = await page.evaluate(
"() => document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas').length"
)
logger.reflect(
"Native filter applied through the filter bar UI", src=_SRC,
payload={"applied_mode": applied_mode, "values": len(applied_values), "rendered_charts": rendered_charts},
)
return {
"applied": True,
"applied_mode": applied_mode,
"applied_values": applied_values,
"filter_target": filter_input.get("filter_id") or filter_input.get("filter_name") or filter_input.get("column"),
"chart_data_observed": bool(rendered_charts),
}
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.Apply
from .browser_native_filter_ui import _apply_filter_mode # noqa: F401
from .browser_native_filter_ui import apply_native_filter_via_ui # noqa: F401
# #endregion ScenarioExecution.BrowserProvider.NativeFilter

View File

@@ -0,0 +1,243 @@
# #region SemanticRepair.browser_native_filter_ui [C:4] [TYPE Module]
# @BRIEF Execute legacy native-filter modes through scoped controls and bounded chart settlement.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import browser_native_filter as seam
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyValues [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,values]
# @ingroup ScenarioExecution
# @BRIEF Open the control, select each typed value option and click the filter bar Apply control.
# @POST Returns the applied values; a missing option or Apply control raises
# BrowserTransportSelectorNotFound (typed, no retry).
async def _settle_dropdown(page: seam.Any) -> None:
"""Bounded one-animation-cycle settle after opening a dropdown (antd slide-up). Test
doubles without wait_for_timeout just yield control — the settle is timing-only."""
wait_for_timeout = getattr(page, "wait_for_timeout", None)
if wait_for_timeout is not None:
await wait_for_timeout(seam._DROPDOWN_SETTLE_MS)
else:
import asyncio
await asyncio.sleep(0)
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyValues
# #region SemanticRepair.browser_native_filter_ui.apply_filter_values [C:3] [TYPE Function]
async def _apply_filter_values(service: seam.Any, page: seam.Any, control: seam.Any, values: list[str], timeout_ms: int) -> list[str]:
await control.click(timeout=timeout_ms)
for value in values:
option_candidates = [page.get_by_text(value, exact=True)]
if seam._ATTRIBUTE_IDENTITY_RE.fullmatch(value):
option_candidates.insert(0, page.locator(f'.ant-select-item-option[title="{value}"]'))
# The dropdown panel mounts asynchronously after the control click; synchronize on the
# first bounded render before the fail-closed visibility snapshot (a genuine miss still
# types BROWSER_SELECTOR_NOT_FOUND after the bound — this wait is not a retry loop).
# The settle pause lets the antd slide-up animation finish: Playwright visibility can
# flip between animation frames and a same-frame snapshot races the opening overlay
# (live flake on ss-prod dashboard 11, 2026-09-18).
try:
await page.locator(".ant-select-dropdown, [role=listbox]").first.wait_for(
state="visible", timeout=min(timeout_ms, seam._DROPDOWN_MOUNT_TIMEOUT_MS),
)
except Exception:
pass
await seam._settle_dropdown(page)
option = await service._find_first_visible_locator(option_candidates)
if option is None:
seam.logger.explore("Native filter option not found", src=seam._SRC, payload={"value": value}, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
return list(values)
# #endregion SemanticRepair.browser_native_filter_ui.apply_filter_values
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyWithSearch [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,search]
# @ingroup ScenarioExecution
# @BRIEF B02 search flow: open the control, type into the dropdown search input, click the first
# bounded matching option (B02 checklists: search → «Применить фильтр» on the result).
# @POST Returns the applied value; a missing search input or matching option raises
# BrowserTransportSelectorNotFound (typed, no retry). No Apply click here — the caller
# clicks the shared filter-bar Apply control once per step.
async def _apply_filter_search(service: seam.Any, page: seam.Any, control: seam.Any, search_text: str, timeout_ms: int) -> str:
await control.click(timeout=timeout_ms)
search_candidates = [page.locator(selector) for selector in seam._DROPDOWN_SEARCH_INPUT_SELECTORS]
try:
await search_candidates[0].first.wait_for(state="visible", timeout=min(timeout_ms, seam._DROPDOWN_MOUNT_TIMEOUT_MS))
except Exception:
pass
search_input = await service._find_first_visible_locator(search_candidates)
if search_input is None:
seam.logger.explore("Native filter search input not found", src=seam._SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await search_input.fill(search_text, timeout=timeout_ms)
option = await service._find_first_visible_locator([page.locator(f'.ant-select-item-option[title*="{search_text}"]'), page.get_by_text(search_text, exact=False)])
if option is None:
seam.logger.explore("Native filter search option not found", src=seam._SRC, payload={"search_text": search_text}, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
return search_text
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyWithSearch
# #region ScenarioExecution.BrowserProvider.NativeFilter.ClearValues [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,clear]
# @ingroup ScenarioExecution
# @BRIEF Clear mode: remove the control's selected values (chip removes first, then a bounded
# clear-all inside the dropdown), then Apply. Values must be empty (admission enforces).
# @POST Returns the cleared control's previously selected labels (bounded); a clear affordance
# miss raises BrowserTransportSelectorNotFound when nothing could be cleared.
# @RATIONALE Chip-remove before clear-all: prod Superset builds differ on whether the filter bar
# exposes a global Clear button; the per-chip remove icon is the always-present floor.
async def _clear_filter_values(service: seam.Any, page: seam.Any, control: seam.Any, timeout_ms: int) -> list[str]:
prior = await seam._observe_current_selection(control)
chip_remove = await service._find_first_visible_locator(
[control.locator(selector) for selector in seam._CHIP_REMOVE_SELECTORS]
)
if chip_remove is not None:
remove_count = min(await chip_remove.count(), seam._MAX_FILTER_VALUES)
for index in range(remove_count):
await chip_remove.first.click(timeout=timeout_ms)
else:
await control.click(timeout=timeout_ms)
# Same bounded settle as the values flow: the dropdown's slide-up animation must
# finish before the fail-closed clear-affordance snapshot.
await seam._settle_dropdown(page)
clear_button = await service._find_first_visible_locator(
[page.locator(selector) for selector in seam._CLEAR_ALL_SELECTORS]
)
if clear_button is None:
seam.logger.explore("Native filter clear affordance not found", src=seam._SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await clear_button.click(timeout=timeout_ms)
return prior
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ClearValues
# #region ScenarioExecution.BrowserProvider.NativeFilter.ApplyDate [C:3] [TYPE Function] [SEMANTICS provider,browser,native-filter,date-picker]
# @ingroup ScenarioExecution
# @BRIEF Date mode: the control is an antd DatePicker, not an ant-select — type the ISO date into
# the picker input, confirm via the calendar cell or OK, then let the caller Apply.
# @POST Returns the typed date string; a missing picker input/confirmation raises
# BrowserTransportSelectorNotFound (typed, no retry).
async def _apply_filter_date(service: seam.Any, page: seam.Any, control: seam.Any, date: str, timeout_ms: int) -> str:
date_value = date.strip()
picker_input = await service._find_first_visible_locator(
[control.locator(selector) for selector in seam._DATE_PICKER_INPUT_SELECTORS]
)
if picker_input is None:
seam.logger.explore("Native filter date picker input not found", src=seam._SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await picker_input.fill(date_value, timeout=timeout_ms)
await picker_input.press("Enter", timeout=timeout_ms)
cell = await service._find_first_visible_locator(
[page.locator(selector.format(date=date_value)) for selector in seam._DATE_PICKER_CELL_SELECTORS]
)
if cell is not None:
await cell.click(timeout=timeout_ms)
else:
ok_button = await service._find_first_visible_locator(
[page.locator(selector) for selector in seam._DATE_OK_BUTTON_SELECTORS]
)
if ok_button is not None:
await ok_button.click(timeout=timeout_ms)
return date_value
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ApplyDate
# #region ScenarioExecution.BrowserProvider.NativeFilter.ObserveCurrent [C:2] [TYPE Function] [SEMANTICS provider,browser,native-filter,observe]
# @ingroup ScenarioExecution
# @BRIEF Current-state mode: open the control and read its bounded rendered selection without changes.
# @POST Returns up to _MAX_FILTER_VALUES selected labels; no filter state is modified.
async def _observe_current_selection(control: seam.Any) -> list[str]:
selected = control.locator(seam._SELECTED_VALUE_SELECTOR)
count = min(await selected.count(), seam._MAX_FILTER_VALUES)
labels: list[str] = []
for index in range(count):
labels.append(str(await selected.nth(index).text_content() or "").strip())
return [label for label in labels if label]
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.ObserveCurrent
# #region SemanticRepair.browser_native_filter.apply_filter_mode [C:3] [TYPE Function]
# @BRIEF Execute one declared native-filter mode and return its observed UI values.
async def _apply_filter_mode(service, page, control, filter_input, timeout_ms):
values = list(filter_input.get("values") or [])
mode = str(filter_input.get("mode") or "set")
search_text = filter_input.get("search_text")
date = filter_input.get("date")
if mode == "clear":
applied_values = await seam._clear_filter_values(service, page, control, timeout_ms)
applied_mode = "clear"
elif values:
applied_values = await seam._apply_filter_values(service, page, control, values, timeout_ms)
applied_mode = "values"
elif search_text:
applied_values = [await seam._apply_filter_search(service, page, control, str(search_text), timeout_ms)]
applied_mode = "search"
elif date:
applied_values = [await seam._apply_filter_date(service, page, control, str(date), timeout_ms)]
applied_mode = "date"
else:
applied_values = await seam._observe_current_selection(control)
applied_mode = "current_state"
return applied_values, applied_mode
# #endregion SemanticRepair.browser_native_filter.apply_filter_mode
# #region ScenarioExecution.BrowserProvider.NativeFilter.Apply [C:4] [TYPE Function] [SEMANTICS provider,browser,native-filter,apply,settle]
# @ingroup ScenarioExecution
# @BRIEF Apply the native filter through the filter bar UI and wait for bounded chart settle.
# @PRE filter_input is normalized by parse_native_filter_input; the dashboard page is open.
# @POST Returns typed details for the transport outcome; raises BrowserTransportSelectorNotFound on
# any locator miss (bar, control, option, apply control) before evidence exists.
# @SIDE_EFFECT Filter bar clicks; optional wait_state load-state wait; chart settle polling.
# @RELATION CALLS -> [ScenarioExecution.BrowserScopedFilter.Apply]
async def apply_native_filter_via_ui(
service: seam.Any,
page: seam.Any,
filter_input: dict[str, seam.Any],
*,
timeout_seconds: float,
) -> dict[str, seam.Any]:
if filter_input.get("required_filter_identity") is True:
from .browser_scoped_filter import apply_scoped_native_filter
return await apply_scoped_native_filter(service, page, filter_input, timeout_seconds=timeout_seconds)
timeout_ms = int(timeout_seconds * 1000)
# Reused run-scoped sessions may hold a dropdown left open by a previous step; a control
# click would TOGGLE it closed and the option search below would miss. Escape closes any
# open antd overlay without mutating filter state (SCEX filter-canary finding 2026-09-18).
# Test doubles without a keyboard cannot hold an open overlay — skip the press there.
keyboard = getattr(page, "keyboard", None)
if keyboard is not None:
await keyboard.press("Escape")
bar = await service._find_first_visible_locator([page.locator(selector) for selector in seam._FILTER_BAR_SELECTORS])
if bar is None:
seam.logger.explore("Dashboard filter bar not found", src=seam._SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
control = await seam._resolve_filter_control(service, page, bar, filter_input)
if control is None:
seam.logger.explore(
"Native filter control not found", src=seam._SRC,
payload={"identity": filter_input.get("filter_id") or filter_input.get("filter_name") or filter_input.get("column")},
error_code="BROWSER_SELECTOR_NOT_FOUND",
)
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
applied_values, applied_mode = await seam._apply_filter_mode(service, page, control, filter_input, timeout_ms)
if applied_mode != "current_state":
apply_button = await service._find_first_visible_locator([page.locator(selector) for selector in seam._APPLY_BUTTON_SELECTORS])
if apply_button is None:
seam.logger.explore("Native filter apply control not found", src=seam._SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise seam.BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await apply_button.click(timeout=timeout_ms)
wait_state = filter_input.get("wait_state")
if wait_state is not None:
await page.wait_for_load_state(str(wait_state), timeout=timeout_ms)
await service._wait_for_charts_stabilized(page, timeout_ms=min(timeout_ms, seam._CHART_SETTLE_TIMEOUT_MS))
rendered_charts = await page.evaluate(
"() => document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas').length"
)
seam.logger.reflect(
"Native filter applied through the filter bar UI", src=seam._SRC,
payload={"applied_mode": applied_mode, "values": len(applied_values), "rendered_charts": rendered_charts},
)
return {
"applied": True,
"applied_mode": applied_mode,
"applied_values": applied_values,
"filter_target": filter_input.get("filter_id") or filter_input.get("filter_name") or filter_input.get("column"),
"chart_data_observed": bool(rendered_charts),
}
# #endregion ScenarioExecution.BrowserProvider.NativeFilter.Apply
# #endregion SemanticRepair.browser_native_filter_ui

View File

@@ -0,0 +1,137 @@
# #region SemanticRepair.browser_provider_action [C:4] [TYPE Module]
# @BRIEF Surround admitted browser dispatch with capacity ownership and unconditional lease cleanup.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import browser as seam
# #region SemanticRepair.browser_provider_action.provider_failure [C:3] [TYPE Function]
# @BRIEF Close failed run sessions and retain mutation reconciliation requirements.
def _provider_failure(exc, session_manager, run_id, mutating, operation_id):
if session_manager is not None:
session_manager.close(run_id, reason="step_error")
seam.logger.explore("Browser provider failed", src=seam._SRC, payload={"run_id": run_id if isinstance(run_id, str) else None}, error=repr(exc))
if mutating:
seam.finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "provider_error"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_FAILED")
# #endregion SemanticRepair.browser_provider_action.provider_failure
# #region ScenarioExecution.BrowserProvider.Execute [C:5] [TYPE Function]
# @POST Capacity release and typed failure reconciliation surround every admitted browser action.
def _browser_action_provider(context: seam.Any, *, transport, storage, event_loop, action_timeout_seconds, max_screenshot_bytes, max_download_bytes, session_manager) -> seam.LiveAdapterResult:
rejection, admission = seam.admit_browser_action(event_loop, context.step)
if rejection is not None or admission is None:
return rejection or seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ADMISSION_INVALID")
binding = admission["binding"]
metadata = admission["metadata"]
run_id: str = admission["run_id"]
action: str = admission["action"]
mutating: bool = admission["mutating"]
descriptor = admission["descriptor"]
seam.logger.reason(
"Browser action admitted for run", src=seam._SRC,
payload={"run_id": run_id, "dashboard_id": binding.dashboard_id, "action": action, "environment_class": admission["environment_class"], "mutating": mutating},
)
lease_id: str | None = None
operation_id: str | None = None
try:
try:
with seam.SessionLocal() as db:
lease = seam.claim_capacity(
db,
environment_id=binding.environment_id,
environment_class=admission["environment_class"],
workload_class="browser",
provider_id="browser",
run_id=run_id,
logical_step_id=metadata.get("logical_step_id"),
)
if mutating:
receipt = seam.open_provider_operation(
db,
run_id=run_id,
logical_step_id=str(metadata.get("logical_step_id")),
attempt=int(metadata.get("attempt") or 1),
provider_id="browser",
provider_version=str(descriptor.get("provider_version") or "unset"),
action=action,
descriptor_fingerprint=seam.descriptor_fingerprint(descriptor),
binding_ref=binding.binding_ref,
execution_principal_fingerprint=binding.execution_principal_fingerprint,
idempotency_key=f"{run_id}:{metadata.get('logical_step_id')}:{int(metadata.get('attempt') or 1)}",
capacity_lease_id=lease["lease_id"],
effect_state="unknown",
summary=seam.mutation_receipt_summary(binding=binding, descriptor=descriptor, metadata=metadata),
)
operation_id = receipt["operation_id"]
db.commit()
lease_id = lease["lease_id"]
# T032: heartbeat refreshes the lease TTL across any pre-I/O admission work so the
# provider loop submission window stays covered; an already-expired/lost lease is a
# typed capacity refusal (walker parks the run), never I/O without a lease.
try:
with seam.SessionLocal() as db:
seam.heartbeat_capacity(db, lease_id)
db.commit()
except seam.CapacityUnavailable as exc:
seam.logger.explore("Browser lease lost before I/O", src=seam._SRC, payload={"run_id": run_id}, error=str(exc))
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_CAPACITY_UNAVAILABLE")
except seam.CapacityUnavailable as exc:
seam.logger.explore("Browser capacity unavailable", src=seam._SRC, payload={"run_id": run_id}, error=str(exc))
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_CAPACITY_UNAVAILABLE")
if action in {'pagination', 'navigate_tabs'}:
from .browser_traversal_runtime import execute_traversal
return execute_traversal(step=context.step,admission=admission,storage=storage,
capacity_lease_id=lease_id,event_loop=event_loop,transport=transport,session_manager=session_manager)
session_plan_box: list[seam.Any] = [None]
transport_factory = seam.build_transport_factory(
session_manager=session_manager, session_plan_box=session_plan_box, transport=transport,
binding=binding, admission=admission, metadata=metadata, descriptor=descriptor,
action=action, mutating=mutating, action_timeout_seconds=action_timeout_seconds,
)
session_checkpoint: dict[str, seam.Any] | None = None
try:
if session_manager is not None:
with session_manager.run_guard(run_id):
session_plan_box[0] = session_manager.prepare_step(
run_id=run_id,
lease_id=lease_id,
dashboard_id=binding.dashboard_id,
)
outcome, session_checkpoint = event_loop.submit(transport_factory, timeout=action_timeout_seconds * 3)
else:
outcome = event_loop.submit(transport_factory, timeout=action_timeout_seconds * 3)
except Exception as exc:
rejected = seam._transport_error(exc, run_id, action, mutating, operation_id, session_manager)
if rejected is None:
raise
return rejected
return seam._store_browser_result(outcome, storage, run_id, action, mutating, operation_id, session_checkpoint, max_screenshot_bytes, max_download_bytes)
except Exception as exc:
return _provider_failure(exc, session_manager, run_id, mutating, operation_id)
finally:
_release_browser_capacity(lease_id)
# #endregion ScenarioExecution.BrowserProvider.Execute
# #region ScenarioExecution.BrowserProvider.Release [C:3] [TYPE Function]
# @POST Best-effort capacity release leaves the original outcome intact.
def _release_browser_capacity(lease_id):
if lease_id is not None:
try:
with seam.SessionLocal() as db:
seam.release_capacity(db, lease_id)
db.commit()
except Exception:
seam.logger.explore("Capacity release failed after browser action", src=seam._SRC, payload={"lease_id": lease_id}, error_code="CAPACITY_RELEASE_FAILED")
# #endregion ScenarioExecution.BrowserProvider.Release
# #endregion SemanticRepair.browser_provider_action

View File

@@ -0,0 +1,189 @@
# #region SemanticRepair.browser_provider_errors [C:4] [TYPE Module]
# @BRIEF Translate transport failures to original typed outcomes, cleanup and mutation reconciliation.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import browser as seam
# #region ScenarioExecution.BrowserProvider.Errors.BrowserCheckpointMissing [C:3] [TYPE Function]
# @POST Preserve typed BrowserCheckpointMissing reconciliation and session cleanup behavior.
def _transport_BrowserCheckpointMissing(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore(
"Browser recovery blocked: no declared checkpoint", src=seam._SRC,
payload={"run_id": run_id, "action": action},
claim="PRE: declared checkpoint for recovery",
error_code="BROWSER_CHECKPOINT_MISSING",
)
if mutating:
seam.finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "checkpoint_missing"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_CHECKPOINT_MISSING",
details={"action": action, "retry_disposition": "manual_only"},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserCheckpointMissing
# #region ScenarioExecution.BrowserProvider.Errors.TimeoutError [C:3] [TYPE Function]
# @POST Preserve typed TimeoutError reconciliation and session cleanup behavior.
def _transport_TimeoutError(exc, run_id, action, mutating, operation_id, session_manager):
if session_manager is not None:
session_manager.close(run_id, reason="step_timeout")
if mutating:
seam.logger.explore("Mutating action deadline expired with unknown effect", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_RECONCILE_REQUIRED")
seam.finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "timeout"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
seam.logger.explore("Browser action exceeded the deadline", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_ACTION_TIMEOUT")
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_ACTION_TIMEOUT")
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.TimeoutError
# #region ScenarioExecution.BrowserProvider.Errors.ProviderSubmissionOverflow [C:3] [TYPE Function]
# @POST Preserve typed ProviderSubmissionOverflow reconciliation and session cleanup behavior.
def _transport_ProviderSubmissionOverflow(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore("Browser submission overflowed the bounded queue", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_LOOP_OVERFLOW")
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_LOOP_OVERFLOW")
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.ProviderSubmissionOverflow
# #region ScenarioExecution.BrowserProvider.Errors.BrowserTransportPreconditionMismatch [C:3] [TYPE Function]
# @POST Preserve typed BrowserTransportPreconditionMismatch reconciliation and session cleanup behavior.
def _transport_BrowserTransportPreconditionMismatch(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore("Mutation precondition mismatch; nothing mutated", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_PRECONDITION_MISMATCH")
seam.finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "precondition_mismatch"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_PRECONDITION_MISMATCH",
details={"effect_state": "not_started", "retry_disposition": "manual_only", "operation_id": operation_id},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserTransportPreconditionMismatch
# #region ScenarioExecution.BrowserProvider.Errors.BrowserTransportCleanupFailed [C:3] [TYPE Function]
# @POST Preserve typed BrowserTransportCleanupFailed reconciliation and session cleanup behavior.
def _transport_BrowserTransportCleanupFailed(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore(
"Mutation completed but fixture restore failed", src=seam._SRC,
payload={"run_id": run_id}, error_code="BROWSER_MUTATION_CLEANUP_FAILED",
)
seam.finalize_provider_receipt(operation_id, "reconciliation_required", "completed", summary={"phase": "cleanup_failed"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_CLEANUP_FAILED",
details={"effect_state": "completed", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserTransportCleanupFailed
# #region ScenarioExecution.BrowserProvider.Errors.BrowserTransportReadbackMismatch [C:3] [TYPE Function]
# @POST Preserve typed BrowserTransportReadbackMismatch reconciliation and session cleanup behavior.
def _transport_BrowserTransportReadbackMismatch(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore(
"Independent readback diverges from the expected mutation state", src=seam._SRC,
payload={"run_id": run_id}, error_code="BROWSER_MUTATION_READBACK_MISMATCH",
)
seam.finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "readback_mismatch"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_READBACK_MISMATCH",
details={
"effect_state": "unknown",
"reconciliation_required": True,
"retry_disposition": "after_reconciliation",
"operation_id": operation_id,
},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserTransportReadbackMismatch
# #region ScenarioExecution.BrowserProvider.Errors.BrowserTransportUnsupported [C:3] [TYPE Function]
# @POST Preserve typed BrowserTransportUnsupported reconciliation and session cleanup behavior.
def _transport_BrowserTransportUnsupported(exc, run_id, action, mutating, operation_id, session_manager):
seam.logger.explore("Transport cannot execute the action; nothing started", src=seam._SRC, payload={"action": action}, error_code="BROWSER_ACTION_NOT_SUPPORTED")
seam.finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "unsupported"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_ACTION_NOT_SUPPORTED",
details={"effect_state": "not_started", "retry_disposition": "manual_only", "operation_id": operation_id},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserTransportUnsupported
# #region ScenarioExecution.BrowserProvider.Errors.BrowserTransportSelectorNotFound [C:3] [TYPE Function]
# @POST Preserve typed BrowserTransportSelectorNotFound reconciliation and session cleanup behavior.
def _transport_BrowserTransportSelectorNotFound(exc, run_id, action, mutating, operation_id, session_manager):
if mutating:
raise
seam.logger.explore(
"Filter-bar locator miss; typed inconclusive without retry", src=seam._SRC,
payload={"run_id": run_id, "action": action}, error_code="BROWSER_SELECTOR_NOT_FOUND",
)
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_SELECTOR_NOT_FOUND",
details={"action": action, "retry_disposition": "manual_only"},
)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.BrowserTransportSelectorNotFound
# #region ScenarioExecution.BrowserProvider.Errors.ValueError [C:3] [TYPE Function]
# @POST Preserve typed ValueError reconciliation and session cleanup behavior.
def _transport_ValueError(exc, run_id, action, mutating, operation_id, session_manager):
code = str(exc)
if mutating or not code.startswith("BROWSER_"):
raise
seam.logger.explore(
"Browser action input rejected by the transport", src=seam._SRC,
payload={"run_id": run_id, "action": action, "code": code}, error_code=code,
)
return seam.LiveAdapterResult(status="inconclusive", reason_code=code)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.ValueError
# #region ScenarioExecution.BrowserProvider.Errors.RuntimeError [C:3] [TYPE Function]
# @POST Preserve typed RuntimeError reconciliation and session cleanup behavior.
def _transport_RuntimeError(exc, run_id, action, mutating, operation_id, session_manager):
if str(exc) == "PROVIDER_LOOP_NOT_RUNNING":
if session_manager is not None:
session_manager.close(run_id, reason="loop_unavailable")
seam.logger.explore("Provider loop stopped mid-dispatch", src=seam._SRC, error_code="BROWSER_LOOP_UNAVAILABLE")
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_LOOP_UNAVAILABLE")
if mutating:
if session_manager is not None:
session_manager.close(run_id, reason="step_crashed")
seam.logger.explore("Mutating action failed with unknown effect", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_MUTATION_RECONCILE_REQUIRED")
seam.finalize_provider_receipt(operation_id, "reconciliation_required", "unknown", summary={"phase": "runtime_error"})
return seam.LiveAdapterResult(
status="inconclusive",
reason_code="BROWSER_MUTATION_RECONCILE_REQUIRED",
details={"effect_state": "unknown", "reconciliation_required": True, "retry_disposition": "after_reconciliation", "operation_id": operation_id},
)
raise
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.RuntimeError
# #region ScenarioExecution.BrowserProvider.Errors.Dispatch [C:3] [TYPE Function]
# @POST Dispatch original exception precedence and propagate unclassified failures to the outer provider guard.
def _transport_error(exc, run_id, action, mutating, operation_id, session_manager):
handlers = [
(seam.BrowserCheckpointMissing, seam._transport_BrowserCheckpointMissing),
(TimeoutError, seam._transport_TimeoutError),
(seam.ProviderSubmissionOverflow, seam._transport_ProviderSubmissionOverflow),
(seam.BrowserTransportPreconditionMismatch, seam._transport_BrowserTransportPreconditionMismatch),
(seam.BrowserTransportCleanupFailed, seam._transport_BrowserTransportCleanupFailed),
(seam.BrowserTransportReadbackMismatch, seam._transport_BrowserTransportReadbackMismatch),
(seam.BrowserTransportUnsupported, seam._transport_BrowserTransportUnsupported),
(seam.BrowserTransportSelectorNotFound, seam._transport_BrowserTransportSelectorNotFound),
(ValueError, seam._transport_ValueError),
(RuntimeError, seam._transport_RuntimeError),
]
for kind, handler in handlers:
if isinstance(exc,kind):
return handler(exc, run_id, action, mutating, operation_id, session_manager)
return None
# #endregion ScenarioExecution.BrowserProvider.Errors.Dispatch
# #endregion SemanticRepair.browser_provider_errors

View File

@@ -0,0 +1,70 @@
# #region SemanticRepair.browser_provider_evidence [C:4] [TYPE Module]
# @BRIEF Materialize bounded browser evidence, downloads and independent mutation readback.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import browser as seam
# #region SemanticRepair.browser.store_browser_result [C:3] [TYPE Function]
# @BRIEF Store bounded observation/download bytes and finalize independent mutation evidence.
def _store_browser_result(outcome, storage, run_id, action, mutating, operation_id, session_checkpoint, max_screenshot_bytes, max_download_bytes):
evidence = outcome.evidence_png
if not evidence:
seam.logger.explore("Transport produced no evidence", src=seam._SRC, payload={"run_id": run_id}, error_code="BROWSER_EVIDENCE_REQUIRED")
seam.finalize_provider_receipt(operation_id, "reconciliation_required" if mutating else "failed", "unknown" if mutating else "not_started", summary={"phase": "evidence_missing"})
return seam.LiveAdapterResult(status="inconclusive", reason_code="BROWSER_EVIDENCE_REQUIRED")
rejection, artifact_refs, artifact_digests, ref_bytes, ref_types, table_ref = seam.store_browser_observation(
action, outcome, storage, run_id, max_screenshot_bytes=max_screenshot_bytes,
)
if rejection is not None:
seam.finalize_provider_receipt(operation_id, "reconciliation_required" if mutating else "failed", "unknown" if mutating else "not_started", summary={"phase": "evidence_store"})
return rejection
download_bytes = getattr(outcome, "download_bytes", None)
download_ref: str | None = None
if download_bytes is not None:
# Round-2 download action: the captured bytes become a second content-addressed
# artifact ref beside the screenshot evidence (helper enforces the 25 MiB bound
# at the storage gate; read-only, never a mutation receipt).
rejection, download_ref, artifact_refs, artifact_digests, ref_bytes, ref_types = seam.store_download_side_artifact(
download_bytes=download_bytes, storage=storage, run_id=run_id,
artifact_refs=artifact_refs, artifact_digests=artifact_digests,
ref_bytes=ref_bytes, ref_types=ref_types, evidence=evidence,
max_download_bytes=max_download_bytes, operation_id=operation_id,
)
if rejection is not None:
seam.finalize_provider_receipt(operation_id, "failed", "not_started", summary={"phase": "download_store"})
return rejection
effect_state = outcome.effect_state if mutating else "none"
if mutating:
# SCEX-FR-038: the receipt carries the independent readback proof next to the
# mutation flow's own post-rows.
seam.finalize_provider_receipt(operation_id, "completed", effect_state, summary=seam.mutation_readback_summary(outcome))
seam.logger.reflect(
"Browser action completed with evidence", src=seam._SRC,
payload={"run_id": run_id, "checkpoints": list(outcome.checkpoints), "bytes": len(evidence), "effect_state": effect_state, "operation_id": operation_id},
)
return seam.LiveAdapterResult(
status="passed",
reason_code="BROWSER_ACTION_EXECUTED",
details={
"sha256": artifact_digests[artifact_refs[0]],
"checkpoints": list(outcome.checkpoints),
"page_url": outcome.page_url,
"action": action,
"effect_state": effect_state,
**({"operation_id": operation_id} if operation_id else {}),
# Evaluation manifest inputs: per-ref byte length + sniffed MIME let
# ScenarioExecution.EvaluationAdapter.Manifest admit browser/download evidence.
"artifact_byte_lengths": ref_bytes,
"artifact_content_types": ref_types,
**({"download_artifact_ref": download_ref} if download_ref else {}),
**outcome.details,
**({"table_artifact_ref": table_ref} if table_ref else {}),
# DG-1 browser-safe checkpoint: reconstructible filter/tab/wait state slice.
**({"browser_checkpoint": session_checkpoint} if session_checkpoint else {}),
},
artifact_refs=artifact_refs,
artifact_digests=artifact_digests,
)
# #endregion SemanticRepair.browser.store_browser_result
# #endregion SemanticRepair.browser_provider_evidence

View File

@@ -138,20 +138,11 @@ async def apply_table_filter_flow(
# #endregion ScenarioExecution.BrowserProvider.ReadOnlyActions.ApplyTableFilter
# #region ScenarioExecution.BrowserProvider.ReadOnlyActions.ExtractTable [C:4] [TYPE Function] [SEMANTICS provider,browser,extract,table,bounded]
# @RELATION CALLS -> [ScenarioExecution.BrowserScopedFilter.Observe]
# @ingroup ScenarioExecution
# @BRIEF Extract bounded table data from the dashboard DOM (10 000 rows, 100 columns, 10 MiB).
# @POST Returns typed details {columns, rows, row_count, column_count}; oversized output raises
# ValueError("BROWSER_EXTRACT_TOO_LARGE") before evidence is produced.
# @INVARIANT The complete rendered table must fit the declared bounds; unrendered pagination is outside this DOM observation.
# @INVARIANT require_selector pins one visible chart container; missing or ambiguous scope never falls back to another table.
# @REJECTED Silently slicing an oversized rendered table would turn partial evidence into an apparent complete observation.
async def extract_table_flow(
service: Any, page: Any, action_input: dict[str, Any], *, timeout_seconds: float,
) -> dict[str, Any]:
max_rows = int(action_input.get("max_rows") or _MAX_EXTRACT_ROWS)
max_cols = int(action_input.get("max_columns") or _MAX_EXTRACT_COLUMNS)
# #region SemanticRepair.browser_readonly_flows_nav.resolve_extract_table [C:3] [TYPE Function]
# @BRIEF Resolve exact required table scope or the historical optional hint/fallback.
async def _resolve_extract_table(service, page, action_input):
hint = action_input.get("selector_hint")
table_loc = None
if action_input.get("require_selector") is True:
@@ -168,6 +159,24 @@ async def extract_table_flow(
if table_loc is None:
logger.explore("Table container not found for extract_table", src=_SRC, error_code="BROWSER_SELECTOR_NOT_FOUND")
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
return table_loc
# #endregion SemanticRepair.browser_readonly_flows_nav.resolve_extract_table
# #region ScenarioExecution.BrowserProvider.ReadOnlyActions.ExtractTable [C:4] [TYPE Function] [SEMANTICS provider,browser,extract,table,bounded]
# @RELATION CALLS -> [ScenarioExecution.BrowserScopedFilter.Observe]
# @ingroup ScenarioExecution
# @BRIEF Extract bounded table data from the dashboard DOM (10 000 rows, 100 columns, 10 MiB).
# @POST Returns typed details {columns, rows, row_count, column_count}; oversized output raises
# ValueError("BROWSER_EXTRACT_TOO_LARGE") before evidence is produced.
# @INVARIANT The complete rendered table must fit the declared bounds; unrendered pagination is outside this DOM observation.
# @INVARIANT require_selector pins one visible chart container; missing or ambiguous scope never falls back to another table.
# @REJECTED Silently slicing an oversized rendered table would turn partial evidence into an apparent complete observation.
async def extract_table_flow(
service: Any, page: Any, action_input: dict[str, Any], *, timeout_seconds: float,
) -> dict[str, Any]:
max_rows = int(action_input.get("max_rows") or _MAX_EXTRACT_ROWS)
max_cols = int(action_input.get("max_columns") or _MAX_EXTRACT_COLUMNS)
table_loc = await _resolve_extract_table(service, page, action_input)
scope = None
if "required_native_filters" in action_input:
from .browser_scoped_filter import observe_scoped_table

View File

@@ -96,6 +96,30 @@ async def _settled_table(service, page, *, chart_id, column, values, timeout_ms)
# #endregion ScenarioExecution.BrowserScopedFilter.Settle
# #region SemanticRepair.browser_scoped_filter.replace_scoped_values [C:3] [TYPE Function]
# @BRIEF Clear exact-owner chips, select literal options, and close the dropdown.
async def _replace_scoped_values(owner, selector, page, values, timeout_ms, current):
for _ in range(len(current)):
remove = owner.locator(".ant-select-selection-item").first.locator(
".ant-select-selection-item-remove, .ant-tag-close-icon")
if await remove.count() != 1 or not await remove.is_visible():
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await remove.click(timeout=timeout_ms)
if await _observed_values(owner):
raise ValueError("BROWSER_FILTER_SCOPE_MISMATCH")
await selector.click(timeout=timeout_ms)
dropdown = page.locator(".ant-select-dropdown:visible")
await dropdown.first.wait_for(state="visible", timeout=min(timeout_ms, 5000))
for value in values:
option = dropdown.locator(".ant-select-item-option-content").filter(has_text=re.compile("^" + re.escape(value) + "$"))
if await option.count() != 1 or not await option.is_visible():
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
await page.keyboard.press("Escape")
# #endregion SemanticRepair.browser_scoped_filter.replace_scoped_values
# #region ScenarioExecution.BrowserScopedFilter.Apply [C:4] [TYPE Function] [SEMANTICS native,apply,readback,settled]
# @PRE Inputs are a pinned server directive; only STRING IN with explicit column/target chart is supported.
# @POST Reports actual selected chips after Apply and corresponding settled rendered row scope; missing/ambiguous controls refuse.
@@ -120,23 +144,7 @@ async def apply_scoped_native_filter(service, page, filter_input, *, timeout_sec
await page.keyboard.press("Escape")
current = await _observed_values(owner)
if current != values:
for _ in range(len(current)):
remove = owner.locator(".ant-select-selection-item").first.locator(
".ant-select-selection-item-remove, .ant-tag-close-icon")
if await remove.count() != 1 or not await remove.is_visible():
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await remove.click(timeout=timeout_ms)
if await _observed_values(owner):
raise ValueError("BROWSER_FILTER_SCOPE_MISMATCH")
await selector.click(timeout=timeout_ms)
dropdown = page.locator(".ant-select-dropdown:visible")
await dropdown.first.wait_for(state="visible", timeout=min(timeout_ms, 5000))
for value in values:
option = dropdown.locator(".ant-select-item-option-content").filter(has_text=re.compile("^" + re.escape(value) + "$"))
if await option.count() != 1 or not await option.is_visible():
raise BrowserTransportSelectorNotFound("BROWSER_SELECTOR_NOT_FOUND")
await option.click(timeout=timeout_ms)
await page.keyboard.press("Escape")
await _replace_scoped_values(owner, selector, page, values, timeout_ms, current)
observed = await _observed_values(owner)
if observed != values:
raise ValueError("BROWSER_FILTER_SCOPE_MISMATCH")

View File

@@ -39,6 +39,7 @@ from src.services.dashboard_testing.execution.providers.browser_native_filter im
from src.services.dashboard_testing.scenario.templates import validate_action_step
# #region SemanticRepair.runner_plan.topological_order [C:3] [TYPE Function]
def _topological_order(steps: list[dict[str, Any]], edges: list[dict[str, Any]]) -> list[str]:
ids = [str(step.get("logical_step_id", step.get("id", index))) for index, step in enumerate(steps)]
incoming = dict.fromkeys(ids, 0)
@@ -61,6 +62,7 @@ def _topological_order(steps: list[dict[str, Any]], edges: list[dict[str, Any]])
if len(result) != len(ids):
raise ValueError("revision graph contains dependency cycle")
return result
# #endregion SemanticRepair.runner_plan.topological_order
# #region ScenarioExecution.RunnerPlan.EvaluationMode [C:3] [TYPE Function] [SEMANTICS scenario,execution,runnerplan,evaluation,mode]
@@ -129,6 +131,36 @@ def _bind_filter_values_param(step: dict[str, Any], params: dict[str, Any] | Non
# #endregion ScenarioExecution.RunnerPlan.BindParams
# #region SemanticRepair.runner_plan.validate_executable_graph [C:3] [TYPE Function]
# @BRIEF Reject bootstrap-only revisions, unbound baselines and invalid token-only recipes before plan derivation.
def _validate_executable_graph(graph, revision, steps, scenario_id, revision_id):
token_only = any(isinstance(step.get("agent_evaluation_spec"), dict)
and step["agent_evaluation_spec"].get("limits", {}).get("budget_mode") == "token_only"
for step in steps if isinstance(step, dict))
if token_only and (graph.get("schema_version") != 2 or graph.get("metric_text_recipe") is None):
raise ValueError("EVALUATION_TOKEN_ONLY_REQUIRES_RECIPE")
if graph.get("schema_version", revision.schema_version or 1) != 2 and any(
step.get("action") == "compare_to_baseline" or (
isinstance(step.get("expected"), dict) and step["expected"].get("kind") == "baseline_ref"
)
for step in steps if isinstance(step, dict)
):
raise ValueError("UNBOUND_BASELINE_GRAPH")
if {"compiled_handle_id", "draft_pack_id", "draft_pack_digest"} <= set(graph) and not steps:
logger.explore(
"Refusing plan derivation for an un-promoted bootstrap revision",
src="ScenarioExecution.RunnerPlan.Derive",
claim="POST: revision contains an executable, materialized program",
error_code="BOOTSTRAP_REVISION_NOT_RUNNABLE",
payload={"scenario_id": scenario_id, "revision_id": revision_id},
error="create_initial writes draft-pack provenance only; real steps arrive through "
"propose/promote/save/activate before this revision becomes runnable",
)
raise ValueError("BOOTSTRAP_REVISION_NOT_RUNNABLE")
# #endregion SemanticRepair.runner_plan.validate_executable_graph
# #region ScenarioExecution.RunnerPlan.Derive [C:4] [TYPE Function] [SEMANTICS scenario,execution,runnerplan,derive]
# @ingroup ScenarioExecution
# @BRIEF Derive env targets, topological order and executor mapping from a revision snapshot.
@@ -174,29 +206,7 @@ def derive_runner_plan(
registry_version = graph.get("action_registry_version")
registry_hash = graph.get("action_registry_hash")
steps = list(graph.get("steps") or [])
token_only = any(isinstance(step.get("agent_evaluation_spec"), dict)
and step["agent_evaluation_spec"].get("limits", {}).get("budget_mode") == "token_only"
for step in steps if isinstance(step, dict))
if token_only and (graph.get("schema_version") != 2 or graph.get("metric_text_recipe") is None):
raise ValueError("EVALUATION_TOKEN_ONLY_REQUIRES_RECIPE")
if graph.get("schema_version", revision.schema_version or 1) != 2 and any(
step.get("action") == "compare_to_baseline" or (
isinstance(step.get("expected"), dict) and step["expected"].get("kind") == "baseline_ref"
)
for step in steps if isinstance(step, dict)
):
raise ValueError("UNBOUND_BASELINE_GRAPH")
if {"compiled_handle_id", "draft_pack_id", "draft_pack_digest"} <= set(graph) and not steps:
logger.explore(
"Refusing plan derivation for an un-promoted bootstrap revision",
src="ScenarioExecution.RunnerPlan.Derive",
claim="POST: revision contains an executable, materialized program",
error_code="BOOTSTRAP_REVISION_NOT_RUNNABLE",
payload={"scenario_id": scenario_id, "revision_id": revision_id},
error="create_initial writes draft-pack provenance only; real steps arrive through "
"propose/promote/save/activate before this revision becomes runnable",
)
raise ValueError("BOOTSTRAP_REVISION_NOT_RUNNABLE")
_validate_executable_graph(graph, revision, steps, scenario_id, revision_id)
dependencies = list(graph.get("dependencies") or [])
metric_graph = None
if graph.get("schema_version") == 2:

View File

@@ -52,12 +52,14 @@ def _has_covering_evaluation_step(plan: dict[str, Any], outcome: dict[str, Any],
from .evaluation_binding import _covering_evaluation_step, _comparison_id
return _covering_evaluation_step(plan, _comparison_id(outcome, step_meta)) is not None
# #endregion ScenarioExecution.Runner.CoveringEvaluationStep
# T051 deferral release: once every covering agent_evaluation step of the plan is terminal
# (persisted, failed, blocked or skipped), the deferred comparison must be revisited this
# call — a covering step that finished without persisting means the deferral resolves through
# the existing per-step path instead of looping.
# #region SemanticRepair.walker.covering_evaluation_terminal [C:3] [TYPE Function]
def _covering_evaluation_terminal(existing: dict[str, Any], plan: dict[str, Any]) -> bool:
eval_steps = [
str(step.get("logical_step_id"))
@@ -69,11 +71,13 @@ def _covering_evaluation_terminal(existing: dict[str, Any], plan: dict[str, Any]
and existing[step_id].status in {"passed", "failed", "inconclusive", "blocked", "skipped"}
for step_id in eval_steps
)
# #endregion SemanticRepair.walker.covering_evaluation_terminal
# T051 deadlock guard: does any dependency path lead from this step to an agent_evaluation
# step? Deferring a step the evaluation consumes would wait on the evaluation's own input —
# the defer must only apply when the evaluation can actually run without this step.
# #region SemanticRepair.walker.step_feeds_evaluation [C:3] [TYPE Function]
def _step_feeds_evaluation(plan: dict[str, Any], step_id: str) -> bool:
edges = plan.get("dependencies") or []
children: dict[str, list[str]] = {}
@@ -99,7 +103,7 @@ def _step_feeds_evaluation(plan: dict[str, Any], step_id: str) -> bool:
return True
frontier.append(target)
return False
# #endregion ScenarioExecution.Runner.CoveringEvaluationStep
# #endregion SemanticRepair.walker.step_feeds_evaluation
@@ -124,7 +128,7 @@ def _step_feeds_evaluation(plan: dict[str, Any], step_id: str) -> bool:
# @REJECTED Registering evidence with an all-zero digest was rejected — it falsifies provenance.
# @REJECTED Treating every non-human tool as retry-safe was rejected because it can replay a
# mutating browser/report/artifact effect after crash or retry.
def _advance_run(db: Session, run: ScenarioRun, registry: ScenarioExecutorRegistry, *, worker_id: str = "api", lease_seconds: int = 30) -> dict[str, Any]: # noqa: C901
def _advance_run(db: Session, run: ScenarioRun, registry: ScenarioExecutorRegistry, *, worker_id: str = "api", lease_seconds: int = 30) -> dict[str, Any]:
plan = run.runner_plan or {}
try:
validate_pinned_runner_plan(plan)
@@ -170,289 +174,9 @@ def _advance_run(db: Session, run: ScenarioRun, registry: ScenarioExecutorRegist
db.flush()
return build_result(run, list(existing.values()))
step_id = next_step_id
step_meta = next(
step for step in (plan.get("steps") or [])
if str(step.get("logical_step_id", step.get("id", ""))) == step_id
)
tool = str(step_meta["tool"])
descriptor = step_meta["action_descriptor"]
step: ScenarioStepRun | None = None
if tool != "human":
if step_id not in existing:
step = ScenarioStepRun(
id=str(uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="queued",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=0,
step_outcome={},
)
db.add(step)
db.flush()
existing[step_id] = step
step = existing[step_id]
step.status = "running"
step.started_at = datetime.now(UTC)
db.flush()
try:
claim_step(
db,
run.id,
step_id,
worker_id=worker_id,
side_effect_key=(
f"{run.id}:{step_id}:{step.attempt}"
if descriptor["side_effect_key_policy"] == "per_attempt"
else None
),
idempotent=bool(descriptor["idempotent"]),
retry_safe=bool(descriptor["retry_safe"]),
lease_seconds=max(1, min(lease_seconds, int(descriptor["timeout_ms"]) // 1000)),
)
except ValueError:
step.status = "queued"
db.flush()
return build_result(run, list(existing.values()))
if tool == 'browser' and step_meta['action'] in {'pagination', 'navigate_tabs'}:
# Restricted long traversal journals renew committed worker/provider leases independently.
db.commit()
outcome = dispatch_step(
{
"logical_step_id": step_id,
"tool": tool,
"action": step_meta["action"],
"action_descriptor": descriptor,
"step_meta": step_meta,
"scenario_run_id": run.id,
"target_snapshot": run.target_snapshot,
"execution_principal_fingerprint": run.execution_principal_fingerprint,
"live_execution_binding_ref": run.live_execution_binding_ref,
"live_execution_binding_snapshot": run.live_execution_binding_snapshot,
},
completed=completed,
registry=registry,
edges=dependencies,
)
db.refresh(run)
if tool == 'browser' and step_meta['action'] in {'pagination', 'navigate_tabs'} and run.phase == 'paused':
step.status = 'queued'
db.flush()
return build_result(run, list(existing.values()))
if step is not None and outcome.get("error_code") in _CAPACITY_RETRY_CODES:
return block_run_on_capacity(db, run, step, outcome)
if (
step is not None
and run.status == "inconclusive"
and step.status == "inconclusive"
and step.error_code == "STEP_TIMEOUT"
):
return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all())
if outcome.get("status") == "blocked":
step = step or existing.get(step_id) or ScenarioStepRun(
id=str(uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="blocked",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=100,
error_code=outcome.get("reason") or "dependency_failed",
step_outcome=outcome,
)
step.status = "blocked"
step.progress = 100
step.error_code = outcome.get("reason") or "dependency_failed"
step.step_outcome = outcome
step.finished_at = datetime.now(UTC)
db.add(step)
db.flush()
continue
if tool == "human":
step = existing.get(step_id) or ScenarioStepRun(
id=str(uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="queued",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=0,
step_outcome={},
)
step.status = "waiting_human"
step.progress = 100
db.add(step)
db.flush()
suspend_for_human(db, run.id, step_id, evidence_refs=[])
db.flush()
return build_result(run, [*existing.values(), step])
assert step is not None
step.step_outcome = outcome
recovery_history = (step.outputs or {}).get("recovery_history") or []
if any(entry.get("reconstruction_replay") for entry in recovery_history):
outcome = {**outcome, "reconstruction_replay": True}
step.step_outcome = outcome
step.status = str(outcome.get("status", "failed"))
step.progress = 100 if step.status in {"passed", "failed", "inconclusive", "blocked"} else 0
if step.status in {"failed", "inconclusive", "blocked"}:
step.error_code = outcome.get("error_code") or "executor_failed"
if step.status in {"passed", "failed", "inconclusive", "blocked"}:
step.finished_at = datetime.now(UTC)
if outcome.get("output_refs"):
step.outputs = {"refs": outcome["output_refs"]}
integrity = None
if outcome.get("artifact_refs"):
step.artifact_refs = list(outcome["artifact_refs"])
integrity = register_step_evidence(
db,
run_id=run.id,
logical_step_id=step_id,
artifact_refs=list(outcome["artifact_refs"]),
outcome=outcome,
attempt=step.attempt,
)
if integrity is not None:
outcome = {
**outcome,
"status": "inconclusive",
"error_code": integrity["reason_code"],
"artifact_integrity": integrity,
}
step.step_outcome = outcome
step.status = "inconclusive"
step.error_code = integrity["reason_code"]
evaluation_record = _evaluation_record_from_outcome(step.step_outcome)
evaluation_publish_failed = False
if step_meta.get("tool") == "agent_evaluation" and isinstance(evaluation_record, dict):
evaluation_record = stamp_baseline_pin(evaluation_record, run.runner_plan or {})
try:
record = AgentEvaluation.model_validate(evaluation_record)
validate_evaluation_evidence(
db, run_id=run.id, logical_step_id=step_id, attempt=step.attempt,
input_manifest=record.model_dump().get("input_manifest", []),
raw_response_artifact_ref=record.raw_response_artifact_ref,
raw_response_sha256=record.raw_response_sha256,
findings=record.model_dump().get("findings", []),
succeeded=record.status == "succeeded",
)
row = persist_agent_evaluation(db, record)
step.step_outcome = {**step.step_outcome, "agent_evaluation_ids": [row.evaluation_id]}
except ValueError as exc:
evaluation_publish_failed = True
step.status = "inconclusive"
step.error_code = str(exc)
step.step_outcome = {**step.step_outcome, "agent_evaluation_ids": []}
decision_inputs = None if evaluation_publish_failed else policy_inputs_from_outcome(outcome, step_meta, integrity)
if decision_inputs is not None and decision_inputs.evaluation is None and str(step_meta.get("tool")) == "assertion":
# T051/T046 closure: the canonical chain places the declared evaluation BETWEEN the
# comparison and the pinned policy — the binding applies to COMPARISON steps only.
# Evidence-producing steps (screenshot/sql) precede their covering evaluation by
# chain order and cannot await it (a defer there deadlocks the evaluation's own inputs).
from .evaluation_binding import bind_covering_evaluation
from .runner_plan import resolve_pinned_policy as _policy_for_binding
policy_now = _policy_for_binding(run.runner_plan or {})
if policy_now.evaluation_mode == "required":
bound = bind_covering_evaluation(
db, run_id=run.id, plan=plan, outcome=outcome, step_meta=step_meta,
)
if bound is not None:
from .decision_policy import EvaluationInput
decision_inputs = decision_inputs.model_copy(
update={"evaluation": EvaluationInput.model_validate(bound)}
)
outcome = {**outcome, "evaluation_input": bound}
# The comparison decision is now covered by the bound evaluation: stamp
# its identity on the comparison step for traceability (the record itself
# stays owned by the covering agent_evaluation step).
outcome = {**outcome, "agent_evaluation_ids": [bound["evaluation_id"]]}
step.step_outcome = {
**step.step_outcome,
"evaluation_input": bound,
"agent_evaluation_ids": [bound["evaluation_id"]],
}
elif _has_covering_evaluation_step(plan, outcome, step_meta) and not _step_feeds_evaluation(plan, step_id):
# Defer ONLY when the covering evaluation does not depend on this step:
# deferring a producer the evaluation itself consumes would deadlock the
# chain (live finding 2026-09-21: evaluate-visual depends on capture).
step.status = "queued"
step.progress = 0
_deferred_this_call.add(step_id)
db.flush()
continue
if decision_inputs is not None:
policy = resolve_pinned_policy(run.runner_plan or {})
decision = decide_step_outcome(decision_inputs, policy)
if decision.status is not None:
step.step_outcome = {
**step.step_outcome,
"status": decision.status,
"decision_policy_id": policy.policy_id,
"decision_policy_version": policy.version,
"reason_codes": decision.reason_codes,
"comparison_ids": [comparison.comparison_id for comparison in decision_inputs.comparisons],
"agent_evaluation_ids": step.step_outcome.get("agent_evaluation_ids", []),
"deterministic_evidence_refs": verified_evidence_refs(outcome, integrity),
"decided_at": datetime.now(UTC).isoformat(),
}
step.status = decision.status
if decision.status in {"failed", "inconclusive", "blocked"} and not step.error_code:
step.error_code = decision.reason_codes[0]
db.flush()
db.refresh(run)
if run.status == "cancel_requested":
from src.services.dashboard_testing.execution.lifecycle import cancel_run
cancel_run(db, run.id, drain_in_flight=False)
return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all())
if step.status in {"passed", "failed", "inconclusive", "blocked"}:
completed[step_id] = step.step_outcome
if run.status in {"pending_approval", "queued", "running", "waiting_human"}:
# A step-level inconclusive result is aggregated after all reachable steps; it must not
# make the run terminal before a later human checkpoint can safely suspend it.
run.status = step.status if step.status in {"failed", "blocked"} else run.status
run.phase = "executing"
db.flush()
# Bound v2 comparison requires committed producer/artifact ownership evidence.
# Return to the service transaction before the next worker advance compares it.
recipe = (plan.get("metric_graph") or {}).get("metric_text_recipe") or {}
if (plan.get("metric_admission_version") == 1 and recipe.get("recipe_id") == "table_text_v1"
and step.status in {"failed", "blocked", "inconclusive"}):
# Every node in the closed recipe is required; unproven upstream context
# cannot authorize a dependent browser observation or provider request.
run.status, run.phase = step.status, "completed"
run.finished_at = datetime.now(UTC)
db.flush()
_record_terminal_side_effects(db, run)
db.flush()
return build_result(run, list(existing.values()))
durable_recipe_browser = (recipe.get("recipe_id") == "table_text_v1"
and step_meta.get("action") == "extract_table")
if (plan.get("metric_admission_version") == 1 and step.status == "passed"
and (step_meta.get("action") == "execute_metric" or durable_recipe_browser)):
# Requeue atomically with the completed producer/evidence transaction;
# the next dispatcher CAS resumes at comparison, never replays this step.
claimed = db.execute(update(ScenarioRun).where(
ScenarioRun.id == run.id, ScenarioRun.status == "running",
).values(status="queued", phase="executing"))
if claimed.rowcount == 1:
from .lifecycle_helpers import _expire_step_leases
_expire_step_leases(db, run.id, {step_id})
db.flush()
db.refresh(run)
return build_result(run, list(existing.values()))
result = _advance_step(db, run, registry, worker_id, lease_seconds, plan, order, dependencies, existing, completed, next_step_id, _deferred_this_call)
if result is not None:
return result
run.status = build_result(run, list(existing.values()))["status"]
run.phase = "completed"
@@ -462,4 +186,100 @@ def _advance_run(db: Session, run: ScenarioRun, registry: ScenarioExecutorRegist
return build_result(run, list(existing.values()))
# #endregion ScenarioExecution.Runner.Walker
from .walker_step_control import _claim_automated_step # noqa: F401
from .walker_publication import _retain_step_outcome # noqa: F401
from .walker_publication import _publish_step_evaluation # noqa: F401
from .walker_publication import _apply_step_policy # noqa: F401
from .walker_step_control import _handle_suspended_step # noqa: F401
from .walker_step_control import _commit_metric_frontier # noqa: F401
from .walker_step_aggregation import _aggregate_step_state # noqa: F401
# #region ScenarioExecution.Walker.AdvanceStep [C:5] [TYPE Function]
# @POST One selected DAG step keeps lease, outcome, policy and transaction-frontier ordering.
def _advance_step(db, run, registry, worker_id, lease_seconds, plan, order, dependencies, existing, completed, step_id, _deferred_this_call):
step_meta = next(
step for step in (plan.get("steps") or [])
if str(step.get("logical_step_id", step.get("id", ""))) == step_id
)
tool = str(step_meta["tool"])
descriptor = step_meta["action_descriptor"]
step: ScenarioStepRun | None = None
step, claim_failed = _claim_automated_step(db, run, step_id, order, existing, descriptor, worker_id, lease_seconds, tool)
if claim_failed:
return build_result(run,list(existing.values()))
if tool == 'browser' and step_meta['action'] in {'pagination', 'navigate_tabs'}:
# Restricted long traversal journals renew committed worker/provider leases independently.
db.commit()
outcome = dispatch_step(
{
"logical_step_id": step_id,
"tool": tool,
"action": step_meta["action"],
"action_descriptor": descriptor,
"step_meta": step_meta,
"scenario_run_id": run.id,
"target_snapshot": run.target_snapshot,
"execution_principal_fingerprint": run.execution_principal_fingerprint,
"live_execution_binding_ref": run.live_execution_binding_ref,
"live_execution_binding_snapshot": run.live_execution_binding_snapshot,
},
completed=completed,
registry=registry,
edges=dependencies,
)
db.refresh(run)
if tool == 'browser' and step_meta['action'] in {'pagination', 'navigate_tabs'} and run.phase == 'paused':
step.status = 'queued'
db.flush()
return build_result(run, list(existing.values()))
if step is not None and outcome.get("error_code") in _CAPACITY_RETRY_CODES:
return block_run_on_capacity(db, run, step, outcome)
if (
step is not None
and run.status == "inconclusive"
and step.status == "inconclusive"
and step.error_code == "STEP_TIMEOUT"
):
return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all())
handled, result = _handle_suspended_step(db, run, step, step_id, order, existing, outcome, tool)
if handled:
return result
assert step is not None
outcome, integrity = _retain_step_outcome(db, run, step, step_id, outcome)
evaluation_publish_failed = _publish_step_evaluation(db, run, step, step_id, step_meta)
if _apply_step_policy(db, run, step, step_id, step_meta, plan, outcome, integrity, evaluation_publish_failed, _deferred_this_call):
return None
db.flush()
db.refresh(run)
if run.status == "cancel_requested":
from src.services.dashboard_testing.execution.lifecycle import cancel_run
cancel_run(db, run.id, drain_in_flight=False)
return build_result(run, db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id == run.id).all())
_aggregate_step_state(db, run, step, step_id, completed)
return _commit_metric_frontier(db, run, step, step_id, step_meta, plan, existing)
# #endregion ScenarioExecution.Walker.AdvanceStep
# #endregion ScenarioExecution.Walker

View File

@@ -0,0 +1,135 @@
# #region SemanticRepair.walker_publication [C:4] [TYPE Module]
# @BRIEF Publish owned step artifacts/evaluations and apply pinned comparison policy.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import walker as seam
# #region SemanticRepair.walker.retain_step_outcome [C:3] [TYPE Function]
# @BRIEF Retain executor outcome and owned artifacts while preserving reconstruction history and integrity refusals.
def _retain_step_outcome(db, run, step, step_id, outcome):
step.step_outcome = outcome
recovery_history = (step.outputs or {}).get("recovery_history") or []
if any(entry.get("reconstruction_replay") for entry in recovery_history):
outcome = {**outcome, "reconstruction_replay": True}
step.step_outcome = outcome
step.status = str(outcome.get("status", "failed"))
step.progress = 100 if step.status in {"passed", "failed", "inconclusive", "blocked"} else 0
if step.status in {"failed", "inconclusive", "blocked"}:
step.error_code = outcome.get("error_code") or "executor_failed"
if step.status in {"passed", "failed", "inconclusive", "blocked"}:
step.finished_at = seam.datetime.now(seam.UTC)
if outcome.get("output_refs"):
step.outputs = {"refs": outcome["output_refs"]}
integrity = None
if outcome.get("artifact_refs"):
step.artifact_refs = list(outcome["artifact_refs"])
integrity = seam.register_step_evidence(
db,
run_id=run.id,
logical_step_id=step_id,
artifact_refs=list(outcome["artifact_refs"]),
outcome=outcome,
attempt=step.attempt,
)
if integrity is not None:
outcome = {
**outcome,
"status": "inconclusive",
"error_code": integrity["reason_code"],
"artifact_integrity": integrity,
}
step.step_outcome = outcome
step.status = "inconclusive"
step.error_code = integrity["reason_code"]
return outcome, integrity
# #endregion SemanticRepair.walker.retain_step_outcome
# #region SemanticRepair.walker.publish_step_evaluation [C:3] [TYPE Function]
# @BRIEF Validate and persist immutable evaluation evidence; failed publication remains inconclusive.
def _publish_step_evaluation(db, run, step, step_id, step_meta):
evaluation_record = seam._evaluation_record_from_outcome(step.step_outcome)
evaluation_publish_failed = False
if step_meta.get("tool") == "agent_evaluation" and isinstance(evaluation_record, dict):
evaluation_record = seam.stamp_baseline_pin(evaluation_record, run.runner_plan or {})
try:
record = seam.AgentEvaluation.model_validate(evaluation_record)
seam.validate_evaluation_evidence(
db, run_id=run.id, logical_step_id=step_id, attempt=step.attempt,
input_manifest=record.model_dump().get("input_manifest", []),
raw_response_artifact_ref=record.raw_response_artifact_ref,
raw_response_sha256=record.raw_response_sha256,
findings=record.model_dump().get("findings", []),
succeeded=record.status == "succeeded",
)
row = seam.persist_agent_evaluation(db, record)
step.step_outcome = {**step.step_outcome, "agent_evaluation_ids": [row.evaluation_id]}
except ValueError as exc:
evaluation_publish_failed = True
step.status = "inconclusive"
step.error_code = str(exc)
step.step_outcome = {**step.step_outcome, "agent_evaluation_ids": []}
return evaluation_publish_failed
# #endregion SemanticRepair.walker.publish_step_evaluation
# #region SemanticRepair.walker.apply_step_policy [C:3] [TYPE Function]
# @BRIEF Bind committed covering evaluations, defer unresolved comparisons, and apply the pinned policy.
def _apply_step_policy(db, run, step, step_id, step_meta, plan, outcome, integrity, evaluation_publish_failed, _deferred_this_call):
decision_inputs = None if evaluation_publish_failed else seam.policy_inputs_from_outcome(outcome, step_meta, integrity)
if decision_inputs is not None and decision_inputs.evaluation is None and str(step_meta.get("tool")) == "assertion":
# T051/T046 closure: the canonical chain places the declared evaluation BETWEEN the
# comparison and the pinned policy — the binding applies to COMPARISON steps only.
# Evidence-producing steps (screenshot/sql) precede their covering evaluation by
# chain order and cannot await it (a defer there deadlocks the evaluation's own inputs).
from .evaluation_binding import bind_covering_evaluation
from .runner_plan import resolve_pinned_policy as _policy_for_binding
policy_now = _policy_for_binding(run.runner_plan or {})
if policy_now.evaluation_mode == "required":
bound = bind_covering_evaluation(
db, run_id=run.id, plan=plan, outcome=outcome, step_meta=step_meta,
)
if bound is not None:
from .decision_policy import EvaluationInput
decision_inputs = decision_inputs.model_copy(
update={"evaluation": EvaluationInput.model_validate(bound)}
)
outcome = {**outcome, "evaluation_input": bound}
# The comparison decision is now covered by the bound evaluation: stamp
# its identity on the comparison step for traceability (the record itself
# stays owned by the covering agent_evaluation step).
outcome = {**outcome, "agent_evaluation_ids": [bound["evaluation_id"]]}
step.step_outcome = {
**step.step_outcome,
"evaluation_input": bound,
"agent_evaluation_ids": [bound["evaluation_id"]],
}
elif seam._has_covering_evaluation_step(plan, outcome, step_meta) and not seam._step_feeds_evaluation(plan, step_id):
# Defer ONLY when the covering evaluation does not depend on this step:
# deferring a producer the evaluation itself consumes would deadlock the
# chain (live finding 2026-09-21: evaluate-visual depends on capture).
step.status = "queued"
step.progress = 0
_deferred_this_call.add(step_id)
db.flush()
return True
if decision_inputs is not None:
policy = seam.resolve_pinned_policy(run.runner_plan or {})
decision = seam.decide_step_outcome(decision_inputs, policy)
if decision.status is not None:
step.step_outcome = {
**step.step_outcome,
"status": decision.status,
"decision_policy_id": policy.policy_id,
"decision_policy_version": policy.version,
"reason_codes": decision.reason_codes,
"comparison_ids": [comparison.comparison_id for comparison in decision_inputs.comparisons],
"agent_evaluation_ids": step.step_outcome.get("agent_evaluation_ids", []),
"deterministic_evidence_refs": seam.verified_evidence_refs(outcome, integrity),
"decided_at": seam.datetime.now(seam.UTC).isoformat(),
}
step.status = decision.status
if decision.status in {"failed", "inconclusive", "blocked"} and not step.error_code:
step.error_code = decision.reason_codes[0]
return False
# #endregion SemanticRepair.walker.apply_step_policy
# #endregion SemanticRepair.walker_publication

View File

@@ -0,0 +1,20 @@
# #region SemanticRepair.walker_step_aggregation [C:4] [TYPE Module]
# @BRIEF Aggregate terminal step state without premature scenario completion.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import walker as seam
# #region SemanticRepair.walker.aggregate_step_state [C:3] [TYPE Function]
# @BRIEF Advance completed-step bookkeeping without making inconclusive results prematurely terminal.
def _aggregate_step_state(db, run, step, step_id, completed):
if step.status in {"passed", "failed", "inconclusive", "blocked"}:
completed[step_id] = step.step_outcome
if run.status in {"pending_approval", "queued", "running", "waiting_human"}:
# A step-level inconclusive result is aggregated after all reachable steps; it must not
# make the run terminal before a later human checkpoint can safely suspend it.
run.status = step.status if step.status in {"failed", "blocked"} else run.status
run.phase = "executing"
db.flush()
# #endregion SemanticRepair.walker.aggregate_step_state
# #endregion SemanticRepair.walker_step_aggregation

View File

@@ -0,0 +1,139 @@
# #region SemanticRepair.walker_step_control [C:4] [TYPE Module]
# @BRIEF Manage descriptor leases, human suspension and committed metric producer frontiers.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import walker as seam
# #region SemanticRepair.walker.claim_automated_step [C:3] [TYPE Function]
# @BRIEF Create or reuse the step, claim its descriptor-owned lease, and restore queued state on claim refusal.
def _claim_automated_step(db, run, step_id, order, existing, descriptor, worker_id, lease_seconds, tool):
step = None
if tool != "human":
if step_id not in existing:
step = seam.ScenarioStepRun(
id=str(seam.uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="queued",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=0,
step_outcome={},
)
db.add(step)
db.flush()
existing[step_id] = step
step = existing[step_id]
step.status = "running"
step.started_at = seam.datetime.now(seam.UTC)
db.flush()
try:
seam.claim_step(
db,
run.id,
step_id,
worker_id=worker_id,
side_effect_key=(
f"{run.id}:{step_id}:{step.attempt}"
if descriptor["side_effect_key_policy"] == "per_attempt"
else None
),
idempotent=bool(descriptor["idempotent"]),
retry_safe=bool(descriptor["retry_safe"]),
lease_seconds=max(1, min(lease_seconds, int(descriptor["timeout_ms"]) // 1000)),
)
except ValueError:
step.status = "queued"
db.flush()
return step,True
return step,False
# #endregion SemanticRepair.walker.claim_automated_step
# #region SemanticRepair.walker.handle_suspended_step [C:3] [TYPE Function]
# @BRIEF Persist blocked outcomes or suspend genuine human checkpoints without ordinary evidence execution.
def _handle_suspended_step(db, run, step, step_id, order, existing, outcome, tool):
if outcome.get("status") == "blocked":
step = step or existing.get(step_id) or seam.ScenarioStepRun(
id=str(seam.uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="blocked",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=100,
error_code=outcome.get("reason") or "dependency_failed",
step_outcome=outcome,
)
step.status = "blocked"
step.progress = 100
step.error_code = outcome.get("reason") or "dependency_failed"
step.step_outcome = outcome
step.finished_at = seam.datetime.now(seam.UTC)
db.add(step)
db.flush()
return True,None
if tool == "human":
step = existing.get(step_id) or seam.ScenarioStepRun(
id=str(seam.uuid.uuid4()),
run_id=run.id,
logical_step_id=step_id,
step_position=order.index(step_id),
attempt=1,
status="queued",
inputs_snapshot={},
outputs={},
artifact_refs=[],
progress=0,
step_outcome={},
)
step.status = "waiting_human"
step.progress = 100
db.add(step)
db.flush()
seam.suspend_for_human(db, run.id, step_id, evidence_refs=[])
db.flush()
return True,seam.build_result(run, [*existing.values(), step])
return False,None
# #endregion SemanticRepair.walker.handle_suspended_step
# #region SemanticRepair.walker.commit_metric_frontier [C:3] [TYPE Function]
# @BRIEF Commit required recipe failures or requeue durable producers before dependent comparisons.
def _commit_metric_frontier(db, run, step, step_id, step_meta, plan, existing):
# Bound v2 comparison requires committed producer/artifact ownership evidence.
# Return to the service transaction before the next worker advance compares it.
recipe = (plan.get("metric_graph") or {}).get("metric_text_recipe") or {}
if (plan.get("metric_admission_version") == 1 and recipe.get("recipe_id") == "table_text_v1"
and step.status in {"failed", "blocked", "inconclusive"}):
# Every node in the closed recipe is required; unproven upstream context
# cannot authorize a dependent browser observation or provider request.
run.status, run.phase = step.status, "completed"
run.finished_at = seam.datetime.now(seam.UTC)
db.flush()
seam._record_terminal_side_effects(db, run)
db.flush()
return seam.build_result(run, list(existing.values()))
durable_recipe_browser = (recipe.get("recipe_id") == "table_text_v1"
and step_meta.get("action") == "extract_table")
if (plan.get("metric_admission_version") == 1 and step.status == "passed"
and (step_meta.get("action") == "execute_metric" or durable_recipe_browser)):
# Requeue atomically with the completed producer/evidence transaction;
# the next dispatcher CAS resumes at comparison, never replays this step.
claimed = db.execute(seam.update(seam.ScenarioRun).where(
seam.ScenarioRun.id == run.id, seam.ScenarioRun.status == "running",
).values(status="queued", phase="executing"))
if claimed.rowcount == 1:
from .lifecycle_helpers import _expire_step_leases
_expire_step_leases(db, run.id, {step_id})
db.flush()
db.refresh(run)
return seam.build_result(run, list(existing.values()))
# #endregion SemanticRepair.walker.commit_metric_frontier
# #endregion SemanticRepair.walker_step_control

View File

@@ -33,173 +33,25 @@ from src.schemas.dashboard_testing import (
)
# #region BaselineEngine.QueryModel.Inspect.SafeJsonLoad [C:1] [TYPE Function] [SEMANTICS json,parsing]
# @ingroup BaselineEngine
# @BRIEF Safely parse JSON from string or dict, returning empty dict on failure.
def _safe_json_load(raw: Any) -> dict:
"""Parse JSON from string or return empty dict on failure."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
return {}
# #endregion BaselineEngine.QueryModel.Inspect.SafeJsonLoad
from .query_model_parsing import _safe_json_load # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ParseVizType [C:1] [TYPE Function] [SEMANTICS superset,viz-type]
# @ingroup BaselineEngine
# @BRIEF Map Superset viz_type string to VizType enum.
def _parse_viz_type(raw: str | None) -> VizType:
"""Map Superset viz_type string to our enum."""
if not raw:
return VizType.OTHER
mapping: dict[str, VizType] = {
"table": VizType.TABLE, "bar": VizType.BAR, "line": VizType.LINE,
"pie": VizType.PIE, "big_number": VizType.BIG_NUMBER,
"big_number_total": VizType.BIG_NUMBER_TOTAL,
"filter_box": VizType.FILTER_BOX,
}
return mapping.get(raw, VizType.OTHER)
# #endregion BaselineEngine.QueryModel.Inspect.ParseVizType
from .query_model_parsing import _parse_viz_type # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ComputeFingerprint [C:2] [TYPE Function] [SEMANTICS fingerprint,sha256]
# @ingroup BaselineEngine
# @BRIEF Compute deterministic SHA-256 fingerprint from canonical sorted JSON.
def _compute_fingerprint(model_dict: dict) -> str:
"""Compute deterministic SHA-256 fingerprint of the query model."""
canonical = json.dumps(model_dict, sort_keys=True, default=str)
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
# #endregion BaselineEngine.QueryModel.Inspect.ComputeFingerprint
from .query_model_parsing import _compute_fingerprint # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ParseNativeFilters [C:2] [TYPE Function] [SEMANTICS parsing,native-filters]
# @ingroup BaselineEngine
# @BRIEF Extract native filter models from json_metadata.native_filter_configuration.
# @RATIONALE Superset emits the time-grain discriminator as `filter_timegrain`; normalize it to the existing TIME_GRAIN model type.
# @REJECTED Treating this alias as STRING was rejected because its `time_grain_sqla` payload selects temporal grain, not a dimension value.
def _parse_native_filters(raw_filters: list) -> list[NativeFilterModel]:
"""Extract native filter models from json_metadata."""
result: list[NativeFilterModel] = []
for rf in raw_filters:
targets: list[FilterTarget] = []
filter_type = rf.get("filterType", "filter_select")
type_map = {
"filter_date": "DATE", "filter_time": "TIME",
"filter_time_grain": "TIME_GRAIN", "filter_timegrain": "TIME_GRAIN",
"filter_range": "NUMERIC",
"filter_select": "STRING",
}
result.append(NativeFilterModel(
filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER",
name=rf.get("name", rf.get("id", "")),
column=(rf.get("targets") or [{}])[0].get("column", {}).get("name", ""),
dataset_id=(rf.get("targets") or [{}])[0].get("datasetId", 0),
type=type_map.get(filter_type, "STRING"),
targets=targets,
))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ParseNativeFilters
from .query_model_parsing import _parse_native_filters # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ParseMetrics [C:2] [TYPE Function] [SEMANTICS parsing,metrics]
# @ingroup BaselineEngine
# @BRIEF Parse metrics from raw metric list (strings or dicts with expression type).
# @RATIONALE Null-safe: Superset metrics may carry label=None or metric_name=None
# (e.g. dashboard #3) — None is coerced to a non-empty fallback instead of
# failing MetricDescriptor validation (previously 500 "Input should be a valid
# string" which aborted the whole query-model inspection).
def _parse_metrics(raw_metrics: list) -> list[MetricDescriptor]:
"""Parse metrics from raw metric list."""
result: list[MetricDescriptor] = []
for rm in raw_metrics:
if isinstance(rm, str):
result.append(MetricDescriptor(
metric_name=rm, label=rm, expression_type="SIMPLE"))
elif isinstance(rm, dict):
metric_name = rm.get("metric_name") or rm.get("label") or ""
label = rm.get("label") or metric_name or ""
expr = rm.get("expressionType")
expression_type = (
expr if expr in ("SIMPLE", "SQL_EXPRESSION", "SAVED_METRIC") else "SIMPLE"
)
column_ref = None
raw_column = rm.get("column")
if isinstance(raw_column, dict):
column_ref = ColumnRef(
column_name=raw_column.get("column_name") or "",
type=raw_column.get("type"),
)
aggregate = rm.get("aggregate")
sql_expression = rm.get("sqlExpression")
result.append(MetricDescriptor(
metric_name=metric_name,
label=label,
expression_type=expression_type,
column=column_ref,
aggregate=aggregate if isinstance(aggregate, str) else None,
sql_expression=sql_expression if isinstance(sql_expression, str) else None,
chart_spec=rm,
))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ParseMetrics
from .query_model_parsing import _parse_metrics # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.DatasetId [C:2] [TYPE Function] [SEMANTICS superset,chart,dataset]
# @BRIEF Resolve a chart dataset ID from authoritative chart metadata or Superset form_data datasource.
def _chart_dataset_id(chart_obj: dict, form_data: dict) -> int:
raw_id = chart_obj.get("datasource_id")
if isinstance(raw_id, int) and raw_id > 0:
return raw_id
if isinstance(raw_id, str) and raw_id.isdecimal() and int(raw_id) > 0:
return int(raw_id)
datasource = form_data.get("datasource")
if isinstance(datasource, str):
match = datasource.split("__", 1)
if len(match) == 2 and match[0].isdecimal() and match[1] == "table" and int(match[0]) > 0:
return int(match[0])
return 0
# #endregion BaselineEngine.QueryModel.Inspect.DatasetId
from .query_model_parsing import _chart_dataset_id # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ProcessChartsData [C:3] [TYPE Function] [SEMANTICS processing,charts]
# @ingroup BaselineEngine
# @BRIEF Process chart metadata from dashboard/charts endpoint into ChartQueryModel list.
# @RELATION CALLS -> [BaselineEngine.QueryModel.MetricMetadata.Metrics]
def _process_charts_data(charts_data: list) -> list[ChartQueryModel]:
"""Process chart metadata from dashboard/charts endpoint."""
charts: list[ChartQueryModel] = []
for chart_obj in charts_data:
cid = chart_obj.get("id")
if cid is None:
continue
cid = int(cid)
form_data = _safe_json_load(chart_obj.get("form_data", "{}"))
params_str = chart_obj.get("params")
params = _safe_json_load(params_str) if params_str else {}
raw_metrics, metric_authority = chart_metrics(params, form_data)
metrics = _parse_metrics(raw_metrics)
groupby = params.get("groupby") or form_data.get("groupby", [])
charts.append(ChartQueryModel(
chart_id=cid,
chart_uuid=chart_obj.get("uuid"),
slice_name=chart_obj.get("slice_name", f"Chart {cid}"),
viz_type=_parse_viz_type(
form_data.get("viz_type") or chart_obj.get("viz_type")),
dataset_id=_chart_dataset_id(chart_obj, form_data),
dataset_uuid=None,
dataset_name=chart_obj.get("datasource_name_text", ""),
metrics=metrics,
group_by_columns=list(groupby) if groupby else [],
applied_filter_ids=[],
excluded_filter_ids=[],
execution_capable=metric_authority,
))
return charts
# #endregion BaselineEngine.QueryModel.Inspect.ProcessChartsData
from .query_model_parsing import _process_charts_data # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.FetchRemainingCharts [C:3] [TYPE Function] [SEMANTICS fetching,charts,individual]
@@ -245,111 +97,16 @@ async def _fetch_remaining_charts(
# #endregion BaselineEngine.QueryModel.Inspect.FetchRemainingCharts
# #region BaselineEngine.QueryModel.Inspect.ProcessDatasetsData [C:2] [TYPE Function] [SEMANTICS processing,datasets]
# @ingroup BaselineEngine
# @BRIEF Process dataset metadata from dashboard/datasets endpoint into DatasetQueryModel.
def _process_datasets_data(datasets_data: list) -> list[DatasetQueryModel]:
"""Process dataset metadata from dashboard/datasets endpoint."""
result: list[DatasetQueryModel] = []
for ds in datasets_data:
did = ds.get("id", 0)
columns = [
ColumnInfo(column_name=col.get("column_name", ""),
type=col.get("type", "STRING"),
groupby=col.get("groupby", False),
filterable=col.get("filterable", False))
for col in ds.get("columns", [])
]
ds_metrics = [
MetricDescriptor(
metric_name=m.get("metric_name") or "",
label=(m.get("verbose_name") or m.get("metric_name") or ""),
expression_type="SIMPLE",
column=ColumnRef(column_name=(m.get("column") or {}).get("column_name") or ""))
for m in ds.get("metrics", [])
]
result.append(DatasetQueryModel(
dataset_id=did, dataset_uuid=ds.get("uuid"),
dataset_name=ds.get("table_name", f"Dataset {did}"),
columns=columns, metrics=ds_metrics, access_state="accessible"))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ProcessDatasetsData
from .query_model_parsing import _process_datasets_data # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ExtractChartIds [C:2] [TYPE Function] [SEMANTICS extraction,chart-ids]
# @ingroup BaselineEngine
# @BRIEF Extract chart IDs from dashboard position metadata JSON.
def _extract_chart_ids(position_json: dict) -> set[int]:
"""Extract chart IDs from dashboard position metadata."""
chart_ids: set[int] = set()
for _key, value in position_json.items():
if isinstance(value, dict):
meta = value.get("meta", {})
cid = meta.get("chartId")
if cid is not None:
chart_ids.add(int(cid))
return chart_ids
# #endregion BaselineEngine.QueryModel.Inspect.ExtractChartIds
from .query_model_parsing import _extract_chart_ids # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.ResolveFilterMapping [C:3] [TYPE Function] [SEMANTICS resolution,filters,charts]
# @ingroup BaselineEngine
# @BRIEF Assign applied_filter_ids to charts based on native filter dataset targets.
def _resolve_filter_mapping(
charts: list[ChartQueryModel], native_filters: list[NativeFilterModel],
position_json: dict, raw_filters: list[dict], datasets: list[DatasetQueryModel],
) -> None:
"""Resolve Superset rootPath/excluded chart scope against layout and dataset binding."""
def descendants(root: str) -> set[int]:
seen: set[str] = set()
pending = [root]
found: set[int] = set()
while pending:
node_id = pending.pop()
if node_id in seen:
continue
seen.add(node_id)
node = position_json.get(node_id, {})
if not isinstance(node, dict):
continue
chart_id = node.get("meta", {}).get("chartId")
if chart_id is not None:
found.add(int(chart_id))
pending.extend(child for child in node.get("children", []) if isinstance(child, str))
return found
for chart in charts:
chart.applied_filter_ids = []
definitions = {item.get("id"): item for item in raw_filters}
dataset_columns = {
dataset.dataset_id: {column.column_name for column in dataset.columns if column.filterable}
for dataset in datasets
}
for nf in native_filters:
raw = definitions.get(nf.filter_id, {})
scope = raw.get("scope", {})
root_path = scope.get("rootPath", [])
excluded = {str(chart_id) for chart_id in scope.get("excluded", [])}
if root_path:
scoped = descendants(root_path[-1]) if root_path[-1] in position_json else set()
else:
scoped = {chart.chart_id for chart in charts}
dataset_ids = {target.get("datasetId") for target in raw.get("targets", [])}
nf.targets = [
FilterTarget(chart_id=chart.chart_id, dataset_id=chart.dataset_id)
for chart in charts
if chart.chart_id in scoped and str(chart.chart_id) not in excluded
and (nf.type in ("DATE", "TIME", "TIME_GRAIN")
or chart.dataset_id in dataset_ids
or nf.column in dataset_columns.get(chart.dataset_id, set()))
and chart.execution_capable
]
for chart in charts:
if any(target.chart_id == chart.chart_id for target in nf.targets):
chart.applied_filter_ids.append(nf.filter_id)
else:
chart.excluded_filter_ids.append(nf.filter_id)
# #endregion BaselineEngine.QueryModel.Inspect.ResolveFilterMapping
from .query_model_parsing import _layout_descendants # noqa: F401
from .query_model_parsing import _resolve_filter_mapping # noqa: F401
# #region BaselineEngine.QueryModel.Inspect.InspectModel [C:5] [TYPE Function] [SEMANTICS baseline,inspection,authoritative]

View File

@@ -0,0 +1,276 @@
# #region SemanticRepair.query_model_parsing [C:4] [TYPE Module]
# @BRIEF Decode Superset chart/dataset/layout metadata into deterministic query-model coordinates.
# @RATIONALE Resolve original module dependencies at call time to retain public monkeypatch seams.
from __future__ import annotations
from . import query_model as seam
# #region BaselineEngine.QueryModel.Inspect.SafeJsonLoad [C:1] [TYPE Function] [SEMANTICS json,parsing]
# @ingroup BaselineEngine
# @BRIEF Safely parse JSON from string or dict, returning empty dict on failure.
def _safe_json_load(raw: seam.Any) -> dict:
"""Parse JSON from string or return empty dict on failure."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return seam.json.loads(raw)
except (seam.json.JSONDecodeError, TypeError):
return {}
return {}
# #endregion BaselineEngine.QueryModel.Inspect.SafeJsonLoad
# #region BaselineEngine.QueryModel.Inspect.ParseVizType [C:1] [TYPE Function] [SEMANTICS superset,viz-type]
# @ingroup BaselineEngine
# @BRIEF Map Superset viz_type string to VizType enum.
def _parse_viz_type(raw: str | None) -> seam.VizType:
"""Map Superset viz_type string to our enum."""
if not raw:
return seam.VizType.OTHER
mapping: dict[str, seam.VizType] = {
"table": seam.VizType.TABLE, "bar": seam.VizType.BAR, "line": seam.VizType.LINE,
"pie": seam.VizType.PIE, "big_number": seam.VizType.BIG_NUMBER,
"big_number_total": seam.VizType.BIG_NUMBER_TOTAL,
"filter_box": seam.VizType.FILTER_BOX,
}
return mapping.get(raw, seam.VizType.OTHER)
# #endregion BaselineEngine.QueryModel.Inspect.ParseVizType
# #region BaselineEngine.QueryModel.Inspect.ComputeFingerprint [C:2] [TYPE Function] [SEMANTICS fingerprint,sha256]
# @ingroup BaselineEngine
# @BRIEF Compute deterministic SHA-256 fingerprint from canonical sorted JSON.
def _compute_fingerprint(model_dict: dict) -> str:
"""Compute deterministic SHA-256 fingerprint of the query model."""
canonical = seam.json.dumps(model_dict, sort_keys=True, default=str)
return "sha256:" + seam.hashlib.sha256(canonical.encode()).hexdigest()
# #endregion BaselineEngine.QueryModel.Inspect.ComputeFingerprint
# #region BaselineEngine.QueryModel.Inspect.ParseNativeFilters [C:2] [TYPE Function] [SEMANTICS parsing,native-filters]
# @ingroup BaselineEngine
# @BRIEF Extract native filter models from json_metadata.native_filter_configuration.
# @RATIONALE Superset emits the time-grain discriminator as `filter_timegrain`; normalize it to the existing TIME_GRAIN model type.
# @REJECTED Treating this alias as STRING was rejected because its `time_grain_sqla` payload selects temporal grain, not a dimension value.
def _parse_native_filters(raw_filters: list) -> list[seam.NativeFilterModel]:
"""Extract native filter models from json_metadata."""
result: list[seam.NativeFilterModel] = []
for rf in raw_filters:
targets: list[seam.FilterTarget] = []
filter_type = rf.get("filterType", "filter_select")
type_map = {
"filter_date": "DATE", "filter_time": "TIME",
"filter_time_grain": "TIME_GRAIN", "filter_timegrain": "TIME_GRAIN",
"filter_range": "NUMERIC",
"filter_select": "STRING",
}
result.append(seam.NativeFilterModel(
filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER",
name=rf.get("name", rf.get("id", "")),
column=(rf.get("targets") or [{}])[0].get("column", {}).get("name", ""),
dataset_id=(rf.get("targets") or [{}])[0].get("datasetId", 0),
type=type_map.get(filter_type, "STRING"),
targets=targets,
))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ParseNativeFilters
# #region BaselineEngine.QueryModel.Inspect.ParseMetrics [C:2] [TYPE Function] [SEMANTICS parsing,metrics]
# @ingroup BaselineEngine
# @BRIEF Parse metrics from raw metric list (strings or dicts with expression type).
# @RATIONALE Null-safe: Superset metrics may carry label=None or metric_name=None
# (e.g. dashboard #3) — None is coerced to a non-empty fallback instead of
# failing MetricDescriptor validation (previously 500 "Input should be a valid
# string" which aborted the whole query-model inspection).
def _parse_metrics(raw_metrics: list) -> list[seam.MetricDescriptor]:
"""Parse metrics from raw metric list."""
result: list[seam.MetricDescriptor] = []
for rm in raw_metrics:
if isinstance(rm, str):
result.append(seam.MetricDescriptor(
metric_name=rm, label=rm, expression_type="SIMPLE"))
elif isinstance(rm, dict):
metric_name = rm.get("metric_name") or rm.get("label") or ""
label = rm.get("label") or metric_name or ""
expr = rm.get("expressionType")
expression_type = (
expr if expr in ("SIMPLE", "SQL_EXPRESSION", "SAVED_METRIC") else "SIMPLE"
)
column_ref = None
raw_column = rm.get("column")
if isinstance(raw_column, dict):
column_ref = seam.ColumnRef(
column_name=raw_column.get("column_name") or "",
type=raw_column.get("type"),
)
aggregate = rm.get("aggregate")
sql_expression = rm.get("sqlExpression")
result.append(seam.MetricDescriptor(
metric_name=metric_name,
label=label,
expression_type=expression_type,
column=column_ref,
aggregate=aggregate if isinstance(aggregate, str) else None,
sql_expression=sql_expression if isinstance(sql_expression, str) else None,
chart_spec=rm,
))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ParseMetrics
# #region BaselineEngine.QueryModel.Inspect.DatasetId [C:2] [TYPE Function] [SEMANTICS superset,chart,dataset]
# @BRIEF Resolve a chart dataset ID from authoritative chart metadata or Superset form_data datasource.
def _chart_dataset_id(chart_obj: dict, form_data: dict) -> int:
raw_id = chart_obj.get("datasource_id")
if isinstance(raw_id, int) and raw_id > 0:
return raw_id
if isinstance(raw_id, str) and raw_id.isdecimal() and int(raw_id) > 0:
return int(raw_id)
datasource = form_data.get("datasource")
if isinstance(datasource, str):
match = datasource.split("__", 1)
if len(match) == 2 and match[0].isdecimal() and match[1] == "table" and int(match[0]) > 0:
return int(match[0])
return 0
# #endregion BaselineEngine.QueryModel.Inspect.DatasetId
# #region BaselineEngine.QueryModel.Inspect.ProcessChartsData [C:3] [TYPE Function] [SEMANTICS processing,charts]
# @ingroup BaselineEngine
# @BRIEF Process chart metadata from dashboard/charts endpoint into ChartQueryModel list.
# @RELATION CALLS -> [BaselineEngine.QueryModel.MetricMetadata.Metrics]
def _process_charts_data(charts_data: list) -> list[seam.ChartQueryModel]:
"""Process chart metadata from dashboard/charts endpoint."""
charts: list[seam.ChartQueryModel] = []
for chart_obj in charts_data:
cid = chart_obj.get("id")
if cid is None:
continue
cid = int(cid)
form_data = seam._safe_json_load(chart_obj.get("form_data", "{}"))
params_str = chart_obj.get("params")
params = seam._safe_json_load(params_str) if params_str else {}
raw_metrics, metric_authority = seam.chart_metrics(params, form_data)
metrics = seam._parse_metrics(raw_metrics)
groupby = params.get("groupby") or form_data.get("groupby", [])
charts.append(seam.ChartQueryModel(
chart_id=cid,
chart_uuid=chart_obj.get("uuid"),
slice_name=chart_obj.get("slice_name", f"Chart {cid}"),
viz_type=seam._parse_viz_type(
form_data.get("viz_type") or chart_obj.get("viz_type")),
dataset_id=seam._chart_dataset_id(chart_obj, form_data),
dataset_uuid=None,
dataset_name=chart_obj.get("datasource_name_text", ""),
metrics=metrics,
group_by_columns=list(groupby) if groupby else [],
applied_filter_ids=[],
excluded_filter_ids=[],
execution_capable=metric_authority,
))
return charts
# #endregion BaselineEngine.QueryModel.Inspect.ProcessChartsData
# #region BaselineEngine.QueryModel.Inspect.ProcessDatasetsData [C:2] [TYPE Function] [SEMANTICS processing,datasets]
# @ingroup BaselineEngine
# @BRIEF Process dataset metadata from dashboard/datasets endpoint into DatasetQueryModel.
def _process_datasets_data(datasets_data: list) -> list[seam.DatasetQueryModel]:
"""Process dataset metadata from dashboard/datasets endpoint."""
result: list[seam.DatasetQueryModel] = []
for ds in datasets_data:
did = ds.get("id", 0)
columns = [
seam.ColumnInfo(column_name=col.get("column_name", ""),
type=col.get("type", "STRING"),
groupby=col.get("groupby", False),
filterable=col.get("filterable", False))
for col in ds.get("columns", [])
]
ds_metrics = [
seam.MetricDescriptor(
metric_name=m.get("metric_name") or "",
label=(m.get("verbose_name") or m.get("metric_name") or ""),
expression_type="SIMPLE",
column=seam.ColumnRef(column_name=(m.get("column") or {}).get("column_name") or ""))
for m in ds.get("metrics", [])
]
result.append(seam.DatasetQueryModel(
dataset_id=did, dataset_uuid=ds.get("uuid"),
dataset_name=ds.get("table_name", f"Dataset {did}"),
columns=columns, metrics=ds_metrics, access_state="accessible"))
return result
# #endregion BaselineEngine.QueryModel.Inspect.ProcessDatasetsData
# #region BaselineEngine.QueryModel.Inspect.ExtractChartIds [C:2] [TYPE Function] [SEMANTICS extraction,chart-ids]
# @ingroup BaselineEngine
# @BRIEF Extract chart IDs from dashboard position metadata JSON.
def _extract_chart_ids(position_json: dict) -> set[int]:
"""Extract chart IDs from dashboard position metadata."""
chart_ids: set[int] = set()
for _key, value in position_json.items():
if isinstance(value, dict):
meta = value.get("meta", {})
cid = meta.get("chartId")
if cid is not None:
chart_ids.add(int(cid))
return chart_ids
# #endregion BaselineEngine.QueryModel.Inspect.ExtractChartIds
# #region BaselineEngine.QueryModel.Inspect.LayoutDescendants [C:3] [TYPE Function]
# @POST Resolve exact descendant chart IDs with cycle-safe layout traversal.
def _layout_descendants(root: str, position_json: dict) -> set[int]:
seen: set[str] = set()
pending = [root]
found: set[int] = set()
while pending:
node_id = pending.pop()
if node_id in seen:
continue
seen.add(node_id)
node = position_json.get(node_id, {})
if not isinstance(node, dict):
continue
chart_id = node.get("meta", {}).get("chartId")
if chart_id is not None:
found.add(int(chart_id))
pending.extend(child for child in node.get("children", []) if isinstance(child, str))
return found
# #endregion BaselineEngine.QueryModel.Inspect.LayoutDescendants
# #region BaselineEngine.QueryModel.Inspect.ResolveFilterMapping [C:3] [TYPE Function] [SEMANTICS resolution,filters,charts]
# @ingroup BaselineEngine
# @BRIEF Assign applied_filter_ids to charts based on native filter dataset targets.
def _resolve_filter_mapping(
charts: list[seam.ChartQueryModel], native_filters: list[seam.NativeFilterModel],
position_json: dict, raw_filters: list[dict], datasets: list[seam.DatasetQueryModel],
) -> None:
"""Resolve Superset rootPath/excluded chart scope against layout and dataset binding."""
for chart in charts:
chart.applied_filter_ids = []
definitions = {item.get("id"): item for item in raw_filters}
dataset_columns = {
dataset.dataset_id: {column.column_name for column in dataset.columns if column.filterable}
for dataset in datasets
}
for nf in native_filters:
raw = definitions.get(nf.filter_id, {})
scope = raw.get("scope", {})
root_path = scope.get("rootPath", [])
excluded = {str(chart_id) for chart_id in scope.get("excluded", [])}
if root_path:
scoped = seam._layout_descendants(root_path[-1],position_json) if root_path[-1] in position_json else set()
else:
scoped = {chart.chart_id for chart in charts}
dataset_ids = {target.get("datasetId") for target in raw.get("targets", [])}
nf.targets = [
seam.FilterTarget(chart_id=chart.chart_id, dataset_id=chart.dataset_id)
for chart in charts
if chart.chart_id in scoped and str(chart.chart_id) not in excluded
and (nf.type in ("DATE", "TIME", "TIME_GRAIN")
or chart.dataset_id in dataset_ids
or nf.column in dataset_columns.get(chart.dataset_id, set()))
and chart.execution_capable
]
for chart in charts:
if any(target.chart_id == chart.chart_id for target in nf.targets):
chart.applied_filter_ids.append(nf.filter_id)
else:
chart.excluded_filter_ids.append(nf.filter_id)
# #endregion BaselineEngine.QueryModel.Inspect.ResolveFilterMapping
# #endregion SemanticRepair.query_model_parsing

View File

@@ -35,12 +35,14 @@ from src.services.dashboard_testing.scenario.handles import mark_handles_consume
from src.services.dashboard_testing.scenario.templates import ACTION_REGISTRY_VERSION, action_registry_fingerprint
# #region SemanticRepair.create.scenario_key [C:3] [TYPE Function]
def _scenario_key(intended_path: str) -> str:
path = PurePosixPath(intended_path)
parts = path.parts
if len(parts) < 3 or parts[0] != "dashboard-tests":
raise ValueError("draft pack has no canonical scenario path")
return parts[1]
# #endregion SemanticRepair.create.scenario_key
# #region ScenarioRegistry.Create.FromConsumedSave [C:4] [TYPE Function] [SEMANTICS scenario,registry,create,save,bridge]
@@ -88,110 +90,107 @@ def register_consumed_save(
# #endregion ScenarioRegistry.Create.FromConsumedSave
# #region ScenarioRegistry.Create.Register [C:5] [TYPE Function] [SEMANTICS scenario,registry,create,transaction]
# @ingroup ScenarioRegistry
# @BRIEF Validate a server-owned draft pack and stage entry + candidate revision atomically.
# @PRE draft_pack_id identifies a valid scenario_pack artifact owned by the authenticated user.
# @POST Returns ScenarioRegistryEntry and ScenarioRevision; no commit is performed by this service.
def create_scenario(
db: Session,
*,
compiled_handle_id: str,
draft_pack_id: str,
draft_pack_digest: str,
user_id: str,
owner_username: str | None = None,
validate_compiled_handle: bool = False,
expected_environment_id: str | None = None,
expected_case_ids: list[str] | None = None,
require_receipt: bool = False,
) -> tuple[ScenarioRegistryEntry, ScenarioRevision]:
# New 038 handle path. Keep the legacy DraftArtifact path below during the
# migration window so existing 039 save callers remain compatible.
stored_pack = db.get(DraftPackHandle, draft_pack_id)
if stored_pack is not None:
stored_compiled = db.get(CompiledScenarioHandle, compiled_handle_id)
if stored_compiled is None:
raise ValueError("HANDLE_NOT_FOUND")
chain = verify_handle_chain(
db,
compiled_handle_id=compiled_handle_id,
draft_pack_id=draft_pack_id,
draft_pack_digest=draft_pack_digest,
owner_principal=user_id,
dashboard_id=int(stored_compiled.dashboard_id),
expected_environment_id=expected_environment_id,
expected_case_ids=expected_case_ids,
require_receipt=require_receipt,
)
graph = {
**chain["graph"],
"action_registry_version": ACTION_REGISTRY_VERSION,
"action_registry_hash": action_registry_fingerprint(),
}
# T029m live-replay defect: compiled steps carry no per-step target identity, so the
# runner's live-binding admission (target match) cannot fire. Identity is taken from the
# scenario's declared dashboard_context and materialized server-side; for a VERIFIED pack
# that context is the server-owned live model (context_authority evaluated at the register
# boundary), while a fail-open `unverified`/legacy pack keeps its client-declared target —
# PROD is separately blocked for unverified contexts (CONTEXT_AUTHORITY_REQUIRED_FOR_PROD).
# Steps that already declare identity (authored graphs) are never overwritten.
_context = graph.get("dashboard_context") or {}
_identity = {
"environment_id": _context.get("environment_id"),
"dashboard_id": _context.get("dashboard_id"),
}
if any(_identity.values()):
for _step in graph.get("steps") or []:
if isinstance(_step, dict):
for _key, _value in _identity.items():
if _value is not None and _step.get(_key) is None:
_step[_key] = _value
if stored_pack.context_authority is not None:
# Server-owned marker (T029h): evaluated at the register boundary, materialized here.
graph["context_authority"] = stored_pack.context_authority
scenario_id = str(uuid.uuid4())
revision_id = str(uuid.uuid4())
dashboard_context = graph.get("dashboard_context") or {}
dashboard_id = int(dashboard_context.get("dashboard_id") or 0)
environments = [str(dashboard_context.get("environment_id"))] if dashboard_context.get("environment_id") else []
scenario_key = str(graph.get("scenario_id") or stored_pack.scenario_key)
entry = ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=scenario_key,
name=scenario_key, description=None, dashboard_id=dashboard_id,
environment_ids=environments, owner_id=str(user_id),
owner_username=owner_username or str(user_id), tags=[],
lifecycle_status=ScenarioLifecycleState.DRAFT,
validation_status=ScenarioValidationStatus.VALID,
health=ScenarioHealthState.NONE,
)
revision = ScenarioRevision(
revision_id=revision_id, scenario_id=scenario_id,
content_hash=chain["compiled"].content_hash, graph_snapshot=graph,
execution_template_hash=chain["compiled"].content_hash,
template_version=str(graph.get("template_version") or "v1"),
schema_version=int(graph.get("schema_version") or 1),
compatibility_family="default",
change_summary={"type": "initial_save", "draft_pack_id": draft_pack_id},
created_by=str(user_id), activation_status=ScenarioActivationStatus.CANDIDATE,
)
db.add(entry)
db.add(revision)
db.flush()
db.add(RevisionMaterialization(revision_id=revision_id, status="pending"))
db.add(OutboxEvent(
aggregate_type="scenario_revision",
aggregate_id=revision_id,
event_type="materialize_revision",
payload={"scenario_id": scenario_id, "revision_id": revision_id,
"content_hash": chain["compiled"].content_hash},
idempotency_key=f"materialize_revision:{revision_id}",
))
mark_handles_consumed(db, chain["compiled"], chain["pack"], revision_id)
logger.reflect("Scenario staged from server-owned handles", src="ScenarioRegistry.Create.Register",
payload={"scenario_id": scenario_id, "revision_id": revision_id})
return entry, revision
# #region SemanticRepair.create.materialize_target_identity [C:3] [TYPE Function]
# @BRIEF Fill missing step targets from dashboard context without overwriting authored identity.
def _materialize_target_identity(graph):
_context = graph.get("dashboard_context") or {}
_identity = {
"environment_id": _context.get("environment_id"),
"dashboard_id": _context.get("dashboard_id"),
}
if any(_identity.values()):
for _step in graph.get("steps") or []:
if isinstance(_step, dict):
for _key, _value in _identity.items():
if _value is not None and _step.get(_key) is None:
_step[_key] = _value
# #endregion SemanticRepair.create.materialize_target_identity
# #region SemanticRepair.create.create_from_handles [C:3] [TYPE Function]
# @BRIEF Verify the server-owned handle chain and stage its registry entry/revision transaction.
def _create_from_handles(db, compiled_handle_id, draft_pack_id, draft_pack_digest, user_id, owner_username, expected_environment_id, expected_case_ids, require_receipt, stored_pack):
stored_compiled = db.get(CompiledScenarioHandle, compiled_handle_id)
if stored_compiled is None:
raise ValueError("HANDLE_NOT_FOUND")
chain = verify_handle_chain(
db,
compiled_handle_id=compiled_handle_id,
draft_pack_id=draft_pack_id,
draft_pack_digest=draft_pack_digest,
owner_principal=user_id,
dashboard_id=int(stored_compiled.dashboard_id),
expected_environment_id=expected_environment_id,
expected_case_ids=expected_case_ids,
require_receipt=require_receipt,
)
graph = {
**chain["graph"],
"action_registry_version": ACTION_REGISTRY_VERSION,
"action_registry_hash": action_registry_fingerprint(),
}
# T029m live-replay defect: compiled steps carry no per-step target identity, so the
# runner's live-binding admission (target match) cannot fire. Identity is taken from the
# scenario's declared dashboard_context and materialized server-side; for a VERIFIED pack
# that context is the server-owned live model (context_authority evaluated at the register
# boundary), while a fail-open `unverified`/legacy pack keeps its client-declared target —
# PROD is separately blocked for unverified contexts (CONTEXT_AUTHORITY_REQUIRED_FOR_PROD).
# Steps that already declare identity (authored graphs) are never overwritten.
_materialize_target_identity(graph)
if stored_pack.context_authority is not None:
# Server-owned marker (T029h): evaluated at the register boundary, materialized here.
graph["context_authority"] = stored_pack.context_authority
scenario_id = str(uuid.uuid4())
revision_id = str(uuid.uuid4())
dashboard_context = graph.get("dashboard_context") or {}
dashboard_id = int(dashboard_context.get("dashboard_id") or 0)
environments = [str(dashboard_context.get("environment_id"))] if dashboard_context.get("environment_id") else []
scenario_key = str(graph.get("scenario_id") or stored_pack.scenario_key)
entry = ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=scenario_key,
name=scenario_key, description=None, dashboard_id=dashboard_id,
environment_ids=environments, owner_id=str(user_id),
owner_username=owner_username or str(user_id), tags=[],
lifecycle_status=ScenarioLifecycleState.DRAFT,
validation_status=ScenarioValidationStatus.VALID,
health=ScenarioHealthState.NONE,
)
revision = ScenarioRevision(
revision_id=revision_id, scenario_id=scenario_id,
content_hash=chain["compiled"].content_hash, graph_snapshot=graph,
execution_template_hash=chain["compiled"].content_hash,
template_version=str(graph.get("template_version") or "v1"),
schema_version=int(graph.get("schema_version") or 1),
compatibility_family="default",
change_summary={"type": "initial_save", "draft_pack_id": draft_pack_id},
created_by=str(user_id), activation_status=ScenarioActivationStatus.CANDIDATE,
)
db.add(entry)
db.add(revision)
db.flush()
db.add(RevisionMaterialization(revision_id=revision_id, status="pending"))
db.add(OutboxEvent(
aggregate_type="scenario_revision",
aggregate_id=revision_id,
event_type="materialize_revision",
payload={"scenario_id": scenario_id, "revision_id": revision_id,
"content_hash": chain["compiled"].content_hash},
idempotency_key=f"materialize_revision:{revision_id}",
))
mark_handles_consumed(db, chain["compiled"], chain["pack"], revision_id)
logger.reflect("Scenario staged from server-owned handles", src="ScenarioRegistry.Create.Register",
payload={"scenario_id": scenario_id, "revision_id": revision_id})
return entry, revision
# #endregion SemanticRepair.create.create_from_handles
# #region SemanticRepair.create.validate_legacy_pack [C:3] [TYPE Function]
# @BRIEF Authorize the legacy draft owner and verify its compiled-handle and stored-byte digests.
def _validate_legacy_pack(db, require_receipt, draft_pack_id, compiled_handle_id, user_id, validate_compiled_handle, draft_pack_digest):
if require_receipt:
raise ValueError("HANDLE_NOT_FOUND")
draft = (
@@ -224,7 +223,33 @@ def create_scenario(
raise ValueError("draft pack digest mismatch")
if validate_compiled_handle and (handle is None or handle.group(2).lower() != actual_digest.lower()):
raise ValueError("compiled handle digest mismatch")
return draft, run
# #endregion SemanticRepair.create.validate_legacy_pack
# #region ScenarioRegistry.Create.Register [C:5] [TYPE Function] [SEMANTICS scenario,registry,create,transaction]
# @ingroup ScenarioRegistry
# @BRIEF Validate a server-owned draft pack and stage entry + candidate revision atomically.
# @PRE draft_pack_id identifies a valid scenario_pack artifact owned by the authenticated user.
# @POST Returns ScenarioRegistryEntry and ScenarioRevision; no commit is performed by this service.
def create_scenario(
db: Session,
*,
compiled_handle_id: str,
draft_pack_id: str,
draft_pack_digest: str,
user_id: str,
owner_username: str | None = None,
validate_compiled_handle: bool = False,
expected_environment_id: str | None = None,
expected_case_ids: list[str] | None = None,
require_receipt: bool = False,
) -> tuple[ScenarioRegistryEntry, ScenarioRevision]:
# New 038 handle path. Keep the legacy DraftArtifact path below during the
# migration window so existing 039 save callers remain compatible.
stored_pack = db.get(DraftPackHandle, draft_pack_id)
if stored_pack is not None:
return _create_from_handles(db, compiled_handle_id, draft_pack_id, draft_pack_digest, user_id, owner_username, expected_environment_id, expected_case_ids, require_receipt, stored_pack)
draft, run = _validate_legacy_pack(db, require_receipt, draft_pack_id, compiled_handle_id, user_id, validate_compiled_handle, draft_pack_digest)
scenario_key = _scenario_key(draft.intended_path)
runner_plan = (
db.query(DraftArtifact)

View File

@@ -0,0 +1,271 @@
# #region ScenarioGraph.Handles.Minting [C:4] [TYPE Module] [SEMANTICS scenario,handles,canonical]
# @defgroup ScenarioGraph.Handles.Minting Persist compiled, validation and draft handles with canonical profile receipts.
from __future__ import annotations
from src.services.dashboard_testing.scenario import handles as api
# #region ScenarioGraph.Handles.MintCompiled [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,compiled]
# @ingroup ScenarioGraph
# @BRIEF Mint (or idempotently reuse) the CompiledScenarioHandle for a canonical graph.
# @PRE scenario is a validated DashboardTestScenario; owner_principal is the authenticated id.
# @POST Returns the handle row; content_hash is SHA-256 of scenario.canonical_bytes(); the canonical
# bytes are persisted behind canonical_bytes_ref. Unconsumed (owner, content_hash) rows are reused.
# @SIDE_EFFECT DB insert/flush + canonical blob write (never commits; caller's transaction owns it).
def mint_compiled_handle(
db: api.Session,
scenario: api.DashboardTestScenario,
*,
owner_principal: str,
dashboard_id: int,
agent_run_id: str | None = None,
metric_admission=None,
) -> api.CompiledScenarioHandle:
if scenario.schema_version == 2 and (metric_admission is None or not metric_admission.matches(scenario)):
raise ValueError("METRIC_V2_ADMISSION_UNAVAILABLE")
content_hash = api.sha256_hex(scenario.canonical_bytes())
existing = (
db.query(api.CompiledScenarioHandle)
.filter(
api.CompiledScenarioHandle.owner_principal == str(owner_principal),
api.CompiledScenarioHandle.content_hash == content_hash,
api.CompiledScenarioHandle.dashboard_id == int(dashboard_id),
api.CompiledScenarioHandle.agent_run_id.is_(None)
if agent_run_id is None else api.CompiledScenarioHandle.agent_run_id == agent_run_id,
api.CompiledScenarioHandle.consumed_by_revision_id.is_(None),
)
.order_by(api.CompiledScenarioHandle.created_at.desc())
.first()
)
if existing is not None:
api.logger.reflect("Compiled handle reused (idempotent mint)", src="ScenarioGraph.Handles.MintCompiled",
payload={"handle_id": existing.handle_id, "content_hash": content_hash[:12]})
return existing
blob = api.get_handle_storage().store(content_hash, scenario.canonical_bytes())
handle = api.CompiledScenarioHandle(
owner_principal=str(owner_principal),
agent_run_id=agent_run_id,
dashboard_id=int(dashboard_id),
canonical_bytes_ref=blob,
content_hash=content_hash,
compiler_version=scenario.compiler_version,
schema_version=scenario.schema_version,
)
db.add(handle)
db.flush()
api.logger.reflect("Compiled handle minted", src="ScenarioGraph.Handles.MintCompiled",
payload={"handle_id": handle.handle_id, "content_hash": content_hash[:12], "dashboard_id": int(dashboard_id)})
return handle
# #endregion ScenarioGraph.Handles.MintCompiled
# #region ScenarioGraph.Handles.MintValidation [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,validation]
# @ingroup ScenarioGraph
# @BRIEF Mint (or reuse) the ValidationResultHandle bound to the exact compiled hash and versions.
# @POST Returns the result row; result_digest is a deterministic digest over the findings payload.
# @SIDE_EFFECT DB insert/flush; never commits.
def mint_validation_result(
db: api.Session,
compiled: api.CompiledScenarioHandle,
validation: api.Any,
) -> api.ValidationResultHandle:
findings = {
"valid": bool(validation.valid),
"errors": [f.model_dump() for f in validation.errors],
"warnings": [f.model_dump() for f in validation.warnings],
"blockers": [f.model_dump() for f in validation.blockers],
}
result_digest = api.sha256_hex(api.canonical_dump(findings))
existing = (
db.query(api.ValidationResultHandle)
.filter(
api.ValidationResultHandle.compiled_handle_id == compiled.handle_id,
api.ValidationResultHandle.validator_version == api.VALIDATOR_VERSION,
api.ValidationResultHandle.schema_version == compiled.schema_version,
api.ValidationResultHandle.result_digest == result_digest,
)
.first()
)
if existing is not None:
return existing
result = api.ValidationResultHandle(
compiled_handle_id=compiled.handle_id,
content_hash=compiled.content_hash,
validator_version=api.VALIDATOR_VERSION,
schema_version=compiled.schema_version,
result_digest=result_digest,
valid=bool(validation.valid),
blockers_count=len(validation.blockers),
errors_count=len(validation.errors),
warnings_count=len(validation.warnings),
)
db.add(result)
db.flush()
api.logger.reflect("Validation result handle minted", src="ScenarioGraph.Handles.MintValidation",
payload={"result_id": result.result_id, "valid": result.valid, "compiled": compiled.handle_id[:12]})
return result
# #endregion ScenarioGraph.Handles.MintValidation
# #region ScenarioGraph.Handles.MintDraftPack [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,draft-pack]
# @ingroup ScenarioGraph
# @BRIEF Mint (or reuse) the DraftPackHandle for a rendered pack (save_eligible or preview_only).
# @POST digest covers the server-rendered manifest (template version, status, scenario key, sorted
# artifact refs with their byte digests); artifact_refs stay empty for preview_only.
# context_authority ('verified'/'unverified'/None) is part of reuse identity: re-minting the
# same content with a different evaluation appends a new row instead of mutating the old one.
# @SIDE_EFFECT DB insert/flush; never commits.
def mint_draft_pack_handle(
db: api.Session,
compiled: api.CompiledScenarioHandle,
*,
owner_principal: str,
agent_run_id: str | None,
scenario_key: str,
status: str,
template_version: str,
artifact_refs: list[dict[str, str]] | None = None,
context_authority: str | None = None,
profile_session: api.TestPackProfileSession | None = None,
metric_admission=None,
) -> api.DraftPackHandle:
if status not in {"save_eligible", "preview_only"}:
raise ValueError(f"invalid draft pack status: {status}")
if context_authority is not None and context_authority not in {"verified", "unverified"}:
raise ValueError(f"invalid context authority marker: {context_authority}")
refs = sorted(
({"artifact_key": r["artifact_key"], "intended_path": r["intended_path"], "sha256": r["sha256"]} for r in (artifact_refs or [])),
key=lambda item: item["artifact_key"],
) if status == "save_eligible" else []
compiled_bytes = api.get_handle_storage().retrieve(compiled.canonical_bytes_ref)
if compiled_bytes is None or api.sha256_hex(compiled_bytes) != compiled.content_hash:
raise ValueError(api.HANDLE_BYTES_MISSING)
graph_payload = api.json.loads(compiled_bytes.decode("utf-8"))
graph_payload["revision_hash"] = compiled.content_hash
profile_receipt = api.build_profile_receipt(
api.DashboardTestScenario.model_validate(graph_payload),
dashboard_id=int(compiled.dashboard_id),
profile_digest=profile_session.profile_digest if profile_session is not None else None,
metric_admission=metric_admission,
)
if profile_session is not None:
profile_receipt["profile_handle_id"] = profile_session.profile_handle_id
profile_receipt["profile_cas_version"] = profile_session.cas_version
profile_receipt["receipt_digest"] = api.sha256_hex(api.canonical_dump({
key: value for key, value in profile_receipt.items() if key != "receipt_digest"
}))
digest = api.sha256_hex(api.canonical_dump({
"compiled_content_hash": compiled.content_hash,
"scenario_key": scenario_key,
"status": status,
"template_version": template_version,
"artifacts": refs,
}))
existing = (
db.query(api.DraftPackHandle)
.filter(
api.DraftPackHandle.owner_principal == str(owner_principal),
api.DraftPackHandle.compiled_handle_id == compiled.handle_id,
api.DraftPackHandle.digest == digest,
api.DraftPackHandle.context_authority.is_(None)
if context_authority is None
else api.DraftPackHandle.context_authority == context_authority,
api.DraftPackHandle.consumed_by_revision_id.is_(None),
)
.all()
)
for candidate in existing:
if candidate.profile_receipt == profile_receipt:
return candidate
pack = api.DraftPackHandle(
compiled_handle_id=compiled.handle_id,
owner_principal=str(owner_principal),
agent_run_id=agent_run_id,
scenario_key=scenario_key,
digest=digest,
template_version=template_version,
status=status,
context_authority=context_authority,
profile_receipt=profile_receipt,
artifact_refs=refs,
)
db.add(pack)
db.flush()
api.logger.reflect("Draft pack handle minted", src="ScenarioGraph.Handles.MintDraftPack",
payload={"draft_pack_id": pack.draft_pack_id, "status": status, "digest": digest[:12]})
return pack
# #endregion ScenarioGraph.Handles.MintDraftPack
# #region ScenarioGraph.Handles.ProfileReceipt [C:4] [TYPE Function] [SEMANTICS scenario,profile,receipt,eligibility]
# @ingroup ScenarioGraph.Handles
# @BRIEF Build the canonical server-owned profile receipt bound to one compiled graph.
# @PRE scenario was produced by the canonical server compiler; no caller payload is trusted as authority.
# @POST Receipt binds profile digest, dashboard, environment, selected cases, authoritative context
# fingerprint and every unresolved/blocked profile state deterministically.
# @INVARIANT A receipt never turns preview_only or unresolved content into save eligibility.
def build_profile_receipt(
scenario: api.DashboardTestScenario,
*,
profile_digest: str | None = None,
dashboard_id: int | None = None,
metric_admission=None,
) -> dict[str, api.Any]:
context = scenario.dashboard_context or {}
query = context.get("query") if isinstance(context.get("query"), dict) else {}
selected = sorted({str(value) for value in (scenario.objective or {}).get("selected_case_ids", [])})
unresolved = sorted({
str(step.automation_status)
for step in scenario.steps
if str(step.automation_status) not in {"ready", "completed"}
})
unresolved.extend(
f"{item.code}:{item.case_id or item.json_pointer or ''}"
for item in scenario.blockers
)
unresolved = sorted(set(unresolved))
blockers = sorted({str(item.code) for item in scenario.blockers})
authority_fingerprint = str(
query.get("query_model_fingerprint")
or (scenario.input_fingerprints or {}).get("query_model")
or ""
)
body = {
"profile_version": scenario.schema_version,
"profile_digest": profile_digest or api.sha256_hex(api.canonical_dump({
"dashboard_id": dashboard_id if dashboard_id is not None else context.get("dashboard_id"),
"environment_id": context.get("environment_id"),
"selected_case_ids": selected,
"query_model_fingerprint": authority_fingerprint,
"unresolved": unresolved,
"blockers": blockers,
})),
"dashboard_id": int(dashboard_id if dashboard_id is not None else context.get("dashboard_id") or 0),
"environment_id": str(context.get("environment_id") or ""),
"selected_case_ids": selected,
"authoritative_context_fingerprint": authority_fingerprint,
"unresolved_resolution_state": {
"unresolved": unresolved,
"blockers": blockers,
"save_eligible": not unresolved and not blockers,
},
}
if scenario.schema_version == 2:
body.update(
graph_schema_version=2,
graph_content_hash=api.sha256_hex(scenario.canonical_bytes()),
baseline_binding_digests=[binding.binding_digest for binding in scenario.baseline_bindings],
)
if metric_admission is None or not metric_admission.matches(scenario):
body["unresolved_resolution_state"]["save_eligible"] = False
body["unresolved_resolution_state"]["blockers"] = sorted(set(
body["unresolved_resolution_state"]["blockers"] + ["METRIC_V2_ADMISSION_UNAVAILABLE"]
))
elif any(step.expected.kind == "baseline_ref" for step in scenario.steps):
body["unresolved_resolution_state"]["save_eligible"] = False
body["unresolved_resolution_state"]["blockers"] = sorted(set(
body["unresolved_resolution_state"]["blockers"] + ["UNBOUND_BASELINE_GRAPH"]
))
body["receipt_digest"] = api.sha256_hex(api.canonical_dump(body))
return body
# #endregion ScenarioGraph.Handles.ProfileReceipt
# #endregion ScenarioGraph.Handles.Minting

View File

@@ -0,0 +1,223 @@
# #region ScenarioGraph.Handles.Verification [C:4] [TYPE Module] [SEMANTICS scenario,handles,canonical]
# @defgroup ScenarioGraph.Handles.Verification Verify stored handle chain and profile authority before consumption.
from __future__ import annotations
from src.services.dashboard_testing.scenario import handles as api
# #region ScenarioGraph.Handles.VerifyChain [C:5] [TYPE Function] [SEMANTICS scenario,handles,verify,authority]
# @RELATION CALLS -> [ScenarioGraph.Handles.LoadBoundPair]
# @RELATION CALLS -> [ScenarioGraph.Handles.ReadCanonicalGraph]
# @RELATION CALLS -> [ScenarioGraph.Handles.ReadProfileReceipt]
# @RELATION CALLS -> [ScenarioGraph.Handles.VerifyDurableProfile]
# @RELATION CALLS -> [ScenarioGraph.Handles.VerifyReceiptContext]
# @RELATION CALLS -> [ScenarioGraph.Handles.ReadValidation]
# @RELATION CALLS -> [ScenarioGraph.Handles.VerifyProfileBinding]
# @RELATION CALLS -> [ScenarioGraph.MetricHandleAuthority.Verify]
# @ingroup ScenarioGraph
# @BRIEF Verify the compiled/draft-pack/validation chain and materialize the canonical graph for 042.
# @PRE ids were returned by server minting boundaries; owner_principal is the authenticated caller.
# @POST Returns {"graph": canonical DashboardTestScenario dict, "compiled": row, "pack": row,
# "validation": row}; raises typed ValueError codes with zero side effects otherwise.
# @INVARIANT Authority comes only from stored rows plus digest-verified canonical bytes; a caller
# digest or graph payload in the request can never substitute for them.
def verify_handle_chain(
db: api.Session,
*,
compiled_handle_id: str,
draft_pack_id: str,
draft_pack_digest: str,
owner_principal: str,
dashboard_id: int,
expected_environment_id: str | None = None,
expected_case_ids: list[str] | None = None,
require_receipt: bool = False,
) -> dict[str, api.Any]:
# SELECT ... FOR UPDATE serializes concurrent consumers on the handle rows, so two
# create/save transactions can never both observe an unconsumed handle and double-consume.
# populate_existing forces a fresh read of consumed_by_revision_id after the lock wait —
# the earlier db.get() may have loaded a stale identity-map row.
compiled, pack = _load_bound_pair(db, compiled_handle_id, draft_pack_id, owner_principal, require_receipt, draft_pack_digest)
graph = _read_canonical_graph(compiled)
receipt = _read_profile_receipt(pack, compiled)
if compiled.schema_version == 2:
api.verify_metric_profile_authority(
db=db, compiled=compiled, graph=graph, receipt=receipt, owner_principal=owner_principal,
)
if require_receipt and receipt.get("profile_handle_id"):
_verify_durable_profile(db, receipt, owner_principal, dashboard_id)
_verify_receipt_context(receipt, graph, dashboard_id, expected_environment_id)
if int(compiled.dashboard_id) != int(dashboard_id):
raise ValueError(api.HANDLE_DASHBOARD_MISMATCH)
validation = _read_validation(db, compiled)
api._verify_profile_binding(graph, expected_environment_id, expected_case_ids)
api.logger.reflect("Handle chain verified", src="ScenarioGraph.Handles.VerifyChain",
claim="POST: authority comes from stored rows plus digest-verified bytes",
payload={"compiled": compiled.handle_id[:12], "pack": pack.draft_pack_id[:12], "content_hash": compiled.content_hash[:12]})
return {"graph": graph, "compiled": compiled, "pack": pack, "validation": validation, "profile_receipt": receipt}
# #endregion ScenarioGraph.Handles.VerifyChain
# #region ScenarioGraph.Handles.VerifyProfileBinding [C:3] [TYPE Function] [SEMANTICS scenario,handles,profile,binding]
# @ingroup ScenarioGraph.Handles
# @BRIEF Reject bootstrap intent that does not match the digest-verified compiled environment and case coverage.
# @PRE graph came from VerifyChain canonical bytes; optional values came from a strict bootstrap intent.
# @POST Matching environment/cases are accepted; mismatch raises before handle consumption or registry writes.
def _verify_profile_binding(graph: dict[str, api.Any], environment_id: str | None, case_ids: list[str] | None) -> None:
context = graph.get("dashboard_context") or {}
if environment_id is not None and str(context.get("environment_id")) != str(environment_id):
raise ValueError("PROFILE_ENVIRONMENT_MISMATCH")
if case_ids is not None:
selected = sorted(set(str(case_id) for case_id in case_ids))
graph_selected = sorted(set((graph.get("objective") or {}).get("selected_case_ids") or []))
covered = sorted({item.get("case_id") for item in graph.get("checklist_coverage") or []
if isinstance(item, dict) and item.get("case_id") in selected})
if graph_selected != selected or covered != selected:
raise ValueError("PROFILE_CASE_COVERAGE_MISMATCH")
# #endregion ScenarioGraph.Handles.VerifyProfileBinding
# #region ScenarioGraph.Handles.LoadBoundPair [C:3] [TYPE Function]
# @BRIEF Lock and validate the unconsumed owner-bound handle pair before reading bytes.
def _load_bound_pair(db, compiled_handle_id, draft_pack_id, owner_principal, require_receipt, draft_pack_digest):
compiled = (
db.query(api.CompiledScenarioHandle)
.filter(api.CompiledScenarioHandle.handle_id == compiled_handle_id)
.populate_existing()
.with_for_update()
.first()
)
pack = (
db.query(api.DraftPackHandle)
.filter(api.DraftPackHandle.draft_pack_id == draft_pack_id)
.populate_existing()
.with_for_update()
.first()
)
if compiled is None or pack is None:
raise ValueError(api.HANDLE_NOT_FOUND)
if compiled.owner_principal != str(owner_principal) or pack.owner_principal != str(owner_principal):
api.logger.explore("Handle access denied", src="ScenarioGraph.Handles.VerifyChain",
claim="INVARIANT: handles are owner-scoped", error_code=api.HANDLE_ACCESS_DENIED,
payload={"compiled": compiled_handle_id[:12]})
raise ValueError(api.HANDLE_ACCESS_DENIED)
if compiled.consumed_by_revision_id is not None or pack.consumed_by_revision_id is not None:
raise ValueError(api.HANDLE_CONSUMED)
if pack.compiled_handle_id != compiled.handle_id:
raise ValueError(api.HANDLE_CROSS_BINDING)
if require_receipt:
if not compiled.agent_run_id or not pack.agent_run_id:
raise ValueError(api.HANDLE_RECEIPT_MISSING)
if compiled.agent_run_id != pack.agent_run_id:
raise ValueError(api.HANDLE_RECEIPT_MISMATCH)
if pack.digest != draft_pack_digest:
raise ValueError(api.HANDLE_DIGEST_MISMATCH)
if pack.status != "save_eligible":
raise ValueError(api.HANDLE_NOT_SAVE_ELIGIBLE)
return compiled, pack
# #endregion ScenarioGraph.Handles.LoadBoundPair
# #region ScenarioGraph.Handles.ReadCanonicalGraph [C:3] [TYPE Function]
# @BRIEF Read digest-verified canonical bytes and reject unbound historical baseline graphs.
def _read_canonical_graph(compiled):
blob = api.get_handle_storage().retrieve(compiled.canonical_bytes_ref)
if blob is None:
raise ValueError(api.HANDLE_BYTES_MISSING)
if api.sha256_hex(blob) != compiled.content_hash:
raise ValueError(api.HANDLE_BYTES_MISSING)
graph_payload = api.json.loads(blob.decode("utf-8"))
# CanonicalBytes deliberately excludes volatile revision fields; rehydrate the
# required model identity from the verified content hash before parsing.
graph_payload["revision_hash"] = compiled.content_hash
graph = api.DashboardTestScenario.model_validate(graph_payload).model_dump()
if compiled.schema_version == 1 and any(
step.get("action") == "compare_to_baseline" or (step.get("expected") or {}).get("kind") == "baseline_ref"
for step in graph.get("steps", [])
):
# Historical validator/receipt rows cannot upgrade an unbound graph's authority.
raise ValueError("UNBOUND_BASELINE_GRAPH")
return graph
# #endregion ScenarioGraph.Handles.ReadCanonicalGraph
# #region ScenarioGraph.Handles.ReadProfileReceipt [C:3] [TYPE Function]
# @BRIEF Check the receipt version and canonical digest before evaluating authority.
def _read_profile_receipt(pack, compiled):
receipt = pack.profile_receipt or {}
if not receipt:
raise ValueError("PROFILE_RECEIPT_MISSING")
if receipt.get("profile_version") != compiled.schema_version:
raise ValueError("PROFILE_RECEIPT_INVALID")
receipt_body = {key: value for key, value in receipt.items() if key != "receipt_digest"}
if receipt.get("receipt_digest") != api.sha256_hex(api.canonical_dump(receipt_body)):
raise ValueError("PROFILE_RECEIPT_INVALID")
return receipt
# #endregion ScenarioGraph.Handles.ReadProfileReceipt
# #region ScenarioGraph.Handles.VerifyDurableProfile [C:3] [TYPE Function]
# @BRIEF Match a locked owner profile to its saved receipt and save-eligible snapshot.
def _verify_durable_profile(db, receipt, owner_principal, dashboard_id):
profile_handle_id = receipt["profile_handle_id"]
profile = db.query(api.TestPackProfileSession).filter(
api.TestPackProfileSession.profile_handle_id == profile_handle_id,
api.TestPackProfileSession.owner_principal == str(owner_principal),
).with_for_update().first() if profile_handle_id else None
if profile is None:
raise ValueError(api.HANDLE_RECEIPT_MISSING)
if (profile.profile_digest != receipt.get("profile_digest")
or profile.cas_version != receipt.get("profile_cas_version")
or profile.dashboard_id != int(dashboard_id)
or profile.environment_id != receipt.get("environment_id")
or profile.context_fingerprint != receipt.get("authoritative_context_fingerprint")
or sorted(profile.selected_case_ids or []) != sorted(receipt.get("selected_case_ids") or [])
or (profile.profile_snapshot or {}).get("status") != "save_eligible"
or not (profile.profile_snapshot or {}).get("eligible")):
raise ValueError(api.HANDLE_RECEIPT_MISMATCH)
# #endregion ScenarioGraph.Handles.VerifyDurableProfile
# #region ScenarioGraph.Handles.VerifyReceiptContext [C:3] [TYPE Function]
# @BRIEF Validate receipt eligibility, dashboard, environment, context fingerprint and selected cases.
def _verify_receipt_context(receipt, graph, dashboard_id, expected_environment_id):
if not isinstance(receipt.get("profile_digest"), str) or len(receipt["profile_digest"]) != 64:
raise ValueError("PROFILE_RECEIPT_INVALID")
if not (receipt.get("unresolved_resolution_state") or {}).get("save_eligible"):
raise ValueError("PROFILE_RECEIPT_UNRESOLVED")
if int(receipt.get("dashboard_id") or 0) != int(dashboard_id):
raise ValueError("PROFILE_RECEIPT_DASHBOARD_MISMATCH")
if expected_environment_id is not None and str(receipt.get("environment_id")) != str(expected_environment_id):
raise ValueError("PROFILE_ENVIRONMENT_MISMATCH")
if not receipt.get("authoritative_context_fingerprint"):
raise ValueError("PROFILE_RECEIPT_CONTEXT_MISSING")
graph_context = graph.get("dashboard_context") or {}
graph_query = graph_context.get("query") if isinstance(graph_context.get("query"), dict) else {}
graph_fingerprint = str(
graph_query.get("query_model_fingerprint")
or (graph.get("input_fingerprints") or {}).get("query_model")
or ""
)
if str(receipt.get("authoritative_context_fingerprint")) != graph_fingerprint:
raise ValueError("PROFILE_RECEIPT_CONTEXT_MISMATCH")
if sorted(str(item) for item in receipt.get("selected_case_ids") or []) != sorted(
str(item) for item in ((graph.get("objective") or {}).get("selected_case_ids") or [])
):
raise ValueError("PROFILE_RECEIPT_CASE_MISMATCH")
# #endregion ScenarioGraph.Handles.VerifyReceiptContext
# #region ScenarioGraph.Handles.ReadValidation [C:3] [TYPE Function]
# @BRIEF Require a valid stored validation for the current hash, schema and validator version.
def _read_validation(db, compiled):
validation = (
db.query(api.ValidationResultHandle)
.filter(
api.ValidationResultHandle.compiled_handle_id == compiled.handle_id,
api.ValidationResultHandle.content_hash == compiled.content_hash,
api.ValidationResultHandle.validator_version == api.VALIDATOR_VERSION,
api.ValidationResultHandle.schema_version == compiled.schema_version,
)
.order_by(api.ValidationResultHandle.created_at.desc())
.first()
)
if validation is None:
raise ValueError(api.HANDLE_VALIDATION_MISSING)
if not validation.valid or validation.blockers_count:
raise ValueError(api.HANDLE_VALIDATION_INVALID)
return validation
# #endregion ScenarioGraph.Handles.ReadValidation
# #endregion ScenarioGraph.Handles.Verification

View File

@@ -50,6 +50,7 @@ def canonicalize_action(action: str) -> str:
# #region ScenarioGraph.Compiler.EmitSelectedCase [C:4] [TYPE Function] [SEMANTICS scenario,compiler,chain,dag]
# @RELATION CALLS -> [ScenarioGraph.Compiler.AppendEvidenceChain]
# @BRIEF Emit the compiled step chain (or blocker) for one selected catalog case.
# @PRE mapping is the capability classification for case_id; selected_case_ids are already sorted.
# @POST Unsupported selected cases return no steps and an UNSUPPORTED_ACTION blocker.
@@ -83,6 +84,7 @@ def emit_selected_case(
action, tool, _ = STEP_TEMPLATES[template]
action = canonicalize_action(action)
# #region ScenarioGraph.Compiler.ChainEmission.emit_selected_case._step [C:3] [TYPE Function]
def _step(
step_action: str,
step_tool: str,
@@ -100,6 +102,7 @@ def emit_selected_case(
depends_on=depends_on,
evaluation_spec=spec,
)
# #endregion ScenarioGraph.Compiler.ChainEmission.emit_selected_case._step
if tool == "human":
return [_step(action, tool, [])], []
@@ -123,13 +126,24 @@ def emit_selected_case(
))
return steps, blockers
last_id = _append_evidence_chain(case_id, tool, screenshot, baseline, steps, blockers, last_id, _step)
if evaluation_spec is not None:
steps.append(_step("evaluate_declared_spec", "agent_evaluation", [last_id], evaluation_spec))
return steps, blockers
# #endregion ScenarioGraph.Compiler.EmitSelectedCase
# #region ScenarioGraph.Compiler.AppendEvidenceChain [C:3] [TYPE Function]
# @BRIEF Append capture and baseline comparison steps, or the missing-screenshot blocker, in dependency order.
def _append_evidence_chain(case_id, tool, screenshot, baseline, steps, blockers, last_id, build):
if tool == "browser":
if screenshot:
capture = _step("capture_screenshot", "screenshot", [last_id])
capture = build("capture_screenshot", "screenshot", [last_id])
steps.append(capture)
last_id = capture.id
if baseline:
compare = _step("compare_to_baseline", "assertion", [last_id])
compare = build("compare_to_baseline", "assertion", [last_id])
steps.append(compare)
last_id = compare.id
elif baseline:
@@ -141,13 +155,10 @@ def emit_selected_case(
recovery_options=["enable screenshot capability", "remove baseline comparison"],
))
elif tool in _METRIC_TOOLS and baseline:
compare = _step("compare_to_baseline", "assertion", [last_id])
compare = build("compare_to_baseline", "assertion", [last_id])
steps.append(compare)
last_id = compare.id
if evaluation_spec is not None:
steps.append(_step("evaluate_declared_spec", "agent_evaluation", [last_id], evaluation_spec))
return steps, blockers
# #endregion ScenarioGraph.Compiler.EmitSelectedCase
return last_id
# #endregion ScenarioGraph.Compiler.AppendEvidenceChain
# #endregion ScenarioGraph.Compiler.ChainEmission

View File

@@ -1,4 +1,5 @@
# #region ScenarioGraph.Handles [C:5] [TYPE Module] [SEMANTICS scenario,handles,mint,verify,consume,canonical]
# @RATIONALE Minting and verification leaves resolve get_handle_storage and validator authority through handles so caller storage fixtures retain control of canonical bytes.
# @defgroup ScenarioGraph Server-owned handle minting/verification for the 038 authoring pipeline.
# @BRIEF Mint CompiledScenarioHandle/ValidationResultHandle/DraftPackHandle at persisted boundaries,
# verify the handle chain before 042 consumption, and enforce single consumption.
@@ -56,17 +57,22 @@ HANDLE_RECEIPT_MISMATCH = "HANDLE_RECEIPT_MISMATCH"
# @POST store() returns an opaque `handle:{sha256}` ref; retrieve() verifies the digest.
# @SIDE_EFFECT Filesystem writes/reads under FileCategory.DRAFT/handles/.
class HandleBlobStore:
# #region ScenarioGraph.Handles.HandleBlobStore.__init__ [C:3] [TYPE Function]
def __init__(self, storage_root: str | Path | None = None):
from src.dependencies import get_storage_service
self._storage = get_storage_service(storage_root) if storage_root is not None else get_storage_service()
# #endregion ScenarioGraph.Handles.HandleBlobStore.__init__
# #region ScenarioGraph.Handles.HandleBlobStore.store [C:3] [TYPE Function]
def store(self, sha256: str, data: bytes) -> str:
if sha256_hex(data) != sha256:
raise ValueError("canonical bytes digest mismatch")
self._storage.save_bytes(FileCategory.DRAFT, f"handles/{sha256}", data)
return f"handle:{sha256}"
# #endregion ScenarioGraph.Handles.HandleBlobStore.store
# #region ScenarioGraph.Handles.HandleBlobStore.retrieve [C:3] [TYPE Function]
def retrieve(self, content_ref: str) -> bytes | None:
if not content_ref.startswith("handle:") or len(content_ref) != len("handle:") + 64:
raise ValueError(f"invalid handle ref format: {content_ref[:40]}")
@@ -81,16 +87,20 @@ class HandleBlobStore:
payload={"ref": content_ref[:20]})
return None
return data
# #endregion ScenarioGraph.Handles.HandleBlobStore.retrieve
# #region ScenarioGraph.Handles.HandleBlobStore.delete [C:3] [TYPE Function]
def delete(self, content_ref: str) -> bool:
sha256 = content_ref.removeprefix("handle:")
return self._storage.delete(FileCategory.DRAFT, f"handles/{sha256}")
# #endregion ScenarioGraph.Handles.HandleBlobStore.delete
# #endregion ScenarioGraph.Handles.BlobStore
_blob_store: HandleBlobStore | None = None
# #region ScenarioGraph.Handles.get_handle_storage [C:3] [TYPE Function]
def get_handle_storage(storage_root: str | None = None) -> HandleBlobStore:
global _blob_store
if _blob_store is None:
@@ -99,443 +109,11 @@ def get_handle_storage(storage_root: str | None = None) -> HandleBlobStore:
root = storage_root or os.environ.get("HANDLE_STORAGE_ROOT")
_blob_store = HandleBlobStore(root)
return _blob_store
# #endregion ScenarioGraph.Handles.get_handle_storage
# #region ScenarioGraph.Handles.MintCompiled [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,compiled]
# @ingroup ScenarioGraph
# @BRIEF Mint (or idempotently reuse) the CompiledScenarioHandle for a canonical graph.
# @PRE scenario is a validated DashboardTestScenario; owner_principal is the authenticated id.
# @POST Returns the handle row; content_hash is SHA-256 of scenario.canonical_bytes(); the canonical
# bytes are persisted behind canonical_bytes_ref. Unconsumed (owner, content_hash) rows are reused.
# @SIDE_EFFECT DB insert/flush + canonical blob write (never commits; caller's transaction owns it).
def mint_compiled_handle(
db: Session,
scenario: DashboardTestScenario,
*,
owner_principal: str,
dashboard_id: int,
agent_run_id: str | None = None,
metric_admission=None,
) -> CompiledScenarioHandle:
if scenario.schema_version == 2 and (metric_admission is None or not metric_admission.matches(scenario)):
raise ValueError("METRIC_V2_ADMISSION_UNAVAILABLE")
content_hash = sha256_hex(scenario.canonical_bytes())
existing = (
db.query(CompiledScenarioHandle)
.filter(
CompiledScenarioHandle.owner_principal == str(owner_principal),
CompiledScenarioHandle.content_hash == content_hash,
CompiledScenarioHandle.dashboard_id == int(dashboard_id),
CompiledScenarioHandle.agent_run_id.is_(None)
if agent_run_id is None else CompiledScenarioHandle.agent_run_id == agent_run_id,
CompiledScenarioHandle.consumed_by_revision_id.is_(None),
)
.order_by(CompiledScenarioHandle.created_at.desc())
.first()
)
if existing is not None:
logger.reflect("Compiled handle reused (idempotent mint)", src="ScenarioGraph.Handles.MintCompiled",
payload={"handle_id": existing.handle_id, "content_hash": content_hash[:12]})
return existing
blob = get_handle_storage().store(content_hash, scenario.canonical_bytes())
handle = CompiledScenarioHandle(
owner_principal=str(owner_principal),
agent_run_id=agent_run_id,
dashboard_id=int(dashboard_id),
canonical_bytes_ref=blob,
content_hash=content_hash,
compiler_version=scenario.compiler_version,
schema_version=scenario.schema_version,
)
db.add(handle)
db.flush()
logger.reflect("Compiled handle minted", src="ScenarioGraph.Handles.MintCompiled",
payload={"handle_id": handle.handle_id, "content_hash": content_hash[:12], "dashboard_id": int(dashboard_id)})
return handle
# #endregion ScenarioGraph.Handles.MintCompiled
# #region ScenarioGraph.Handles.MintValidation [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,validation]
# @ingroup ScenarioGraph
# @BRIEF Mint (or reuse) the ValidationResultHandle bound to the exact compiled hash and versions.
# @POST Returns the result row; result_digest is a deterministic digest over the findings payload.
# @SIDE_EFFECT DB insert/flush; never commits.
def mint_validation_result(
db: Session,
compiled: CompiledScenarioHandle,
validation: Any,
) -> ValidationResultHandle:
findings = {
"valid": bool(validation.valid),
"errors": [f.model_dump() for f in validation.errors],
"warnings": [f.model_dump() for f in validation.warnings],
"blockers": [f.model_dump() for f in validation.blockers],
}
result_digest = sha256_hex(canonical_dump(findings))
existing = (
db.query(ValidationResultHandle)
.filter(
ValidationResultHandle.compiled_handle_id == compiled.handle_id,
ValidationResultHandle.validator_version == VALIDATOR_VERSION,
ValidationResultHandle.schema_version == compiled.schema_version,
ValidationResultHandle.result_digest == result_digest,
)
.first()
)
if existing is not None:
return existing
result = ValidationResultHandle(
compiled_handle_id=compiled.handle_id,
content_hash=compiled.content_hash,
validator_version=VALIDATOR_VERSION,
schema_version=compiled.schema_version,
result_digest=result_digest,
valid=bool(validation.valid),
blockers_count=len(validation.blockers),
errors_count=len(validation.errors),
warnings_count=len(validation.warnings),
)
db.add(result)
db.flush()
logger.reflect("Validation result handle minted", src="ScenarioGraph.Handles.MintValidation",
payload={"result_id": result.result_id, "valid": result.valid, "compiled": compiled.handle_id[:12]})
return result
# #endregion ScenarioGraph.Handles.MintValidation
# #region ScenarioGraph.Handles.MintDraftPack [C:4] [TYPE Function] [SEMANTICS scenario,handles,mint,draft-pack]
# @ingroup ScenarioGraph
# @BRIEF Mint (or reuse) the DraftPackHandle for a rendered pack (save_eligible or preview_only).
# @POST digest covers the server-rendered manifest (template version, status, scenario key, sorted
# artifact refs with their byte digests); artifact_refs stay empty for preview_only.
# context_authority ('verified'/'unverified'/None) is part of reuse identity: re-minting the
# same content with a different evaluation appends a new row instead of mutating the old one.
# @SIDE_EFFECT DB insert/flush; never commits.
def mint_draft_pack_handle(
db: Session,
compiled: CompiledScenarioHandle,
*,
owner_principal: str,
agent_run_id: str | None,
scenario_key: str,
status: str,
template_version: str,
artifact_refs: list[dict[str, str]] | None = None,
context_authority: str | None = None,
profile_session: TestPackProfileSession | None = None,
metric_admission=None,
) -> DraftPackHandle:
if status not in {"save_eligible", "preview_only"}:
raise ValueError(f"invalid draft pack status: {status}")
if context_authority is not None and context_authority not in {"verified", "unverified"}:
raise ValueError(f"invalid context authority marker: {context_authority}")
refs = sorted(
({"artifact_key": r["artifact_key"], "intended_path": r["intended_path"], "sha256": r["sha256"]} for r in (artifact_refs or [])),
key=lambda item: item["artifact_key"],
) if status == "save_eligible" else []
compiled_bytes = get_handle_storage().retrieve(compiled.canonical_bytes_ref)
if compiled_bytes is None or sha256_hex(compiled_bytes) != compiled.content_hash:
raise ValueError(HANDLE_BYTES_MISSING)
graph_payload = json.loads(compiled_bytes.decode("utf-8"))
graph_payload["revision_hash"] = compiled.content_hash
profile_receipt = build_profile_receipt(
DashboardTestScenario.model_validate(graph_payload),
dashboard_id=int(compiled.dashboard_id),
profile_digest=profile_session.profile_digest if profile_session is not None else None,
metric_admission=metric_admission,
)
if profile_session is not None:
profile_receipt["profile_handle_id"] = profile_session.profile_handle_id
profile_receipt["profile_cas_version"] = profile_session.cas_version
profile_receipt["receipt_digest"] = sha256_hex(canonical_dump({
key: value for key, value in profile_receipt.items() if key != "receipt_digest"
}))
digest = sha256_hex(canonical_dump({
"compiled_content_hash": compiled.content_hash,
"scenario_key": scenario_key,
"status": status,
"template_version": template_version,
"artifacts": refs,
}))
existing = (
db.query(DraftPackHandle)
.filter(
DraftPackHandle.owner_principal == str(owner_principal),
DraftPackHandle.compiled_handle_id == compiled.handle_id,
DraftPackHandle.digest == digest,
DraftPackHandle.context_authority.is_(None)
if context_authority is None
else DraftPackHandle.context_authority == context_authority,
DraftPackHandle.consumed_by_revision_id.is_(None),
)
.all()
)
for candidate in existing:
if candidate.profile_receipt == profile_receipt:
return candidate
pack = DraftPackHandle(
compiled_handle_id=compiled.handle_id,
owner_principal=str(owner_principal),
agent_run_id=agent_run_id,
scenario_key=scenario_key,
digest=digest,
template_version=template_version,
status=status,
context_authority=context_authority,
profile_receipt=profile_receipt,
artifact_refs=refs,
)
db.add(pack)
db.flush()
logger.reflect("Draft pack handle minted", src="ScenarioGraph.Handles.MintDraftPack",
payload={"draft_pack_id": pack.draft_pack_id, "status": status, "digest": digest[:12]})
return pack
# #endregion ScenarioGraph.Handles.MintDraftPack
# #region ScenarioGraph.Handles.ProfileReceipt [C:4] [TYPE Function] [SEMANTICS scenario,profile,receipt,eligibility]
# @ingroup ScenarioGraph.Handles
# @BRIEF Build the canonical server-owned profile receipt bound to one compiled graph.
# @PRE scenario was produced by the canonical server compiler; no caller payload is trusted as authority.
# @POST Receipt binds profile digest, dashboard, environment, selected cases, authoritative context
# fingerprint and every unresolved/blocked profile state deterministically.
# @INVARIANT A receipt never turns preview_only or unresolved content into save eligibility.
def build_profile_receipt(
scenario: DashboardTestScenario,
*,
profile_digest: str | None = None,
dashboard_id: int | None = None,
metric_admission=None,
) -> dict[str, Any]:
context = scenario.dashboard_context or {}
query = context.get("query") if isinstance(context.get("query"), dict) else {}
selected = sorted({str(value) for value in (scenario.objective or {}).get("selected_case_ids", [])})
unresolved = sorted({
str(step.automation_status)
for step in scenario.steps
if str(step.automation_status) not in {"ready", "completed"}
})
unresolved.extend(
f"{item.code}:{item.case_id or item.json_pointer or ''}"
for item in scenario.blockers
)
unresolved = sorted(set(unresolved))
blockers = sorted({str(item.code) for item in scenario.blockers})
authority_fingerprint = str(
query.get("query_model_fingerprint")
or (scenario.input_fingerprints or {}).get("query_model")
or ""
)
body = {
"profile_version": scenario.schema_version,
"profile_digest": profile_digest or sha256_hex(canonical_dump({
"dashboard_id": dashboard_id if dashboard_id is not None else context.get("dashboard_id"),
"environment_id": context.get("environment_id"),
"selected_case_ids": selected,
"query_model_fingerprint": authority_fingerprint,
"unresolved": unresolved,
"blockers": blockers,
})),
"dashboard_id": int(dashboard_id if dashboard_id is not None else context.get("dashboard_id") or 0),
"environment_id": str(context.get("environment_id") or ""),
"selected_case_ids": selected,
"authoritative_context_fingerprint": authority_fingerprint,
"unresolved_resolution_state": {
"unresolved": unresolved,
"blockers": blockers,
"save_eligible": not unresolved and not blockers,
},
}
if scenario.schema_version == 2:
body.update(
graph_schema_version=2,
graph_content_hash=sha256_hex(scenario.canonical_bytes()),
baseline_binding_digests=[binding.binding_digest for binding in scenario.baseline_bindings],
)
if metric_admission is None or not metric_admission.matches(scenario):
body["unresolved_resolution_state"]["save_eligible"] = False
body["unresolved_resolution_state"]["blockers"] = sorted(set(
body["unresolved_resolution_state"]["blockers"] + ["METRIC_V2_ADMISSION_UNAVAILABLE"]
))
elif any(step.expected.kind == "baseline_ref" for step in scenario.steps):
body["unresolved_resolution_state"]["save_eligible"] = False
body["unresolved_resolution_state"]["blockers"] = sorted(set(
body["unresolved_resolution_state"]["blockers"] + ["UNBOUND_BASELINE_GRAPH"]
))
body["receipt_digest"] = sha256_hex(canonical_dump(body))
return body
# #endregion ScenarioGraph.Handles.ProfileReceipt
# #region ScenarioGraph.Handles.VerifyChain [C:5] [TYPE Function] [SEMANTICS scenario,handles,verify,authority]
# @RELATION CALLS -> [ScenarioGraph.MetricHandleAuthority.Verify]
# @ingroup ScenarioGraph
# @BRIEF Verify the compiled/draft-pack/validation chain and materialize the canonical graph for 042.
# @PRE ids were returned by server minting boundaries; owner_principal is the authenticated caller.
# @POST Returns {"graph": canonical DashboardTestScenario dict, "compiled": row, "pack": row,
# "validation": row}; raises typed ValueError codes with zero side effects otherwise.
# @INVARIANT Authority comes only from stored rows plus digest-verified canonical bytes; a caller
# digest or graph payload in the request can never substitute for them.
def verify_handle_chain(
db: Session,
*,
compiled_handle_id: str,
draft_pack_id: str,
draft_pack_digest: str,
owner_principal: str,
dashboard_id: int,
expected_environment_id: str | None = None,
expected_case_ids: list[str] | None = None,
require_receipt: bool = False,
) -> dict[str, Any]:
# SELECT ... FOR UPDATE serializes concurrent consumers on the handle rows, so two
# create/save transactions can never both observe an unconsumed handle and double-consume.
# populate_existing forces a fresh read of consumed_by_revision_id after the lock wait —
# the earlier db.get() may have loaded a stale identity-map row.
compiled = (
db.query(CompiledScenarioHandle)
.filter(CompiledScenarioHandle.handle_id == compiled_handle_id)
.populate_existing()
.with_for_update()
.first()
)
pack = (
db.query(DraftPackHandle)
.filter(DraftPackHandle.draft_pack_id == draft_pack_id)
.populate_existing()
.with_for_update()
.first()
)
if compiled is None or pack is None:
raise ValueError(HANDLE_NOT_FOUND)
if compiled.owner_principal != str(owner_principal) or pack.owner_principal != str(owner_principal):
logger.explore("Handle access denied", src="ScenarioGraph.Handles.VerifyChain",
claim="INVARIANT: handles are owner-scoped", error_code=HANDLE_ACCESS_DENIED,
payload={"compiled": compiled_handle_id[:12]})
raise ValueError(HANDLE_ACCESS_DENIED)
if compiled.consumed_by_revision_id is not None or pack.consumed_by_revision_id is not None:
raise ValueError(HANDLE_CONSUMED)
if pack.compiled_handle_id != compiled.handle_id:
raise ValueError(HANDLE_CROSS_BINDING)
if require_receipt:
if not compiled.agent_run_id or not pack.agent_run_id:
raise ValueError(HANDLE_RECEIPT_MISSING)
if compiled.agent_run_id != pack.agent_run_id:
raise ValueError(HANDLE_RECEIPT_MISMATCH)
if pack.digest != draft_pack_digest:
raise ValueError(HANDLE_DIGEST_MISMATCH)
if pack.status != "save_eligible":
raise ValueError(HANDLE_NOT_SAVE_ELIGIBLE)
blob = get_handle_storage().retrieve(compiled.canonical_bytes_ref)
if blob is None:
raise ValueError(HANDLE_BYTES_MISSING)
if sha256_hex(blob) != compiled.content_hash:
raise ValueError(HANDLE_BYTES_MISSING)
graph_payload = json.loads(blob.decode("utf-8"))
# CanonicalBytes deliberately excludes volatile revision fields; rehydrate the
# required model identity from the verified content hash before parsing.
graph_payload["revision_hash"] = compiled.content_hash
graph = DashboardTestScenario.model_validate(graph_payload).model_dump()
if compiled.schema_version == 1 and any(
step.get("action") == "compare_to_baseline" or (step.get("expected") or {}).get("kind") == "baseline_ref"
for step in graph.get("steps", [])
):
# Historical validator/receipt rows cannot upgrade an unbound graph's authority.
raise ValueError("UNBOUND_BASELINE_GRAPH")
receipt = pack.profile_receipt or {}
if not receipt:
raise ValueError("PROFILE_RECEIPT_MISSING")
if receipt.get("profile_version") != compiled.schema_version:
raise ValueError("PROFILE_RECEIPT_INVALID")
receipt_body = {key: value for key, value in receipt.items() if key != "receipt_digest"}
if receipt.get("receipt_digest") != sha256_hex(canonical_dump(receipt_body)):
raise ValueError("PROFILE_RECEIPT_INVALID")
if compiled.schema_version == 2:
verify_metric_profile_authority(
db=db, compiled=compiled, graph=graph, receipt=receipt, owner_principal=owner_principal,
)
if require_receipt and receipt.get("profile_handle_id"):
profile_handle_id = receipt["profile_handle_id"]
profile = db.query(TestPackProfileSession).filter(
TestPackProfileSession.profile_handle_id == profile_handle_id,
TestPackProfileSession.owner_principal == str(owner_principal),
).with_for_update().first() if profile_handle_id else None
if profile is None:
raise ValueError(HANDLE_RECEIPT_MISSING)
if (profile.profile_digest != receipt.get("profile_digest")
or profile.cas_version != receipt.get("profile_cas_version")
or profile.dashboard_id != int(dashboard_id)
or profile.environment_id != receipt.get("environment_id")
or profile.context_fingerprint != receipt.get("authoritative_context_fingerprint")
or sorted(profile.selected_case_ids or []) != sorted(receipt.get("selected_case_ids") or [])
or (profile.profile_snapshot or {}).get("status") != "save_eligible"
or not (profile.profile_snapshot or {}).get("eligible")):
raise ValueError(HANDLE_RECEIPT_MISMATCH)
if not isinstance(receipt.get("profile_digest"), str) or len(receipt["profile_digest"]) != 64:
raise ValueError("PROFILE_RECEIPT_INVALID")
if not (receipt.get("unresolved_resolution_state") or {}).get("save_eligible"):
raise ValueError("PROFILE_RECEIPT_UNRESOLVED")
if int(receipt.get("dashboard_id") or 0) != int(dashboard_id):
raise ValueError("PROFILE_RECEIPT_DASHBOARD_MISMATCH")
if expected_environment_id is not None and str(receipt.get("environment_id")) != str(expected_environment_id):
raise ValueError("PROFILE_ENVIRONMENT_MISMATCH")
if not receipt.get("authoritative_context_fingerprint"):
raise ValueError("PROFILE_RECEIPT_CONTEXT_MISSING")
graph_context = graph.get("dashboard_context") or {}
graph_query = graph_context.get("query") if isinstance(graph_context.get("query"), dict) else {}
graph_fingerprint = str(
graph_query.get("query_model_fingerprint")
or (graph.get("input_fingerprints") or {}).get("query_model")
or ""
)
if str(receipt.get("authoritative_context_fingerprint")) != graph_fingerprint:
raise ValueError("PROFILE_RECEIPT_CONTEXT_MISMATCH")
if sorted(str(item) for item in receipt.get("selected_case_ids") or []) != sorted(
str(item) for item in ((graph.get("objective") or {}).get("selected_case_ids") or [])
):
raise ValueError("PROFILE_RECEIPT_CASE_MISMATCH")
if int(compiled.dashboard_id) != int(dashboard_id):
raise ValueError(HANDLE_DASHBOARD_MISMATCH)
validation = (
db.query(ValidationResultHandle)
.filter(
ValidationResultHandle.compiled_handle_id == compiled.handle_id,
ValidationResultHandle.content_hash == compiled.content_hash,
ValidationResultHandle.validator_version == VALIDATOR_VERSION,
ValidationResultHandle.schema_version == compiled.schema_version,
)
.order_by(ValidationResultHandle.created_at.desc())
.first()
)
if validation is None:
raise ValueError(HANDLE_VALIDATION_MISSING)
if not validation.valid or validation.blockers_count:
raise ValueError(HANDLE_VALIDATION_INVALID)
_verify_profile_binding(graph, expected_environment_id, expected_case_ids)
logger.reflect("Handle chain verified", src="ScenarioGraph.Handles.VerifyChain",
claim="POST: authority comes from stored rows plus digest-verified bytes",
payload={"compiled": compiled.handle_id[:12], "pack": pack.draft_pack_id[:12], "content_hash": compiled.content_hash[:12]})
return {"graph": graph, "compiled": compiled, "pack": pack, "validation": validation, "profile_receipt": receipt}
# #endregion ScenarioGraph.Handles.VerifyChain
# #region ScenarioGraph.Handles.VerifyProfileBinding [C:3] [TYPE Function] [SEMANTICS scenario,handles,profile,binding]
# @ingroup ScenarioGraph.Handles
# @BRIEF Reject bootstrap intent that does not match the digest-verified compiled environment and case coverage.
# @PRE graph came from VerifyChain canonical bytes; optional values came from a strict bootstrap intent.
# @POST Matching environment/cases are accepted; mismatch raises before handle consumption or registry writes.
def _verify_profile_binding(graph: dict[str, Any], environment_id: str | None, case_ids: list[str] | None) -> None:
context = graph.get("dashboard_context") or {}
if environment_id is not None and str(context.get("environment_id")) != str(environment_id):
raise ValueError("PROFILE_ENVIRONMENT_MISMATCH")
if case_ids is not None:
selected = sorted(set(str(case_id) for case_id in case_ids))
graph_selected = sorted(set((graph.get("objective") or {}).get("selected_case_ids") or []))
covered = sorted({item.get("case_id") for item in graph.get("checklist_coverage") or []
if isinstance(item, dict) and item.get("case_id") in selected})
if graph_selected != selected or covered != selected:
raise ValueError("PROFILE_CASE_COVERAGE_MISMATCH")
# #endregion ScenarioGraph.Handles.VerifyProfileBinding
# #region ScenarioGraph.Handles.Consume [C:4] [TYPE Function] [SEMANTICS scenario,handles,consume,single]
@@ -586,4 +164,17 @@ def purge_unconsumed_handles(db: Session, *, older_than_days: int = 30) -> int:
db.flush()
return removed
# #endregion ScenarioGraph.Handles.Purge
# The public module retains dependency authority for relocated implementations.
from ._handle_minting import (
mint_compiled_handle,
mint_validation_result,
mint_draft_pack_handle,
build_profile_receipt,
)
from ._handle_verification import (
verify_handle_chain,
_verify_profile_binding,
)
# #endregion ScenarioGraph.Handles

View File

@@ -26,12 +26,33 @@ class MetricAdmissionProof:
binding_digests: tuple[str, ...]
expected_entries: dict[str, dict]
# #region SemanticRepair.metric_admission.matches [C:3] [TYPE Function]
def matches(self, scenario) -> bool:
return (self.graph_content_hash == sha256(scenario.canonical_bytes()).hexdigest()
and self.binding_digests == tuple(item.binding_digest for item in scenario.baseline_bindings))
# #endregion SemanticRepair.metric_admission.matches
# #endregion ScenarioGraph.MetricAdmission.Proof
# #region SemanticRepair.metric_admission.validate_schema_authority [C:3] [TYPE Function]
# @BRIEF Verify principal/RLS scope and retained typed schema-response bytes.
def _validate_schema_authority(authority, coordinate, execution_principal_fingerprint, rls_security_fingerprint, evidence_storage):
actual = (authority.environment_id, authority.dashboard_id, authority.chart_id, authority.dataset_id,
authority.result_key, authority.query_model_fingerprint, authority.filters_hash, authority.value_type,
authority.execution_principal_fingerprint, authority.rls_security_fingerprint)
expected = (coordinate.environment_id, coordinate.dashboard_id, coordinate.chart_id, coordinate.dataset_id,
coordinate.metric_name, coordinate.query_model_fingerprint, coordinate.normalized_filters.filters_hash,
coordinate.value_type, execution_principal_fingerprint, rls_security_fingerprint)
if actual != expected or not execution_principal_fingerprint or not rls_security_fingerprint:
raise ValueError("METRIC_SCHEMA_SCOPE_MISMATCH")
raw = evidence_storage.retrieve(authority.evidence_content_ref)
if (not isinstance(raw, bytes) or authority.source_response_hash != "sha256:" + sha256(raw).hexdigest()
or observed_metric_type(raw, coordinate.metric_name) != (authority.value_type, authority.generic_data_type)):
raise ValueError("METRIC_SCHEMA_EVIDENCE_INVALID")
# #endregion SemanticRepair.metric_admission.validate_schema_authority
# #region ScenarioGraph.MetricAdmission.Validate [C:5] [TYPE Function] [SEMANTICS metric,published,selection,schema,scope]
# @BRIEF Prove exact approved entry, current inspected model and retained observed schema bytes.
# @PRE query_model, principal/RLS and evidence_storage originate from authorized server composition; not graph claims.
@@ -68,18 +89,7 @@ def validate_metric_admission(
authority = authorities.get(coordinate.coordinate_id)
if authority is None:
raise ValueError("METRIC_SCHEMA_AUTHORITY_REQUIRED")
actual = (authority.environment_id, authority.dashboard_id, authority.chart_id, authority.dataset_id,
authority.result_key, authority.query_model_fingerprint, authority.filters_hash, authority.value_type,
authority.execution_principal_fingerprint, authority.rls_security_fingerprint)
expected = (coordinate.environment_id, coordinate.dashboard_id, coordinate.chart_id, coordinate.dataset_id,
coordinate.metric_name, coordinate.query_model_fingerprint, coordinate.normalized_filters.filters_hash,
coordinate.value_type, execution_principal_fingerprint, rls_security_fingerprint)
if actual != expected or not execution_principal_fingerprint or not rls_security_fingerprint:
raise ValueError("METRIC_SCHEMA_SCOPE_MISMATCH")
raw = evidence_storage.retrieve(authority.evidence_content_ref)
if (not isinstance(raw, bytes) or authority.source_response_hash != "sha256:" + sha256(raw).hexdigest()
or observed_metric_type(raw, coordinate.metric_name) != (authority.value_type, authority.generic_data_type)):
raise ValueError("METRIC_SCHEMA_EVIDENCE_INVALID")
_validate_schema_authority(authority, coordinate, execution_principal_fingerprint, rls_security_fingerprint, evidence_storage)
selected = select_published_metric_entry(
db=db, baseline_set=selection.baseline_set, baseline_set_version=selection.baseline_set_version,
environment_id=coordinate.environment_id, dashboard_id=coordinate.dashboard_id,

View File

@@ -82,6 +82,37 @@ class PublishedComparisonBinding(BaseModel):
# #endregion ScenarioGraph.MetricBinding.Published
# #region SemanticRepair.metric_binding.validate_bound_coordinate [C:3] [TYPE Function]
# @BRIEF Validate producer output references and the exact query-model/filter binding.
def _validate_bound_coordinate(producer, comparison, coordinate, binding, context, query):
expected_ref = f"step.{producer.id}.out"
if (len(producer.outputs) != 1 or producer.outputs[0].name != expected_ref
or producer.outputs[0].kind != "step_output"
or producer.outputs[0].value_type != coordinate.value_type
or len(comparison.inputs) != 1 or comparison.inputs[0] != producer.outputs[0]
or comparison.expected.kind != "baseline_ref"
or comparison.expected.ref != f"baseline.{binding.binding_id}"):
raise ValueError("METRIC_BINDING_REFS_INVALID")
if (coordinate.environment_id != context.get("environment_id")
or coordinate.dashboard_id != context.get("dashboard_id")
or coordinate.query_model_fingerprint != query.get("query_model_fingerprint")):
raise ValueError("METRIC_BINDING_CONTEXT_MISMATCH")
charts = [chart for chart in query.get("charts", [])
if chart.get("chart_id") == coordinate.chart_id and chart.get("execution_capable", True)]
metrics = [metric for chart in charts if chart.get("dataset_id") == coordinate.dataset_id
for metric in chart.get("metrics", []) if metric.get("metric_name") == coordinate.metric_name]
if len(charts) != 1 or len(metrics) != 1:
raise ValueError("METRIC_BINDING_MODEL_MISMATCH")
from src.schemas.dashboard_testing.query_model import DashboardQueryModel
from src.services.dashboard_testing.filters import normalize_filters
normalized = normalize_filters(coordinate.normalized_filters.filters, DashboardQueryModel.model_validate(query))
if normalized != coordinate.normalized_filters:
raise ValueError("METRIC_FILTER_CONTEXT_NONCANONICAL")
# #endregion SemanticRepair.metric_binding.validate_bound_coordinate
# #region ScenarioGraph.MetricBinding.ValidateGraph [C:4] [TYPE Function] [SEMANTICS graph,edge,metric,authority]
# @BRIEF Prove every bound comparison consumes exactly its same-case typed producer.
# @POST Missing, duplicate, cross-case or unbound metric edges reject before admission.
@@ -117,30 +148,7 @@ def validate_metric_graph(scenario) -> None:
or producer.metric_coordinate != coordinate
or comparison.metric_coordinate is not None):
raise ValueError("METRIC_BINDING_EDGE_INVALID")
expected_ref = f"step.{producer.id}.out"
if (len(producer.outputs) != 1 or producer.outputs[0].name != expected_ref
or producer.outputs[0].kind != "step_output"
or producer.outputs[0].value_type != coordinate.value_type
or len(comparison.inputs) != 1 or comparison.inputs[0] != producer.outputs[0]
or comparison.expected.kind != "baseline_ref"
or comparison.expected.ref != f"baseline.{binding.binding_id}"):
raise ValueError("METRIC_BINDING_REFS_INVALID")
if (coordinate.environment_id != context.get("environment_id")
or coordinate.dashboard_id != context.get("dashboard_id")
or coordinate.query_model_fingerprint != query.get("query_model_fingerprint")):
raise ValueError("METRIC_BINDING_CONTEXT_MISMATCH")
charts = [chart for chart in query.get("charts", [])
if chart.get("chart_id") == coordinate.chart_id and chart.get("execution_capable", True)]
metrics = [metric for chart in charts if chart.get("dataset_id") == coordinate.dataset_id
for metric in chart.get("metrics", []) if metric.get("metric_name") == coordinate.metric_name]
if len(charts) != 1 or len(metrics) != 1:
raise ValueError("METRIC_BINDING_MODEL_MISMATCH")
from src.schemas.dashboard_testing.query_model import DashboardQueryModel
from src.services.dashboard_testing.filters import normalize_filters
normalized = normalize_filters(coordinate.normalized_filters.filters, DashboardQueryModel.model_validate(query))
if normalized != coordinate.normalized_filters:
raise ValueError("METRIC_FILTER_CONTEXT_NONCANONICAL")
_validate_bound_coordinate(producer, comparison, coordinate, binding, context, query)
if (comparisons != {step.id for step in scenario.steps if step.expected.kind == "baseline_ref"}
or producers != {step.id for step in scenario.steps if step.action == "execute_metric"}
or any(step.metric_coordinate is not None and step.id not in producers for step in scenario.steps)):

View File

@@ -1,9 +1,12 @@
# #region Services.Branch.GitServiceBranchMixin [C:4] [TYPE Module] [SEMANTICS git, branch, checkout, list, namespace, lock]
# @RELATION DEPENDS_ON -> [Services.Branch.Execution]
# @defgroup Services Module group.
# @LAYER Infrastructure
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes, delete, validate, classify (all concurrent-safe via per-dashboard locks).
# @RELATION CALLED_BY -> [Services.Init.GitService]
import sys as _sys
from ._branch_execution import (_ensure_gitflow_branches_implementation, checkout_branch_implementation, undo_last_commit_implementation)
from datetime import UTC, datetime
import os
import re
@@ -30,79 +33,17 @@ _BUGFIX_PREFIX = "bugfix/"
# @defgroup Services Module group.
# @BRIEF Mixin providing branch and commit operations for GitService.
class GitServiceBranchMixin:
# region Services.Branch.EnsureGitflowBranches [C:4] [TYPE Function] [SEMANTICS git,gitflow,branch,lock]
# #region Services.Branch.EnsureGitflowBranches [C:4] [TYPE Function] [SEMANTICS git,gitflow,branch,lock]
# @RELATION CALLS -> [Services.Branch.Execution.ensure_gitflow_branches]
# @BRIEF: Ensure standard GitFlow branches (prod/dev/preprod) exist locally and on origin.
# @PRE repo is a valid GitPython Repo instance.
# @POST prod, dev, preprod are available in local repository and pushed to origin when available.
# Active branch unchanged (no spurious checkout).
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["prod", "dev", "preprod"]
local_heads = {head.name: head for head in getattr(repo, "heads", [])}
base_commit = None
try:
base_commit = repo.head.commit
except Exception:
base_commit = None
if "prod" in local_heads:
base_commit = local_heads["prod"].commit
if base_commit is None:
logger.reason(
f"Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits",
extra={"src": "_ensure_gitflow_branches"},
)
return
if "prod" not in local_heads:
local_heads["prod"] = repo.create_head("prod", base_commit)
logger.reason(f"Created local branch prod for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["prod"].commit)
logger.reason(
f"Created local branch {branch_name} for dashboard {dashboard_id}",
extra={"src": "_ensure_gitflow_branches"},
)
try:
if repo.active_branch.name != "dev":
repo.git.checkout("dev")
except Exception as e:
logger.reason(f"Could not checkout dev branch for dashboard {dashboard_id}: {e}", extra={"src": "_ensure_gitflow_branches"})
try:
origin = repo.remote(name="origin")
except ValueError:
logger.reason(
f"Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation",
extra={"src": "_ensure_gitflow_branches"},
)
return
remote_branch_names = set()
try:
origin.fetch()
for ref in origin.refs:
remote_head = getattr(ref, "remote_head", None)
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
logger.reason(f"Failed to fetch origin refs: {e}", extra={"src": "_ensure_gitflow_branches"})
for branch_name in required_branches:
if branch_name in remote_branch_names:
continue
try:
origin.push(refspec=f"{branch_name}:{branch_name}")
logger.reason(
f"Pushed branch {branch_name} to origin for dashboard {dashboard_id}",
extra={"src": "_ensure_gitflow_branches"},
)
except Exception as e:
logger.explore("Failed to push branch to origin", extra={"src": "_ensure_gitflow_branches"}, payload={"branch": branch_name, "dashboard_id": dashboard_id}, error=str(e))
raise HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {e!s}",
)
# endregion Services.Branch.EnsureGitflowBranches
return _ensure_gitflow_branches_implementation(_sys.modules[__name__], self, repo, dashboard_id)
# #endregion Services.Branch.EnsureGitflowBranches
# region Services.Branch.ListBranches [C:4] [TYPE Function] [SEMANTICS git,branch,list,lock,classify]
# #region Services.Branch.ListBranches [C:4] [TYPE Function] [SEMANTICS git,branch,list,lock,classify]
# @BRIEF: List all branches (excluding tags) for a dashboard's repository, concurrent-safe,
# with branch_type classification for UI grouping.
# @PRE Repository for dashboard_id exists.
@@ -150,9 +91,9 @@ class GitServiceBranchMixin:
"branch_type": "environment",
})
return branches
# endregion Services.Branch.ListBranches
# #endregion Services.Branch.ListBranches
# region Services.Branch.BranchCommitsAheadOfDev [C:2] [TYPE Function] [SEMANTICS git,branch,feature,comparison]
# #region Services.Branch.BranchCommitsAheadOfDev [C:2] [TYPE Function] [SEMANTICS git,branch,feature,comparison]
# @BRIEF: Give BI UI a small, meaningful indication of how much a feature differs from DEV.
# @INVARIANT Remote/environment branches do not trigger an extra Git comparison.
@staticmethod
@@ -163,9 +104,9 @@ class GitServiceBranchMixin:
return max(0, int(repo.git.rev_list("--count", f"dev..{name}").strip()))
except Exception:
return None
# endregion Services.Branch.BranchCommitsAheadOfDev
# #endregion Services.Branch.BranchCommitsAheadOfDev
# region Services.Branch.CreateBranch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock,validate]
# #region Services.Branch.CreateBranch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock,validate]
# @BRIEF: Create a new branch from an existing one (concurrent-safe).
# @PARAM name (str) - New branch name.
# @PARAM from_branch (str) - Source branch.
@@ -197,75 +138,20 @@ class GitServiceBranchMixin:
except Exception as e:
logger.explore("Failed to create branch", extra={"src": "create_branch"}, error=str(e))
raise
# endregion Services.Branch.CreateBranch
# #endregion Services.Branch.CreateBranch
# region Services.Branch.CheckoutBranch [C:4] [TYPE Function] [SEMANTICS git,branch,checkout,lock]
# #region Services.Branch.CheckoutBranch [C:4] [TYPE Function] [SEMANTICS git,branch,checkout,lock]
# @RELATION CALLS -> [Services.Branch.Execution.checkout_branch]
# @BRIEF: Switch to a specific branch (concurrent-safe).
# @PRE Repository exists and the specified branch name exists.
# @POST The repository working directory is updated to the specified branch.
# @SIDE_EFFECT May raise HTTPException(409) if local changes conflict with checkout.
# @SIDE_EFFECT May raise HTTPException(500) if Git operation fails for other reasons.
async def checkout_branch(self, dashboard_id: int, name: str):
with self._locked(dashboard_id):
with belief_scope("GitService.checkout_branch"):
repo = await self.get_repo(dashboard_id)
logger.reason(f"Checking out branch {name}", extra={"src": "checkout_branch"})
try:
repo.git.checkout(name)
except GitCommandError as e:
stderr = str(e.stderr or "")
details = str(e)
lowered = stderr.lower()
if "local changes" in lowered or "would be overwritten" in lowered:
# Superset updates only the export timestamp in the root metadata
# manifest. It is excluded from the dashboard content hash, so it
# must not prevent a BI analyst from opening a feature draft.
# Deliberately reset *only* this exact tracked path; every other
# modified or untracked file keeps the normal checkout protection.
files = self._checkout_conflict_files(stderr)
if files == ["metadata.yaml"]:
logger.reason(
"Discarding volatile metadata.yaml timestamp before branch checkout",
extra={"src": "checkout_branch", "target_branch": name},
)
repo.git.checkout("HEAD", "--", "metadata.yaml")
repo.git.checkout(name)
return
raise HTTPException(
status_code=409,
detail={
"error_code": "GIT_CHECKOUT_LOCAL_CHANGES",
"message": (
f"Невозможно переключиться на ветку '{name}' — "
f"локальные изменения будут перезаписаны. "
f"Зафиксируйте или отложите изменения."
),
"message_en": (
f"Cannot checkout branch '{name}' — "
f"local changes would be overwritten. "
f"Commit or stash your changes first."
),
"files": files,
"next_steps": [
"Зафиксируйте изменения (Commit) в текущей ветке перед переключением",
"Отложите изменения через Stash (вручную: git stash)",
"Отмените локальные изменения, если они не нужны",
],
"next_steps_en": [
"Commit your changes in the current branch before switching",
"Stash your changes manually: git stash",
"Discard local changes if not needed",
],
},
)
logger.explore("Failed to checkout branch", extra={"src": "checkout_branch"}, error=str(e))
raise HTTPException(
status_code=500,
detail=f"Git checkout failed: {details}",
)
# endregion Services.Branch.CheckoutBranch
return await checkout_branch_implementation(_sys.modules[__name__], self, dashboard_id, name)
# #endregion Services.Branch.CheckoutBranch
# region Services.Branch.CheckoutConflictFiles [C:2] [TYPE Function] [SEMANTICS git,checkout,metadata]
# #region Services.Branch.CheckoutConflictFiles [C:2] [TYPE Function] [SEMANTICS git,checkout,metadata]
# @BRIEF: Extract only the files Git says would be overwritten by branch checkout.
# @INVARIANT Never infers a conflict from unrelated staged or modified files.
# @INVARIANT Unknown stderr formatting returns an empty list and keeps checkout protected.
@@ -279,9 +165,9 @@ class GitServiceBranchMixin:
):
files.append(stripped)
return files
# endregion Services.Branch.CheckoutConflictFiles
# #endregion Services.Branch.CheckoutConflictFiles
# region Services.Branch.CommitChanges [C:4] [TYPE Function] [SEMANTICS git,commit,stage,lock]
# #region Services.Branch.CommitChanges [C:4] [TYPE Function] [SEMANTICS git,commit,stage,lock]
# @BRIEF: Stage and commit changes (concurrent-safe).
# @PARAM message (str) - Commit message.
# @PARAM files (List[str]) - Optional list of specific files to stage.
@@ -302,9 +188,10 @@ class GitServiceBranchMixin:
repo.git.add(A=True)
repo.index.commit(message)
logger.reflect("Committed changes", extra={"src": "commit_changes"}, payload={"message": message})
# endregion Services.Branch.CommitChanges
# #endregion Services.Branch.CommitChanges
# region Services.Branch.UndoLastCommit [C:4] [TYPE Function] [SEMANTICS git,commit,undo,soft-reset,lock]
# #region Services.Branch.UndoLastCommit [C:4] [TYPE Function] [SEMANTICS git,commit,undo,soft-reset,lock]
# @RELATION CALLS -> [Services.Branch.Execution.undo_last_commit]
# @BRIEF: Undo the last commit via `git reset --soft HEAD~1`, keeping changes staged (concurrent-safe).
# @PRE Repository exists, HEAD has at least 2 commits (or 1 commit with no parent handled), last commit NOT pushed.
# @POST HEAD moved back one commit; changes remain staged for editing/re-commit.
@@ -317,51 +204,10 @@ class GitServiceBranchMixin:
# @REJECTED Revert-commit approach (like rollback_commit) rejected for the undo use case — it creates a
# noisy "undo" commit in history, confusing the version audit trail for a not-yet-published mistake.
async def undo_last_commit(self, dashboard_id: int):
with self._locked(dashboard_id):
with belief_scope("GitService.undo_last_commit"):
repo = await self.get_repo(dashboard_id)
try:
head_commit = repo.head.commit
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=409, detail="No commits to undo") from exc
# Guard: forbid undo when the last commit is already on the remote.
# Rationale: if we cannot even determine the active branch (detached HEAD),
# undo is unsafe — the analyst may be on an unexpected ref; refuse explicitly.
try:
active_branch = repo.active_branch
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=409, detail="Cannot undo in detached HEAD state — checkout a branch first") from exc
tracking_branch = None
try:
tracking_branch = active_branch.tracking_branch()
except Exception:
tracking_branch = None
if tracking_branch is not None:
ahead_count = sum(1 for _ in repo.iter_commits(f"{tracking_branch.name}..{active_branch.name}"))
if ahead_count == 0:
logger.reason(
"Undo rejected — last commit already pushed",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
)
raise HTTPException(status_code=409, detail="Last version is already published to the remote — use rollback instead")
else:
# No upstream configured: nothing has ever been pushed, undo is safe.
logger.reason(
"No upstream tracking branch — undo allowed (nothing pushed)",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
)
undone_message = (head_commit.message or "").strip()
undone_hash = head_commit.hexsha
repo.git.reset("--soft", "HEAD~1")
logger.reflect(
"Last commit undone (soft reset)",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
payload={"undone_hash": undone_hash, "undone_message": undone_message},
)
return {"status": "success", "undone_hash": undone_hash, "undone_message": undone_message}
# endregion Services.Branch.UndoLastCommit
return await undo_last_commit_implementation(_sys.modules[__name__], self, dashboard_id)
# #endregion Services.Branch.UndoLastCommit
# region Services.Branch.ClassifyBranchType [C:2] [TYPE Function] [SEMANTICS git,branch,classify,type]
# #region Services.Branch.ClassifyBranchType [C:2] [TYPE Function] [SEMANTICS git,branch,classify,type]
# @BRIEF: Classify a branch name into its semantic type for UI grouping.
# @RETURN str — one of: environment, feature, hotfix, bugfix, legacy, remote_ref, other
# @PRE branch_name is a non-empty string.
@@ -383,9 +229,9 @@ class GitServiceBranchMixin:
if name.startswith(_BUGFIX_PREFIX):
return "bugfix"
return "other"
# endregion Services.Branch.ClassifyBranchType
# #endregion Services.Branch.ClassifyBranchType
# region Services.Branch.ValidateBranchName [C:2] [TYPE Function] [SEMANTICS git,branch,validate,regex]
# #region Services.Branch.ValidateBranchName [C:2] [TYPE Function] [SEMANTICS git,branch,validate,regex]
# @BRIEF: Validate a branch name against allowed pattern.
# @PRE name is non-empty string.
# @POST Returns True if valid; raises HTTPException(400) if invalid.
@@ -426,9 +272,9 @@ class GitServiceBranchMixin:
detail="Branch name must be 255 characters or fewer",
)
return True
# endregion Services.Branch.ValidateBranchName
# #endregion Services.Branch.ValidateBranchName
# region Services.Branch.DeleteBranch [C:4] [TYPE Function] [SEMANTICS git,branch,delete,protection,lock]
# #region Services.Branch.DeleteBranch [C:4] [TYPE Function] [SEMANTICS git,branch,delete,protection,lock]
# @BRIEF: Delete a branch (local and remote if origin exists) with protection for environment branches.
# @PRE Repository exists; branch is not a protected environment branch (unless force=True).
# @POST Branch is deleted both locally and on origin.
@@ -484,9 +330,9 @@ class GitServiceBranchMixin:
logger.explore("Failed to delete remote branch", extra={"src": "delete_branch"}, error=str(e))
# Non-fatal: local delete already succeeded
return {"status": "deleted", "branch_name": branch_name}
# endregion Services.Branch.DeleteBranch
# #endregion Services.Branch.DeleteBranch
# region Services.Branch.GetBranchProtectionRules [C:2] [TYPE Function] [SEMANTICS git,branch,protection,rules]
# #region Services.Branch.GetBranchProtectionRules [C:2] [TYPE Function] [SEMANTICS git,branch,protection,rules]
# @BRIEF: Return protection rules for environment branches.
# @RETURN List[dict] — protection rules keyed by branch name.
@staticmethod
@@ -514,6 +360,6 @@ class GitServiceBranchMixin:
"allow_direct_delete": False,
},
]
# endregion Services.Branch.GetBranchProtectionRules
# #endregion Services.Branch.GetBranchProtectionRules
# #endregion Services.Branch.GitServiceBranchMixin.Class
# #endregion Services.Branch.GitServiceBranchMixin

View File

@@ -0,0 +1,201 @@
# #region Services.Branch.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from git import Repo
# #region Services.Branch.Execution.ensure_gitflow_branches [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
def _ensure_gitflow_branches_implementation(authority, self, repo: Repo, dashboard_id: int):
with authority.belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["prod", "dev", "preprod"]
local_heads = {head.name: head for head in getattr(repo, "heads", [])}
base_commit = None
try:
base_commit = repo.head.commit
except Exception:
base_commit = None
if "prod" in local_heads:
base_commit = local_heads["prod"].commit
if base_commit is None:
authority.logger.reason(
f"Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits",
extra={"src": "_ensure_gitflow_branches"},
)
return
if "prod" not in local_heads:
local_heads["prod"] = repo.create_head("prod", base_commit)
authority.logger.reason(f"Created local branch prod for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["prod"].commit)
authority.logger.reason(
f"Created local branch {branch_name} for dashboard {dashboard_id}",
extra={"src": "_ensure_gitflow_branches"},
)
try:
if repo.active_branch.name != "dev":
repo.git.checkout("dev")
except Exception as e:
authority.logger.reason(f"Could not checkout dev branch for dashboard {dashboard_id}: {e}", extra={"src": "_ensure_gitflow_branches"})
try:
origin = repo.remote(name="origin")
except ValueError:
authority.logger.reason(
f"Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation",
extra={"src": "_ensure_gitflow_branches"},
)
return
_publish_bootstrap_branches(authority, origin, required_branches, dashboard_id)
# #endregion Services.Branch.Execution.ensure_gitflow_branches
# #region Services.Branch.Execution.checkout_branch [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def checkout_branch_implementation(authority, self, dashboard_id: int, name: str):
with self._locked(dashboard_id):
with authority.belief_scope("GitService.checkout_branch"):
repo = await self.get_repo(dashboard_id)
authority.logger.reason(f"Checking out branch {name}", extra={"src": "checkout_branch"})
try:
repo.git.checkout(name)
except authority.GitCommandError as e:
stderr = str(e.stderr or "")
details = str(e)
lowered = stderr.lower()
if "local changes" in lowered or "would be overwritten" in lowered:
# Superset updates only the export timestamp in the root metadata
# manifest. It is excluded from the dashboard content hash, so it
# must not prevent a BI analyst from opening a feature draft.
# Deliberately reset *only* this exact tracked path; every other
# modified or untracked file keeps the normal checkout protection.
files = self._checkout_conflict_files(stderr)
if files == ["metadata.yaml"]:
authority.logger.reason(
"Discarding volatile metadata.yaml timestamp before branch checkout",
extra={"src": "checkout_branch", "target_branch": name},
)
repo.git.checkout("HEAD", "--", "metadata.yaml")
repo.git.checkout(name)
return
raise authority.HTTPException(
status_code=409,
detail={
"error_code": "GIT_CHECKOUT_LOCAL_CHANGES",
"message": (
f"Невозможно переключиться на ветку '{name}' — "
f"локальные изменения будут перезаписаны. "
f"Зафиксируйте или отложите изменения."
),
"message_en": (
f"Cannot checkout branch '{name}' — "
f"local changes would be overwritten. "
f"Commit or stash your changes first."
),
"files": files,
"next_steps": [
"Зафиксируйте изменения (Commit) в текущей ветке перед переключением",
"Отложите изменения через Stash (вручную: git stash)",
"Отмените локальные изменения, если они не нужны",
],
"next_steps_en": [
"Commit your changes in the current branch before switching",
"Stash your changes manually: git stash",
"Discard local changes if not needed",
],
},
)
authority.logger.explore("Failed to checkout branch", extra={"src": "checkout_branch"}, error=str(e))
raise authority.HTTPException(
status_code=500,
detail=f"Git checkout failed: {details}",
)
# #endregion Services.Branch.Execution.checkout_branch
# #region Services.Branch.Execution.undo_last_commit [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def undo_last_commit_implementation(authority, self, dashboard_id: int):
with self._locked(dashboard_id):
with authority.belief_scope("GitService.undo_last_commit"):
repo = await self.get_repo(dashboard_id)
try:
head_commit = repo.head.commit
except (ValueError, TypeError) as exc:
raise authority.HTTPException(status_code=409, detail="No commits to undo") from exc
# Guard: forbid undo when the last commit is already on the remote.
# Rationale: if we cannot even determine the active branch (detached HEAD),
# undo is unsafe — the analyst may be on an unexpected ref; refuse explicitly.
try:
active_branch = repo.active_branch
except (TypeError, ValueError) as exc:
raise authority.HTTPException(status_code=409, detail="Cannot undo in detached HEAD state — checkout a branch first") from exc
tracking_branch = None
try:
tracking_branch = active_branch.tracking_branch()
except Exception:
tracking_branch = None
if tracking_branch is not None:
ahead_count = sum(1 for _ in repo.iter_commits(f"{tracking_branch.name}..{active_branch.name}"))
if ahead_count == 0:
authority.logger.reason(
"Undo rejected — last commit already pushed",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
)
raise authority.HTTPException(status_code=409, detail="Last version is already published to the remote — use rollback instead")
else:
# No upstream configured: nothing has ever been pushed, undo is safe.
authority.logger.reason(
"No upstream tracking branch — undo allowed (nothing pushed)",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
)
undone_message = (head_commit.message or "").strip()
undone_hash = head_commit.hexsha
repo.git.reset("--soft", "HEAD~1")
authority.logger.reflect(
"Last commit undone (soft reset)",
extra={"src": "undo_last_commit", "dashboard_id": dashboard_id},
payload={"undone_hash": undone_hash, "undone_message": undone_message},
)
return {"status": "success", "undone_hash": undone_hash, "undone_message": undone_message}
# #endregion Services.Branch.Execution.undo_last_commit
# #region Services.Branch.Execution.PublishBootstrap [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _publish_bootstrap_branches(authority, origin, required_branches, dashboard_id):
remote_branch_names = set()
try:
origin.fetch()
for ref in origin.refs:
remote_head = getattr(ref, "remote_head", None)
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
authority.logger.reason(f"Failed to fetch origin refs: {e}", extra={"src": "_ensure_gitflow_branches"})
for branch_name in required_branches:
if branch_name in remote_branch_names:
continue
try:
origin.push(refspec=f"{branch_name}:{branch_name}")
authority.logger.reason(
f"Pushed branch {branch_name} to origin for dashboard {dashboard_id}",
extra={"src": "_ensure_gitflow_branches"},
)
except Exception as e:
authority.logger.explore("Failed to push branch to origin", extra={"src": "_ensure_gitflow_branches"}, payload={"branch": branch_name, "dashboard_id": dashboard_id}, error=str(e))
raise authority.HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {e!s}",
)
# #endregion Services.Branch.Execution.PublishBootstrap
# #endregion Services.Branch.Execution

View File

@@ -1,9 +1,12 @@
# #region Services.Merge.GitServiceMergeMixin [C:4] [TYPE Module] [SEMANTICS git, merge, branch, conflict, resolution, lock]
# @RELATION DEPENDS_ON -> [Services.Merge.Execution]
# @defgroup Services Module group.
# @LAYER Infrastructure
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote (all concurrent-safe via per-dashboard locks).
# @RELATION CALLED_BY -> [Services.Init.GitService]
import sys as _sys
from ._merge_execution import (promote_direct_merge_implementation, merge_branch_implementation)
import os
from pathlib import Path
from typing import Any
@@ -20,7 +23,7 @@ from src.core.logger import belief_scope, logger
# @defgroup Services Module group.
# @BRIEF Mixin providing merge operations for GitService.
class GitServiceMergeMixin:
# region Services.Merge.ReadBlobText [TYPE Function]
# #region Services.Merge.ReadBlobText [TYPE Function]
# @BRIEF: Read text from a Git blob.
def _read_blob_text(self, blob: Blob) -> str:
with belief_scope("GitService._read_blob_text"):
@@ -31,9 +34,9 @@ class GitServiceMergeMixin:
except Exception:
logger.debug("[_read_blob_text] Could not decode blob text")
return ""
# endregion Services.Merge.ReadBlobText
# #endregion Services.Merge.ReadBlobText
# region Services.Merge.GetUnmergedFilePaths [TYPE Function]
# #region Services.Merge.GetUnmergedFilePaths [TYPE Function]
# @BRIEF: List files with merge conflicts.
def _get_unmerged_file_paths(self, repo: Repo) -> list[str]:
with belief_scope("GitService._get_unmerged_file_paths"):
@@ -41,9 +44,9 @@ class GitServiceMergeMixin:
return sorted(list(repo.index.unmerged_blobs().keys()))
except Exception:
return []
# endregion Services.Merge.GetUnmergedFilePaths
# #endregion Services.Merge.GetUnmergedFilePaths
# region Services.Merge.BuildUnfinishedMergePayload [TYPE Function]
# #region Services.Merge.BuildUnfinishedMergePayload [TYPE Function]
# @BRIEF: 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"):
@@ -88,9 +91,9 @@ class GitServiceMergeMixin:
],
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
}
# endregion Services.Merge.BuildUnfinishedMergePayload
# #endregion Services.Merge.BuildUnfinishedMergePayload
# region Services.Merge.GetMergeStatus [C:4] [TYPE Function] [SEMANTICS git,merge,status,lock]
# #region Services.Merge.GetMergeStatus [C:4] [TYPE Function] [SEMANTICS git,merge,status,lock]
# @BRIEF: Get current merge status for a dashboard repository (concurrent-safe).
async def get_merge_status(self, dashboard_id: int) -> dict[str, Any]:
with self._locked(dashboard_id):
@@ -122,9 +125,9 @@ class GitServiceMergeMixin:
"merge_message_preview": payload["merge_message_preview"],
"conflicts_count": int(payload.get("conflicts_count") or 0),
}
# endregion Services.Merge.GetMergeStatus
# #endregion Services.Merge.GetMergeStatus
# region Services.Merge.GetMergeConflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,list,lock]
# #region Services.Merge.GetMergeConflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,list,lock]
# @BRIEF: List all files with conflicts and their contents (concurrent-safe).
async def get_merge_conflicts(self, dashboard_id: int) -> list[dict[str, Any]]:
with self._locked(dashboard_id):
@@ -146,9 +149,9 @@ class GitServiceMergeMixin:
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
})
return sorted(conflicts, key=lambda item: item["file_path"])
# endregion Services.Merge.GetMergeConflicts
# #endregion Services.Merge.GetMergeConflicts
# region Services.Merge.ResolveMergeConflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,resolve,lock]
# #region Services.Merge.ResolveMergeConflicts [C:4] [TYPE Function] [SEMANTICS git,conflict,resolve,lock]
# @BRIEF: Resolve conflicts using specified strategy (concurrent-safe).
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: list[dict[str, Any]]) -> list[str]:
with self._locked(dashboard_id):
@@ -180,9 +183,9 @@ class GitServiceMergeMixin:
repo.git.add(file_path)
resolved_files.append(file_path)
return resolved_files
# endregion Services.Merge.ResolveMergeConflicts
# #endregion Services.Merge.ResolveMergeConflicts
# region Services.Merge.AbortMerge [C:4] [TYPE Function] [SEMANTICS git,merge,abort,lock]
# #region Services.Merge.AbortMerge [C:4] [TYPE Function] [SEMANTICS git,merge,abort,lock]
# @BRIEF: Abort ongoing merge (concurrent-safe).
def abort_merge(self, dashboard_id: int) -> dict[str, Any]:
with self._locked(dashboard_id):
@@ -197,9 +200,9 @@ class GitServiceMergeMixin:
return {"status": "no_merge_in_progress"}
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
return {"status": "aborted"}
# endregion Services.Merge.AbortMerge
# #endregion Services.Merge.AbortMerge
# region Services.Merge.ContinueMerge [C:4] [TYPE Function] [SEMANTICS git,merge,continue,lock]
# #region Services.Merge.ContinueMerge [C:4] [TYPE Function] [SEMANTICS git,merge,continue,lock]
# @BRIEF: Finalize merge after conflict resolution (concurrent-safe).
def continue_merge(self, dashboard_id: int, message: str | None = None) -> dict[str, Any]:
with self._locked(dashboard_id):
@@ -233,9 +236,10 @@ class GitServiceMergeMixin:
except Exception:
commit_hash = ""
return {"status": "committed", "commit_hash": commit_hash}
# endregion Services.Merge.ContinueMerge
# #endregion Services.Merge.ContinueMerge
# region Services.Merge.PromoteDirectMerge [C:4] [TYPE Function] [SEMANTICS git,merge,promote,branch,isolation]
# #region Services.Merge.PromoteDirectMerge [C:4] [TYPE Function] [SEMANTICS git,merge,promote,branch,isolation]
# @RELATION CALLS -> [Services.Merge.Execution.promote_direct_merge]
# @BRIEF: Perform direct merge between branches with branch isolation — original branch restored on error.
# @PRE Repository exists and both branches are valid.
# @POST Target branch contains merged changes from source branch. Active branch restored to original.
@@ -243,66 +247,11 @@ class GitServiceMergeMixin:
# @SIDE_EFFECT Changes local branch state during merge; restores original branch in finally block.
# @RETURN Dict[str, Any]
async def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> dict[str, Any]:
with self._locked(dashboard_id), belief_scope("GitService.promote_direct_merge"):
if not from_branch or not to_branch:
raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
repo = await self.get_repo(dashboard_id)
source = from_branch.strip()
target = to_branch.strip()
if source == target:
raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")
try:
origin = repo.remote(name="origin")
except ValueError:
origin = None
return await promote_direct_merge_implementation(_sys.modules[__name__], self, dashboard_id, from_branch, to_branch)
# #endregion Services.Merge.PromoteDirectMerge
# Remember original branch for restoration in finally
original_branch = None
try:
original_branch = repo.active_branch.name
except Exception:
original_branch = None
try:
if origin:
origin.fetch()
if source not in [head.name for head in repo.heads]:
if f"origin/{source}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", source, f"origin/{source}")
else:
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target in [head.name for head in repo.heads]:
repo.git.checkout(target)
elif f"origin/{target}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", target, f"origin/{target}")
else:
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
if origin:
try:
origin.pull(target)
except Exception:
logger.debug("Could not pull target branch %s before direct promote", target)
repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
if origin:
origin.push(refspec=f"{target}:{target}")
except HTTPException:
raise
except Exception as e:
message = str(e)
if "CONFLICT" in message.upper():
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
finally:
# Restore original branch even if merge/push partially failed
if original_branch:
try:
repo.git.checkout(original_branch)
except Exception:
logger.debug("Could not restore original branch %s after direct promote", original_branch)
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
# endregion Services.Merge.PromoteDirectMerge
# region Services.Merge.MergeBranch [C:4] [TYPE Function] [SEMANTICS git,merge,branch,feature,hotfix,isolation]
# #region Services.Merge.MergeBranch [C:4] [TYPE Function] [SEMANTICS git,merge,branch,feature,hotfix,isolation]
# @RELATION CALLS -> [Services.Merge.Execution.merge_branch]
# @BRIEF: Merge source_branch into target_branch with branch isolation, conflict detection, and optional auto-delete.
# @PRE Repository exists and both branches are valid and different.
# @POST Target branch contains merged changes from source. Active branch restored to original.
@@ -320,152 +269,7 @@ class GitServiceMergeMixin:
message: str | None = None,
auto_delete_source: bool = False,
) -> dict[str, Any]:
with self._locked(dashboard_id), belief_scope("GitService.merge_branch"):
source = source_branch.strip()
target = target_branch.strip()
if not source or not target:
raise HTTPException(status_code=400, detail="source_branch and target_branch are required")
if source == target:
raise HTTPException(status_code=400, detail="source_branch and target_branch must be different")
repo = await self.get_repo(dashboard_id)
original_branch = None
try:
original_branch = repo.active_branch.name
except Exception:
original_branch = None
# Validate branches exist
all_head_names = [head.name for head in repo.heads]
if source not in all_head_names:
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target not in all_head_names:
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
# Pull latest for target
try:
origin = repo.remote(name="origin")
origin.fetch()
repo.git.checkout(target)
try:
origin.pull(target)
except Exception:
logger.debug("Could not pull target branch %s before merge", target)
except ValueError:
logger.debug("No remote origin configured for dashboard %d", dashboard_id)
merge_success = False
merge_commit_hash = ""
conflicts: list[str] = []
error_message = ""
source_deleted = False
try:
merge_msg = str(message or f"chore(merge): merge {source} -> {target}")
repo.git.merge(source, "-m", merge_msg)
merge_success = True
try:
merge_commit_hash = repo.head.commit.hexsha
except Exception:
merge_commit_hash = ""
logger.reflect(
"Branch merged successfully",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "commit": merge_commit_hash},
)
except GitCommandError as e:
error_str = str(e)
if "CONFLICT" in error_str.upper():
# Abort the merge immediately — return conflict info
try:
repo.git.merge("--abort")
except Exception:
logger.debug("Could not abort merge for dashboard %d", dashboard_id)
conflicts = self._get_unmerged_file_paths(repo) or []
error_message = f"Merge conflict: {source} -> {target}"
logger.explore(
"Merge conflicts detected, aborted",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "conflict_files": conflicts},
error=error_str,
)
return {
"source_branch": source,
"target_branch": target,
"status": "conflicts",
"commit_hash": None,
"conflicts": conflicts,
"error_message": error_message,
"source_deleted": False,
}
# Check if already up-to-date
if "already up to date" in error_str.lower() or "already up-to-date" in error_str.lower():
merge_success = True
try:
merge_commit_hash = repo.head.commit.hexsha
except Exception:
merge_commit_hash = ""
logger.reflect(
"Branch already up-to-date",
extra={"src": "merge_branch"},
payload={"source": source, "target": target},
)
else:
error_message = f"Merge failed: {error_str}"
logger.explore(
"Merge failed",
extra={"src": "merge_branch"},
error=error_str,
)
raise HTTPException(status_code=500, detail=error_message)
except HTTPException:
raise
except Exception as e:
error_message = str(e)
logger.explore("Unexpected merge error", extra={"src": "merge_branch"}, error=error_message)
raise HTTPException(status_code=500, detail=f"Merge operation failed: {error_message}")
finally:
# Restore original branch
if original_branch:
try:
repo.git.checkout(original_branch)
except Exception:
logger.debug("Could not restore original branch %s after merge", original_branch)
# Push target branch to remote after successful merge
if merge_success:
try:
origin = repo.remote(name="origin")
origin.push(refspec=f"{target}:{target}")
logger.reason(f"Pushed merged {target} to origin", extra={"src": "merge_branch"})
except Exception:
logger.debug("Could not push target branch %s after merge", target)
# Auto-delete source branch if requested
if auto_delete_source:
try:
await self.delete_branch(dashboard_id, source, force=True)
source_deleted = True
logger.reason(
f"Auto-deleted source branch {source} after merge",
extra={"src": "merge_branch"},
)
except Exception as e:
logger.explore(
"Auto-delete source branch failed",
extra={"src": "merge_branch"},
error=str(e),
)
return {
"source_branch": source,
"target_branch": target,
"status": "success" if merge_success else "conflicts",
"commit_hash": merge_commit_hash or None,
"conflicts": conflicts,
"error_message": error_message or None,
"source_deleted": source_deleted,
}
# endregion Services.Merge.MergeBranch
return await merge_branch_implementation(_sys.modules[__name__], self, dashboard_id, source_branch, target_branch, message, auto_delete_source)
# #endregion Services.Merge.MergeBranch
# #endregion Services.Merge.GitServiceMergeMixin.Class
# #endregion Services.Merge.GitServiceMergeMixin

View File

@@ -0,0 +1,274 @@
# #region Services.Merge.Execution [C:4] [TYPE Module] [SEMANTICS git,refactor,facade]
# @BRIEF Extract operation bodies while the original module supplies dependency authority.
# @RATIONALE Explicit facade injection preserves existing patched imports across structural extraction.
# @REJECTED Copying facade dependencies into this leaf would bypass existing monkeypatch and singleton ownership.
from __future__ import annotations
# #region Services.Merge.Execution.promote_direct_merge [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def promote_direct_merge_implementation(authority, self, dashboard_id: int, from_branch: str, to_branch: str):
with self._locked(dashboard_id), authority.belief_scope("GitService.promote_direct_merge"):
if not from_branch or not to_branch:
raise authority.HTTPException(status_code=400, detail="from_branch and to_branch are required")
repo = await self.get_repo(dashboard_id)
source = from_branch.strip()
target = to_branch.strip()
if source == target:
raise authority.HTTPException(status_code=400, detail="from_branch and to_branch must be different")
origin = _merge_origin(repo)
# Remember original branch for restoration in finally
original_branch = _original_promote_branch(repo)
try:
if origin:
origin.fetch()
_checkout_promote_branches(authority, repo, source, target)
if origin:
try:
origin.pull(target)
except Exception:
authority.logger.debug("Could not pull target branch %s before direct promote", target)
repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
if origin:
origin.push(refspec=f"{target}:{target}")
except authority.HTTPException:
raise
except Exception as e:
message = str(e)
if "CONFLICT" in message.upper():
raise authority.HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
raise authority.HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
finally:
# Restore original branch even if merge/push partially failed
_restore_promote_branch(authority, repo, original_branch)
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
# #endregion Services.Merge.Execution.promote_direct_merge
# #region Services.Merge.Execution.merge_branch [C:4] [TYPE Function]
# @PRE authority is the originating facade module; its dependencies retain live monkeypatch authority.
# @POST Preserve the original return, exception, ordering and cleanup contract.
async def merge_branch_implementation(authority, self, dashboard_id: int, source_branch: str, target_branch: str, message: str | None, auto_delete_source: bool):
with self._locked(dashboard_id), authority.belief_scope("GitService.merge_branch"):
source = source_branch.strip()
target = target_branch.strip()
if not source or not target:
raise authority.HTTPException(status_code=400, detail="source_branch and target_branch are required")
if source == target:
raise authority.HTTPException(status_code=400, detail="source_branch and target_branch must be different")
repo = await self.get_repo(dashboard_id)
original_branch = _original_promote_branch(repo)
_prepare_merge_target(authority, repo, source, target, dashboard_id)
merge_success = False
merge_commit_hash = ""
conflicts: list[str] = []
error_message = ""
source_deleted = False
try:
merge_msg = str(message or f"chore(merge): merge {source} -> {target}")
repo.git.merge(source, "-m", merge_msg)
merge_success = True
merge_commit_hash = _head_commit_hash(repo)
authority.logger.reflect(
"Branch merged successfully",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "commit": merge_commit_hash},
)
except authority.GitCommandError as e:
error_str = str(e)
if "CONFLICT" in error_str.upper():
# Abort the merge immediately — return conflict info
try:
repo.git.merge("--abort")
except Exception:
authority.logger.debug("Could not abort merge for dashboard %d", dashboard_id)
conflicts = self._get_unmerged_file_paths(repo) or []
error_message = f"Merge conflict: {source} -> {target}"
authority.logger.explore(
"Merge conflicts detected, aborted",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "conflict_files": conflicts},
error=error_str,
)
return {
"source_branch": source,
"target_branch": target,
"status": "conflicts",
"commit_hash": None,
"conflicts": conflicts,
"error_message": error_message,
"source_deleted": False,
}
# Check if already up-to-date
if "already up to date" in error_str.lower() or "already up-to-date" in error_str.lower():
merge_success = True
merge_commit_hash = _head_commit_hash(repo)
authority.logger.reflect(
"Branch already up-to-date",
extra={"src": "merge_branch"},
payload={"source": source, "target": target},
)
else:
error_message = f"Merge failed: {error_str}"
authority.logger.explore(
"Merge failed",
extra={"src": "merge_branch"},
error=error_str,
)
raise authority.HTTPException(status_code=500, detail=error_message)
except authority.HTTPException:
raise
except Exception as e:
error_message = str(e)
authority.logger.explore("Unexpected merge error", extra={"src": "merge_branch"}, error=error_message)
raise authority.HTTPException(status_code=500, detail=f"Merge operation failed: {error_message}")
finally:
# Restore original branch
_restore_merge_branch(authority, repo, original_branch)
source_deleted = await _publish_merged_branch(authority, self, repo, target, source, dashboard_id, merge_success, auto_delete_source, source_deleted)
return {
"source_branch": source,
"target_branch": target,
"status": "success" if merge_success else "conflicts",
"commit_hash": merge_commit_hash or None,
"conflicts": conflicts,
"error_message": error_message or None,
"source_deleted": source_deleted,
}
# #endregion Services.Merge.Execution.merge_branch
# #region Services.Merge.Execution.CheckoutPromote [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _checkout_promote_branches(authority, repo, source, target):
if source not in [head.name for head in repo.heads]:
if f"origin/{source}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", source, f"origin/{source}")
else:
raise authority.HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target in [head.name for head in repo.heads]:
repo.git.checkout(target)
elif f"origin/{target}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", target, f"origin/{target}")
else:
raise authority.HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
# #endregion Services.Merge.Execution.CheckoutPromote
# #region Services.Merge.Execution.RestorePromote [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _restore_promote_branch(authority, repo, original_branch):
if original_branch:
try:
repo.git.checkout(original_branch)
except Exception:
authority.logger.debug("Could not restore original branch %s after direct promote", original_branch)
# #endregion Services.Merge.Execution.RestorePromote
# #region Services.Merge.Execution.PrepareTarget [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _prepare_merge_target(authority, repo, source, target, dashboard_id):
# Validate branches exist
all_head_names = [head.name for head in repo.heads]
if source not in all_head_names:
raise authority.HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target not in all_head_names:
raise authority.HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
# Pull latest for target
try:
origin = repo.remote(name="origin")
origin.fetch()
repo.git.checkout(target)
try:
origin.pull(target)
except Exception:
authority.logger.debug("Could not pull target branch %s before merge", target)
except ValueError:
authority.logger.debug("No remote origin configured for dashboard %d", dashboard_id)
# #endregion Services.Merge.Execution.PrepareTarget
# #region Services.Merge.Execution.PublishTarget [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
async def _publish_merged_branch(authority, self, repo, target, source, dashboard_id, merge_success, auto_delete_source, source_deleted):
# Push target branch to remote after successful merge
if merge_success:
try:
origin = repo.remote(name="origin")
origin.push(refspec=f"{target}:{target}")
authority.logger.reason(f"Pushed merged {target} to origin", extra={"src": "merge_branch"})
except Exception:
authority.logger.debug("Could not push target branch %s after merge", target)
# Auto-delete source branch if requested
if auto_delete_source:
try:
await self.delete_branch(dashboard_id, source, force=True)
source_deleted = True
authority.logger.reason(
f"Auto-deleted source branch {source} after merge",
extra={"src": "merge_branch"},
)
except Exception as e:
authority.logger.explore(
"Auto-delete source branch failed",
extra={"src": "merge_branch"},
error=str(e),
)
return source_deleted
# #endregion Services.Merge.Execution.PublishTarget
# #region Services.Merge.Execution.Origin [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _merge_origin(repo):
try:
origin = repo.remote(name="origin")
except ValueError:
origin = None
return origin
# #endregion Services.Merge.Execution.Origin
# #region Services.Merge.Execution.OriginalBranch [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _original_promote_branch(repo):
original_branch = None
try:
original_branch = repo.active_branch.name
except Exception:
original_branch = None
return original_branch
# #endregion Services.Merge.Execution.OriginalBranch
# #region Services.Merge.Execution.RestoreMerge [C:3] [TYPE Function]
# @BRIEF Restore the original branch after success, conflict or exception, retaining best-effort cleanup.
def _restore_merge_branch(authority, repo, original_branch):
if original_branch:
try:
repo.git.checkout(original_branch)
except Exception:
authority.logger.debug("Could not restore original branch %s after merge", original_branch)
# #endregion Services.Merge.Execution.RestoreMerge
# #region Services.Merge.Execution.HeadHash [C:2] [TYPE Function]
# @BRIEF Read the merged commit hash with the existing empty fallback.
def _head_commit_hash(repo):
try:
return repo.head.commit.hexsha
except Exception:
return ""
# #endregion Services.Merge.Execution.HeadHash
# #endregion Services.Merge.Execution

View File

@@ -53,7 +53,7 @@ class GitServiceStatusMixin:
return staged, modified, untracked
# #endregion Services.Status.ParseStatusPorcelain
# region Services.Status.GetStatus [C:4] [TYPE Function] [SEMANTICS git,status,lock]
# #region Services.Status.GetStatus [C:4] [TYPE Function] [SEMANTICS git,status,lock]
# @BRIEF: Get current repository status (concurrent-safe).
# @PRE Repository for dashboard_id exists.
# @POST Returns a dictionary representing the Git status.
@@ -107,20 +107,7 @@ class GitServiceStatusMixin:
staged_files, modified_files, untracked_files = self._parse_status_porcelain(repo)
is_dirty = bool(staged_files or modified_files or untracked_files)
is_diverged = ahead_count > 0 and behind_count > 0
if not has_remote:
sync_state = "LOCAL_CHANGES" if is_dirty else "LOCAL_CLEAN"
elif is_diverged:
sync_state = "DIVERGED"
elif behind_count > 0:
sync_state = "BEHIND_REMOTE"
elif ahead_count > 0:
sync_state = "AHEAD_REMOTE"
elif has_remote and not has_upstream:
sync_state = "AHEAD_REMOTE"
elif is_dirty or modified_files or staged_files or untracked_files:
sync_state = "CHANGES"
else:
sync_state = "SYNCED"
sync_state = _sync_state(has_remote, is_dirty, is_diverged, behind_count, ahead_count, has_upstream, modified_files, staged_files, untracked_files)
return {
"is_dirty": is_dirty,
"has_remote": has_remote,
@@ -140,9 +127,9 @@ class GitServiceStatusMixin:
"last_commit_author": last_commit_author,
"last_commit_date": last_commit_date,
}
# endregion Services.Status.GetStatus
# #endregion Services.Status.GetStatus
# region Services.Status.GetDiff [C:4] [TYPE Function] [SEMANTICS git,diff,lock]
# #region Services.Status.GetDiff [C:4] [TYPE Function] [SEMANTICS git,diff,lock]
# @BRIEF: Generate diff for a file or the whole repository (concurrent-safe).
# @PARAM file_path (str) - Optional specific file.
# @PARAM staged (bool) - Whether to show staged changes.
@@ -159,9 +146,9 @@ class GitServiceStatusMixin:
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# endregion Services.Status.GetDiff
# #endregion Services.Status.GetDiff
# region Services.Status.GetCommitHistory [C:4] [TYPE Function] [SEMANTICS git,history,lock]
# #region Services.Status.GetCommitHistory [C:4] [TYPE Function] [SEMANTICS git,history,lock]
# @BRIEF: Retrieve commit history for a repository (concurrent-safe).
# @PARAM limit (int) - Max number of commits to return.
# @PRE Repository for dashboard_id exists.
@@ -188,7 +175,7 @@ class GitServiceStatusMixin:
logger.explore(f"Could not retrieve commit history for dashboard {dashboard_id}: {e}", extra={"src": "get_commit_history"})
return []
return commits
# endregion Services.Status.GetCommitHistory
# #endregion Services.Status.GetCommitHistory
# #region GitService.get_branch_commits [C:4] [TYPE Function] [SEMANTICS git,history,per-branch,lock]
# @ingroup GitServiceStatusMixin
@@ -232,7 +219,7 @@ class GitServiceStatusMixin:
return []
return commits
# #endregion GitService.get_branch_commits
# region Services.Status.GetCommitDiff [C:3] [TYPE Function] [SEMANTICS git,diff,historical]
# #region Services.Status.GetCommitDiff [C:3] [TYPE Function] [SEMANTICS git,diff,historical]
# @BRIEF: Return unified or raw diff between two commit-ish (for viz "diff between versions").
# Falls back to working tree behavior if to_ref is None.
async def get_commit_diff(self, dashboard_id: int, from_ref: str, to_ref: str | None = None) -> str:
@@ -246,9 +233,9 @@ class GitServiceStatusMixin:
except Exception as e:
logger.explore(f"get_commit_diff failed for {from_ref}..{to_ref} on {dashboard_id}: {e}", extra={"src": "get_commit_diff"})
raise
# endregion Services.Status.GetCommitDiff
# #endregion Services.Status.GetCommitDiff
# region Services.Status.RollbackCommit [C:3] [TYPE Function] [SEMANTICS git,history,rollback,lock]
# #region Services.Status.RollbackCommit [C:3] [TYPE Function] [SEMANTICS git,history,rollback,lock]
# @BRIEF: Roll back one commit by creating a revert commit (concurrent-safe).
# @PRE Repository for dashboard_id exists and commit_hash identifies an existing commit.
# @POST A new revert commit is created; history is preserved.
@@ -272,6 +259,27 @@ class GitServiceStatusMixin:
"reverted_commit": normalized_hash,
"rollback_commit": repo.head.commit.hexsha,
}
# endregion Services.Status.RollbackCommit
# #endregion Services.Status.RollbackCommit
# #endregion Services.Status.GitServiceStatusMixin
# #region Services.Status.SyncState [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _sync_state(has_remote, is_dirty, is_diverged, behind_count, ahead_count, has_upstream, modified_files, staged_files, untracked_files):
if not has_remote:
sync_state = "LOCAL_CHANGES" if is_dirty else "LOCAL_CLEAN"
elif is_diverged:
sync_state = "DIVERGED"
elif behind_count > 0:
sync_state = "BEHIND_REMOTE"
elif ahead_count > 0:
sync_state = "AHEAD_REMOTE"
elif has_remote and not has_upstream:
sync_state = "AHEAD_REMOTE"
elif is_dirty or modified_files or staged_files or untracked_files:
sync_state = "CHANGES"
else:
sync_state = "SYNCED"
return sync_state
# #endregion Services.Status.SyncState
# #endregion Services.Status.GitStatusModule

View File

@@ -17,6 +17,8 @@ from src.core.logger import belief_scope, logger
from src.models.git import GitRepository, GitServerConfig
# #region Services.Sync.HttpHost [C:2] [TYPE Function] [SEMANTICS git,remote,host]
# @BRIEF Normalize HTTP(S) repository hosts for Git-server binding comparison.
def _http_host(url_value: str | None) -> str | None:
"""Return a normalized HTTP(S) host without requiring another mixin."""
try:
@@ -26,24 +28,24 @@ def _http_host(url_value: str | None) -> str | None:
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return None
return f"{parsed.hostname.lower()}:{parsed.port}" if parsed.port else parsed.hostname.lower()
# #endregion Services.Sync.HttpHost
# #region Services.Sync.GitServiceSyncMixin.Class [C:3] [TYPE Class]
# @defgroup Services Module group.
# @BRIEF Mixin providing push and pull operations with safe repository-binding checks.
class GitServiceSyncMixin:
# #region Services.Sync.GitServiceSyncMixin.RedactPat [C:2] [TYPE Function] [SEMANTICS git,credentials,redaction]
# @BRIEF Remove the supplied token and URL password from Git exception messages.
@staticmethod
def _redact_pat_from_message(message: str, pat: str | None = None) -> str:
redacted = str(message or "")
if pat:
redacted = redacted.replace(pat, "***")
return re.sub(r"(https?://[^\s:/@]+:)[^\s@]+(@)", r"\1***\2", redacted)
# #endregion Services.Sync.GitServiceSyncMixin.RedactPat
# region Services.Sync.PushChanges [C:4] [TYPE Function] [SEMANTICS git,push,lock]
# @BRIEF: Push local commits to remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
# region Services.Sync.EmbedPatInOriginUrl [C:2] [TYPE Function] [SEMANTICS git,auth,pat,url]
# #region Services.Sync.EmbedPatInOriginUrl [C:2] [TYPE Function] [SEMANTICS git,auth,pat,url]
# @BRIEF Temporarily embed a personal access token into the origin remote URL.
# @POST Origin URL is updated with embedded PAT; returns the original URL for restoration.
@staticmethod
@@ -72,8 +74,12 @@ class GitServiceSyncMixin:
return original_url
except Exception:
return None
# endregion Services.Sync.EmbedPatInOriginUrl
# #endregion Services.Sync.EmbedPatInOriginUrl
# #region Services.Sync.PushChanges [C:4] [TYPE Function] [SEMANTICS git,push,lock]
# @BRIEF: Push local commits to remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
async def push_changes(self, dashboard_id: int, pat: str | None = None):
with self._locked(dashboard_id):
with belief_scope("GitService.push_changes"):
@@ -90,34 +96,7 @@ class GitServiceSyncMixin:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
binding_remote_url = None
binding_config_id = None
binding_config_url = None
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
binding_remote_url = db_repo.remote_url
binding_config_id = db_repo.config_id
db_config = (
session.query(GitServerConfig)
.filter(GitServerConfig.id == db_repo.config_id)
.first()
)
if db_config:
binding_config_url = db_config.url
finally:
session.close()
except Exception as diag_error:
logger.reason(
"Failed to load repository binding diagnostics",
extra={"src": "push_changes", "dashboard_id": dashboard_id, "error": str(diag_error)},
)
binding_remote_url, binding_config_id, binding_config_url = _push_binding_diagnostics(dashboard_id)
config_host = _http_host(binding_config_url)
binding_host = _http_host(binding_remote_url)
if config_host and binding_host and config_host != binding_host:
@@ -141,26 +120,7 @@ class GitServiceSyncMixin:
try:
current_branch = repo.active_branch
logger.reason(f"Pushing branch {current_branch.name} to origin", extra={"src": "push_changes"})
tracking_branch = None
try:
tracking_branch = current_branch.tracking_branch()
except Exception:
tracking_branch = None
if tracking_branch is None:
local_names = {head.name for head in repo.heads}
published_names = {ref.remote_head for ref in origin.refs if ref.remote_head != "HEAD"}
initial_publish = {"dev", "preprod", "prod"} <= local_names and not published_names
names = ["dev", "preprod", "prod"] if initial_publish else []
if current_branch.name not in names:
names.append(current_branch.name)
repo.git.push("--set-upstream", "origin", *(f"{name}:{name}" for name in names))
else:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
safe_summary = self._redact_pat_from_message(str(info.summary), pat)
logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=safe_summary)
raise Exception(f"Git push error for {info.remote_ref_string}: {safe_summary}")
_push_current_branch(self, repo, origin, current_branch, pat)
except GitCommandError as e:
details = self._redact_pat_from_message(str(e), pat)
lowered = details.lower()
@@ -176,14 +136,10 @@ class GitServiceSyncMixin:
logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
finally:
if _original_push_url is not None:
try:
origin.set_url(_original_push_url)
except Exception:
pass
# endregion Services.Sync.PushChanges
_restore_push_origin(origin, _original_push_url)
# #endregion Services.Sync.PushChanges
# region Services.Sync.PullChanges [C:4] [TYPE Function] [SEMANTICS git,pull,lock]
# #region Services.Sync.PullChanges [C:4] [TYPE Function] [SEMANTICS git,pull,lock]
# @BRIEF: Pull changes from remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch.
@@ -217,20 +173,7 @@ class GitServiceSyncMixin:
f"Pull diagnostics dashboard={dashboard_id} repo_path={repo.working_tree_dir} branch={current_branch} origin_urls={origin_urls}",
extra={"src": "pull_changes"},
)
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
logger.reason(
f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
extra={"src": "pull_changes"},
)
if not has_remote_branch:
raise HTTPException(
status_code=409,
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
)
logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
repo.git.pull("--no-rebase", "origin", current_branch)
_pull_current_branch(repo, origin, current_branch, dashboard_id)
except ValueError:
logger.explore("Remote 'origin' not found", extra={"src": "pull_changes"}, payload={"dashboard_id": dashboard_id})
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
@@ -251,11 +194,110 @@ class GitServiceSyncMixin:
logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=details)
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
finally:
if _original_pull_url is not None and origin is not None:
try:
origin.set_url(_original_pull_url)
except Exception:
pass
# endregion Services.Sync.PullChanges
_restore_pull_origin(origin, _original_pull_url)
# #endregion Services.Sync.PullChanges
# #endregion Services.Sync.GitServiceSyncMixin.Class
# #region Services.Sync.PushBindingDiagnostics [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _push_binding_diagnostics(dashboard_id):
binding_remote_url = None
binding_config_id = None
binding_config_url = None
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
binding_remote_url = db_repo.remote_url
binding_config_id = db_repo.config_id
db_config = (
session.query(GitServerConfig)
.filter(GitServerConfig.id == db_repo.config_id)
.first()
)
if db_config:
binding_config_url = db_config.url
finally:
session.close()
except Exception as diag_error:
logger.reason(
"Failed to load repository binding diagnostics",
extra={"src": "push_changes", "dashboard_id": dashboard_id, "error": str(diag_error)},
)
return binding_remote_url, binding_config_id, binding_config_url
# #endregion Services.Sync.PushBindingDiagnostics
# #region Services.Sync.PushCurrentBranch [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _push_current_branch(self, repo, origin, current_branch, pat):
tracking_branch = None
try:
tracking_branch = current_branch.tracking_branch()
except Exception:
tracking_branch = None
if tracking_branch is None:
local_names = {head.name for head in repo.heads}
published_names = {ref.remote_head for ref in origin.refs if ref.remote_head != "HEAD"}
initial_publish = {"dev", "preprod", "prod"} <= local_names and not published_names
names = ["dev", "preprod", "prod"] if initial_publish else []
if current_branch.name not in names:
names.append(current_branch.name)
repo.git.push("--set-upstream", "origin", *(f"{name}:{name}" for name in names))
else:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
safe_summary = self._redact_pat_from_message(str(info.summary), pat)
logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=safe_summary)
raise Exception(f"Git push error for {info.remote_ref_string}: {safe_summary}")
# #endregion Services.Sync.PushCurrentBranch
# #region Services.Sync.PullCurrentBranch [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _pull_current_branch(repo, origin, current_branch, dashboard_id):
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
logger.reason(
f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
extra={"src": "pull_changes"},
)
if not has_remote_branch:
raise HTTPException(
status_code=409,
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
)
logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
repo.git.pull("--no-rebase", "origin", current_branch)
# #endregion Services.Sync.PullCurrentBranch
# #region Services.Sync.RestorePushOrigin [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _restore_push_origin(origin, _original_push_url):
if _original_push_url is not None:
try:
origin.set_url(_original_push_url)
except Exception:
pass
# #endregion Services.Sync.RestorePushOrigin
# #region Services.Sync.RestorePullOrigin [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _restore_pull_origin(origin, _original_pull_url):
if _original_pull_url is not None and origin is not None:
try:
origin.set_url(_original_pull_url)
except Exception:
pass
# #endregion Services.Sync.RestorePullOrigin
# #endregion Services.Sync.GitServiceSyncMixin

View File

@@ -0,0 +1,206 @@
# #region Test.McpScenarioE2E.SharedFixtures [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup Test.McpScenarioE2E.SharedFixtures Hardcoded canonical graph/catalog fixtures and isolated operator/registry setup.
import os
import secrets
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-mcp")
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_mcp_e2e_test.db")
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from src.mcp_server import server as mcp_server
import src.mcp_server.rbac_server as rbac_server_module
import src.mcp_server.tools_agent_run as tools_agent_run_module
import src.mcp_server.tools_authoring as tools_authoring_module
import src.mcp_server.tools_scenario as tools_scenario_module
from src.mcp_server.server import _access_token_context
from src.core.auth.security import get_password_hash
from src.core.database import SessionLocal
from src.models.agent_authoring_workspace import AgentAuthoringWorkspace, AgentAuthoringWorkspaceOperation
from src.models.auth import McpToolInvocationRecord, Permission, Role, User
from src.models.mapping import Base
from src.models.scenario_handles import DraftPackHandle, TestPackProfileSession as ProfileSessionRow
from src.models.scenario_approval import ActionApprovalGate
from src.models.scenario_registry import ScenarioEditProposal, ScenarioRegistryEntry, ScenarioRevision
from src.models.scenario_run import ScenarioRun, ScenarioStepRun
from src.schemas.dashboard_testing.query_model import DashboardQueryModel
from src.services.dashboard_testing.filters import _compute_filters_hash
from src.services.dashboard_testing.reference_source import build_reference_source
_EXPECTED_CHAIN_TOOLS = (
"create_authoring_session",
"propose_test_plan",
"start_exploration",
"get_exploration_result",
"propose_graph_revision",
"get_graph_diff",
"promote_to_scenario",
"request_save",
"activate_revision",
"validate_scenario",
"propose_test_pack_profile",
"resolve_test_pack_profile",
)
# #region Test.McpScenarioE2E.AsyncValue [C:1] [TYPE Function] [SEMANTICS test,mcp,async]
async def _async_value(value):
return value
# #endregion Test.McpScenarioE2E.AsyncValue
# #region Test.McpScenarioE2E.Fixture [C:3] [TYPE Function]
# @ingroup Test.McpScenarioE2E
# @BRIEF Seed one registry entry whose current revision carries the editor graph shape.
# #region Test.McpScenarioE2E.SharedFixtures._scenario_fixture_graph [C:1] [TYPE Function]
def _scenario_fixture_graph() -> dict:
# A lifecycle/editor fixture has no baseline authority claim. Typed M01 is tested separately.
from src.services.dashboard_testing.scenario.templates import (
ACTION_REGISTRY_VERSION, action_registry_fingerprint, resolve_action_descriptor,
)
return {
"schema_version": 1, "action_registry_version": ACTION_REGISTRY_VERSION,
"action_registry_hash": action_registry_fingerprint(), "environment_ids": ["preprod"],
"parameters": {"region": {"default": "emea", "kind": "string"},
"currency": {"default": "EUR", "kind": "string"}},
"steps": [{"id": "open", "logical_step_id": "open", "tool": "browser", "action": "open_dashboard",
"action_descriptor": resolve_action_descriptor(
tool="browser", action="open_dashboard", registry_version=ACTION_REGISTRY_VERSION,
registry_hash=action_registry_fingerprint()).snapshot()}],
"dependencies": [],
}
# #endregion Test.McpScenarioE2E.SharedFixtures._scenario_fixture_graph
# #region Test.McpScenarioE2E.SharedFixtures._published_catalog_snapshot [C:1] [TYPE Function]
def _published_catalog_snapshot() -> dict:
import json
from pathlib import Path
refresh = json.loads(
(
Path(__file__).resolve().parents[2]
/ "specs" / "044-dashboard-scenario-execution" / "fixtures" / "production-contract-refresh.json"
).read_text(encoding="utf-8")
)
pin = refresh["baseline_pin"]
# Synthetic published source for this registry fixture (dashboard 42, preprod).
source = build_reference_source(
url="https://superset.example.test/superset/dashboard/42/",
url_kind="dashboard",
source_url_ref="draft:reference-run:" + "1" * 64,
environment_id="preprod",
dashboard_id=42,
filters={"filters": [], "filters_hash": _compute_filters_hash([])},
query_model_fingerprint="sha256:" + "c" * 64,
)
revision = refresh["catalog_revision"]
assert revision["catalog_digest"] == pin["catalog_digest"]
for item in revision["entry_revisions"]:
item["reference_source"] = dict(source)
item["entry"]["dashboard_id"] = 42
item["entry"]["normalized_filters"]["filters_hash"] = source["filters_hash"]
item["entry"]["provenance"]["environment"] = "preprod"
return {
"baseline_set_id": pin["baseline_set_id"],
"baseline_set_version": pin["baseline_set_version"],
"release_id": pin["release_id"],
"baseline_family": pin["baseline_family"],
"catalog_digest": pin["catalog_digest"],
"catalog_revision": revision,
}
# #endregion Test.McpScenarioE2E.SharedFixtures._published_catalog_snapshot
_COMPILE_REQUEST = {
"agent_run_id": "550e8400-e29b-41d4-a716-446655440000",
"objective": {"goal": "verify filters metric xlsx", "selected_case_ids": ["B01", "C04"], "rationale": "rc"},
"query_model": {"dashboard_key": "fi-0080"},
"checklist_catalog_version": 1,
"baseline_version": "2026-07-01",
"capabilities": {"browser": True, "native_filters": True, "xlsx_export": True, "persistence_refresh": True, "dataset_fields": True},
"parameters": {"test_date": {"type": "date"}, "counterparty": {"type": "string"}},
"has_dataset_fields": True,
"environment_id": "env-prod-01",
"dashboard_id": 80,
"dashboard_name": "FI-0080",
}
# #region Test.McpScenarioE2E.SharedFixtures._seed_registry [C:1] [TYPE Function]
def _seed_registry(scenario_id: str, base_revision_id: str) -> None:
with SessionLocal() as db:
db.add(ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=f"e2e-{scenario_id[:8]}", name="e2e parity scenario",
dashboard_id=42, owner_id="owner", owner_username="owner",
environment_ids=["env-dev"],
current_revision_id=base_revision_id,
))
db.add(ScenarioRevision(
revision_id=base_revision_id, scenario_id=scenario_id,
content_hash="e" * 64, graph_snapshot=_scenario_fixture_graph(),
created_by="owner", activation_status="current",
))
db.commit()
# #endregion Test.McpScenarioE2E.SharedFixtures._seed_registry
# #region Test.McpScenarioE2E.SharedFixtures._seed_operator [C:1] [TYPE Function]
def _seed_operator() -> tuple[str, str]:
suffix = secrets.token_hex(4)
username = f"e2e-operator-{suffix}"
role_name = f"ScenarioEditor-{suffix}"
role = Role(name=role_name, is_admin=False, permissions=[
Permission(resource="scenario", action="EDIT"),
Permission(resource="scenario", action="RUN"),
])
user = User(username=username, password_hash=get_password_hash("pw"), is_active=True, roles=[role])
with SessionLocal() as db:
db.add_all([user])
db.commit()
return username, role_name
# #endregion Test.McpScenarioE2E.SharedFixtures._seed_operator
# #region Test.McpScenarioE2E.SharedFixtures._cleanup [C:1] [TYPE Function]
def _cleanup(principal: str, role_name: str, scenario_id: str, workspace_id: str | None) -> None:
with SessionLocal() as db:
if workspace_id is not None:
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
run_ids = [row[0] for row in db.query(ScenarioRun.id).filter(ScenarioRun.scenario_id == scenario_id).all()]
if run_ids:
db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id.in_(run_ids)).delete(synchronize_session=False)
db.query(ScenarioRun).filter(ScenarioRun.id.in_(run_ids)).delete(synchronize_session=False)
db.query(ScenarioEditProposal).filter(ScenarioEditProposal.scenario_id == scenario_id).delete()
db.query(ScenarioRevision).filter(ScenarioRevision.scenario_id == scenario_id).delete()
db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete()
db.query(ActionApprovalGate).filter(
ActionApprovalGate.owner_id.in_(db.query(McpToolInvocationRecord.id).filter(McpToolInvocationRecord.subject == principal))
).delete(synchronize_session=False)
db.query(McpToolInvocationRecord).filter(McpToolInvocationRecord.subject == principal).delete()
user = db.query(User).filter(User.username == principal).first()
if user is not None:
db.delete(user)
db.flush()
role = db.query(Role).filter(Role.name == role_name).first()
if role is not None:
db.delete(role)
db.commit()
# #endregion Test.McpScenarioE2E.SharedFixtures._cleanup
# #region Test.McpScenarioE2E.SharedFixtures._unwrap [C:1] [TYPE Function]
def _unwrap(result):
return result[1] if isinstance(result, tuple) else result
# #endregion Test.McpScenarioE2E.SharedFixtures._unwrap
# #endregion Test.McpScenarioE2E.Fixture
# #endregion Test.McpScenarioE2E.SharedFixtures

View File

@@ -0,0 +1,16 @@
# #region Test.McpServer.CompileFixture [C:1] [TYPE Module]
# @defgroup Test.McpServer.CompileFixture Static MCP compilation input shared across tool checks.
_COMPILE_REQUEST = {
"agent_run_id": "550e8400-e29b-41d4-a716-446655440000",
"objective": {"goal": "verify filters metric xlsx", "selected_case_ids": ["B01", "C04", "C05", "T01"], "rationale": "rc"},
"query_model": {"dashboard_key": "fi-0080"},
"checklist_catalog_version": 1,
"baseline_version": "2026-07-01",
"capabilities": {"browser": True, "native_filters": True, "xlsx_export": True, "persistence_refresh": True, "dataset_fields": True},
"parameters": {"test_date": {"type": "date"}, "counterparty": {"type": "string"}},
"has_dataset_fields": True,
"environment_id": "env-prod-01",
"dashboard_id": 80,
"dashboard_name": "FI-0080",
}
# #endregion Test.McpServer.CompileFixture

View File

@@ -80,13 +80,13 @@ def test_real_bootstrap_keeps_receipted_objective_dict(db_session,monkeypatch):
# #region Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.Init [C:1] [TYPE Function]
def __init__(self):
self.values={}
# #region Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.store [C:1] [TYPE Function]
# #endregion Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.Init
# #region Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.store [C:1] [TYPE Function]
def store(self,digest,data):
self.values['handle:'+digest]=data
return 'handle:'+digest
# #region Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.retrieve [C:1] [TYPE Function]
# #endregion Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.store
# #region Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.retrieve [C:1] [TYPE Function]
def retrieve(self,ref):
return self.values.get(ref)
# #endregion Test.ScenarioEditor.RegisteredAuthority.test_real_bootstrap_keeps_receipted_objective_dict.Storage.retrieve

View File

@@ -0,0 +1,39 @@
# #region Test.GitService.StatusFixture [C:2] [TYPE Module]
# @BRIEF Shared status mixin fixture; repository and locking boundaries are supplied by tests.
from unittest.mock import MagicMock
from src.services.git._status import GitServiceStatusMixin
# #region Test.GitService.StatusFixture.Instance [C:1] [TYPE Class]
class TestableGitStatus(GitServiceStatusMixin):
"""Concrete test class providing the minimum _locked and get_repo stubs.
_locked is a no-op context manager. get_repo returns a pre-set mock.
"""
# #region Test.GitService.StatusFixture.Init [C:1] [TYPE Function]
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
self._lock_called = False
# #endregion Test.GitService.StatusFixture.Init
# #region Test.GitService.StatusFixture.Repository [C:1] [TYPE Function]
async def get_repo(self, dashboard_id):
return self._mock_repo
# #endregion Test.GitService.StatusFixture.Repository
# #region Test.GitService.StatusFixture.Lock [C:1] [TYPE Function]
def _locked(self, dashboard_id):
import contextlib
# #region Test.GitService.StatusFixture.NoOpLock [C:1] [TYPE Function]
@contextlib.contextmanager
def _lock():
self._lock_called = True
yield
# #endregion Test.GitService.StatusFixture.NoOpLock
return _lock()
# #endregion Test.GitService.StatusFixture.Lock
# #endregion Test.GitService.StatusFixture.Instance
# #endregion Test.GitService.StatusFixture

View File

@@ -29,29 +29,11 @@ from src.services.git._status import GitServiceStatusMixin
# ── Helper: create a testable instance of the mixin ──
class TestableGitStatus(GitServiceStatusMixin):
"""Concrete test class providing the minimum _locked and get_repo stubs.
_locked is a no-op context manager. get_repo returns a pre-set mock.
"""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
self._lock_called = False
async def get_repo(self, dashboard_id):
return self._mock_repo
def _locked(self, dashboard_id):
import contextlib
@contextlib.contextmanager
def _lock():
self._lock_called = True
yield
return _lock()
from tests.services.git.git_status_fixture import TestableGitStatus
# ── _parse_status_porcelain ──
# #region Test.GitService.Status.TestParseStatusPorcelain [C:2] [TYPE Class]
class TestParseStatusPorcelain:
"""_parse_status_porcelain — git status --porcelain parser."""
@@ -158,8 +140,10 @@ class TestParseStatusPorcelain:
# #endregion Test.GitService.TestPorcelainGitFailure
# #endregion Test.GitService.Status.TestParseStatusPorcelain
# ── get_status ──
# #region Test.GitService.Status.TestGetStatus [C:2] [TYPE Class]
class TestGetStatus:
"""get_status — full repository status computation."""
@@ -358,8 +342,10 @@ class TestGetStatus:
# #endregion Test.GitService.TestGetStatusIterCommitsException
# #endregion Test.GitService.Status.TestGetStatus
# ── rollback_commit ──
# #region Test.GitService.Status.TestRollbackCommit [C:2] [TYPE Class]
class TestRollbackCommit:
"""rollback_commit — creates a revert commit without rewriting history."""
@@ -380,8 +366,10 @@ class TestRollbackCommit:
# #endregion Test.GitService.TestRollbackCommitRevertsTarget
# #endregion Test.GitService.Status.TestRollbackCommit
# ── get_diff ──
# #region Test.GitService.Status.TestGetDiff [C:2] [TYPE Class]
class TestGetDiff:
"""get_diff — diff generation."""
@@ -434,8 +422,10 @@ class TestGetDiff:
# #endregion Test.GitService.TestGetDiffStagedWithFile
# #endregion Test.GitService.Status.TestGetDiff
# ── get_commit_history ──
# #region Test.GitService.Status.TestGetCommitHistory [C:2] [TYPE Class]
class TestGetCommitHistory:
"""get_commit_history — commit log retrieval."""
@@ -529,158 +519,5 @@ class TestGetCommitHistory:
# Should not have called iter_commits (early return)
repo.iter_commits.assert_not_called()
# #endregion Test.GitService.TestGetBranchCommitsMissingProdBranch
# #endregion Test.GitService.Status.TestGetCommitHistory
# #endregion Test.GitService.Status
# #region Test.Git.Status.AdditionalBranches [C:3] [TYPE Module]
# @defgroup get_status metadata, get_branch_commits, get_commit_diff branches.
# #region Test.Git.Status.AdditionalBranches.Metadata
class TestGetStatusMetadata:
"""get_status — commit metadata happy path and failure branch."""
@pytest.mark.asyncio
async def test_get_status_commit_metadata_happy(self):
repo = MagicMock()
commit = MagicMock()
commit.hexsha = "abc123"
commit.message = " fix: thing "
commit.author.name = "dev"
commit.committed_date = 1700000000
repo.head.commit = commit
repo.active_branch.name = "main"
repo.active_branch.tracking_branch.return_value = None
svc = TestableGitStatus(repo)
result = await svc.get_status(42)
assert result["last_commit_hash"] == "abc123"
assert result["last_commit_message"] == "fix: thing"
assert result["last_commit_author"] == "dev"
assert result["last_commit_date"] is not None
@pytest.mark.asyncio
async def test_get_status_commit_metadata_exception(self):
repo = MagicMock()
commit = MagicMock()
commit.committed_date = "not-a-timestamp" # fromtimestamp raises TypeError
repo.head.commit = commit
repo.active_branch.name = "main"
repo.active_branch.tracking_branch.return_value = None
svc = TestableGitStatus(repo)
result = await svc.get_status(42)
assert result["last_commit_hash"] is not None or result["last_commit_hash"] is None
assert result["last_commit_date"] is None
# #endregion Test.Git.Status.AdditionalBranches.Metadata
# #region Test.Git.Status.AdditionalBranches.BranchCommits
class TestGetBranchCommits:
"""get_branch_commits — missing branch and failure branches."""
@pytest.mark.asyncio
async def test_branch_not_in_local_heads_returns_empty(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
repo.iter_commits = MagicMock()
svc = TestableGitStatus(repo)
result = await svc.get_branch_commits(42, "develop")
assert result == []
repo.iter_commits.assert_not_called()
@pytest.mark.asyncio
async def test_no_heads_no_remotes_returns_empty(self):
repo = MagicMock()
repo.heads = []
repo.remotes = []
svc = TestableGitStatus(repo)
assert await svc.get_branch_commits(42, "main") == []
@pytest.mark.asyncio
async def test_iter_commits_exception_returns_empty(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
repo.iter_commits.side_effect = Exception("bad revision")
svc = TestableGitStatus(repo)
assert await svc.get_branch_commits(42, "main") == []
# #endregion Test.Git.Status.AdditionalBranches.BranchCommits
# #region Test.Git.Status.AdditionalBranches.CommitDiff
class TestGetCommitDiffExtra:
"""get_commit_diff — to_ref variant and failure."""
@pytest.mark.asyncio
async def test_with_to_ref(self):
repo = MagicMock()
repo.git.diff.return_value = "diff v1 v2"
svc = TestableGitStatus(repo)
result = await svc.get_commit_diff(42, "v1", "v2")
assert result == "diff v1 v2"
repo.git.diff.assert_called_once_with("v1", "v2")
@pytest.mark.asyncio
async def test_without_to_ref(self):
repo = MagicMock()
repo.git.diff.return_value = "diff v1"
svc = TestableGitStatus(repo)
result = await svc.get_commit_diff(42, "v1")
assert result == "diff v1"
repo.git.diff.assert_called_once_with("v1")
@pytest.mark.asyncio
async def test_failure_reraises(self):
repo = MagicMock()
repo.git.diff.side_effect = Exception("boom")
svc = TestableGitStatus(repo)
with pytest.raises(Exception, match="boom"):
await svc.get_commit_diff(42, "v1")
# #endregion Test.Git.Status.AdditionalBranches.CommitDiff
# #region Test.Git.Status.AdditionalBranches.Happy
class TestGetBranchCommitsHappy:
"""get_branch_commits — success path."""
@pytest.mark.asyncio
async def test_happy_path_builds_commit_list(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
commit = MagicMock()
commit.hexsha = "abc123"
commit.author.name = "dev"
commit.author.email = "d@x.com"
commit.committed_date = 1700000000
commit.message = " feat: x "
commit.stats.files.keys.return_value = ["a.py", "b.py"]
repo.iter_commits.return_value = [commit]
svc = TestableGitStatus(repo)
commits = await svc.get_branch_commits(42, "main", limit=5)
assert len(commits) == 1
assert commits[0]["hash"] == "abc123"
assert commits[0]["message"] == "feat: x"
assert commits[0]["files_changed"] == ["a.py", "b.py"]
assert commits[0]["branch"] == "main"
repo.iter_commits.assert_called_once_with("main", max_count=5)
# #endregion Test.Git.Status.AdditionalBranches.Happy
class TestRollbackCommitNoReason:
"""rollback_commit without a reason — skips the logging block."""
@pytest.mark.asyncio
async def test_without_reason(self):
repo = MagicMock()
repo.head.commit.hexsha = "rollback456"
svc = TestableGitStatus(repo)
result = await svc.rollback_commit(42, "abcdef2")
assert result["status"] == "success"
repo.commit.assert_called_once_with("abcdef2")
# #endregion Test.Git.Status.AdditionalBranches

View File

@@ -0,0 +1,181 @@
# #region Test.GitService.StatusHistoryImports [C:1] [TYPE Module]
# @BRIEF Supply the shared repository fixture to the preserved additional status cohort.
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from tests.services.git.git_status_fixture import TestableGitStatus
# #endregion Test.GitService.StatusHistoryImports
# #region Test.Git.Status.AdditionalBranches [C:3] [TYPE Module]
# @defgroup get_status metadata, get_branch_commits, get_commit_diff branches.
# #region Test.Git.Status.AdditionalBranches.Metadata
class TestGetStatusMetadata:
"""get_status — commit metadata happy path and failure branch."""
# #region Test.GitService.StatusHistory.test_get_status_commit_metadata_happy [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_status_commit_metadata_happy(self):
repo = MagicMock()
commit = MagicMock()
commit.hexsha = "abc123"
commit.message = " fix: thing "
commit.author.name = "dev"
commit.committed_date = 1700000000
repo.head.commit = commit
repo.active_branch.name = "main"
repo.active_branch.tracking_branch.return_value = None
svc = TestableGitStatus(repo)
result = await svc.get_status(42)
assert result["last_commit_hash"] == "abc123"
assert result["last_commit_message"] == "fix: thing"
assert result["last_commit_author"] == "dev"
assert result["last_commit_date"] is not None
# #endregion Test.GitService.StatusHistory.test_get_status_commit_metadata_happy
# #region Test.GitService.StatusHistory.test_get_status_commit_metadata_exception [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_status_commit_metadata_exception(self):
repo = MagicMock()
commit = MagicMock()
commit.committed_date = "not-a-timestamp" # fromtimestamp raises TypeError
repo.head.commit = commit
repo.active_branch.name = "main"
repo.active_branch.tracking_branch.return_value = None
svc = TestableGitStatus(repo)
result = await svc.get_status(42)
assert result["last_commit_hash"] is not None or result["last_commit_hash"] is None
assert result["last_commit_date"] is None
# #endregion Test.GitService.StatusHistory.test_get_status_commit_metadata_exception
# #endregion Test.Git.Status.AdditionalBranches.Metadata
# #region Test.Git.Status.AdditionalBranches.BranchCommits
class TestGetBranchCommits:
"""get_branch_commits — missing branch and failure branches."""
# #region Test.GitService.StatusHistory.test_branch_not_in_local_heads_returns_empty [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_branch_not_in_local_heads_returns_empty(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
repo.iter_commits = MagicMock()
svc = TestableGitStatus(repo)
result = await svc.get_branch_commits(42, "develop")
assert result == []
repo.iter_commits.assert_not_called()
# #endregion Test.GitService.StatusHistory.test_branch_not_in_local_heads_returns_empty
# #region Test.GitService.StatusHistory.test_no_heads_no_remotes_returns_empty [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_no_heads_no_remotes_returns_empty(self):
repo = MagicMock()
repo.heads = []
repo.remotes = []
svc = TestableGitStatus(repo)
assert await svc.get_branch_commits(42, "main") == []
# #endregion Test.GitService.StatusHistory.test_no_heads_no_remotes_returns_empty
# #region Test.GitService.StatusHistory.test_iter_commits_exception_returns_empty [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_iter_commits_exception_returns_empty(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
repo.iter_commits.side_effect = Exception("bad revision")
svc = TestableGitStatus(repo)
assert await svc.get_branch_commits(42, "main") == []
# #endregion Test.GitService.StatusHistory.test_iter_commits_exception_returns_empty
# #endregion Test.Git.Status.AdditionalBranches.BranchCommits
# #region Test.Git.Status.AdditionalBranches.CommitDiff
class TestGetCommitDiffExtra:
"""get_commit_diff — to_ref variant and failure."""
# #region Test.GitService.StatusHistory.test_with_to_ref [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_with_to_ref(self):
repo = MagicMock()
repo.git.diff.return_value = "diff v1 v2"
svc = TestableGitStatus(repo)
result = await svc.get_commit_diff(42, "v1", "v2")
assert result == "diff v1 v2"
repo.git.diff.assert_called_once_with("v1", "v2")
# #endregion Test.GitService.StatusHistory.test_with_to_ref
# #region Test.GitService.StatusHistory.test_without_to_ref [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_without_to_ref(self):
repo = MagicMock()
repo.git.diff.return_value = "diff v1"
svc = TestableGitStatus(repo)
result = await svc.get_commit_diff(42, "v1")
assert result == "diff v1"
repo.git.diff.assert_called_once_with("v1")
# #endregion Test.GitService.StatusHistory.test_without_to_ref
# #region Test.GitService.StatusHistory.test_failure_reraises [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_failure_reraises(self):
repo = MagicMock()
repo.git.diff.side_effect = Exception("boom")
svc = TestableGitStatus(repo)
with pytest.raises(Exception, match="boom"):
await svc.get_commit_diff(42, "v1")
# #endregion Test.GitService.StatusHistory.test_failure_reraises
# #endregion Test.Git.Status.AdditionalBranches.CommitDiff
# #region Test.Git.Status.AdditionalBranches.Happy
class TestGetBranchCommitsHappy:
"""get_branch_commits — success path."""
# #region Test.GitService.StatusHistory.test_happy_path_builds_commit_list [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_happy_path_builds_commit_list(self):
repo = MagicMock()
head = MagicMock()
head.name = "main"
repo.heads = [head]
repo.remotes = [MagicMock()]
commit = MagicMock()
commit.hexsha = "abc123"
commit.author.name = "dev"
commit.author.email = "d@x.com"
commit.committed_date = 1700000000
commit.message = " feat: x "
commit.stats.files.keys.return_value = ["a.py", "b.py"]
repo.iter_commits.return_value = [commit]
svc = TestableGitStatus(repo)
commits = await svc.get_branch_commits(42, "main", limit=5)
assert len(commits) == 1
assert commits[0]["hash"] == "abc123"
assert commits[0]["message"] == "feat: x"
assert commits[0]["files_changed"] == ["a.py", "b.py"]
assert commits[0]["branch"] == "main"
repo.iter_commits.assert_called_once_with("main", max_count=5)
# #endregion Test.GitService.StatusHistory.test_happy_path_builds_commit_list
# #endregion Test.Git.Status.AdditionalBranches.Happy
# #region Test.GitService.StatusHistory.TestRollbackCommitNoReason [C:2] [TYPE Class]
class TestRollbackCommitNoReason:
"""rollback_commit without a reason — skips the logging block."""
# #region Test.GitService.StatusHistory.test_without_reason [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_without_reason(self):
repo = MagicMock()
repo.head.commit.hexsha = "rollback456"
svc = TestableGitStatus(repo)
result = await svc.rollback_commit(42, "abcdef2")
assert result["status"] == "success"
repo.commit.assert_called_once_with("abcdef2")
# #endregion Test.GitService.StatusHistory.test_without_reason
# #endregion Test.GitService.StatusHistory.TestRollbackCommitNoReason
# #endregion Test.Git.Status.AdditionalBranches

View File

@@ -60,148 +60,11 @@ _EXPECTED_CHAIN_TOOLS = (
)
# #region Test.McpScenarioE2E.AsyncValue [C:1] [TYPE Function] [SEMANTICS test,mcp,async]
async def _async_value(value):
return value
# #endregion Test.McpScenarioE2E.AsyncValue
# #region Test.McpScenarioE2E.Fixture [C:3] [TYPE Function]
# @ingroup Test.McpScenarioE2E
# @BRIEF Seed one registry entry whose current revision carries the editor graph shape.
def _scenario_fixture_graph() -> dict:
# A lifecycle/editor fixture has no baseline authority claim. Typed M01 is tested separately.
from src.services.dashboard_testing.scenario.templates import (
ACTION_REGISTRY_VERSION, action_registry_fingerprint, resolve_action_descriptor,
)
return {
"schema_version": 1, "action_registry_version": ACTION_REGISTRY_VERSION,
"action_registry_hash": action_registry_fingerprint(), "environment_ids": ["preprod"],
"parameters": {"region": {"default": "emea", "kind": "string"},
"currency": {"default": "EUR", "kind": "string"}},
"steps": [{"id": "open", "logical_step_id": "open", "tool": "browser", "action": "open_dashboard",
"action_descriptor": resolve_action_descriptor(
tool="browser", action="open_dashboard", registry_version=ACTION_REGISTRY_VERSION,
registry_hash=action_registry_fingerprint()).snapshot()}],
"dependencies": [],
}
def _published_catalog_snapshot() -> dict:
import json
from pathlib import Path
refresh = json.loads(
(
Path(__file__).resolve().parents[2]
/ "specs" / "044-dashboard-scenario-execution" / "fixtures" / "production-contract-refresh.json"
).read_text(encoding="utf-8")
)
pin = refresh["baseline_pin"]
# Synthetic published source for this registry fixture (dashboard 42, preprod).
source = build_reference_source(
url="https://superset.example.test/superset/dashboard/42/",
url_kind="dashboard",
source_url_ref="draft:reference-run:" + "1" * 64,
environment_id="preprod",
dashboard_id=42,
filters={"filters": [], "filters_hash": _compute_filters_hash([])},
query_model_fingerprint="sha256:" + "c" * 64,
)
revision = refresh["catalog_revision"]
assert revision["catalog_digest"] == pin["catalog_digest"]
for item in revision["entry_revisions"]:
item["reference_source"] = dict(source)
item["entry"]["dashboard_id"] = 42
item["entry"]["normalized_filters"]["filters_hash"] = source["filters_hash"]
item["entry"]["provenance"]["environment"] = "preprod"
return {
"baseline_set_id": pin["baseline_set_id"],
"baseline_set_version": pin["baseline_set_version"],
"release_id": pin["release_id"],
"baseline_family": pin["baseline_family"],
"catalog_digest": pin["catalog_digest"],
"catalog_revision": revision,
}
_COMPILE_REQUEST = {
"agent_run_id": "550e8400-e29b-41d4-a716-446655440000",
"objective": {"goal": "verify filters metric xlsx", "selected_case_ids": ["B01", "C04"], "rationale": "rc"},
"query_model": {"dashboard_key": "fi-0080"},
"checklist_catalog_version": 1,
"baseline_version": "2026-07-01",
"capabilities": {"browser": True, "native_filters": True, "xlsx_export": True, "persistence_refresh": True, "dataset_fields": True},
"parameters": {"test_date": {"type": "date"}, "counterparty": {"type": "string"}},
"has_dataset_fields": True,
"environment_id": "env-prod-01",
"dashboard_id": 80,
"dashboard_name": "FI-0080",
}
def _seed_registry(scenario_id: str, base_revision_id: str) -> None:
with SessionLocal() as db:
db.add(ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=f"e2e-{scenario_id[:8]}", name="e2e parity scenario",
dashboard_id=42, owner_id="owner", owner_username="owner",
environment_ids=["env-dev"],
current_revision_id=base_revision_id,
))
db.add(ScenarioRevision(
revision_id=base_revision_id, scenario_id=scenario_id,
content_hash="e" * 64, graph_snapshot=_scenario_fixture_graph(),
created_by="owner", activation_status="current",
))
db.commit()
def _seed_operator() -> tuple[str, str]:
suffix = secrets.token_hex(4)
username = f"e2e-operator-{suffix}"
role_name = f"ScenarioEditor-{suffix}"
role = Role(name=role_name, is_admin=False, permissions=[
Permission(resource="scenario", action="EDIT"),
Permission(resource="scenario", action="RUN"),
])
user = User(username=username, password_hash=get_password_hash("pw"), is_active=True, roles=[role])
with SessionLocal() as db:
db.add_all([user])
db.commit()
return username, role_name
def _cleanup(principal: str, role_name: str, scenario_id: str, workspace_id: str | None) -> None:
with SessionLocal() as db:
if workspace_id is not None:
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
run_ids = [row[0] for row in db.query(ScenarioRun.id).filter(ScenarioRun.scenario_id == scenario_id).all()]
if run_ids:
db.query(ScenarioStepRun).filter(ScenarioStepRun.run_id.in_(run_ids)).delete(synchronize_session=False)
db.query(ScenarioRun).filter(ScenarioRun.id.in_(run_ids)).delete(synchronize_session=False)
db.query(ScenarioEditProposal).filter(ScenarioEditProposal.scenario_id == scenario_id).delete()
db.query(ScenarioRevision).filter(ScenarioRevision.scenario_id == scenario_id).delete()
db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete()
db.query(ActionApprovalGate).filter(
ActionApprovalGate.owner_id.in_(db.query(McpToolInvocationRecord.id).filter(McpToolInvocationRecord.subject == principal))
).delete(synchronize_session=False)
db.query(McpToolInvocationRecord).filter(McpToolInvocationRecord.subject == principal).delete()
user = db.query(User).filter(User.username == principal).first()
if user is not None:
db.delete(user)
db.flush()
role = db.query(Role).filter(Role.name == role_name).first()
if role is not None:
db.delete(role)
db.commit()
def _unwrap(result):
return result[1] if isinstance(result, tuple) else result
# #endregion Test.McpScenarioE2E.Fixture
from mcp_scenario_fixtures import (
_async_value, _scenario_fixture_graph, _published_catalog_snapshot,
_seed_registry, _seed_operator, _cleanup, _unwrap,
)
# #region Test.McpScenarioE2E.Vertical [C:5] [TYPE Function]
# @ingroup Test.McpScenarioE2E
@@ -337,228 +200,9 @@ async def test_external_client_creates_and_activates_scenario_revision_end_to_en
_cleanup(principal, role_name, scenario_id, workspace_id)
# #region Test.McpScenarioE2E.ProfilePreview [C:4] [TYPE Function] [SEMANTICS test,mcp,profile,preview]
# @ingroup Test.McpScenarioE2E
# @BRIEF Expose a deterministic typed preview without returning a save handle or graph authority.
@pytest.mark.asyncio
async def test_propose_test_pack_profile_returns_unresolved_preview_only(monkeypatch) -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="profile-token", client_id="profile-client", scopes=["mcp"],
subject="profile-operator", claims={"principal_type": "user"},
)
context_token = _access_token_context.set(access)
monkeypatch.setattr(tools_scenario_module, "get_config_manager", lambda: SimpleNamespace(
get_environment=lambda environment_id: SimpleNamespace(id=environment_id)
))
fixture_path = Path(__file__).resolve().parent / "fixtures" / "dashboard_scenarios" / "query_model_sales.json"
query_payload = json.loads(fixture_path.read_text(encoding="utf-8"))
query_payload["charts"][0]["metrics"] = [{
"metric_name": "sum__amount", "label": "Total sales", "expression_type": "SIMPLE",
"column": {"column_name": "amount", "type": "DOUBLE"}, "aggregate": "SUM",
}]
query_model = DashboardQueryModel.model_validate(query_payload)
monkeypatch.setattr(tools_scenario_module, "get_superset_client", lambda _: _async_value(object()))
monkeypatch.setattr(tools_scenario_module, "inspect_dashboard_query_model", lambda *_: _async_value(query_model))
monkeypatch.setattr(tools_scenario_module, "resolve_browser_availability", lambda: True)
request = {"environment_id": "ss-prod", "dashboard_id": 11,
"objective": "Verify dashboard filters and sales metric", "selected_case_ids": ["B01", "B02"]}
try:
profile = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": request}))
replay = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": request}))
assert profile["status"] == "preview_only"
assert profile["profile"]["selected_case_ids"] == ["B01", "B02"]
assert {case["case_id"] for case in profile["profile"]["cases"]} == {"B01", "B02"}
assert profile["profile"]["profile_digest"] == replay["profile"]["profile_digest"]
assert {item["kind"] for item in profile["profile"]["unresolved"]} == {"needs_selector"}
assert "scenario" not in profile and "draft_pack" not in profile
assert profile["preview"]["step_count"] >= 1
coordinates = profile["profile"]["coordinates"]
assert coordinates and coordinates[0]["metric_name"] == "sum__amount"
forged_coordinate = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**request, "profile_handle_id": profile["profile_handle_id"],
"idempotency_key": "profile-forged-coordinate-1", "expected_cas_version": 0,
"expected_profile_digest": profile["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": "case:B01:step:none:needs_metric",
"coordinate_id": coordinates[0]["coordinate_id"],
"reason": "No selected case requests a metric baseline."}]}
}))
assert forged_coordinate["status"] == "blocked"
selectors = [item for item in profile["profile"]["unresolved"] if item["kind"] == "needs_selector"]
assert len(selectors) >= 2
unresolved = selectors[0]
unresolved_id = unresolved["id"]
selected_step = unresolved["step_id"]
resolved_request = {
**request,
"profile_handle_id": profile["profile_handle_id"],
"idempotency_key": "profile-selector-1",
"expected_cas_version": 0,
"expected_profile_digest": profile["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": unresolved_id, "step_id": selected_step,
"selector_hint": "#sales-filter", "reason": "Analyst verified this control."}],
}
forged = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request, "idempotency_key": "profile-forged-selector-1",
"resolutions": [{**resolved_request["resolutions"][0],
"step_id": "phase-2-B02-apply_native_filter"}]}
}))
assert forged["status"] == "blocked"
resolved = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": resolved_request}))
assert resolved["status"] == "preview_only"
assert {item["id"] for item in resolved["profile"]["unresolved"]} == {
item["id"] for item in profile["profile"]["unresolved"] if item["id"] != unresolved_id
}
assert "scenario" not in resolved and "draft_pack" not in resolved
replay = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": resolved_request}))
assert replay["replayed"] is True
next_selector = selectors[1]
next_request = {
**resolved_request,
"idempotency_key": "profile-selector-2",
"expected_cas_version": resolved["cas_version"],
"expected_profile_digest": resolved["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": next_selector["id"], "step_id": next_selector["step_id"],
"selector_hint": "#sales-search", "reason": "Analyst verified this control."}],
}
next_result = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": next_request}))
assert next_result["cas_version"] == resolved["cas_version"] + 1
assert next_result["profile"]["profile_digest"] != resolved["profile"]["profile_digest"]
assert "scenario" not in next_result and "draft_pack" not in next_result
stale = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request, "expected_cas_version": next_result["cas_version"],
"expected_profile_digest": "0" * 64,
"idempotency_key": "profile-stale-1"}
}))
assert stale["status"] == "conflict"
assert stale["error"] == "PROFILE_STALE_CONTEXT"
conflict = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request,
"resolutions": [{**resolved_request["resolutions"][0], "reason": "changed"}]}
}))
assert conflict == {"status": "conflict", "error": "IDEMPOTENCY_CONFLICT"}
with pytest.raises(Exception):
await server.call_tool("propose_test_pack_profile", {
"request": {**request, "parameters": {"expected_revenue": 999999}}
})
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpScenarioE2E.ProfilePreview
# #region Test.McpScenarioE2E.ProfileBootstrap [C:5] [TYPE Function] [SEMANTICS test,mcp,profile,bootstrap,receipt]
# @ingroup Test.McpScenarioE2E
# @BRIEF A human MCP principal resolves safe selectors and bootstraps a revision from a server graph.
# @TEST_INVARIANT profile_bootstrap_binding -> VERIFIED_BY: [test_profile_session_bootstraps_without_caller_graph]
# @TEST_EDGE changed query-model fingerprint blocks registration before registry writes.
@pytest.mark.asyncio
async def test_profile_session_bootstraps_without_caller_graph(monkeypatch, tmp_path) -> None:
engine = create_engine(f"sqlite:///{tmp_path / 'profile-bootstrap.db'}", connect_args={"check_same_thread": False})
event.listen(engine, "connect", lambda connection, _: connection.execute("PRAGMA foreign_keys=ON"))
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
for module in (tools_scenario_module, tools_agent_run_module, tools_authoring_module, rbac_server_module):
monkeypatch.setattr(module, "SessionLocal", sessions)
monkeypatch.setenv("DRAFT_STORAGE_ROOT", str(tmp_path / "drafts"))
monkeypatch.setenv("HANDLE_STORAGE_ROOT", str(tmp_path / "handles"))
import src.services.agent_runs.artifacts as artifacts
import src.services.dashboard_testing.scenario.handles as handles
artifacts._draft_storage = None
handles._blob_store = None
principal = f"profile-bootstrap-{uuid4()}"
with sessions() as db:
db.add(User(username=principal, password_hash=get_password_hash("test"), is_active=True,
roles=[Role(name=f"role-{principal}", permissions=[
Permission(resource="dashboard:testing", action="EXECUTE"),
Permission(resource="dashboard:testing", action="WRITE"),
])]))
db.commit()
fixture_path = Path(__file__).resolve().parent / "fixtures" / "dashboard_scenarios" / "query_model_sales.json"
query_model = DashboardQueryModel.model_validate(json.loads(fixture_path.read_text(encoding="utf-8")))
inspected = {"model": query_model}
monkeypatch.setattr(tools_scenario_module, "get_config_manager", lambda: SimpleNamespace(
get_environment=lambda environment_id: SimpleNamespace(id=environment_id) if environment_id == "ss-prod" else None
))
monkeypatch.setattr(tools_scenario_module, "get_superset_client", lambda _: _async_value(object()))
monkeypatch.setattr(tools_scenario_module, "inspect_dashboard_query_model",
lambda *_: _async_value(inspected["model"]))
monkeypatch.setattr(tools_scenario_module, "resolve_browser_availability", lambda: True)
monkeypatch.setattr(tools_scenario_module, "evaluate_context_authority",
lambda _: _async_value("verified"))
server = mcp_server._build_probe_server()
token = _access_token_context.set(mcp_server.AccessToken(
token="profile-bootstrap-token", client_id="profile-bootstrap-client", scopes=["mcp"],
subject=principal, claims={"principal_type": "user"},
))
try:
run = _unwrap(await server.call_tool("create_agent_run", {"request": {
"dashboard_id": 11, "environment_id": "ss-prod", "dashboard_name": "Sales Dashboard",
"idempotency_key": f"profile-run-{uuid4()}",
}}))
assert run["status"] == "ok", run
intent = {"environment_id": "ss-prod", "dashboard_id": 11,
"objective": "Verify sales dashboard filters", "selected_case_ids": ["B01", "B02"]}
proposed = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": intent}))
assert proposed["status"] == "preview_only", proposed
questions = [item for item in proposed["profile"]["unresolved"] if item["kind"] == "needs_selector"]
assert len(questions) == len(proposed["profile"]["unresolved"]) == 2
current = proposed
for index, question in enumerate(questions, start=1):
current = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": {
**intent, "profile_handle_id": proposed["profile_handle_id"],
"expected_profile_digest": current["profile"]["profile_digest"],
"expected_cas_version": current["cas_version"],
"idempotency_key": f"selector-{index}-{uuid4()}",
"resolutions": [{"unresolved_id": question["id"], "step_id": question["step_id"],
"selector_hint": f"#sales-control-{index}", "reason": "Verified control."}],
}}))
assert current["status"] == "save_eligible", current
registration = {"agent_run_id": run["run_id"], "profile_handle_id": proposed["profile_handle_id"]}
caller_graph = json.loads((fixture_path.parent / "scenario_valid.json").read_text(encoding="utf-8"))
with pytest.raises(Exception, match="PROFILE_GRAPH_FORBIDDEN"):
await server.call_tool("register_draft_pack", {"request": {
**registration, "scenario": caller_graph,
}})
inspected["model"] = query_model.model_copy(update={"query_model_fingerprint": "f" * 64})
stale = _unwrap(await server.call_tool("register_draft_pack", {"request": registration}))
assert stale == {"status": "blocked", "error": "PROFILE_STALE_CONTEXT"}
with sessions() as db:
assert db.query(ScenarioRegistryEntry).count() == 0
assert db.query(DraftPackHandle).count() == 0
inspected["model"] = query_model
registered = _unwrap(await server.call_tool("register_draft_pack", {"request": registration}))
assert registered["status"] == "save_eligible", registered
assert registered["profile_receipt"]["profile_handle_id"] == proposed["profile_handle_id"]
assert registered["profile_receipt"]["profile_digest"] == current["profile"]["profile_digest"]
assert registered["profile_receipt"]["profile_cas_version"] == current["cas_version"]
boot = _unwrap(await server.call_tool("bootstrap_authoring_scenario", {"request": {
"idempotency_key": f"profile-bootstrap-{uuid4()}", "title": "Sales filter checks",
"dashboard_id": 11, "allowed_environment_ids": ["ss-prod"],
"selected_environment_id": "ss-prod", "selected_case_ids": ["B01", "B02"],
"objective": "Verify sales dashboard filters",
"compiled_handle_id": registered["compiled_handle_id"],
"draft_pack_id": registered["draft_pack_handle_id"],
"draft_pack_digest": registered["draft_pack_digest"],
}}))
with sessions() as db:
entry = db.get(ScenarioRegistryEntry, boot["scenario_id"])
revision = db.get(ScenarioRevision, boot["revision_id"])
session = db.get(ProfileSessionRow, proposed["profile_handle_id"])
pack = db.get(DraftPackHandle, registered["draft_pack_handle_id"])
assert entry is not None and entry.current_revision_id == boot["revision_id"]
assert revision is not None and revision.activation_status == "current"
assert session is not None and pack.profile_receipt["profile_digest"] == session.profile_digest
assert pack.profile_receipt["profile_handle_id"] == session.profile_handle_id
assert pack.consumed_by_revision_id == revision.revision_id
finally:
_access_token_context.reset(token)
artifacts._draft_storage = None
handles._blob_store = None
engine.dispose()
# #endregion Test.McpScenarioE2E.ProfileBootstrap
# #endregion Test.McpScenarioE2E.Vertical
# #region Test.McpScenarioE2E.test_operator_without_scenario_edit_cannot_save_or_activate [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_operator_without_scenario_edit_cannot_save_or_activate() -> None:
suffix = secrets.token_hex(4)
@@ -598,6 +242,6 @@ async def test_operator_without_scenario_edit_cannot_save_or_activate() -> None:
if role_row is not None:
db.delete(role_row)
db.commit()
# #endregion Test.McpScenarioE2E.Vertical
# #endregion Test.McpScenarioE2E.test_operator_without_scenario_edit_cannot_save_or_activate
# #endregion Test.McpScenarioE2E

View File

@@ -0,0 +1,278 @@
# #region Test.McpScenarioE2E.Profiles [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup Test.McpScenarioE2E.Profiles Profile preview and owner-bound profile bootstrap over MCP.
import os
import secrets
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-mcp")
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_mcp_e2e_test.db")
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from src.mcp_server import server as mcp_server
import src.mcp_server.rbac_server as rbac_server_module
import src.mcp_server.tools_agent_run as tools_agent_run_module
import src.mcp_server.tools_authoring as tools_authoring_module
import src.mcp_server.tools_scenario as tools_scenario_module
from src.mcp_server.server import _access_token_context
from src.core.auth.security import get_password_hash
from src.core.database import SessionLocal
from src.models.agent_authoring_workspace import AgentAuthoringWorkspace, AgentAuthoringWorkspaceOperation
from src.models.auth import McpToolInvocationRecord, Permission, Role, User
from src.models.mapping import Base
from src.models.scenario_handles import DraftPackHandle, TestPackProfileSession as ProfileSessionRow
from src.models.scenario_approval import ActionApprovalGate
from src.models.scenario_registry import ScenarioEditProposal, ScenarioRegistryEntry, ScenarioRevision
from src.models.scenario_run import ScenarioRun, ScenarioStepRun
from src.schemas.dashboard_testing.query_model import DashboardQueryModel
from src.services.dashboard_testing.filters import _compute_filters_hash
from src.services.dashboard_testing.reference_source import build_reference_source
_EXPECTED_CHAIN_TOOLS = (
"create_authoring_session",
"propose_test_plan",
"start_exploration",
"get_exploration_result",
"propose_graph_revision",
"get_graph_diff",
"promote_to_scenario",
"request_save",
"activate_revision",
"validate_scenario",
"propose_test_pack_profile",
"resolve_test_pack_profile",
)
from mcp_scenario_fixtures import (
_async_value, _scenario_fixture_graph, _published_catalog_snapshot,
_seed_registry, _seed_operator, _cleanup, _unwrap,
)
# #region Test.McpScenarioE2E.ProfilePreview [C:4] [TYPE Function] [SEMANTICS test,mcp,profile,preview]
# @ingroup Test.McpScenarioE2E
# @BRIEF Expose a deterministic typed preview without returning a save handle or graph authority.
@pytest.mark.asyncio
async def test_propose_test_pack_profile_returns_unresolved_preview_only(monkeypatch) -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="profile-token", client_id="profile-client", scopes=["mcp"],
subject="profile-operator", claims={"principal_type": "user"},
)
context_token = _access_token_context.set(access)
monkeypatch.setattr(tools_scenario_module, "get_config_manager", lambda: SimpleNamespace(
get_environment=lambda environment_id: SimpleNamespace(id=environment_id)
))
fixture_path = Path(__file__).resolve().parent / "fixtures" / "dashboard_scenarios" / "query_model_sales.json"
query_payload = json.loads(fixture_path.read_text(encoding="utf-8"))
query_payload["charts"][0]["metrics"] = [{
"metric_name": "sum__amount", "label": "Total sales", "expression_type": "SIMPLE",
"column": {"column_name": "amount", "type": "DOUBLE"}, "aggregate": "SUM",
}]
query_model = DashboardQueryModel.model_validate(query_payload)
monkeypatch.setattr(tools_scenario_module, "get_superset_client", lambda _: _async_value(object()))
monkeypatch.setattr(tools_scenario_module, "inspect_dashboard_query_model", lambda *_: _async_value(query_model))
monkeypatch.setattr(tools_scenario_module, "resolve_browser_availability", lambda: True)
request = {"environment_id": "ss-prod", "dashboard_id": 11,
"objective": "Verify dashboard filters and sales metric", "selected_case_ids": ["B01", "B02"]}
try:
profile = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": request}))
replay = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": request}))
assert profile["status"] == "preview_only"
assert profile["profile"]["selected_case_ids"] == ["B01", "B02"]
assert {case["case_id"] for case in profile["profile"]["cases"]} == {"B01", "B02"}
assert profile["profile"]["profile_digest"] == replay["profile"]["profile_digest"]
assert {item["kind"] for item in profile["profile"]["unresolved"]} == {"needs_selector"}
assert "scenario" not in profile and "draft_pack" not in profile
assert profile["preview"]["step_count"] >= 1
coordinates = profile["profile"]["coordinates"]
assert coordinates and coordinates[0]["metric_name"] == "sum__amount"
forged_coordinate = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**request, "profile_handle_id": profile["profile_handle_id"],
"idempotency_key": "profile-forged-coordinate-1", "expected_cas_version": 0,
"expected_profile_digest": profile["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": "case:B01:step:none:needs_metric",
"coordinate_id": coordinates[0]["coordinate_id"],
"reason": "No selected case requests a metric baseline."}]}
}))
assert forged_coordinate["status"] == "blocked"
selectors = [item for item in profile["profile"]["unresolved"] if item["kind"] == "needs_selector"]
assert len(selectors) >= 2
unresolved = selectors[0]
unresolved_id = unresolved["id"]
selected_step = unresolved["step_id"]
resolved_request = {
**request,
"profile_handle_id": profile["profile_handle_id"],
"idempotency_key": "profile-selector-1",
"expected_cas_version": 0,
"expected_profile_digest": profile["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": unresolved_id, "step_id": selected_step,
"selector_hint": "#sales-filter", "reason": "Analyst verified this control."}],
}
forged = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request, "idempotency_key": "profile-forged-selector-1",
"resolutions": [{**resolved_request["resolutions"][0],
"step_id": "phase-2-B02-apply_native_filter"}]}
}))
assert forged["status"] == "blocked"
resolved = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": resolved_request}))
assert resolved["status"] == "preview_only"
assert {item["id"] for item in resolved["profile"]["unresolved"]} == {
item["id"] for item in profile["profile"]["unresolved"] if item["id"] != unresolved_id
}
assert "scenario" not in resolved and "draft_pack" not in resolved
replay = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": resolved_request}))
assert replay["replayed"] is True
next_selector = selectors[1]
next_request = {
**resolved_request,
"idempotency_key": "profile-selector-2",
"expected_cas_version": resolved["cas_version"],
"expected_profile_digest": resolved["profile"]["profile_digest"],
"resolutions": [{"unresolved_id": next_selector["id"], "step_id": next_selector["step_id"],
"selector_hint": "#sales-search", "reason": "Analyst verified this control."}],
}
next_result = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": next_request}))
assert next_result["cas_version"] == resolved["cas_version"] + 1
assert next_result["profile"]["profile_digest"] != resolved["profile"]["profile_digest"]
assert "scenario" not in next_result and "draft_pack" not in next_result
stale = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request, "expected_cas_version": next_result["cas_version"],
"expected_profile_digest": "0" * 64,
"idempotency_key": "profile-stale-1"}
}))
assert stale["status"] == "conflict"
assert stale["error"] == "PROFILE_STALE_CONTEXT"
conflict = _unwrap(await server.call_tool("resolve_test_pack_profile", {
"request": {**resolved_request,
"resolutions": [{**resolved_request["resolutions"][0], "reason": "changed"}]}
}))
assert conflict == {"status": "conflict", "error": "IDEMPOTENCY_CONFLICT"}
with pytest.raises(Exception):
await server.call_tool("propose_test_pack_profile", {
"request": {**request, "parameters": {"expected_revenue": 999999}}
})
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpScenarioE2E.ProfilePreview
# #region Test.McpScenarioE2E.ProfileBootstrap [C:5] [TYPE Function] [SEMANTICS test,mcp,profile,bootstrap,receipt]
# @ingroup Test.McpScenarioE2E
# @BRIEF A human MCP principal resolves safe selectors and bootstraps a revision from a server graph.
# @TEST_INVARIANT profile_bootstrap_binding -> VERIFIED_BY: [test_profile_session_bootstraps_without_caller_graph]
# @TEST_EDGE changed query-model fingerprint blocks registration before registry writes.
@pytest.mark.asyncio
async def test_profile_session_bootstraps_without_caller_graph(monkeypatch, tmp_path) -> None:
engine = create_engine(f"sqlite:///{tmp_path / 'profile-bootstrap.db'}", connect_args={"check_same_thread": False})
event.listen(engine, "connect", lambda connection, _: connection.execute("PRAGMA foreign_keys=ON"))
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
for module in (tools_scenario_module, tools_agent_run_module, tools_authoring_module, rbac_server_module):
monkeypatch.setattr(module, "SessionLocal", sessions)
monkeypatch.setenv("DRAFT_STORAGE_ROOT", str(tmp_path / "drafts"))
monkeypatch.setenv("HANDLE_STORAGE_ROOT", str(tmp_path / "handles"))
import src.services.agent_runs.artifacts as artifacts
import src.services.dashboard_testing.scenario.handles as handles
artifacts._draft_storage = None
handles._blob_store = None
principal = f"profile-bootstrap-{uuid4()}"
with sessions() as db:
db.add(User(username=principal, password_hash=get_password_hash("test"), is_active=True,
roles=[Role(name=f"role-{principal}", permissions=[
Permission(resource="dashboard:testing", action="EXECUTE"),
Permission(resource="dashboard:testing", action="WRITE"),
])]))
db.commit()
fixture_path = Path(__file__).resolve().parent / "fixtures" / "dashboard_scenarios" / "query_model_sales.json"
query_model = DashboardQueryModel.model_validate(json.loads(fixture_path.read_text(encoding="utf-8")))
inspected = {"model": query_model}
monkeypatch.setattr(tools_scenario_module, "get_config_manager", lambda: SimpleNamespace(
get_environment=lambda environment_id: SimpleNamespace(id=environment_id) if environment_id == "ss-prod" else None
))
monkeypatch.setattr(tools_scenario_module, "get_superset_client", lambda _: _async_value(object()))
monkeypatch.setattr(tools_scenario_module, "inspect_dashboard_query_model",
lambda *_: _async_value(inspected["model"]))
monkeypatch.setattr(tools_scenario_module, "resolve_browser_availability", lambda: True)
monkeypatch.setattr(tools_scenario_module, "evaluate_context_authority",
lambda _: _async_value("verified"))
server = mcp_server._build_probe_server()
token = _access_token_context.set(mcp_server.AccessToken(
token="profile-bootstrap-token", client_id="profile-bootstrap-client", scopes=["mcp"],
subject=principal, claims={"principal_type": "user"},
))
try:
run = _unwrap(await server.call_tool("create_agent_run", {"request": {
"dashboard_id": 11, "environment_id": "ss-prod", "dashboard_name": "Sales Dashboard",
"idempotency_key": f"profile-run-{uuid4()}",
}}))
assert run["status"] == "ok", run
intent = {"environment_id": "ss-prod", "dashboard_id": 11,
"objective": "Verify sales dashboard filters", "selected_case_ids": ["B01", "B02"]}
proposed = _unwrap(await server.call_tool("propose_test_pack_profile", {"request": intent}))
assert proposed["status"] == "preview_only", proposed
questions = [item for item in proposed["profile"]["unresolved"] if item["kind"] == "needs_selector"]
assert len(questions) == len(proposed["profile"]["unresolved"]) == 2
current = proposed
for index, question in enumerate(questions, start=1):
current = _unwrap(await server.call_tool("resolve_test_pack_profile", {"request": {
**intent, "profile_handle_id": proposed["profile_handle_id"],
"expected_profile_digest": current["profile"]["profile_digest"],
"expected_cas_version": current["cas_version"],
"idempotency_key": f"selector-{index}-{uuid4()}",
"resolutions": [{"unresolved_id": question["id"], "step_id": question["step_id"],
"selector_hint": f"#sales-control-{index}", "reason": "Verified control."}],
}}))
assert current["status"] == "save_eligible", current
registration = {"agent_run_id": run["run_id"], "profile_handle_id": proposed["profile_handle_id"]}
caller_graph = json.loads((fixture_path.parent / "scenario_valid.json").read_text(encoding="utf-8"))
with pytest.raises(Exception, match="PROFILE_GRAPH_FORBIDDEN"):
await server.call_tool("register_draft_pack", {"request": {
**registration, "scenario": caller_graph,
}})
inspected["model"] = query_model.model_copy(update={"query_model_fingerprint": "f" * 64})
stale = _unwrap(await server.call_tool("register_draft_pack", {"request": registration}))
assert stale == {"status": "blocked", "error": "PROFILE_STALE_CONTEXT"}
with sessions() as db:
assert db.query(ScenarioRegistryEntry).count() == 0
assert db.query(DraftPackHandle).count() == 0
inspected["model"] = query_model
registered = _unwrap(await server.call_tool("register_draft_pack", {"request": registration}))
assert registered["status"] == "save_eligible", registered
assert registered["profile_receipt"]["profile_handle_id"] == proposed["profile_handle_id"]
assert registered["profile_receipt"]["profile_digest"] == current["profile"]["profile_digest"]
assert registered["profile_receipt"]["profile_cas_version"] == current["cas_version"]
boot = _unwrap(await server.call_tool("bootstrap_authoring_scenario", {"request": {
"idempotency_key": f"profile-bootstrap-{uuid4()}", "title": "Sales filter checks",
"dashboard_id": 11, "allowed_environment_ids": ["ss-prod"],
"selected_environment_id": "ss-prod", "selected_case_ids": ["B01", "B02"],
"objective": "Verify sales dashboard filters",
"compiled_handle_id": registered["compiled_handle_id"],
"draft_pack_id": registered["draft_pack_handle_id"],
"draft_pack_digest": registered["draft_pack_digest"],
}}))
with sessions() as db:
entry = db.get(ScenarioRegistryEntry, boot["scenario_id"])
revision = db.get(ScenarioRevision, boot["revision_id"])
session = db.get(ProfileSessionRow, proposed["profile_handle_id"])
pack = db.get(DraftPackHandle, registered["draft_pack_handle_id"])
assert entry is not None and entry.current_revision_id == boot["revision_id"]
assert revision is not None and revision.activation_status == "current"
assert session is not None and pack.profile_receipt["profile_digest"] == session.profile_digest
assert pack.profile_receipt["profile_handle_id"] == session.profile_handle_id
assert pack.consumed_by_revision_id == revision.revision_id
finally:
_access_token_context.reset(token)
artifacts._draft_storage = None
handles._blob_store = None
engine.dispose()
# #endregion Test.McpScenarioE2E.ProfileBootstrap
# #endregion Test.McpScenarioE2E.Profiles

View File

@@ -58,20 +58,25 @@ def _client(config: McpServerConfiguration | None = None) -> TestClient:
# #region Test.McpServer.Auth [C:3] [TYPE Function]
# @ingroup Test.McpServer
# @BRIEF Reject requests without a valid bearer credential.
# #region Test.McpServer.test_missing_bearer_is_rejected [C:2] [TYPE Function]
def test_missing_bearer_is_rejected() -> None:
response = _client().post("/", json={"jsonrpc": "2.0", "id": 1, "method": "ping"})
assert response.status_code == 401
assert response.json()["error"] == "authentication_required"
assert response.headers["www-authenticate"] == "Bearer"
# #endregion Test.McpServer.test_missing_bearer_is_rejected
# #region Test.McpServer.test_fastapi_application_mounts_mcp_endpoint [C:2] [TYPE Function]
def test_fastapi_application_mounts_mcp_endpoint() -> None:
from src.app import app
assert any(route.path == "/mcp" for route in app.routes)
# #endregion Test.McpServer.test_fastapi_application_mounts_mcp_endpoint
# #region Test.McpServer.test_protected_resource_metadata_is_discoverable [C:2] [TYPE Function]
def test_protected_resource_metadata_is_discoverable() -> None:
from src.app import app
@@ -80,8 +85,10 @@ def test_protected_resource_metadata_is_discoverable() -> None:
assert response.status_code == 200
assert response.json()["resource"].endswith("/mcp/")
assert response.json()["authorization_servers"]
# #endregion Test.McpServer.test_protected_resource_metadata_is_discoverable
# #region Test.McpServer.test_invalid_bearer_is_rejected [C:2] [TYPE Function]
def test_invalid_bearer_is_rejected() -> None:
response = _client().post(
"/",
@@ -91,6 +98,7 @@ def test_invalid_bearer_is_rejected() -> None:
assert response.status_code == 401
assert response.json()["error"] == "invalid_token"
# #endregion Test.McpServer.test_invalid_bearer_is_rejected
# #endregion Test.McpServer.Auth
@@ -99,6 +107,7 @@ def test_invalid_bearer_is_rejected() -> None:
# #region Test.McpServer.Limits [C:3] [TYPE Function]
# @ingroup Test.McpServer
# @BRIEF Reject oversized requests before the MCP application receives them.
# #region Test.McpServer.test_oversized_request_is_rejected_before_dispatch [C:2] [TYPE Function]
def test_oversized_request_is_rejected_before_dispatch(monkeypatch) -> None:
monkeypatch.setenv("SERVICE_JWT", "test-service-token")
client = _client(McpServerConfiguration(request_body_limit=16))
@@ -111,8 +120,10 @@ def test_oversized_request_is_rejected_before_dispatch(monkeypatch) -> None:
assert response.status_code == 413
assert response.json()["error"] == "request_too_large"
# #endregion Test.McpServer.test_oversized_request_is_rejected_before_dispatch
# #region Test.McpServer.test_chunked_oversized_request_is_rejected_without_content_length [C:2] [TYPE Function]
def test_chunked_oversized_request_is_rejected_without_content_length(monkeypatch) -> None:
monkeypatch.setenv("SERVICE_JWT", "test-service-token")
client = _client(McpServerConfiguration(request_body_limit=16))
@@ -125,6 +136,7 @@ def test_chunked_oversized_request_is_rejected_without_content_length(monkeypatc
assert response.status_code == 413
assert response.json()["error"] == "request_too_large"
# #endregion Test.McpServer.test_chunked_oversized_request_is_rejected_without_content_length
# #endregion Test.McpServer.Limits
@@ -133,34 +145,43 @@ def test_chunked_oversized_request_is_rejected_without_content_length(monkeypatc
# #region Test.McpServer.Verifier [C:2] [TYPE Function]
# @ingroup Test.McpServer
# @BRIEF Keep service-principal authentication separate from user RBAC.
# #region Test.McpServer.test_missing_service_jwt_does_not_accept_arbitrary_token [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_missing_service_jwt_does_not_accept_arbitrary_token(monkeypatch) -> None:
monkeypatch.delenv("SERVICE_JWT", raising=False)
assert await McpTokenVerifier().verify_token("service") is None
# #endregion Test.McpServer.test_missing_service_jwt_does_not_accept_arbitrary_token
# #region Test.McpServer.test_rbac_server_hides_and_denies_the_same_tool [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_rbac_server_hides_and_denies_the_same_tool(monkeypatch) -> None:
server = RbacFastMCP("test")
# #region Test.McpServer.test_rbac_server_hides_and_denies_the_same_tool.list_environments [C:1] [TYPE Function]
@server.tool(name="list_environments", structured_output=True)
async def list_environments() -> dict[str, bool]:
return {"ok": True}
# #endregion Test.McpServer.test_rbac_server_hides_and_denies_the_same_tool.list_environments
monkeypatch.setattr(server, "_can_use_tool", lambda _: False)
assert await server.list_tools() == []
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("list_environments", {})
# #endregion Test.McpServer.test_rbac_server_hides_and_denies_the_same_tool
# #region Test.McpServer.test_gated_tool_returns_typed_approval_without_dispatch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_gated_tool_returns_typed_approval_without_dispatch(monkeypatch) -> None:
server = RbacFastMCP("test")
# #region Test.McpServer.test_gated_tool_returns_typed_approval_without_dispatch.unexpected_tool [C:1] [TYPE Function]
async def unexpected_tool() -> dict[str, bool]:
return {"unexpected": True}
# #endregion Test.McpServer.test_gated_tool_returns_typed_approval_without_dispatch.unexpected_tool
server.add_tool(
unexpected_tool,
@@ -183,8 +204,10 @@ async def test_gated_tool_returns_typed_approval_without_dispatch(monkeypatch) -
assert result["risk_level"] == "guarded"
assert recorded[-1]["error_code"] == "approval_required"
assert recorded[-1]["outcome"] == "denied"
# #endregion Test.McpServer.test_gated_tool_returns_typed_approval_without_dispatch
# #region Test.McpServer.test_direct_gated_retry_reuses_invocation_and_gate [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_direct_gated_retry_reuses_invocation_and_gate(monkeypatch) -> None:
principal = f"mcp-retry-{uuid4()}"
@@ -226,8 +249,10 @@ async def test_direct_gated_retry_reuses_invocation_and_gate(monkeypatch) -> Non
db.query(ActionApprovalGate).filter(ActionApprovalGate.owner_id == first["invocation_id"]).delete()
db.query(McpToolInvocationRecord).filter(McpToolInvocationRecord.id == first["invocation_id"]).delete()
db.commit()
# #endregion Test.McpServer.test_direct_gated_retry_reuses_invocation_and_gate
# #region Test.McpServer.test_direct_maintenance_read_projects_without_gate_or_enqueue [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_direct_maintenance_read_projects_without_gate_or_enqueue(monkeypatch) -> None:
principal = f"mcp-maintenance-{uuid4()}"
@@ -286,8 +311,10 @@ async def test_direct_maintenance_read_projects_without_gate_or_enqueue(monkeypa
db.query(McpToolInvocationRecord).filter(McpToolInvocationRecord.id == invocation.id).delete()
db.query(MaintenanceEvent).filter(MaintenanceEvent.id == event_id).delete()
db.commit()
# #endregion Test.McpServer.test_direct_maintenance_read_projects_without_gate_or_enqueue
# #region Test.McpServer.test_mcp_catalog_is_explicit_and_unique [C:2] [TYPE Function]
def test_mcp_catalog_is_explicit_and_unique() -> None:
names = [definition.name for definition in _MCP_CATALOG]
@@ -426,8 +453,10 @@ def test_mcp_catalog_is_explicit_and_unique() -> None:
):
assert _MCP_CATALOG_BY_NAME[name].permission == ("scenario:automation", "MANAGE")
assert _MCP_CATALOG_BY_NAME[name].service_allowed is False
# #endregion Test.McpServer.test_mcp_catalog_is_explicit_and_unique
# #region Test.McpServer.test_service_principal_cannot_see_or_use_gated_tools [C:2] [TYPE Function]
def test_service_principal_cannot_see_or_use_gated_tools(monkeypatch) -> None:
server = RbacFastMCP("test")
access = mcp_server.AccessToken(
@@ -441,668 +470,13 @@ def test_service_principal_cannot_see_or_use_gated_tools(monkeypatch) -> None:
assert server._can_use_tool("execute_migration") is False
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpServer.test_service_principal_cannot_see_or_use_gated_tools
_COMPILE_REQUEST = {
"agent_run_id": "550e8400-e29b-41d4-a716-446655440000",
"objective": {"goal": "verify filters metric xlsx", "selected_case_ids": ["B01", "C04", "C05", "T01"], "rationale": "rc"},
"query_model": {"dashboard_key": "fi-0080"},
"checklist_catalog_version": 1,
"baseline_version": "2026-07-01",
"capabilities": {"browser": True, "native_filters": True, "xlsx_export": True, "persistence_refresh": True, "dataset_fields": True},
"parameters": {"test_date": {"type": "date"}, "counterparty": {"type": "string"}},
"has_dataset_fields": True,
"environment_id": "env-prod-01",
"dashboard_id": 80,
"dashboard_name": "FI-0080",
}
@pytest.mark.asyncio
async def test_scenario_resolve_and_generate_draft_pack_are_read_only(monkeypatch) -> None:
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
inspected = await server.call_tool("inspect_scenario", {"request": _COMPILE_REQUEST})
inspected = inspected[1] if isinstance(inspected, tuple) else inspected
assert inspected["status"] == "ok"
scenario = inspected["scenario"]
resolved = await server.call_tool("scenario_resolve", {"request": {
"scenario": scenario,
"changes": [{"kind": "parameter", "target": "test_date", "value": "2026-08-28", "reason": "pin date"}],
}})
resolved = resolved[1] if isinstance(resolved, tuple) else resolved
assert resolved["status"] == "ok"
assert resolved["revision_hash"] != scenario["revision_hash"]
assert resolved["parent_revision_hash"] == scenario["revision_hash"]
assert "validation" in resolved
pack = await server.call_tool("generate_draft_pack", {"request": {"scenario": resolved["scenario"]}})
pack = pack[1] if isinstance(pack, tuple) else pack
assert pack["status"] in {"save_eligible", "preview_only"}
assert pack["scenario_revision_hash"] == resolved["revision_hash"]
assert "manifest" in pack and isinstance(pack["manifest"], dict)
@pytest.mark.asyncio
async def test_scenario_resolve_rejects_oversized_value(monkeypatch) -> None:
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
inspected = await server.call_tool("inspect_scenario", {"request": _COMPILE_REQUEST})
inspected = inspected[1] if isinstance(inspected, tuple) else inspected
with pytest.raises(Exception):
await server.call_tool("scenario_resolve", {"request": {
"scenario": inspected["scenario"],
"changes": [{"kind": "parameter", "target": "test_date", "value": "x" * 5000}],
}})
@pytest.mark.asyncio
async def test_authoring_session_create_replay_conflict_and_no_registry_mutation(monkeypatch) -> None:
principal = f"mcp-authoring-{uuid4()}"
access = mcp_server.AccessToken(
token="test-token",
client_id="acceptance-client",
scopes=["mcp:read"],
subject=principal,
claims={"principal_type": "user"},
)
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
arguments = {
"idempotency_key": f"authoring-{uuid4()}",
"scenario_id": "scenario-1",
"base_content_hash": "a" * 64,
"expires_in_seconds": 120,
}
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).count()
first = await server.call_tool("create_authoring_session", {"request": arguments})
second = await server.call_tool("create_authoring_session", {"request": arguments})
first_projection = first[1] if isinstance(first, tuple) else first
second_projection = second[1] if isinstance(second, tuple) else second
assert first_projection["workspace_id"] == second_projection["workspace_id"]
assert first_projection["owner_principal"] == principal
assert first_projection["session_status"] == "draft"
assert first_projection["cas_version"] == 0
assert set(first_projection) == {
"workspace_id", "session_status", "owner_principal", "agent_principal",
"scenario_id", "base_revision_id", "base_content_hash", "cas_version", "expires_at",
}
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"create_authoring_session",
{"request": {**arguments, "base_content_hash": "b" * 64}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with SessionLocal() as db:
rows = db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).all()
assert len(rows) == before + 1
assert rows[0].workspace_id == first_projection["workspace_id"]
assert rows[0].exploration_ids == []
assert rows[0].artifact_ids == []
db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).delete()
db.commit()
finally:
_access_token_context.reset(context_token)
@pytest.mark.asyncio
async def test_authoring_session_service_principal_is_denied_without_mutation(monkeypatch) -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="service-token",
client_id="service",
scopes=["mcp:read"],
subject="service",
claims={"principal_type": "service"},
)
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AgentAuthoringWorkspace).count()
assert server._can_use_tool("create_authoring_session") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("create_authoring_session", {"request": {"idempotency_key": "service-key"}})
with SessionLocal() as db:
assert db.query(AgentAuthoringWorkspace).count() == before
finally:
_access_token_context.reset(context_token)
@pytest.mark.asyncio
async def test_start_exploration_direct_mcp_is_bounded_idempotent_and_non_executing() -> None:
principal = f"mcp-exploration-{uuid4()}"
access = mcp_server.AccessToken(
token="test-token",
client_id="acceptance-client",
scopes=["mcp:read"],
subject=principal,
claims={"principal_type": "user"},
)
server = mcp_server._build_probe_server()
context_token = _access_token_context.set(access)
workspace_id = None
try:
created = await server.call_tool(
"create_authoring_session",
{"request": {
"idempotency_key": f"exploration-workspace-{uuid4()}",
"scenario_id": "scenario-exploration",
"base_content_hash": "a" * 64,
"expires_in_seconds": 120,
}},
)
created_projection = created[1] if isinstance(created, tuple) else created
workspace_id = created_projection["workspace_id"]
request = {
"workspace_id": workspace_id,
"idempotency_key": f"exploration-{uuid4()}",
"expected_cas_version": 0,
"exploration_spec": {
"objective": "Inspect dashboard",
"actions": ["open_dashboard", "capture_screenshot"],
},
}
with SessionLocal() as db:
before = {
"requests": db.query(AuthoringExplorationRequest).count(),
"operations": db.query(AgentAuthoringWorkspaceOperation).count(),
"registries": db.query(ScenarioRegistryEntry).count(),
"revisions": db.query(ScenarioRevision).count(),
"runs": db.query(ScenarioRun).count(),
"artifacts": db.query(ScenarioArtifact).count(),
}
first = await server.call_tool("start_exploration", {"request": request})
replay = await server.call_tool(
"start_exploration",
{"request": {**request, "expected_cas_version": 99}},
)
first_projection = first[1] if isinstance(first, tuple) else first
replay_projection = replay[1] if isinstance(replay, tuple) else replay
assert first_projection == replay_projection
assert first_projection["status"] == "sandbox_unavailable"
assert first_projection["session_status"] == "draft"
assert first_projection["cas_version"] == 1
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"start_exploration",
{"request": {**request, "exploration_spec": {
**request["exploration_spec"], "objective": "Different"
}}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with pytest.raises(ToolError, match="registered 038 ActionRegistry") as unknown:
await server.call_tool(
"start_exploration",
{"request": {**request, "idempotency_key": f"unknown-{uuid4()}",
"exploration_spec": {"objective": "Inspect", "actions": ["invented_action"]}}},
)
assert unknown.value.__cause__.__class__.__name__ == "WorkspaceExplorationValidationError"
with SessionLocal() as db:
workspace = db.get(AgentAuthoringWorkspace, workspace_id)
assert workspace is not None
assert workspace.session_status == "draft"
assert workspace.cas_version == 1
assert db.query(AuthoringExplorationRequest).count() == before["requests"] + 1
assert db.query(AgentAuthoringWorkspaceOperation).count() == before["operations"] + 1
assert db.query(ScenarioRegistryEntry).count() == before["registries"]
assert db.query(ScenarioRevision).count() == before["revisions"]
assert db.query(ScenarioRun).count() == before["runs"]
assert db.query(ScenarioArtifact).count() == before["artifacts"]
finally:
_access_token_context.reset(context_token)
if workspace_id is not None:
with SessionLocal() as db:
db.query(AuthoringExplorationRequest).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.commit()
@pytest.mark.asyncio
async def test_start_exploration_direct_mcp_denies_service_principal_without_mutation() -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="service-token",
client_id="service",
scopes=["mcp:read"],
subject="service",
claims={"principal_type": "service"},
)
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AuthoringExplorationRequest).count()
assert server._can_use_tool("start_exploration") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("start_exploration", {"request": {"workspace_id": "missing"}})
with SessionLocal() as db:
assert db.query(AuthoringExplorationRequest).count() == before
finally:
_access_token_context.reset(context_token)
# #region Test.McpServer.GetExplorationResult [C:4] [TYPE Function] [SEMANTICS test,mcp,authoring,exploration,read,ownership]
# @ingroup Test.McpServer
# @BRIEF Verify bounded owner reads and typed denial outcomes without domain mutation.
# @TEST_INVARIANT read_only_projection -> request and workspace rows remain unchanged.
@pytest.mark.asyncio
async def test_get_exploration_result_owner_projection_and_denials() -> None:
owner, other = f"owner-{uuid4()}", f"other-{uuid4()}"
server = mcp_server._build_probe_server()
token = _access_token_context.set(mcp_server.AccessToken(
token="user", client_id="test", scopes=["mcp:read"], subject=owner,
claims={"principal_type": "user"},
))
workspace_id = None
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
started = await server.call_tool("start_exploration", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0,
"exploration_spec": {"objective": "Read", "actions": ["open_dashboard"]},
}})
started = started[1] if isinstance(started, tuple) else started
request_id = started["request_id"]
with SessionLocal() as db:
before = (db.query(AgentAuthoringWorkspace).count(), db.query(AuthoringExplorationRequest).count())
result = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": workspace_id, "request_id": request_id,
}})
result = result[1] if isinstance(result, tuple) else result
assert result["status"] == "sandbox_unavailable"
assert set(result) == {"status", "request_id", "workspace_id", "created_at", "receipt_reference"}
assert "exploration_spec" not in result
other_token = _access_token_context.set(mcp_server.AccessToken(
token="other", client_id="test", scopes=["mcp:read"], subject=other,
claims={"principal_type": "user"},
))
try:
denied = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": workspace_id, "request_id": request_id,
}})
assert (denied[1] if isinstance(denied, tuple) else denied)["status"] == "permission_denied"
finally:
_access_token_context.reset(other_token)
with SessionLocal() as db:
assert (db.query(AgentAuthoringWorkspace).count(), db.query(AuthoringExplorationRequest).count()) == before
finally:
_access_token_context.reset(token)
if workspace_id:
with SessionLocal() as db:
db.query(AuthoringExplorationRequest).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.commit()
@pytest.mark.asyncio
async def test_get_exploration_result_service_and_unknown_are_typed() -> None:
server = mcp_server._build_probe_server()
service_token = _access_token_context.set(mcp_server.AccessToken(
token="service", client_id="service", scopes=["mcp:read"], subject="service",
claims={"principal_type": "service"},
))
try:
assert server._can_use_tool("get_exploration_result") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("get_exploration_result", {"request": {
"workspace_id": str(uuid4()), "request_id": str(uuid4()),
}})
finally:
_access_token_context.reset(service_token)
user_token = _access_token_context.set(mcp_server.AccessToken(
token="user", client_id="test", scopes=["mcp:read"], subject=f"unknown-{uuid4()}",
claims={"principal_type": "user"},
))
try:
result = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": str(uuid4()), "request_id": str(uuid4()),
}})
assert (result[1] if isinstance(result, tuple) else result)["status"] == "not_found"
finally:
_access_token_context.reset(user_token)
# #endregion Test.McpServer.GetExplorationResult
# #region Test.McpServer.GraphRevision [C:4] [TYPE Function] [SEMANTICS test,mcp,authoring,graph,proposal,diff]
# @ingroup Test.McpServer
# @BRIEF Verify server-derived graph proposal and bounded owner-scoped diff reads.
# @TEST_INVARIANT proposal_does_not_create_revision -> ScenarioRevision count unchanged after propose.
# @TEST_EDGE service_denied -> permission_denied; changed_ops -> idempotency conflict; other_owner -> permission_denied.
def _seed_scenario_binding(scenario_id: str, base_revision_id: str) -> None:
with SessionLocal() as db:
db.add(ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=f"k-{scenario_id}", name="seed",
dashboard_id=1, owner_id="owner", owner_username="owner",
current_revision_id=base_revision_id,
))
db.add(ScenarioRevision(
revision_id=base_revision_id, scenario_id=scenario_id,
content_hash="c" * 64, graph_snapshot={"schema_version": 1, "phases": ["setup"]},
created_by="owner", activation_status="current",
))
db.commit()
def _cleanup_scenario_binding(scenario_id: str, workspace_id: str | None) -> None:
with SessionLocal() as db:
if workspace_id is not None:
db.query(ScenarioEditProposal).filter(ScenarioEditProposal.scenario_id == scenario_id).delete()
from src.models.agent_authoring_workspace import AgentAuthoringWorkspaceOperation
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.query(ScenarioRevision).filter(ScenarioRevision.scenario_id == scenario_id).delete()
db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete()
db.commit()
@pytest.mark.asyncio
async def test_propose_graph_revision_proposes_replays_and_never_creates_revision(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-graph-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
access = mcp_server.AccessToken(
token="test-token", client_id="acceptance-client", scopes=["mcp:read"],
subject=principal, claims={"principal_type": "user"},
)
workspace_id = None
ops = [{
"op": "set_assertion", "logical_step_id": "step-1",
"comparison": "exact", "baseline_ref": "baseline:step-1",
}]
context_token = _access_token_context.set(access)
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": f"graph-ws-{uuid4()}",
"scenario_id": scenario_id,
"base_revision_id": base_revision_id,
"expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
request = {
"workspace_id": workspace_id,
"idempotency_key": f"graph-{uuid4()}",
"expected_cas_version": 0,
"request_text": "add step-1 assertion",
"operations": ops,
}
with SessionLocal() as db:
before = {
"revisions": db.query(ScenarioRevision).count(),
"proposals": db.query(ScenarioEditProposal).count(),
}
first = await server.call_tool("propose_graph_revision", {"request": request})
replay = await server.call_tool("propose_graph_revision", {"request": {**request, "expected_cas_version": 99}})
first = first[1] if isinstance(first, tuple) else first
replay = replay[1] if isinstance(replay, tuple) else replay
assert first["status"] == "ok"
assert first["proposal_id"] and len(first["proposal_id"]) == 36
assert first["digest"] and len(first["digest"]) == 64
assert first["cas_version"] == 1
assert first["diff"].get("added") or first["diff"].get("changed")
assert replay["proposal_id"] == first["proposal_id"]
assert replay["cas_version"] == first["cas_version"]
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"propose_graph_revision",
{"request": {**request, "operations": [{**ops[0], "logical_step_id": "step-2"}]}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with SessionLocal() as db:
workspace = db.get(AgentAuthoringWorkspace, workspace_id)
assert workspace is not None and workspace.proposal_id == first["proposal_id"]
assert db.query(ScenarioRevision).count() == before["revisions"]
assert db.query(ScenarioEditProposal).count() == before["proposals"] + 1
finally:
_access_token_context.reset(context_token)
_cleanup_scenario_binding(scenario_id, workspace_id)
@pytest.mark.asyncio
async def test_get_graph_diff_owner_reads_other_denied_unknown_typed(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
owner, other = f"owner-{uuid4()}", f"other-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=owner,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
proposal = await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
proposal = proposal[1] if isinstance(proposal, tuple) else proposal
result = await server.call_tool("get_graph_diff", {"request": {"workspace_id": workspace_id}})
result = result[1] if isinstance(result, tuple) else result
assert result["status"] == "ok"
assert result["proposal_id"] == proposal["proposal_id"]
assert result["proposal_status"] == "open"
assert set(result) == {"status", "proposal_id", "base_revision_id", "digest", "diff", "validation", "proposal_status", "cas_version"}
other_token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=other,
claims={"principal_type": "user"},
))
try:
denied = await server.call_tool("get_graph_diff", {"request": {"workspace_id": workspace_id}})
assert (denied[1] if isinstance(denied, tuple) else denied)["status"] == "permission_denied"
finally:
_access_token_context.reset(other_token)
unknown = await server.call_tool("get_graph_diff", {"request": {"workspace_id": str(uuid4())}})
assert (unknown[1] if isinstance(unknown, tuple) else unknown)["status"] == "not_found"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
@pytest.mark.asyncio
async def test_authoring_graph_tools_deny_service_principal() -> None:
server = mcp_server._build_probe_server()
service_token = _access_token_context.set(mcp_server.AccessToken(
token="service", client_id="service", scopes=["mcp:read"], subject="service",
claims={"principal_type": "service"},
))
try:
assert server._can_use_tool("propose_graph_revision") is False
assert server._can_use_tool("get_graph_diff") is False
assert server._can_use_tool("promote_to_scenario") is False
assert server._can_use_tool("request_save") is False
assert server._can_use_tool("activate_revision") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": str(uuid4()), "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "x",
"operations": [{"op": "remove_step", "logical_step_id": "s"}],
}})
finally:
_access_token_context.reset(service_token)
@pytest.mark.asyncio
async def test_promote_to_scenario_advances_review_without_saving(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-promote-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
with SessionLocal() as db:
before = db.query(ScenarioRevision).count()
promoted = await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
promoted = promoted[1] if isinstance(promoted, tuple) else promoted
assert promoted["status"] == "ok"
assert promoted["session_status"] == "awaiting_user_review"
assert promoted["validation"]["status"] == "valid"
assert promoted["cas_version"] == 2
assert set(promoted) == {"status", "proposal_id", "base_revision_id", "digest", "validation", "diff", "session_status", "cas_version"}
with SessionLocal() as db:
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "awaiting_user_review"
assert db.query(ScenarioRevision).count() == before
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
@pytest.mark.asyncio
async def test_request_save_creates_candidate_without_activation(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-save-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
saved = await server.call_tool("request_save", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 2,
}})
saved = saved[1] if isinstance(saved, tuple) else saved
assert saved["status"] == "ok"
assert saved["activation_status"] == "candidate"
assert saved["revision_id"] and saved["parent_revision_id"] == base_revision_id
assert set(saved) == {"status", "revision_id", "scenario_id", "parent_revision_id", "content_hash", "activation_status", "cas_version"}
with SessionLocal() as db:
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "candidate"
assert db.get(ScenarioRegistryEntry, scenario_id).current_revision_id == base_revision_id
candidate = db.query(ScenarioRevision).filter(
ScenarioRevision.revision_id == saved["revision_id"],
).one()
assert candidate.activation_status == "candidate"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
@pytest.mark.asyncio
async def test_activate_revision_promotes_candidate_to_current(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-activate-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
saved = await server.call_tool("request_save", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 2,
}})
saved = saved[1] if isinstance(saved, tuple) else saved
activated = await server.call_tool("activate_revision", {"request": {
"workspace_id": workspace_id, "revision_id": saved["revision_id"],
"idempotency_key": str(uuid4()), "expected_cas_version": 3,
}})
activated = activated[1] if isinstance(activated, tuple) else activated
assert activated["status"] == "ok"
assert activated["activation_status"] == "current"
assert activated["revision_id"] == saved["revision_id"]
assert set(activated) == {"status", "revision_id", "scenario_id", "activation_status", "cas_version"}
with SessionLocal() as db:
assert db.get(ScenarioRegistryEntry, scenario_id).current_revision_id == saved["revision_id"]
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "current"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphRevision
from mcp_server_fixtures import _COMPILE_REQUEST
# #region Test.McpServer.test_streamable_http_initialize_list_and_probe_call [C:2] [TYPE Function]
def test_streamable_http_initialize_list_and_probe_call(monkeypatch) -> None:
monkeypatch.setenv("SERVICE_JWT", "test-service-token")
client = TestClient(create_mcp_asgi_app(), base_url="http://testserver")
@@ -1142,9 +516,9 @@ def test_streamable_http_initialize_list_and_probe_call(monkeypatch) -> None:
assert '"execute_migration"' not in tools_response.text
assert '"show_capabilities"' not in tools_response.text
# #endregion Test.McpServer.test_streamable_http_initialize_list_and_probe_call
# #endregion Test.McpServer.Verifier
# #endregion Test.McpServer

View File

@@ -0,0 +1,372 @@
# #region Test.McpServer.GraphTools [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup Test.McpServer.GraphTools MCP proposal, review, candidate save and explicit activation behavior.
import os
from datetime import UTC, datetime
from uuid import uuid4
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-mcp")
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_mcp_test.db")
from fastapi.testclient import TestClient
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from src.mcp_server import server as mcp_server
import src.mcp_server.rbac_server as rbac_server_module
import src.mcp_server.tools_scenario as tools_scenario_module
from src.mcp_server.server import (
McpServerConfiguration,
McpTokenVerifier,
RbacFastMCP,
_access_token_context,
_MCP_CATALOG,
_MCP_CATALOG_BY_NAME,
create_mcp_asgi_app,
)
from src.core.database import SessionLocal
from src.models.auth import McpToolInvocationRecord
from src.models.agent_authoring_workspace import (
AgentAuthoringWorkspace,
AgentAuthoringWorkspaceOperation,
AuthoringExplorationRequest,
)
from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus
from src.models.scenario_approval import ActionApprovalGate
from src.models.scenario_artifact import ScenarioArtifact
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision, ScenarioEditProposal
from src.models.scenario_run import ScenarioRun
# #region Test.McpServer.GraphRevision [C:4] [TYPE Function] [SEMANTICS test,mcp,authoring,graph,proposal,diff]
# @ingroup Test.McpServer
# @BRIEF Verify server-derived graph proposal and bounded owner-scoped diff reads.
# @TEST_INVARIANT proposal_does_not_create_revision -> ScenarioRevision count unchanged after propose.
# @TEST_EDGE service_denied -> permission_denied; changed_ops -> idempotency conflict; other_owner -> permission_denied.
# #region Test.McpServer.GraphTools._seed_scenario_binding [C:1] [TYPE Function]
def _seed_scenario_binding(scenario_id: str, base_revision_id: str) -> None:
with SessionLocal() as db:
db.add(ScenarioRegistryEntry(
scenario_id=scenario_id, scenario_key=f"k-{scenario_id}", name="seed",
dashboard_id=1, owner_id="owner", owner_username="owner",
current_revision_id=base_revision_id,
))
db.add(ScenarioRevision(
revision_id=base_revision_id, scenario_id=scenario_id,
content_hash="c" * 64, graph_snapshot={"schema_version": 1, "phases": ["setup"]},
created_by="owner", activation_status="current",
))
db.commit()
# #endregion Test.McpServer.GraphTools._seed_scenario_binding
# #region Test.McpServer.GraphTools._cleanup_scenario_binding [C:1] [TYPE Function]
def _cleanup_scenario_binding(scenario_id: str, workspace_id: str | None) -> None:
with SessionLocal() as db:
if workspace_id is not None:
db.query(ScenarioEditProposal).filter(ScenarioEditProposal.scenario_id == scenario_id).delete()
from src.models.agent_authoring_workspace import AgentAuthoringWorkspaceOperation
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.query(ScenarioRevision).filter(ScenarioRevision.scenario_id == scenario_id).delete()
db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete()
db.commit()
# #endregion Test.McpServer.GraphTools._cleanup_scenario_binding
# #region Test.McpServer.GraphTools.test_propose_graph_revision_proposes_replays_and_never_creates_revision [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_propose_graph_revision_proposes_replays_and_never_creates_revision(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-graph-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
access = mcp_server.AccessToken(
token="test-token", client_id="acceptance-client", scopes=["mcp:read"],
subject=principal, claims={"principal_type": "user"},
)
workspace_id = None
ops = [{
"op": "set_assertion", "logical_step_id": "step-1",
"comparison": "exact", "baseline_ref": "baseline:step-1",
}]
context_token = _access_token_context.set(access)
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": f"graph-ws-{uuid4()}",
"scenario_id": scenario_id,
"base_revision_id": base_revision_id,
"expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
request = {
"workspace_id": workspace_id,
"idempotency_key": f"graph-{uuid4()}",
"expected_cas_version": 0,
"request_text": "add step-1 assertion",
"operations": ops,
}
with SessionLocal() as db:
before = {
"revisions": db.query(ScenarioRevision).count(),
"proposals": db.query(ScenarioEditProposal).count(),
}
first = await server.call_tool("propose_graph_revision", {"request": request})
replay = await server.call_tool("propose_graph_revision", {"request": {**request, "expected_cas_version": 99}})
first = first[1] if isinstance(first, tuple) else first
replay = replay[1] if isinstance(replay, tuple) else replay
assert first["status"] == "ok"
assert first["proposal_id"] and len(first["proposal_id"]) == 36
assert first["digest"] and len(first["digest"]) == 64
assert first["cas_version"] == 1
assert first["diff"].get("added") or first["diff"].get("changed")
assert replay["proposal_id"] == first["proposal_id"]
assert replay["cas_version"] == first["cas_version"]
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"propose_graph_revision",
{"request": {**request, "operations": [{**ops[0], "logical_step_id": "step-2"}]}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with SessionLocal() as db:
workspace = db.get(AgentAuthoringWorkspace, workspace_id)
assert workspace is not None and workspace.proposal_id == first["proposal_id"]
assert db.query(ScenarioRevision).count() == before["revisions"]
assert db.query(ScenarioEditProposal).count() == before["proposals"] + 1
finally:
_access_token_context.reset(context_token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphTools.test_propose_graph_revision_proposes_replays_and_never_creates_revision
# #region Test.McpServer.GraphTools.test_get_graph_diff_owner_reads_other_denied_unknown_typed [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_graph_diff_owner_reads_other_denied_unknown_typed(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
owner, other = f"owner-{uuid4()}", f"other-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=owner,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
proposal = await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
proposal = proposal[1] if isinstance(proposal, tuple) else proposal
result = await server.call_tool("get_graph_diff", {"request": {"workspace_id": workspace_id}})
result = result[1] if isinstance(result, tuple) else result
assert result["status"] == "ok"
assert result["proposal_id"] == proposal["proposal_id"]
assert result["proposal_status"] == "open"
assert set(result) == {"status", "proposal_id", "base_revision_id", "digest", "diff", "validation", "proposal_status", "cas_version"}
other_token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=other,
claims={"principal_type": "user"},
))
try:
denied = await server.call_tool("get_graph_diff", {"request": {"workspace_id": workspace_id}})
assert (denied[1] if isinstance(denied, tuple) else denied)["status"] == "permission_denied"
finally:
_access_token_context.reset(other_token)
unknown = await server.call_tool("get_graph_diff", {"request": {"workspace_id": str(uuid4())}})
assert (unknown[1] if isinstance(unknown, tuple) else unknown)["status"] == "not_found"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphTools.test_get_graph_diff_owner_reads_other_denied_unknown_typed
# #region Test.McpServer.GraphTools.test_authoring_graph_tools_deny_service_principal [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_authoring_graph_tools_deny_service_principal() -> None:
server = mcp_server._build_probe_server()
service_token = _access_token_context.set(mcp_server.AccessToken(
token="service", client_id="service", scopes=["mcp:read"], subject="service",
claims={"principal_type": "service"},
))
try:
assert server._can_use_tool("propose_graph_revision") is False
assert server._can_use_tool("get_graph_diff") is False
assert server._can_use_tool("promote_to_scenario") is False
assert server._can_use_tool("request_save") is False
assert server._can_use_tool("activate_revision") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": str(uuid4()), "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "x",
"operations": [{"op": "remove_step", "logical_step_id": "s"}],
}})
finally:
_access_token_context.reset(service_token)
# #endregion Test.McpServer.GraphTools.test_authoring_graph_tools_deny_service_principal
# #region Test.McpServer.GraphTools.test_promote_to_scenario_advances_review_without_saving [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_promote_to_scenario_advances_review_without_saving(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-promote-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
with SessionLocal() as db:
before = db.query(ScenarioRevision).count()
promoted = await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
promoted = promoted[1] if isinstance(promoted, tuple) else promoted
assert promoted["status"] == "ok"
assert promoted["session_status"] == "awaiting_user_review"
assert promoted["validation"]["status"] == "valid"
assert promoted["cas_version"] == 2
assert set(promoted) == {"status", "proposal_id", "base_revision_id", "digest", "validation", "diff", "session_status", "cas_version"}
with SessionLocal() as db:
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "awaiting_user_review"
assert db.query(ScenarioRevision).count() == before
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphTools.test_promote_to_scenario_advances_review_without_saving
# #region Test.McpServer.GraphTools.test_request_save_creates_candidate_without_activation [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_request_save_creates_candidate_without_activation(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-save-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
saved = await server.call_tool("request_save", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 2,
}})
saved = saved[1] if isinstance(saved, tuple) else saved
assert saved["status"] == "ok"
assert saved["activation_status"] == "candidate"
assert saved["revision_id"] and saved["parent_revision_id"] == base_revision_id
assert set(saved) == {"status", "revision_id", "scenario_id", "parent_revision_id", "content_hash", "activation_status", "cas_version"}
with SessionLocal() as db:
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "candidate"
assert db.get(ScenarioRegistryEntry, scenario_id).current_revision_id == base_revision_id
candidate = db.query(ScenarioRevision).filter(
ScenarioRevision.revision_id == saved["revision_id"],
).one()
assert candidate.activation_status == "candidate"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphTools.test_request_save_creates_candidate_without_activation
# #region Test.McpServer.GraphTools.test_activate_revision_promotes_candidate_to_current [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_activate_revision_promotes_candidate_to_current(monkeypatch) -> None:
scenario_id = str(uuid4())
base_revision_id = str(uuid4())
_seed_scenario_binding(scenario_id, base_revision_id)
principal = f"mcp-activate-{uuid4()}"
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
workspace_id = None
token = _access_token_context.set(mcp_server.AccessToken(
token="t", client_id="test", scopes=["mcp:read"], subject=principal,
claims={"principal_type": "user"},
))
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "scenario_id": scenario_id,
"base_revision_id": base_revision_id, "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
await server.call_tool("propose_graph_revision", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0, "request_text": "add assertion",
"operations": [{"op": "set_assertion", "logical_step_id": "s", "comparison": "exact", "baseline_ref": "baseline:s"}],
}})
await server.call_tool("promote_to_scenario", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 1,
}})
saved = await server.call_tool("request_save", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 2,
}})
saved = saved[1] if isinstance(saved, tuple) else saved
activated = await server.call_tool("activate_revision", {"request": {
"workspace_id": workspace_id, "revision_id": saved["revision_id"],
"idempotency_key": str(uuid4()), "expected_cas_version": 3,
}})
activated = activated[1] if isinstance(activated, tuple) else activated
assert activated["status"] == "ok"
assert activated["activation_status"] == "current"
assert activated["revision_id"] == saved["revision_id"]
assert set(activated) == {"status", "revision_id", "scenario_id", "activation_status", "cas_version"}
with SessionLocal() as db:
assert db.get(ScenarioRegistryEntry, scenario_id).current_revision_id == saved["revision_id"]
assert db.get(AgentAuthoringWorkspace, workspace_id).session_status == "current"
finally:
_access_token_context.reset(token)
_cleanup_scenario_binding(scenario_id, workspace_id)
# #endregion Test.McpServer.GraphTools.test_activate_revision_promotes_candidate_to_current
# #endregion Test.McpServer.GraphRevision
# #endregion Test.McpServer.GraphTools

View File

@@ -0,0 +1,383 @@
# #region Test.McpServer.Workspace [C:4] [TYPE Module] [SEMANTICS mcp,authoring,scenario]
# @defgroup Test.McpServer.Workspace MCP workspace creation and bounded exploration read/write behavior.
import os
from datetime import UTC, datetime
from uuid import uuid4
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-mcp")
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_mcp_test.db")
from fastapi.testclient import TestClient
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from src.mcp_server import server as mcp_server
import src.mcp_server.rbac_server as rbac_server_module
import src.mcp_server.tools_scenario as tools_scenario_module
from src.mcp_server.server import (
McpServerConfiguration,
McpTokenVerifier,
RbacFastMCP,
_access_token_context,
_MCP_CATALOG,
_MCP_CATALOG_BY_NAME,
create_mcp_asgi_app,
)
from src.core.database import SessionLocal
from src.models.auth import McpToolInvocationRecord
from src.models.agent_authoring_workspace import (
AgentAuthoringWorkspace,
AgentAuthoringWorkspaceOperation,
AuthoringExplorationRequest,
)
from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus
from src.models.scenario_approval import ActionApprovalGate
from src.models.scenario_artifact import ScenarioArtifact
from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision, ScenarioEditProposal
from src.models.scenario_run import ScenarioRun
from mcp_server_fixtures import _COMPILE_REQUEST
# #region Test.McpServer.Workspace.test_scenario_resolve_and_generate_draft_pack_are_read_only [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_scenario_resolve_and_generate_draft_pack_are_read_only(monkeypatch) -> None:
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
inspected = await server.call_tool("inspect_scenario", {"request": _COMPILE_REQUEST})
inspected = inspected[1] if isinstance(inspected, tuple) else inspected
assert inspected["status"] == "ok"
scenario = inspected["scenario"]
resolved = await server.call_tool("scenario_resolve", {"request": {
"scenario": scenario,
"changes": [{"kind": "parameter", "target": "test_date", "value": "2026-08-28", "reason": "pin date"}],
}})
resolved = resolved[1] if isinstance(resolved, tuple) else resolved
assert resolved["status"] == "ok"
assert resolved["revision_hash"] != scenario["revision_hash"]
assert resolved["parent_revision_hash"] == scenario["revision_hash"]
assert "validation" in resolved
pack = await server.call_tool("generate_draft_pack", {"request": {"scenario": resolved["scenario"]}})
pack = pack[1] if isinstance(pack, tuple) else pack
assert pack["status"] in {"save_eligible", "preview_only"}
assert pack["scenario_revision_hash"] == resolved["revision_hash"]
assert "manifest" in pack and isinstance(pack["manifest"], dict)
# #endregion Test.McpServer.Workspace.test_scenario_resolve_and_generate_draft_pack_are_read_only
# #region Test.McpServer.Workspace.test_scenario_resolve_rejects_oversized_value [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_scenario_resolve_rejects_oversized_value(monkeypatch) -> None:
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
inspected = await server.call_tool("inspect_scenario", {"request": _COMPILE_REQUEST})
inspected = inspected[1] if isinstance(inspected, tuple) else inspected
with pytest.raises(Exception):
await server.call_tool("scenario_resolve", {"request": {
"scenario": inspected["scenario"],
"changes": [{"kind": "parameter", "target": "test_date", "value": "x" * 5000}],
}})
# #endregion Test.McpServer.Workspace.test_scenario_resolve_rejects_oversized_value
# #region Test.McpServer.Workspace.test_authoring_session_create_replay_conflict_and_no_registry_mutation [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_authoring_session_create_replay_conflict_and_no_registry_mutation(monkeypatch) -> None:
principal = f"mcp-authoring-{uuid4()}"
access = mcp_server.AccessToken(
token="test-token",
client_id="acceptance-client",
scopes=["mcp:read"],
subject=principal,
claims={"principal_type": "user"},
)
server = mcp_server._build_probe_server()
monkeypatch.setattr(server, "_can_use_tool", lambda _: True)
arguments = {
"idempotency_key": f"authoring-{uuid4()}",
"scenario_id": "scenario-1",
"base_content_hash": "a" * 64,
"expires_in_seconds": 120,
}
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).count()
first = await server.call_tool("create_authoring_session", {"request": arguments})
second = await server.call_tool("create_authoring_session", {"request": arguments})
first_projection = first[1] if isinstance(first, tuple) else first
second_projection = second[1] if isinstance(second, tuple) else second
assert first_projection["workspace_id"] == second_projection["workspace_id"]
assert first_projection["owner_principal"] == principal
assert first_projection["session_status"] == "draft"
assert first_projection["cas_version"] == 0
assert set(first_projection) == {
"workspace_id", "session_status", "owner_principal", "agent_principal",
"scenario_id", "base_revision_id", "base_content_hash", "cas_version", "expires_at",
}
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"create_authoring_session",
{"request": {**arguments, "base_content_hash": "b" * 64}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with SessionLocal() as db:
rows = db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).all()
assert len(rows) == before + 1
assert rows[0].workspace_id == first_projection["workspace_id"]
assert rows[0].exploration_ids == []
assert rows[0].artifact_ids == []
db.query(AgentAuthoringWorkspace).filter(
AgentAuthoringWorkspace.owner_principal == principal,
).delete()
db.commit()
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpServer.Workspace.test_authoring_session_create_replay_conflict_and_no_registry_mutation
# #region Test.McpServer.Workspace.test_authoring_session_service_principal_is_denied_without_mutation [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_authoring_session_service_principal_is_denied_without_mutation(monkeypatch) -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="service-token",
client_id="service",
scopes=["mcp:read"],
subject="service",
claims={"principal_type": "service"},
)
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AgentAuthoringWorkspace).count()
assert server._can_use_tool("create_authoring_session") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("create_authoring_session", {"request": {"idempotency_key": "service-key"}})
with SessionLocal() as db:
assert db.query(AgentAuthoringWorkspace).count() == before
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpServer.Workspace.test_authoring_session_service_principal_is_denied_without_mutation
# #region Test.McpServer.Workspace.test_start_exploration_direct_mcp_is_bounded_idempotent_and_non_executing [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_start_exploration_direct_mcp_is_bounded_idempotent_and_non_executing() -> None:
principal = f"mcp-exploration-{uuid4()}"
access = mcp_server.AccessToken(
token="test-token",
client_id="acceptance-client",
scopes=["mcp:read"],
subject=principal,
claims={"principal_type": "user"},
)
server = mcp_server._build_probe_server()
context_token = _access_token_context.set(access)
workspace_id = None
try:
created = await server.call_tool(
"create_authoring_session",
{"request": {
"idempotency_key": f"exploration-workspace-{uuid4()}",
"scenario_id": "scenario-exploration",
"base_content_hash": "a" * 64,
"expires_in_seconds": 120,
}},
)
created_projection = created[1] if isinstance(created, tuple) else created
workspace_id = created_projection["workspace_id"]
request = {
"workspace_id": workspace_id,
"idempotency_key": f"exploration-{uuid4()}",
"expected_cas_version": 0,
"exploration_spec": {
"objective": "Inspect dashboard",
"actions": ["open_dashboard", "capture_screenshot"],
},
}
with SessionLocal() as db:
before = {
"requests": db.query(AuthoringExplorationRequest).count(),
"operations": db.query(AgentAuthoringWorkspaceOperation).count(),
"registries": db.query(ScenarioRegistryEntry).count(),
"revisions": db.query(ScenarioRevision).count(),
"runs": db.query(ScenarioRun).count(),
"artifacts": db.query(ScenarioArtifact).count(),
}
first = await server.call_tool("start_exploration", {"request": request})
replay = await server.call_tool(
"start_exploration",
{"request": {**request, "expected_cas_version": 99}},
)
first_projection = first[1] if isinstance(first, tuple) else first
replay_projection = replay[1] if isinstance(replay, tuple) else replay
assert first_projection == replay_projection
assert first_projection["status"] == "sandbox_unavailable"
assert first_projection["session_status"] == "draft"
assert first_projection["cas_version"] == 1
with pytest.raises(ToolError, match="idempotency key conflicts") as conflict:
await server.call_tool(
"start_exploration",
{"request": {**request, "exploration_spec": {
**request["exploration_spec"], "objective": "Different"
}}},
)
assert conflict.value.__cause__.__class__.__name__ == "WorkspaceIdempotencyConflict"
with pytest.raises(ToolError, match="registered 038 ActionRegistry") as unknown:
await server.call_tool(
"start_exploration",
{"request": {**request, "idempotency_key": f"unknown-{uuid4()}",
"exploration_spec": {"objective": "Inspect", "actions": ["invented_action"]}}},
)
assert unknown.value.__cause__.__class__.__name__ == "WorkspaceExplorationValidationError"
with SessionLocal() as db:
workspace = db.get(AgentAuthoringWorkspace, workspace_id)
assert workspace is not None
assert workspace.session_status == "draft"
assert workspace.cas_version == 1
assert db.query(AuthoringExplorationRequest).count() == before["requests"] + 1
assert db.query(AgentAuthoringWorkspaceOperation).count() == before["operations"] + 1
assert db.query(ScenarioRegistryEntry).count() == before["registries"]
assert db.query(ScenarioRevision).count() == before["revisions"]
assert db.query(ScenarioRun).count() == before["runs"]
assert db.query(ScenarioArtifact).count() == before["artifacts"]
finally:
_access_token_context.reset(context_token)
if workspace_id is not None:
with SessionLocal() as db:
db.query(AuthoringExplorationRequest).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.commit()
# #endregion Test.McpServer.Workspace.test_start_exploration_direct_mcp_is_bounded_idempotent_and_non_executing
# #region Test.McpServer.Workspace.test_start_exploration_direct_mcp_denies_service_principal_without_mutation [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_start_exploration_direct_mcp_denies_service_principal_without_mutation() -> None:
server = mcp_server._build_probe_server()
access = mcp_server.AccessToken(
token="service-token",
client_id="service",
scopes=["mcp:read"],
subject="service",
claims={"principal_type": "service"},
)
context_token = _access_token_context.set(access)
try:
with SessionLocal() as db:
before = db.query(AuthoringExplorationRequest).count()
assert server._can_use_tool("start_exploration") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("start_exploration", {"request": {"workspace_id": "missing"}})
with SessionLocal() as db:
assert db.query(AuthoringExplorationRequest).count() == before
finally:
_access_token_context.reset(context_token)
# #endregion Test.McpServer.Workspace.test_start_exploration_direct_mcp_denies_service_principal_without_mutation
# #region Test.McpServer.GetExplorationResult [C:4] [TYPE Function] [SEMANTICS test,mcp,authoring,exploration,read,ownership]
# @ingroup Test.McpServer
# @BRIEF Verify bounded owner reads and typed denial outcomes without domain mutation.
# @TEST_INVARIANT read_only_projection -> request and workspace rows remain unchanged.
# #region Test.McpServer.Workspace.test_get_exploration_result_owner_projection_and_denials [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_exploration_result_owner_projection_and_denials() -> None:
owner, other = f"owner-{uuid4()}", f"other-{uuid4()}"
server = mcp_server._build_probe_server()
token = _access_token_context.set(mcp_server.AccessToken(
token="user", client_id="test", scopes=["mcp:read"], subject=owner,
claims={"principal_type": "user"},
))
workspace_id = None
try:
created = await server.call_tool("create_authoring_session", {"request": {
"idempotency_key": str(uuid4()), "expires_in_seconds": 120,
}})
created = created[1] if isinstance(created, tuple) else created
workspace_id = created["workspace_id"]
started = await server.call_tool("start_exploration", {"request": {
"workspace_id": workspace_id, "idempotency_key": str(uuid4()),
"expected_cas_version": 0,
"exploration_spec": {"objective": "Read", "actions": ["open_dashboard"]},
}})
started = started[1] if isinstance(started, tuple) else started
request_id = started["request_id"]
with SessionLocal() as db:
before = (db.query(AgentAuthoringWorkspace).count(), db.query(AuthoringExplorationRequest).count())
result = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": workspace_id, "request_id": request_id,
}})
result = result[1] if isinstance(result, tuple) else result
assert result["status"] == "sandbox_unavailable"
assert set(result) == {"status", "request_id", "workspace_id", "created_at", "receipt_reference"}
assert "exploration_spec" not in result
other_token = _access_token_context.set(mcp_server.AccessToken(
token="other", client_id="test", scopes=["mcp:read"], subject=other,
claims={"principal_type": "user"},
))
try:
denied = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": workspace_id, "request_id": request_id,
}})
assert (denied[1] if isinstance(denied, tuple) else denied)["status"] == "permission_denied"
finally:
_access_token_context.reset(other_token)
with SessionLocal() as db:
assert (db.query(AgentAuthoringWorkspace).count(), db.query(AuthoringExplorationRequest).count()) == before
finally:
_access_token_context.reset(token)
if workspace_id:
with SessionLocal() as db:
db.query(AuthoringExplorationRequest).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspaceOperation).filter_by(workspace_id=workspace_id).delete()
db.query(AgentAuthoringWorkspace).filter_by(workspace_id=workspace_id).delete()
db.commit()
# #endregion Test.McpServer.Workspace.test_get_exploration_result_owner_projection_and_denials
# #region Test.McpServer.Workspace.test_get_exploration_result_service_and_unknown_are_typed [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_get_exploration_result_service_and_unknown_are_typed() -> None:
server = mcp_server._build_probe_server()
service_token = _access_token_context.set(mcp_server.AccessToken(
token="service", client_id="service", scopes=["mcp:read"], subject="service",
claims={"principal_type": "service"},
))
try:
assert server._can_use_tool("get_exploration_result") is False
with pytest.raises(PermissionError, match="permission_denied"):
await server.call_tool("get_exploration_result", {"request": {
"workspace_id": str(uuid4()), "request_id": str(uuid4()),
}})
finally:
_access_token_context.reset(service_token)
user_token = _access_token_context.set(mcp_server.AccessToken(
token="user", client_id="test", scopes=["mcp:read"], subject=f"unknown-{uuid4()}",
claims={"principal_type": "user"},
))
try:
result = await server.call_tool("get_exploration_result", {"request": {
"workspace_id": str(uuid4()), "request_id": str(uuid4()),
}})
assert (result[1] if isinstance(result, tuple) else result)["status"] == "not_found"
finally:
_access_token_context.reset(user_token)
# #endregion Test.McpServer.Workspace.test_get_exploration_result_service_and_unknown_are_typed
# #endregion Test.McpServer.GetExplorationResult
# #endregion Test.McpServer.Workspace

View File

@@ -1,4 +1,4 @@
#region FullFlow.SupersetFixture [C:5] [TYPE Module] [SEMANTICS superset,bootstrap,dashboard,metric,filters]
# #region FullFlow.SupersetFixture [C:5] [TYPE Module] [SEMANTICS superset,bootstrap,dashboard,metric,filters]
# @PURPOSE Create real Superset users and dashboard metadata backed by deterministic PostgreSQL sales.
# @PRE Superset migrations and security initialization completed; shared fixtures database exists.
# @POST A sales dashboard exposes revenue, regional table and native date/region/time-grain filters.
@@ -57,4 +57,4 @@ with create_app().app_context():
db.session.add(dashboard)
db.session.commit()
print(json.dumps({'stage':os.environ['SUPERSET_METADATA_DB'],'dashboard_id':dashboard.id,'slug':dashboard.slug,'dataset_id':dataset.id,'chart_ids':[c.id for c in charts]}))
#endregion FullFlow.SupersetFixture
# #endregion FullFlow.SupersetFixture

View File

@@ -3,6 +3,9 @@
<!-- @BRIEF Read-only projection of one persisted AgentEvaluation record and the policy-derived
step outcome. The model verdict is never presented as the ScenarioResult (RUNMON-FR-013). -->
<!-- @INVARIANT No prompt/retry/provider/agent controls: evaluation is an immutable audit record. -->
<!-- @UX_STATE Verdict(pass|fail|inconclusive|unknown): localized model verdict and independent policy step status remain visible; absent confidence renders —. -->
<!-- @UX_STATE Findings(empty|present): empty message or severity-marked immutable findings; criterion and provider details expand independently through native details elements. -->
<!-- @UX_REACTIVITY evaluation and stepStatus are parent-owned immutable props; disclosure state is local browser state, never execution authority. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import type { EvaluationRecord } from "$lib/types/scenario-run";

View File

@@ -8,6 +8,10 @@
<!-- @RELATION DEPENDS_ON -> [ScenarioRunMonitor.Component.MetricDeviation] -->
<!-- @INVARIANT Deterministic result, model evaluation and policy outcome stay visually distinct;
unavailable/redacted evidence never renders as a passing badge. -->
<!-- @UX_STATE Metric(no-pin|mismatch|unproved|no-mismatch): show missing-pin warning, persisted failed-comparison details, nonPASS uncertainty, or no-mismatch message without changing result.status. -->
<!-- @UX_STATE Selection(none|selected): failure/deviation controls emit onselectstep; the parent-owned selectedStepId drives the run-bound inspector and suppresses the matching duplicate deviation card. -->
<!-- @UX_STATE Disclosures(collapsed|expanded): provenance and failure technical fields expand locally; failed checks and unavailable evidence retain authoritative statuses. -->
<!-- @UX_REACTIVITY result, steps, plan, runId and selectedStepId come from the same parent-selected run; selection callbacks do not fetch or alter runtime results. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import type { ScenarioExecutionResult, ScenarioStepRun } from "$lib/types/scenario-run";

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
#region FullFlow.Environment [C:3] [TYPE Module] [SEMANTICS docker,secrets,isolated-fixture]
# #region FullFlow.Environment [C:3] [TYPE Module] [SEMANTICS docker,secrets,isolated-fixture]
# @PURPOSE Generate isolated fixture credentials without reading production settings.
# @PRE Target does not exist; parent directory is writable.
# @POST A mode-0600 env file contains fresh secrets; stdout contains its path only.
@@ -23,4 +23,4 @@ values = {
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as file:
file.write(''.join(f'{key}={value}\n' for key, value in values.items()))
print(path)
#endregion FullFlow.Environment
# #endregion FullFlow.Environment

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
#region FullFlow.Readiness [C:4] [TYPE Module] [SEMANTICS docker,readiness,authentication,evidence]
# #region FullFlow.Readiness [C:4] [TYPE Module] [SEMANTICS docker,readiness,authentication,evidence]
# @PURPOSE Probe real API readiness and password authentication without printing tokens.
# @PRE The isolated stack is running; credentials come only from the named fixture env file.
# @POST A nonzero exit identifies unavailable or unauthenticated services; successful probes list IDs.
@@ -37,4 +37,4 @@ except Exception as error:
failures.append('ss-tools')
print(f'ss-tools: probe failed ({type(error).__name__}: {getattr(error, "reason", getattr(error, "code", "unexpected response"))})')
sys.exit(bool(failures))
#endregion FullFlow.Readiness
# #endregion FullFlow.Readiness

View File

@@ -2,6 +2,7 @@
# @BRIEF Exercise fresh MCP profiles using only server-issued questions and CAS identities.
# @INVARIANT All results originate from the real Docker backend API.
import json
from functools import partial
from common import Blocked
@@ -12,46 +13,12 @@ class McpProfileMixin:
# @BRIEF Resolve only questions issued by a fresh external MCP profile.
def profile(self):
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
# #region Tooling.FullFlowMcpProfileMixin.rpc [C:3] [TYPE Function]
# @BRIEF Validate the actual external JSON-RPC and MCP tool response.
def rpc(name, payload):
response = self.client.post("/mcp/", json=payload, headers=headers)
if response.headers.get("mcp-session-id"):
headers["Mcp-Session-Id"] = response.headers["mcp-session-id"]
if not response.content:
value = {}
elif "text/event-stream" in response.headers.get("content-type", ""):
data = [line[6:] for line in response.text.splitlines() if line.startswith("data: ")]
value = json.loads(data[-1]) if data else {}
else:
value = response.json()
tool_error = value.get("result", {}).get("isError") is True
self.record(name, "PASS" if response.status_code in (200,202) and "error" not in value and not tool_error else "BLOCKED",
http_status=response.status_code, response=value)
if response.status_code not in (200,202) or "error" in value or tool_error:
raise Blocked(name)
return value
# #endregion Tooling.FullFlowMcpProfileMixin.rpc
rpc = partial(self._profile_rpc, headers)
rpc("mcp-initialize", {"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{
"protocolVersion":"2025-03-26", "capabilities":{}, "clientInfo":{"name":"docker-full-flow-verifier","version":"1"}}})
headers["MCP-Protocol-Version"] = "2025-03-26"
rpc("mcp-initialized", {"jsonrpc":"2.0", "method":"notifications/initialized"})
# #region Tooling.FullFlowMcpProfileMixin.unwrap [C:3] [TYPE Function]
# @BRIEF Decode structured MCP domain results without hiding tool failures.
def unwrap(response):
result = response.get("result", {})
structured = result.get("structuredContent")
if not structured:
content = next((c["text"] for c in result.get("content",[]) if c.get("type") == "text"), "{}")
try:
structured = json.loads(content)
except json.JSONDecodeError:
self.record("mcp-tool-domain-response", "BLOCKED", error_text=content)
raise Blocked("MCP tool returned unstructured error text") from None
return structured
# #endregion Tooling.FullFlowMcpProfileMixin.unwrap
unwrap = self._profile_unwrap
intent = {"environment_id":"full-flow-preprod", "dashboard_id":self.config.get("dashboard_id",1),
"objective":"Verify filtered sales revenue against approved baseline", "selected_case_ids":["C05"]}
structured = unwrap(rpc("mcp-propose-real-profile", {"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{
@@ -90,6 +57,44 @@ class McpProfileMixin:
# #endregion Tooling.FullFlowMcpProfileMixin.profile
# #region Tooling.FullFlowMcpProfileMixin.rpc [C:3] [TYPE Function]
# @BRIEF Validate the actual external JSON-RPC and MCP tool response.
def _profile_rpc(self, headers, name, payload):
response = self.client.post("/mcp/", json=payload, headers=headers)
if response.headers.get("mcp-session-id"):
headers["Mcp-Session-Id"] = response.headers["mcp-session-id"]
if not response.content:
value = {}
elif "text/event-stream" in response.headers.get("content-type", ""):
data = [line[6:] for line in response.text.splitlines() if line.startswith("data: ")]
value = json.loads(data[-1]) if data else {}
else:
value = response.json()
tool_error = value.get("result", {}).get("isError") is True
self.record(name, "PASS" if response.status_code in (200,202) and "error" not in value and not tool_error else "BLOCKED",
http_status=response.status_code, response=value)
if response.status_code not in (200,202) or "error" in value or tool_error:
raise Blocked(name)
return value
# #endregion Tooling.FullFlowMcpProfileMixin.rpc
# #region Tooling.FullFlowMcpProfileMixin.unwrap [C:3] [TYPE Function]
# @BRIEF Decode structured MCP domain results without hiding tool failures.
def _profile_unwrap(self, response):
result = response.get("result", {})
structured = result.get("structuredContent")
if not structured:
content = next((c["text"] for c in result.get("content",[]) if c.get("type") == "text"), "{}")
try:
structured = json.loads(content)
except json.JSONDecodeError:
self.record("mcp-tool-domain-response", "BLOCKED", error_text=content)
raise Blocked("MCP tool returned unstructured error text") from None
return structured
# #endregion Tooling.FullFlowMcpProfileMixin.unwrap
# #endregion Tooling.FullFlowMcpProfileMixin.McpProfileMixin

View File

@@ -1,7 +1,6 @@
# Real-time isolated Docker soak
## @{ Tooling.Stage6Soak.Protocol [C:4] [TYPE ADR] [SEMANTICS soak,protocol,durable,isolated]
@BRIEF Executable opt-in protocol for the persistent M01 collector and independent auditor.
@RELATION DEPENDS_ON -> [Tooling.Stage6Soak.Collector]
@RELATION DEPENDS_ON -> [Tooling.Stage6Soak.Controller]

View File

@@ -51,16 +51,7 @@ def assess(manifest, journal, runs, byte_findings, *, evaluated_at):
requested = {r["payload"]["index"]: timestamp(r["utc"]) for r in journal if r["event"] == "backend_restart_requested"}
restored = {r["payload"]["index"]: timestamp(r["utc"]) for r in journal if r["event"] == "backend_restart_completed"}
down_windows = [(begin, restored[index]) for index, begin in requested.items() if index in restored]
if started:
start, end = timestamp(started), timestamp(evaluated_at)
due = start.replace(minute=start.minute - start.minute % 5, second=0, microsecond=0) + timedelta(minutes=5)
expected = set()
while due <= end:
if not any(begin <= due <= finish for begin, finish in down_windows):
expected.add(due.isoformat())
due += timedelta(minutes=5)
if expected - set(slots):
issues.append("MISSING_LOGICAL_DUE_SLOTS")
_missing_due_slots(started, evaluated_at, down_windows, slots, issues)
observations = [timestamp(row["utc"]) for row in journal if row["event"] == "observation"]
window = [timestamp(started)] + observations + [timestamp(evaluated_at)] if started else []
gap = max(((b - a).total_seconds() for a, b in zip(window, window[1:])), default=0)
@@ -72,15 +63,7 @@ def assess(manifest, journal, runs, byte_findings, *, evaluated_at):
if any(row["event"] == "observation" and (not row["payload"].get("queue_slo_met")
or not row["payload"].get("schedule_enabled")) for row in journal):
issues.append("QUEUE_OR_SCHEDULE_SLO_VIOLATION")
restarts = [row for row in journal if row["event"] == "backend_restart_completed"]
if {row["payload"].get("index") for row in restarts} != {1, 2, 3} or len(restarts) != 3:
issues.append("THREE_RESTARTS_NOT_PROVEN")
elif any(not row["payload"].get("before", {}).get("started_at")
or not row["payload"].get("after", {}).get("started_at")
or row["payload"]["before"]["started_at"] == row["payload"]["after"]["started_at"]
or any(row["payload"][side].get("project") != "ss-tools-full-flow"
or row["payload"][side].get("service") != "backend" for side in ("before", "after")) for row in restarts):
issues.append("REAL_RESTART_IDENTITY_NOT_PROVEN")
_restart_issues(journal, issues)
tabs = [row["payload"] for row in journal if row["event"] == "tab_canary"]
if not all(any(row.get("tabs") == n and row.get("status") == "passed" for row in tabs) for n in (5, 15, 50)):
issues.append("TAB_CANARIES_NOT_PROVEN")
@@ -134,18 +117,7 @@ def retained_run(cursor, row, manifest, storage):
raw = (Path(storage) / "drafts" / row["id"] / sha).read_bytes()
if hashlib.sha256(raw).hexdigest() != sha:
return issues + ["ACTUAL_RAW_HASH_MISMATCH"]
response = json.loads(raw)["result"]
metric = details["metric_coordinate"]["metric_name"]
if len(response) != 1 or len(response[0]["data"]) != 1 or response[0]["colnames"].count(metric) != 1:
return issues + ["ACTUAL_SCALAR_AMBIGUOUS"]
value = response[0]["data"][0][metric]
position = response[0]["colnames"].index(metric)
column_type = response[0].get("coltypes", [])[position]
if type(column_type) is not int or column_type != 0:
issues.append("ACTUAL_NUMERIC_SCHEMA_NOT_PROVEN")
if type(value) not in {int, float} or not Decimal(str(value)).is_finite() or Decimal(str(value)) != Decimal("16350"):
issues.append("ACTUAL_ORACLE_MISMATCH")
return issues
return _scalar_issues(raw, details, issues)
# #endregion Tooling.Stage6Soak.Audit.RetainedRun
@@ -193,4 +165,53 @@ def execute(root, env, storage):
atomic(root / "independent-audit.json", result)
return result
# #endregion Tooling.Stage6Soak.Audit.Execute
# #region Tooling.Stage6Soak.Audit.MissingSlots [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _missing_due_slots(started, evaluated_at, down_windows, slots, issues):
if started:
start, end = timestamp(started), timestamp(evaluated_at)
due = start.replace(minute=start.minute - start.minute % 5, second=0, microsecond=0) + timedelta(minutes=5)
expected = set()
while due <= end:
if not any(begin <= due <= finish for begin, finish in down_windows):
expected.add(due.isoformat())
due += timedelta(minutes=5)
if expected - set(slots):
issues.append("MISSING_LOGICAL_DUE_SLOTS")
# #endregion Tooling.Stage6Soak.Audit.MissingSlots
# #region Tooling.Stage6Soak.Audit.RestartIssues [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _restart_issues(journal, issues):
restarts = [row for row in journal if row["event"] == "backend_restart_completed"]
if {row["payload"].get("index") for row in restarts} != {1, 2, 3} or len(restarts) != 3:
issues.append("THREE_RESTARTS_NOT_PROVEN")
elif any(not row["payload"].get("before", {}).get("started_at")
or not row["payload"].get("after", {}).get("started_at")
or row["payload"]["before"]["started_at"] == row["payload"]["after"]["started_at"]
or any(row["payload"][side].get("project") != "ss-tools-full-flow"
or row["payload"][side].get("service") != "backend" for side in ("before", "after")) for row in restarts):
issues.append("REAL_RESTART_IDENTITY_NOT_PROVEN")
# #endregion Tooling.Stage6Soak.Audit.RestartIssues
# #region Tooling.Stage6Soak.Audit.ScalarIssues [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _scalar_issues(raw, details, issues):
response = json.loads(raw)["result"]
metric = details["metric_coordinate"]["metric_name"]
if len(response) != 1 or len(response[0]["data"]) != 1 or response[0]["colnames"].count(metric) != 1:
return issues + ["ACTUAL_SCALAR_AMBIGUOUS"]
value = response[0]["data"][0][metric]
position = response[0]["colnames"].index(metric)
column_type = response[0].get("coltypes", [])[position]
if type(column_type) is not int or column_type != 0:
issues.append("ACTUAL_NUMERIC_SCHEMA_NOT_PROVEN")
if type(value) not in {int, float} or not Decimal(str(value)).is_finite() or Decimal(str(value)) != Decimal("16350"):
issues.append("ACTUAL_ORACLE_MISMATCH")
return issues
# #endregion Tooling.Stage6Soak.Audit.ScalarIssues
# #endregion Tooling.Stage6Soak.Audit

View File

@@ -128,15 +128,7 @@ def observe(root, manifest, env, client):
# @POST Signal interruption leaves the owned schedule paused and journal incomplete; normal completion requires real 72h.
def collect(args, env, client):
root, manifest = args.root, manifest_at(args.root)
if manifest.get("stopped_at"):
raise ValueError("SOAK_ALREADY_STOPPED")
if args.command == "start":
if manifest.get("started_at"):
raise ValueError("SOAK_ALREADY_STARTED_USE_RESUME")
manifest["started_at"] = now()
atomic(root / "manifest.json", manifest)
elif not manifest.get("started_at"):
raise ValueError("SOAK_NOT_STARTED")
_begin_collection(args, manifest, root)
switch(client, manifest, True)
append(root, "collector_boot", {"pid": __import__("os").getpid(), "monotonic": time.monotonic()})
stopping, finished = False, False
@@ -174,16 +166,7 @@ def collect(args, env, client):
manifest["stopped_at"] = now()
atomic(root / "manifest.json", manifest)
append(root, "collector_stopped", {"elapsed_seconds": (timestamp(manifest["stopped_at"]) - timestamp(manifest["started_at"])).total_seconds()})
deadline = time.monotonic() + manifest["max_queue_age_seconds"]
while time.monotonic() < deadline:
try:
sample = observe(root, manifest, env, client)
append(root, "drain_observation", sample)
if sample["queued_or_running"] == 0:
break
except Exception as error:
append(root, "drain_error", {"error_type": type(error).__name__})
time.sleep(manifest["poll_seconds"])
_drain_collection(manifest, root, env, client)
return execute(root, env, args.storage)
# #endregion Tooling.Stage6Soak.Collector.Run
@@ -242,4 +225,35 @@ if __name__ == "__main__":
except Exception as failure:
print(json.dumps({"status": "BLOCKED", "error_type": type(failure).__name__}))
sys.exit(1)
# #region Tooling.Stage6Soak.Collector.Begin [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _begin_collection(args, manifest, root):
if manifest.get("stopped_at"):
raise ValueError("SOAK_ALREADY_STOPPED")
if args.command == "start":
if manifest.get("started_at"):
raise ValueError("SOAK_ALREADY_STARTED_USE_RESUME")
manifest["started_at"] = now()
atomic(root / "manifest.json", manifest)
elif not manifest.get("started_at"):
raise ValueError("SOAK_NOT_STARTED")
# #endregion Tooling.Stage6Soak.Collector.Begin
# #region Tooling.Stage6Soak.Collector.Drain [C:3] [TYPE Function]
# @BRIEF Preserve the extracted operation phase and its ordering.
def _drain_collection(manifest, root, env, client):
deadline = time.monotonic() + manifest["max_queue_age_seconds"]
while time.monotonic() < deadline:
try:
sample = observe(root, manifest, env, client)
append(root, "drain_observation", sample)
if sample["queued_or_running"] == 0:
break
except Exception as error:
append(root, "drain_error", {"error_type": type(error).__name__})
time.sleep(manifest["poll_seconds"])
# #endregion Tooling.Stage6Soak.Collector.Drain
# #endregion Tooling.Stage6Soak.Collector

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
{
"rebuild": {
"async": true,
"changed_file_count": 2639,
"contract_count": 12918,
"contract_diff": {
"available": false,
"reason": "no_previous_index"
},
"deleted_file_count": 0,
"duckdb_ms": 980,
"duckdb_path": "/home/busya/dev/ss-tools/.axiom/semantic_index/graph.duckdb",
"duckdb_result": {
"duckdb_path": "/home/busya/dev/ss-tools/.axiom/semantic_index/graph.duckdb",
"status": "success",
"sync_mode": "full"
},
"edge_count": 6079,
"effective_rebuild_mode": "full",
"elapsed_ms": 5135,
"embedding_refresh_summary": null,
"embeddings_skipped": false,
"fallback_reason": null,
"file_count": 2639,
"generated_at": "2026-10-02T09:40:42.374568324+00:00",
"include_runtime_log_scan": false,
"index_generation_id": "gen-1790934042374-12918-6079-3505",
"job_id": "rebuild-1790934039269-0012",
"message": "Semantic index rebuild completed.",
"nav_regen": {
"contract_count": 12918,
"edge_count": 6079,
"output_dir": "/home/busya/dev/ss-tools/.axiom/docs/nav",
"page_count": 15200,
"ran": true
},
"operation_type": "rebuild_semantic_index",
"previous_generation_id": null,
"provenance": {
"completed_at": "2026-10-02T09:40:44.347732288+00:00",
"effective_rebuild_mode": "full",
"index_generation_id": "gen-1790934042374-12918-6079-3505",
"parser_version": 6,
"rebuild_id": "rebuild-1790934039270-15",
"requested_rebuild_mode": "full",
"snapshot_generated_at": "2026-10-02T09:40:42.374568324+00:00",
"started_at": "2026-10-02T09:40:39.270315892+00:00"
},
"rebuild_id": "rebuild-1790934039270-15",
"rebuild_mode": "full",
"refresh_embeddings": false,
"reindex_ms": 3105,
"requested_rebuild_mode": "full",
"runtime_summary": {
"event_count": 0,
"scanned": false
},
"semantic_index_rebuild_result": {
"embedding_provider_id": "none",
"fallback_active": false,
"indexed_contract_count": 12918,
"indexed_edge_count": 6079,
"indexed_event_count": 0,
"rebuild_id": "rebuild-1790934039270-15",
"status": "success",
"summary_text": "Semantic index rebuild completed.",
"warning_messages": []
},
"success": true,
"total_ms": 5077,
"unchanged_file_count": 0,
"workspace_policy": {
"checkpoint_directory": "/home/busya/dev/ss-tools/.axiom/checkpoints",
"policy_id": "workspace-policy-f22678ed97ed",
"runtime_event_log_path": "/home/busya/dev/ss-tools/.axiom/runtime/belief_events.jsonl",
"semantic_index_directory": "/home/busya/dev/ss-tools/.axiom/semantic_index",
"workspace_root": "/home/busya/dev/ss-tools"
}
},
"docs": {
"generation": "gen-1790934042374-12918-6079-3505",
"operation": "emit_nav_graph",
"contract_count": 12918,
"edge_count": 6079,
"page_count": 15200,
"success": true
},
"original_indexed_count": 374,
"missing_original_indexed": [],
"docs_api_nav": {
"tracked_changed_files": 0,
"generated_page_count": 15200,
"selected_hashes": [
{
"path": "docs/api/nav/FullFlow.map",
"sha256": "b956c44cd502252fee6c5713bb7bca2db813031144075dab3e28e5f4fbacd5e6"
},
{
"path": "docs/api/nav/Plugin.GitFingerprint.map",
"sha256": "5c5e277fd510d8891ed167d8b5e41f4180e3c7bc88a37567d46edede11e0dffc"
},
{
"path": "docs/api/nav/Services.Branch.map",
"sha256": "bafdda1b0c3ee70a61f1ea245e9c6247801997578d4911e4000cc00b6444a0f2"
},
{
"path": "docs/api/nav/Services.Merge.map",
"sha256": "9c686e1f2c2b04843959f9f29a55ad03a662a2c8bda61e68c05f0e4083346ad7"
},
{
"path": "docs/api/nav/Services.Status.map",
"sha256": "adc5c089b38fb9be3327a49228e0a5236cf1aac9c1baa18dd72f286f34adf0b2"
},
{
"path": "docs/api/nav/Services.Sync.map",
"sha256": "e9983da26be8eef1d741128c798218f1a9c779879d580198c0198a6b0ac0b2d9"
},
{
"path": "docs/api/nav/nav_id.map",
"sha256": "c9c0ebd15c39b4de619281051dfa8e88e629a2ea1943e5b89f36fbd51384ba3b"
},
{
"path": "docs/api/nav/root.map",
"sha256": "5fa5cc34b5813e169dbb18b687f5de855a42b1576e88c5ec0c96b2c8dd290740"
}
],
"ignored": true,
"handoff_node": {
"path": "docs/api/nav/nodes/FullFlow.SemanticRemediationCheckpoint.md",
"sha256": "3b3e328ff0e23800b4fcd298b04f90496661ef22e9222e655daf510d6d5b31a3"
}
},
"source_scope_count": 89,
"source_scope_contracts": 703,
"all_55_findings_source_structurally_closed": true,
"parent_scope": "Only the repair scope is certified; no release GO/full regression claim.",
"original_incoming_indexed_count": 194,
"persisted_unique_edges": 6040,
"hash_tracked_files": 3505,
"contract_bearing_files": 2639,
"extended_repair_source_files": 94,
"handoff_extension": {
"path": "specs/agent-handoffs/dashboard-testing-consolidated-2026-09-29.md",
"sha256": "cabc0668c00630fe9078b1893109cdaf75fd24701211d0a9753ff7c53c47100f",
"contract": "FullFlow.SemanticRemediationCheckpoint",
"indexed": true,
"parse_warnings": 0,
"checkpoint_links": [
{
"target": "../050-mcp-interface/evidence/semantic-audit-luna-remediation-2026-10-02.json",
"exists": true
},
{
"target": "../050-mcp-interface/evidence/semantic-curation-indexed-edges-2026-10-02.json",
"exists": true
},
{
"target": "../050-mcp-interface/plans/semantic-protocol-luna-remediation-2026-10-02.md",
"exists": true
}
],
"original_ids_indexed": 374,
"original_incoming_edges_indexed": 194,
"missing_original_ids": [],
"missing_original_edges": [],
"code89_hashes_unchanged": true,
"rebuild": {
"async": true,
"changed_file_count": 2639,
"contract_count": 12918,
"contract_diff": {
"available": false,
"reason": "no_previous_index"
},
"deleted_file_count": 0,
"duckdb_ms": 980,
"duckdb_path": "/home/busya/dev/ss-tools/.axiom/semantic_index/graph.duckdb",
"duckdb_result": {
"duckdb_path": "/home/busya/dev/ss-tools/.axiom/semantic_index/graph.duckdb",
"status": "success",
"sync_mode": "full"
},
"edge_count": 6079,
"effective_rebuild_mode": "full",
"elapsed_ms": 5135,
"embedding_refresh_summary": null,
"embeddings_skipped": false,
"fallback_reason": null,
"file_count": 2639,
"generated_at": "2026-10-02T09:40:42.374568324+00:00",
"include_runtime_log_scan": false,
"index_generation_id": "gen-1790934042374-12918-6079-3505",
"job_id": "rebuild-1790934039269-0012",
"message": "Semantic index rebuild completed.",
"nav_regen": {
"contract_count": 12918,
"edge_count": 6079,
"output_dir": "/home/busya/dev/ss-tools/.axiom/docs/nav",
"page_count": 15200,
"ran": true
},
"operation_type": "rebuild_semantic_index",
"previous_generation_id": null,
"provenance": {
"completed_at": "2026-10-02T09:40:44.347732288+00:00",
"effective_rebuild_mode": "full",
"index_generation_id": "gen-1790934042374-12918-6079-3505",
"parser_version": 6,
"rebuild_id": "rebuild-1790934039270-15",
"requested_rebuild_mode": "full",
"snapshot_generated_at": "2026-10-02T09:40:42.374568324+00:00",
"started_at": "2026-10-02T09:40:39.270315892+00:00"
},
"rebuild_id": "rebuild-1790934039270-15",
"rebuild_mode": "full",
"refresh_embeddings": false,
"reindex_ms": 3105,
"requested_rebuild_mode": "full",
"runtime_summary": {
"event_count": 0,
"scanned": false
},
"semantic_index_rebuild_result": {
"embedding_provider_id": "none",
"fallback_active": false,
"indexed_contract_count": 12918,
"indexed_edge_count": 6079,
"indexed_event_count": 0,
"rebuild_id": "rebuild-1790934039270-15",
"status": "success",
"summary_text": "Semantic index rebuild completed.",
"warning_messages": []
},
"success": true,
"total_ms": 5077,
"unchanged_file_count": 0,
"workspace_policy": {
"checkpoint_directory": "/home/busya/dev/ss-tools/.axiom/checkpoints",
"policy_id": "workspace-policy-f22678ed97ed",
"runtime_event_log_path": "/home/busya/dev/ss-tools/.axiom/runtime/belief_events.jsonl",
"semantic_index_directory": "/home/busya/dev/ss-tools/.axiom/semantic_index",
"workspace_root": "/home/busya/dev/ss-tools"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
# Owned text evidence slice
## @{ Stage6.OwnedTextEvidenceSlice [C:5] [TYPE ADR] [SEMANTICS evaluation,text,evidence,ownership,redaction]
@BRIEF Planned retained DOM-table evidence and immutable runtime-owned text loading before provider access.
@RELATION DEPENDS_ON -> [ScenarioExecution.BrowserProvider.ReadOnlyActions.ExtractTable]
@RELATION DEPENDS_ON -> [ScenarioExecution.EvaluationPrompt.Build]

View File

@@ -0,0 +1,148 @@
# Remediation of the Luna semantic audit
## Objective and authority
User authorization: “правь все”. Resolve all 55 findings in the immutable audit of
`bcc69f4bbe02be9fab8e922c7d6b52cf0c43ded5`, retained by `aacd1bd0`.
The original audit remains historical evidence; this task must produce a new audit.
## Acceptance
- Correct exact nested contract closures and all three missing module contracts.
- Declare the actual UX state machines of both reported Svelte components.
- Eliminate the 17 introduced and 14 worsened C901 violations. In every repaired
production/tool module, enforce the repository maximum of 10, including other
inherited over-limit functions exposed by extraction.
- Split all 15 reported production modules into genuine modules below 400 lines.
- Split all three reported oversized test files below the 600-line guidance,
preserving test collection and fixture authority.
- Preserve public imports, API payloads, canonical hashes, ownership/CAS checks,
exception behavior, deadlines, cleanup, monkeypatch seams and contract IDs.
- New helper modules receive their own contracts and exact matching boundaries.
- Focused behavior checks and an independent final semantic audit must pass.
- Preserve unrelated worktree changes captured before this task in
`/tmp/ss-tools-semantic-repair-protected-worktree.json`.
## Disjoint implementation groups
1. **MCP and authoring:** `tools_scenario`, `tools_authoring`, `rbac_server`,
authoring workspace service, scenario handles/chain emission, and the two MCP
test modules. Preserve registered tool names and public monkeypatch authority.
2. **Git, LLM and tooling:** reported Git routes/schemas/services, `llm_http`,
fingerprint dispatcher, Docker/full-flow scripts, soak scripts, oversized Git
status tests. Preserve Git request/response and provider retry contracts.
3. **Dashboard execution and UX:** reported execution/evaluation/browser modules,
runner/walker/query model, metric admission/binding, registry creation,
registered-editor test boundaries, and the two Svelte UX contracts.
Workers own disjoint existing files and their new leaves. Root coordinates
cross-group imports and privileged verification; no worker stages or commits.
## Verification and final closure
Each worker returns actual changed paths and focused check results. Once groups
freeze, run combined contract/behavior gates, compare collection of split tests,
then rerun the Luna audit against the repaired snapshot. Resolve new regressions
found in repaired paths. The final audit must map every original finding to its
closure evidence and state the remaining coverage limits honestly.
## Current checkpoint
Group A is frozen: 27 paths, 74 original IDs retained, 30 MCP tool schemas,
descriptions and order unchanged, and 32 original test bodies/signatures/
decorators and collected cases preserved. Production maximum is 360 lines;
test maximum is 524. Full Ruff and strict C901 <=10 pass. Focused checks passed
58 cases; 14 failures reproduce on immutable `aacd1bd0` (eight workspace fixtures
and six graph/E2E fixtures). Privileged transport/catalog replay passed 17 cases.
Group C is frozen at 28 paths (25 production Python files, one test and two
Svelte components), maximum production length 357. Strict C901, Ruff and compile
pass; focused sampling/editor/query/metric/browser checks passed 302 cases.
The separate runner/obsolete-tab cohort has 25 failures and 13 passes both before
and after, with identical failure names and exception-code sequence; no fixtures
were weakened. Evidence: `/tmp/group-c-runner-compatibility.json` and
`/tmp/group-c-final-manifest.json`.
Group B behavior is frozen at 34 paths: production maximum 394, test maximum 523,
strict C901/Ruff/compile pass, 37 original Git-status test cases retained. The
privileged Git/LLM/routes gate passed 355 cases and failed two; both exact failures
reproduce on immutable `aacd1bd0` with HTTP 409/500. Pure checks passed 128 cases.
The curator owns final metadata corrections, including exact own boundaries,
three fingerprint relation targets, and canonical anchor spelling in four Git
modules that the AXIOM parser otherwise omits. No behavior edits are required.
Independent curation confirmed Group A's original 74 IDs
and 38 relation pairs, tool schema/order/description equality, and original test
AST equality; it also confirmed Group C's original 94 IDs and both UX states.
Luna has started an independent repeat audit of frozen groups A/C. Source incoming
relations and the final fresh graph still require verification after B metadata
freezes. Full git-based source incoming comparison, including frontend, currently
retains 374 original scope IDs and 194 incoming edges from 138 original files;
this replaces the initial backend-snapshot-only edge coverage. The final index
must independently show those original IDs after canonical anchor normalization.
All 89 code files are frozen; final Group B hashes are in
`/tmp/ss-tools-group-b-curator-final-manifest.json`. The initial fresh index showed
373/374 original IDs because `.axiom/axiom_config.yaml` explicitly pruned Docker.
Root authorized removing that prune while preserving all other exclusions:
Docker contains the project's full-flow fixture tooling. The indexer has no
exception support, and a top-level allowlist would constrain future modules.
The tracked scan configuration joins the repair commit; final fresh-index receipt
and regenerated ignored navigation output must reflect that configuration.
Correction to historical audit classification: immutable `aacd1bd0` already has
the legacy `FullFlow.SupersetFixture` region pair. Its remediation is canonical
boundary/metadata normalization and index inclusion, not creation of a new ID.
## Final indexed-edge repair extension
Independent direct DuckDB comparison found 189/194 original incoming triples
indexed. The five absent triples are inherited header-parsing omissions: a blank
line directly after an opening Markdown contract stops metadata scanning. Root
authorized blank-line-only normalization in these three files, outside protected
WIP, preserving every ID, word and decision:
- `specs/050-mcp-interface/plans/stage6-local-acceptance-2026-10-01.md`
- `specs/050-mcp-interface/plans/T029a-owned-text-evidence-slice-2026-10-01.md`
- `scripts/stage6_soak/README.md`
Final acceptance must compare all 194 actual persisted source/type/target triples
after this repair. API edge totals count candidates before deterministic duplicate
removal; file totals include files without contracts. The final report must
distinguish those totals from persisted unique edge/node-bearing file counts.
AXIOM initial status is STALE (generation `gen-1790929865281-12652-5937-3474`,
12,507 contracts / 5,976 edges). Final graph acceptance requires a fresh rebuild;
the stale index cannot prove preservation of all original incoming relations.
Immutable source contract-ID comparison remains an independent acceptance check.
Final independent curation receipt:
[semantic-curation-luna-remediation-2026-10-02.json](../evidence/semantic-curation-luna-remediation-2026-10-02.json).
All 89 frozen paths contain 703 contracts; exact pairs, own-definition boundaries,
metadata placement, relation targets and required UX states pass. Explicit Ruff
C901 with `--ignore-noqa` and threshold 10 passes all 87 Python files. Production
maximum is 394 lines; test maximum 524. Curator metadata changes preserve Python
AST hashes. Read-only DuckDB inspection confirms all 374 original IDs actually
indexed at expected source paths; all 194 original incoming source edges remain.
Full rebuild generation `gen-1790933133873-12917-6063-3505` has 12,917 contracts
and 6,063 edges, zero rebuild warnings. Docker inclusion adds 44 contracts and
26 edges. Three invalid raw fingerprint helper targets now reference real
anchored IDs. Generated `docs/api/nav` has 15,199 pages and is ignored; no
generated output was staged. The report maps all 55 findings to current source
evidence. Global graph debt outside this scope and full behavioral regression
are not certified. A separate Luna receipt is required before publication.
Direct persisted-edge acceptance also passes: all 194 original incoming
source/type/target triples exist in DuckDB `relation_edges`, not merely in source.
The first direct check found 189: inherited blank lines immediately after three
Markdown opening anchors prevented metadata-header parsing. Removing only those
five blank separators and classifying the exact file
`scripts/stage6_soak/README.md` in `doc_dirs` restored all five required edges.
The extension covers three Markdown files and config; 89 code-file hashes did
not change. Files-table count 3,505 includes zero-contract files; contracts occupy
2,639 distinct files. Persisted relation rows are canonical unique triples;
the engine reports its pre-deduplication edge vector. These counters describe
different populations and are not substituted for the exact 194-edge gate.
Existing local runtime acceptance remains unchanged:
first/last sampling passed; default quantile navigation is inconclusive. This
remediation repairs semantic structure, not that separate runtime limitation.

View File

@@ -1,7 +1,6 @@
# Stage 6: local Docker acceptance, 2026-10-01
## @{ Stage6.LocalAcceptance [C:5] [TYPE ADR] [SEMANTICS acceptance,soak,docker,llm,release]
@BRIEF Executable acceptance sequence for remaining local P0 gates, with a persistent real-time soak protocol.
@RELATION DEPENDS_ON -> [ProductionAcceptance.Matrix043050]
@RELATION DEPENDS_ON -> [ScenarioGraph.MetricAdmission.Validate]
@@ -144,7 +143,6 @@ missing-provider finding must remain as historical evidence.
## @} Stage6.LocalAcceptance
## @{ Stage6.LocalAcceptance.SoakProtocol [C:5] [TYPE ADR] [SEMANTICS soak,durable,restarts,dedup,clock]
@BRIEF Persistent real-time observation and independent due-slot dedup proof for the isolated Docker soak.
@RELATION DEPENDS_ON -> [Tooling.Stage6Soak.Protocol]
@RELATION DEPENDS_ON -> [Stage6.LocalAcceptance]
@@ -241,7 +239,6 @@ do not discard the project or count interruption as successful cleanup.
## @} Stage6.LocalAcceptance.SoakProtocol
## @{ Stage6.LocalAcceptance.Commands [C:2] [TYPE Block] [SEMANTICS commands,docs-nav,readiness]
@BRIEF Available command paths and the prerequisite order for executing acceptance packets.
@RELATION DEPENDS_ON -> [Stage6.LocalAcceptance]

View File

@@ -10,6 +10,25 @@ The overall release state is **NO-GO**.
## Latest operational checkpoint — 2026-10-02
## @{ FullFlow.SemanticRemediationCheckpoint [C:3] [TYPE ADR] [SEMANTICS grace-poly,audit,checkpoint,inv1-7]
@BRIEF Close the 55 Luna findings with source, indexed-graph and compatibility evidence.
@RATIONALE Frozen source hashes, unchanged tool schemas/test bodies and direct persisted-edge checks make the semantic repair independently reviewable.
**Semantic remediation: 55/55 findings closed; independent Luna source/index
audit PASS.** The repaired scope contains 89 code files, three Markdown header
normalizations and the AXIOM scan configuration. All 374 original contract IDs
and 194 historical incoming triples are present in the persisted index. The 30
MCP tool schemas/descriptions/order and original MCP/Git-status test cases are
preserved. See the [Luna repeat audit](../050-mcp-interface/evidence/semantic-audit-luna-remediation-2026-10-02.json),
[direct indexed-edge receipt](../050-mcp-interface/evidence/semantic-curation-indexed-edges-2026-10-02.json)
and [remediation plan](../050-mcp-interface/plans/semantic-protocol-luna-remediation-2026-10-02.md).
The focused checks retain **41 baseline failures** (MCP/workspace 14, runner 25,
Git routes 2), reproduced on immutable `aacd1bd0`; no fixtures were weakened.
**Global GO remains NO-GO.** Runtime pagination acceptance and the outstanding
release/human gates are recorded below.
## @} FullFlow.SemanticRemediationCheckpoint
## @{ FullFlow.FinanceRecoveryCheckpoint [C:4] [TYPE ADR] [SEMANTICS finance,traversal,memory,recovery,checkpoint]
@BRIEF Retain the bounded finance-cycle proof and the rejected collection-only recovery while full pagination remains open.
@RATIONALE Retained JS heap and DOM continue growing after successful collection; a fresh document needs its own exact-state reconstruction proof.