feat(task-018): add observability retry cancel and audit trail

This commit is contained in:
2026-05-22 01:23:07 +03:00
parent 318ae790ef
commit 95fec3ba26
26 changed files with 1984 additions and 275 deletions
+146 -12
View File
@@ -12,9 +12,12 @@ from src.domain.contracts import (
AgentJobOutput,
AgentJobResponse,
AgentJobStatus,
AgentJobSummary,
AgentJobTestCodexRequest,
AgentJobType,
Role,
)
from src.application.observability import evaluate_retry_policy, project_job_for_view
VALID_FAKE_AGENT_PROFILE = "fake-codex"
@@ -44,15 +47,27 @@ def create_test_codex_job(
def list_agent_jobs(repository: object) -> AgentJobListResponse:
return AgentJobListResponse(jobs=repository.agent_jobs.list())
jobs = [_project_admin_view(repository, job) for job in repository.agent_jobs.list()]
return AgentJobListResponse(jobs=jobs)
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
return AgentJobResponse(job=repository.agent_jobs.get(job_id))
job = repository.agent_jobs.get(job_id)
return AgentJobResponse(job=_project_admin_view(repository, job))
def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
def retry_agent_job(
repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
parent = repository.agent_jobs.get(job_id)
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, parent)
if not retry_eligible:
raise PermissionError(retry_block_reason or "Retry is blocked for this job.")
now = _now()
retry = repository.agent_jobs.create(
article_id=parent.article_id,
parent_job_id=parent.id,
@@ -62,13 +77,42 @@ def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
status=AgentJobStatus.QUEUED,
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
payload=parent.payload,
queued_at=_now(),
queued_at=now,
)
return AgentJobResponse(job=retry)
_record_job_event(
repository,
job=retry,
event_type="AGENT_JOB_RETRIED",
actor_user_id=actor_user_id,
created_at=now,
payload={
"parent_job_id": str(parent.id),
"attempt": retry.attempt,
"job_type": retry.job_type.value,
},
)
return AgentJobResponse(job=_project_admin_view(repository, retry))
def cancel_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now()))
def cancel_agent_job(
repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
now = _now()
existing = repository.agent_jobs.get(job_id)
job = repository.agent_jobs.cancel(job_id=job_id, finished_at=now)
if existing.status != job.status and job.status == AgentJobStatus.CANCELLED:
_record_job_event(
repository,
job=job,
event_type="AGENT_JOB_CANCELLED",
actor_user_id=actor_user_id,
created_at=now,
payload={"job_type": job.job_type.value, "attempt": job.attempt},
)
return AgentJobResponse(job=_project_admin_view(repository, job))
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
@@ -93,6 +137,7 @@ def complete_agent_job(
try:
validated_output = AgentJobOutput.model_validate(output)
except ValidationError as error:
finished_at = _now()
job = repository.agent_jobs.complete(
job_id=job_id,
status=AgentJobStatus.FAILED,
@@ -105,9 +150,15 @@ def complete_agent_job(
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=_now(),
finished_at=finished_at,
)
return AgentJobResponse(job=job)
_record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, job))
merged_payload = _merge_payload(
base_payload=existing_job.payload,
@@ -133,6 +184,7 @@ def complete_agent_job(
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
)
finished_at = _now()
job = repository.agent_jobs.complete(
job_id=job_id,
status=status,
@@ -147,9 +199,15 @@ def complete_agent_job(
stderr=stderr,
exit_code=exit_code,
duration_ms=duration_ms,
finished_at=_now(),
finished_at=finished_at,
)
return AgentJobResponse(job=job)
_record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, job))
def _now() -> datetime:
@@ -164,9 +222,13 @@ def _normalize_completion_status(
) -> AgentJobStatus:
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
return AgentJobStatus.FAILED
if error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE and exit_code in (
if (
status == AgentJobStatus.SUCCEEDED
and error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
and exit_code in (
None,
0,
)
):
return AgentJobStatus.SUCCEEDED
return status
@@ -201,3 +263,75 @@ def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
if isinstance(claims, list):
return claims
return []
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
def _record_completion_event(
repository: object,
*,
previous: AgentJobSummary,
current: AgentJobSummary,
created_at: datetime,
) -> None:
if current.article_id is None:
return
if previous.status == current.status:
return
if current.status == AgentJobStatus.FAILED:
retry_eligible, _ = evaluate_retry_policy(repository, current)
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_FAILED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
"error_category": (
current.error_category.value if current.error_category is not None else None
),
"error_message": current.error_message,
"retry_eligible": retry_eligible,
},
)
return
if current.status == AgentJobStatus.SUCCEEDED:
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_SUCCEEDED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
},
)
def _record_job_event(
repository: object,
*,
job: AgentJobSummary,
event_type: str,
actor_user_id: UUID | None,
created_at: datetime,
payload: dict[str, Any],
) -> None:
if job.article_id is None:
return
repository.articles.create_workflow_event(
article_id=job.article_id,
event_type=event_type,
from_status=None,
to_status=None,
actor_user_id=actor_user_id,
payload={"job_id": str(job.id), **payload},
created_at=created_at,
)
+16 -1
View File
@@ -12,6 +12,10 @@ from src.domain.contracts import (
CurrentUser,
PublishingStatus,
)
from src.application.observability import (
build_observability_timeline,
project_job_for_view,
)
def create_article(
@@ -54,6 +58,7 @@ def list_articles(repository: object) -> ArticleListResponse:
def get_article_detail(
repository: object,
article_id: UUID,
current_user: CurrentUser,
) -> ArticleDetailResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
@@ -66,6 +71,15 @@ def get_article_detail(
claims = repository.claims.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id)
projected_jobs = [
project_job_for_view(
repository,
job,
viewer_role=current_user.role,
)
for job in agent_jobs
]
timeline = build_observability_timeline(workflow_events, projected_jobs)
publish_commit = repository.publish_commits.latest_for_article(article_id)
return ArticleDetailResponse(
article=article,
@@ -77,7 +91,8 @@ def get_article_detail(
evidence=evidence,
claims=claims,
assets=assets,
agent_jobs=agent_jobs,
agent_jobs=projected_jobs,
timeline=timeline,
research_manifests=research_manifests,
publish_commit=publish_commit,
)
@@ -0,0 +1,239 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
from src.domain.contracts import (
AgentJobStatus,
AgentJobSummary,
AgentJobType,
PublishingStatus,
Role,
WorkflowEventSummary,
)
_RETRYABLE_JOB_TYPES = {
AgentJobType.PLAN_GENERATION,
AgentJobType.RESEARCH,
AgentJobType.SECTION_SCAFFOLD,
AgentJobType.SEO_REVIEW,
AgentJobType.LANGUAGE_REVIEW,
AgentJobType.PUBLISH_COMMIT,
AgentJobType.TEST_CODEX,
}
_SENSITIVE_KEY_TOKENS = (
"token",
"password",
"secret",
"authorization",
"cookie",
"api_key",
"apikey",
"session",
)
_SENSITIVE_VALUE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)[^\s]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(token\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(password\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(secret\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
(re.compile(r"\b(sk-[a-zA-Z0-9_-]{8,})\b"), "[REDACTED]"),
(re.compile(r"\b(gh[pousr]_[a-zA-Z0-9]{8,})\b"), "[REDACTED]"),
)
def project_job_for_view(
repository: object,
job: AgentJobSummary,
*,
viewer_role: Role,
) -> AgentJobSummary:
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, job)
payload = redact_json_value(job.payload)
error_message = _nullable_redacted(job.error_message)
stdout = redact_text(job.stdout)
stderr = redact_text(job.stderr)
safe_failure_summary = build_safe_failure_summary(job)
if viewer_role != Role.ADMIN:
stdout = ""
stderr = ""
return job.model_copy(
update={
"payload": payload,
"error_message": error_message,
"stdout": stdout,
"stderr": stderr,
"retry_eligible": retry_eligible,
"retry_block_reason": retry_block_reason,
"cancel_eligible": is_cancel_eligible(job),
"safe_failure_summary": safe_failure_summary,
},
deep=True,
)
def evaluate_retry_policy(
repository: object,
job: AgentJobSummary,
) -> tuple[bool, str | None]:
if job.status != AgentJobStatus.FAILED:
return False, "Retry is available only for failed jobs."
if job.job_type not in _RETRYABLE_JOB_TYPES:
return False, f"Retry is not supported for job type {job.job_type.value}."
if job.job_type == AgentJobType.PUBLISH_COMMIT:
if job.article_id is None:
return False, "Publish commit retry requires article context."
existing_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
job.article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
)
if existing_publish_commit is not None:
return (
False,
"Publish commit already exists for this article. Retry is blocked.",
)
return True, None
def is_cancel_eligible(job: AgentJobSummary) -> bool:
return job.status in {AgentJobStatus.QUEUED, AgentJobStatus.RUNNING}
def build_safe_failure_summary(job: AgentJobSummary) -> str | None:
if job.status != AgentJobStatus.FAILED:
return None
category = job.error_category.value if job.error_category is not None else "UNKNOWN"
step = (
_string_value(job.payload.get("last_successful_step"))
or _string_value(job.payload.get("artifact_label"))
or _string_value(job.payload.get("heading"))
)
message = redact_text(job.error_message or "").strip()
parts = [f"{job.job_type.value} failed ({category})."]
if step:
parts.append(f"Last successful step: {step}.")
if message:
parts.append(f"Summary: {message}.")
return " ".join(parts)
def build_observability_timeline(
workflow_events: list[WorkflowEventSummary],
agent_jobs: list[AgentJobSummary],
) -> list[dict[str, Any]]:
timeline: list[dict[str, Any]] = []
for event in workflow_events:
source = "USER" if event.actor_user_id is not None else "SYSTEM"
timeline.append(
{
"id": event.id,
"article_id": event.article_id,
"entry_type": "WORKFLOW_EVENT",
"source": source,
"event_type": event.event_type,
"from_status": event.from_status,
"to_status": event.to_status,
"actor_user_id": event.actor_user_id,
"job_id": None,
"job_type": None,
"job_status": None,
"retry_eligible": False,
"cancel_eligible": False,
"safe_failure_summary": None,
"payload": redact_json_value(event.payload),
"created_at": event.created_at,
}
)
for job in agent_jobs:
created_at = job.finished_at or job.started_at or job.queued_at
timeline.append(
{
"id": job.id,
"article_id": job.article_id,
"entry_type": "AGENT_JOB",
"source": "AGENT",
"event_type": f"{job.job_type.value}_{job.status.value}",
"from_status": None,
"to_status": None,
"actor_user_id": None,
"job_id": job.id,
"job_type": job.job_type,
"job_status": job.status,
"retry_eligible": bool(job.retry_eligible),
"cancel_eligible": bool(job.cancel_eligible),
"safe_failure_summary": job.safe_failure_summary,
"payload": {
"attempt": job.attempt,
"error_category": (
job.error_category.value if job.error_category is not None else None
),
"error_message": job.error_message,
},
"created_at": created_at,
}
)
timeline.sort(
key=lambda item: (
_timeline_dt(item.get("created_at")),
str(item.get("id")),
)
)
return timeline
def redact_json_value(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, nested in value.items():
if _contains_sensitive_token(key):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_json_value(nested)
return redacted
if isinstance(value, list):
return [redact_json_value(item) for item in value]
if isinstance(value, str):
return redact_text(value)
return value
def redact_text(value: str) -> str:
redacted = value
for pattern, replacement in _SENSITIVE_VALUE_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted
def _timeline_dt(value: Any) -> datetime:
if isinstance(value, datetime):
return value
return datetime.min
def _contains_sensitive_token(value: str) -> bool:
normalized = value.strip().lower()
return any(token in normalized for token in _SENSITIVE_KEY_TOKENS)
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
return ""
def _nullable_redacted(value: str | None) -> str | None:
if value is None:
return None
redacted = redact_text(value).strip()
if not redacted:
return None
return redacted
@@ -6,6 +6,8 @@ from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionStatus,
@@ -231,6 +233,49 @@ def list_script_config_versions(
return [_script_version_summary(row) for row in rows]
def list_script_config_audit_events(
repository: object,
site_id: UUID,
) -> ScriptConfigVersionAuditEventListResponse:
repository.target_sites.get_by_id(site_id)
version_rows = repository.script_config_versions.list_for_site(site_id)
version_by_id = {str(row["id"]): row for row in version_rows}
events = repository.script_config_version_events.list_for_site(site_id)
summaries: list[ScriptConfigVersionAuditEventSummary] = []
for event in events:
version_row = version_by_id.get(str(event["version_id"]))
payload = event["payload"] if isinstance(event["payload"], dict) else {}
summaries.append(
ScriptConfigVersionAuditEventSummary(
id=event["id"],
target_site_id=event["target_site_id"],
version_id=event["version_id"],
event_type=event["event_type"],
actor_user_id=event["actor_user_id"],
payload=payload,
created_at=event["created_at"],
version=(
int(version_row["version"])
if version_row is not None and version_row.get("version") is not None
else None
),
diff=(
version_row["diff"]
if version_row is not None and isinstance(version_row.get("diff"), dict)
else {}
),
rollback_target_version_id=(
version_row["rollback_target_version_id"]
if version_row is not None
else None
),
)
)
return ScriptConfigVersionAuditEventListResponse(events=summaries)
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
return ScriptConfigVersionSummary(
id=row["id"],
@@ -32,6 +32,7 @@ from .models import (
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
WorkflowEventSummary,
ObservabilityTimelineEventSummary,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
@@ -92,6 +93,8 @@ from .models import (
FinalReviewIssueSummary,
FinalReviewIssuesResponse,
RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
@@ -129,6 +132,7 @@ __all__ = [
"BoundaryQuestionSummary",
"BoundaryQuestionUpdateRequest",
"WorkflowEventSummary",
"ObservabilityTimelineEventSummary",
"AssetGenerateSpecsResponse",
"AssetListResponse",
"AssetResponse",
@@ -199,6 +203,8 @@ __all__ = [
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionAuditEventListResponse",
"ScriptConfigVersionAuditEventSummary",
"SeoReviewRunResponse",
"SeoReviewReportResponse",
"ScriptConfigVersionCreateRequest",
@@ -141,6 +141,23 @@ class ScriptConfigVersionListResponse(ContractModel):
versions: list[ScriptConfigVersionSummary]
class ScriptConfigVersionAuditEventSummary(ContractModel):
id: UUID
target_site_id: UUID
version_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
version: int | None = Field(default=None, ge=1)
diff: JsonObject = Field(default_factory=dict)
rollback_target_version_id: UUID | None = None
class ScriptConfigVersionAuditEventListResponse(ContractModel):
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
class ArticleCreateRequest(ContractModel):
target_site_id: UUID
brief_description: str = Field(min_length=1)
@@ -185,6 +202,25 @@ class WorkflowEventSummary(ContractModel):
created_at: datetime
class ObservabilityTimelineEventSummary(ContractModel):
id: UUID
article_id: UUID
entry_type: str = Field(min_length=1)
source: str = Field(min_length=1)
event_type: str = Field(min_length=1)
from_status: ArticleWorkflowStatus | None = None
to_status: ArticleWorkflowStatus | None = None
actor_user_id: UUID | None = None
job_id: UUID | None = None
job_type: AgentJobType | None = None
job_status: AgentJobStatus | None = None
retry_eligible: bool = False
cancel_eligible: bool = False
safe_failure_summary: str | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class BoundaryQuestionSummary(ContractModel):
id: UUID
article_id: UUID
@@ -493,6 +529,10 @@ class AgentJobSummary(ContractModel):
queued_at: datetime
started_at: datetime | None = None
finished_at: datetime | None = None
retry_eligible: bool = False
retry_block_reason: str | None = None
cancel_eligible: bool = False
safe_failure_summary: str | None = None
class AgentJobTestCodexRequest(ContractModel):
@@ -709,6 +749,7 @@ class ArticleDetailResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list)
reviews: list[ReviewSummary] = Field(default_factory=list)
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
research_manifests: list[ResearchArtifactManifestSummary] = Field(
default_factory=list
)
@@ -38,6 +38,7 @@ from .models import (
BoundaryQuestionResponse,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
ObservabilityTimelineEventSummary,
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
@@ -98,6 +99,8 @@ from .models import (
SeoReviewReportResponse,
SeoReviewRunResponse,
RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
@@ -143,11 +146,14 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse,
ScriptConfigVersionListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionAuditEventListResponse,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
ArticleListResponse,
WorkflowEventSummary,
ObservabilityTimelineEventSummary,
BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest,
BoundaryQuestionResponse,
@@ -81,23 +81,33 @@ def get_agent_job_route(
)
def post_retry_agent_job(
job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return retry_agent_job(repository, job_id)
return retry_agent_job(
repository,
job_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
def post_cancel_agent_job(
job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse:
try:
return cancel_agent_job(repository, job_id)
return cancel_agent_job(
repository,
job_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -61,11 +61,15 @@ def list_articles_route(
)
def get_article(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ArticleDetailResponse:
try:
return get_article_detail(repository=repository, article_id=article_id)
return get_article_detail(
repository=repository,
article_id=article_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -9,6 +9,7 @@ from src.application.site_config import (
create_script_config_version,
create_target_site,
get_target_site,
list_script_config_audit_events,
list_script_config_versions,
rollback_script_config_version,
update_target_site,
@@ -16,6 +17,7 @@ from src.application.site_config import (
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
@@ -92,6 +94,21 @@ def get_script_config_versions(
return ScriptConfigVersionListResponse(versions=versions)
@router.get(
"/sites/{site_id}/publishing-config/audit",
response_model=ScriptConfigVersionAuditEventListResponse,
)
def get_script_config_audit_events(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionAuditEventListResponse:
try:
return list_script_config_audit_events(repository, site_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post(
"/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionResponse,