refactor(semantics): close dashboard testing protocol audit
This commit is contained in:
@@ -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
|
||||
|
||||
120
backend/src/api/routes/git/_commit_message_execution.py
Normal file
120
backend/src/api/routes/git/_commit_message_execution.py
Normal 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
|
||||
187
backend/src/api/routes/git/_deployment_execution.py
Normal file
187
backend/src/api/routes/git/_deployment_execution.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
101
backend/src/api/routes/git/_identity_execution.py
Normal file
101
backend/src/api/routes/git/_identity_execution.py
Normal 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
|
||||
219
backend/src/api/routes/git/_lifecycle_observation.py
Normal file
219
backend/src/api/routes/git/_lifecycle_observation.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
97
backend/src/api/routes/git/_repository_initialization.py
Normal file
97
backend/src/api/routes/git/_repository_initialization.py
Normal 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
|
||||
44
backend/src/api/routes/git/_status_batch_execution.py
Normal file
44
backend/src/api/routes/git/_status_batch_execution.py
Normal 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
|
||||
381
backend/src/api/routes/git_lifecycle_schemas.py
Normal file
381
backend/src/api/routes/git_lifecycle_schemas.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
221
backend/src/core/utils/llm_completion_execution.py
Normal file
221
backend/src/core/utils/llm_completion_execution.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
39
backend/src/core/utils/llm_reasoning_payload.py
Normal file
39
backend/src/core/utils/llm_reasoning_payload.py
Normal 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
|
||||
50
backend/src/core/utils/llm_response_body.py
Normal file
50
backend/src/core/utils/llm_response_body.py
Normal 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
|
||||
218
backend/src/core/utils/llm_response_parsing.py
Normal file
218
backend/src/core/utils/llm_response_parsing.py
Normal 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
|
||||
24
backend/src/core/utils/llm_token_limits.py
Normal file
24
backend/src/core/utils/llm_token_limits.py
Normal 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
|
||||
96
backend/src/mcp_server/_rbac_provenance.py
Normal file
96
backend/src/mcp_server/_rbac_provenance.py
Normal 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
|
||||
122
backend/src/mcp_server/_tool_catalog.py
Normal file
122
backend/src/mcp_server/_tool_catalog.py
Normal 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
|
||||
94
backend/src/mcp_server/_tools_authoring_revisions.py
Normal file
94
backend/src/mcp_server/_tools_authoring_revisions.py
Normal 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
|
||||
301
backend/src/mcp_server/_tools_authoring_workspace.py
Normal file
301
backend/src/mcp_server/_tools_authoring_workspace.py
Normal 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
|
||||
336
backend/src/mcp_server/_tools_scenario_inspection.py
Normal file
336
backend/src/mcp_server/_tools_scenario_inspection.py
Normal 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
|
||||
104
backend/src/mcp_server/_tools_scenario_maintenance_approval.py
Normal file
104
backend/src/mcp_server/_tools_scenario_maintenance_approval.py
Normal 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
|
||||
141
backend/src/mcp_server/_tools_scenario_probe_read.py
Normal file
141
backend/src/mcp_server/_tools_scenario_probe_read.py
Normal 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
|
||||
259
backend/src/mcp_server/_tools_scenario_registration.py
Normal file
259
backend/src/mcp_server/_tools_scenario_registration.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
276
backend/src/services/dashboard_testing/query_model_parsing.py
Normal file
276
backend/src/services/dashboard_testing/query_model_parsing.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
201
backend/src/services/git/_branch_execution.py
Normal file
201
backend/src/services/git/_branch_execution.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
274
backend/src/services/git/_merge_execution.py
Normal file
274
backend/src/services/git/_merge_execution.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
206
backend/tests/mcp_scenario_fixtures.py
Normal file
206
backend/tests/mcp_scenario_fixtures.py
Normal 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
|
||||
16
backend/tests/mcp_server_fixtures.py
Normal file
16
backend/tests/mcp_server_fixtures.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
39
backend/tests/services/git/git_status_fixture.py
Normal file
39
backend/tests/services/git/git_status_fixture.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
181
backend/tests/services/git/test_git_status_history.py
Normal file
181
backend/tests/services/git/test_git_status_history.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
278
backend/tests/test_mcp_scenario_profile_e2e.py
Normal file
278
backend/tests/test_mcp_scenario_profile_e2e.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
372
backend/tests/test_mcp_server_graph.py
Normal file
372
backend/tests/test_mcp_server_graph.py
Normal 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
|
||||
383
backend/tests/test_mcp_server_workspace.py
Normal file
383
backend/tests/test_mcp_server_workspace.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user