feat(task-018): add observability retry cancel and audit trail
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user