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,
@@ -0,0 +1,493 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.domain.contracts import AgentJobStatus, AgentJobType, PublishingStatus # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class ObservabilityRetryCancelAuditPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'observability.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_failed_job_appears_in_article_history_with_retry_eligibility(self) -> None:
article_id = self._prepare_article_for_parallel_production()
created_jobs = self._start_parallel_production(article_id)
section_job = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
failed_job = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [
{"claim_text": "Unverified claim", "risk_level": "high"}
]
},
},
)
self.assertEqual("FAILED", failed_job["status"])
detail_response = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
article_jobs = detail_response.json()["agent_jobs"]
history_failed_job = [job for job in article_jobs if job["id"] == failed_job["id"]][0]
self.assertIn("retry_eligible", history_failed_job)
self.assertTrue(history_failed_job["retry_eligible"])
self.assertIsNone(history_failed_job["retry_block_reason"])
def test_article_timeline_mixes_user_system_and_agent_events_in_order(self) -> None:
article_id = self._prepare_article_for_parallel_production()
section_job = self._start_parallel_production(article_id)[0]
failed = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
},
},
)
self.assertEqual("FAILED", failed["status"])
retry_response = self.client.post(
f"/api/agent-jobs/{failed['id']}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(201, retry_response.status_code, retry_response.text)
detail_response = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
timeline = detail_response.json()["timeline"]
self.assertGreaterEqual(len(timeline), 3)
created_at_values = [entry["created_at"] for entry in timeline]
self.assertEqual(created_at_values, sorted(created_at_values))
sources = {entry["source"] for entry in timeline}
self.assertIn("USER", sources)
self.assertIn("SYSTEM", sources)
self.assertIn("AGENT", sources)
failed_job_entry = [
entry
for entry in timeline
if entry["entry_type"] == "AGENT_JOB"
and entry.get("job_id") == failed["id"]
][0]
self.assertTrue(failed_job_entry["retry_eligible"])
self.assertEqual("FAILED", failed_job_entry["job_status"])
def test_sensitive_logs_are_redacted_and_editor_sees_only_safe_failure_summary(self) -> None:
article_id = self._prepare_article_for_parallel_production()
section_job = self._start_parallel_production(article_id)[0]
failed_job = self._complete_job(
section_job["id"],
exit_code=1,
stdout=(
"token=topsecret123 password=hunter2 "
"Authorization: Bearer very-secret-token\n"
),
stderr="api_key=prod-key-123\n",
output={
"status": "FAILED",
"error_message": "runner failed with sk-1234567890abcdef",
"payload": {"last_successful_step": "outline written"},
},
)
self.assertEqual("FAILED", failed_job["status"])
admin_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
editor_detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
admin_job = [job for job in admin_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
editor_job = [job for job in editor_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
self.assertIn("[REDACTED]", admin_job["stdout"])
self.assertIn("[REDACTED]", admin_job["stderr"])
self.assertNotIn("topsecret123", admin_job["stdout"])
self.assertNotIn("hunter2", admin_job["stdout"])
self.assertNotIn("very-secret-token", admin_job["stdout"])
self.assertNotIn("prod-key-123", admin_job["stderr"])
self.assertNotIn("sk-1234567890abcdef", admin_job["error_message"] or "")
self.assertEqual("", editor_job["stdout"])
self.assertEqual("", editor_job["stderr"])
self.assertIn("failed", editor_job["safe_failure_summary"].lower())
self.assertNotIn("topsecret123", editor_job["safe_failure_summary"])
def test_retry_eligibility_and_cancel_eligibility_follow_policy(self) -> None:
article_id = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
section_failed = self._complete_job(
section_job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/section-failed.md"}],
"payload": {
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
},
},
)
self.assertEqual("FAILED", section_failed["status"])
now = datetime.now(UTC)
evidence_job = self.repository.agent_jobs.create(
article_id=UUID(article_id),
parent_job_id=None,
attempt=1,
job_type=AgentJobType.EVIDENCE_MATRIX,
agent_profile="manual-test",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/evidence.json"}],
queued_at=now,
)
evidence_failed = self.repository.agent_jobs.complete(
job_id=evidence_job.id,
status=AgentJobStatus.FAILED,
workspace_path="/tmp/evidence",
output_files=[],
payload={"phase": "collect"},
error_category=None,
error_message="temporary failure",
stdout="",
stderr="",
exit_code=1,
duration_ms=2,
finished_at=now,
)
self.assertEqual(AgentJobStatus.FAILED, evidence_failed.status)
queued_section = [job for job in jobs if job["id"] != section_job["id"]][0]
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
failed_section_view = [job for job in detail["agent_jobs"] if job["id"] == section_failed["id"]][0]
evidence_view = [job for job in detail["agent_jobs"] if job["id"] == str(evidence_failed.id)][0]
queued_view = [job for job in detail["agent_jobs"] if job["id"] == queued_section["id"]][0]
self.assertTrue(failed_section_view["retry_eligible"])
self.assertIsNone(failed_section_view["retry_block_reason"])
self.assertFalse(failed_section_view["cancel_eligible"])
self.assertFalse(evidence_view["retry_eligible"])
self.assertIn("not supported", evidence_view["retry_block_reason"].lower())
self.assertFalse(evidence_view["cancel_eligible"])
self.assertFalse(queued_view["retry_eligible"])
self.assertTrue(queued_view["cancel_eligible"])
def test_cancelling_running_or_queued_job_prevents_article_state_mutation(self) -> None:
article_id = self._prepare_article_for_parallel_production()
jobs = self._start_parallel_production(article_id)
first_job = jobs[0]
cancel_response = self.client.post(
f"/api/agent-jobs/{first_job['id']}/cancel",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
complete_response = self.client.post(
f"/internal/agent-jobs/{first_job['id']}/complete",
json={
"workspace_path": f"/tmp/{first_job['id']}",
"stdout": "completion after cancellation\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 2,
"output": {
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/cancelled.md"}],
"payload": {"draft_markdown": "should never apply"},
},
},
)
self.assertEqual(200, complete_response.status_code, complete_response.text)
self.assertEqual("CANCELLED", complete_response.json()["job"]["status"])
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()
self.assertEqual("PARALLEL_PRODUCTION_RUNNING", detail["article"]["status"])
cancelled_view = [job for job in detail["agent_jobs"] if job["id"] == first_job["id"]][0]
self.assertEqual("CANCELLED", cancelled_view["status"])
def test_publish_commit_retry_blocked_after_publish_commit_exists(self) -> None:
article = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": self._first_site_id(),
"brief_description": "Publish retry policy test",
"working_title": "Publish Retry Policy",
"content_type": "longform_guide",
"primary_keyword": "publish retry policy",
},
).json()["article"]
article_id = article["id"]
now = datetime.now(UTC)
publish_job = self.repository.agent_jobs.create(
article_id=UUID(article_id),
parent_job_id=None,
attempt=1,
job_type=AgentJobType.PUBLISH_COMMIT,
agent_profile="manual-publish-commit",
status=AgentJobStatus.QUEUED,
input_files=[{"path": "inputs/publish.json"}],
queued_at=now,
)
publish_job = self.repository.agent_jobs.complete(
job_id=publish_job.id,
status=AgentJobStatus.FAILED,
workspace_path="/tmp/publish",
output_files=[],
payload={"stage": "git push"},
error_category=None,
error_message="git push failed",
stdout="",
stderr="",
exit_code=1,
duration_ms=5,
finished_at=now,
)
self.repository.publish_commits.create(
article_id=UUID(article_id),
target_site_id=UUID(self._first_site_id()),
repository_url="git@example.com/site.git",
branch="main",
commit_sha="abc123",
content_bundle_manifest={"validation": {"label": "ok"}},
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
deployment_status=None,
created_at=now,
)
retry_response = self.client.post(
f"/api/agent-jobs/{publish_job.id}/retry",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(409, retry_response.status_code, retry_response.text)
self.assertIn("already exists", retry_response.text.lower())
detail = self.client.get(
f"/api/articles/{article_id}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
).json()
publish_job_view = [job for job in detail["agent_jobs"] if job["id"] == str(publish_job.id)][0]
self.assertFalse(publish_job_view["retry_eligible"])
self.assertIn("already exists", publish_job_view["retry_block_reason"].lower())
def test_script_config_changes_are_audit_visible_with_diff_and_rollback_target(self) -> None:
site_id = self._first_site_id()
first = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"publishing_yaml": "target: demo\n",
"transform_script": "export function transformArticle(a){return a;}\n",
"diff": {"summary": "v1 create"},
"activate": True,
},
)
self.assertEqual(201, first.status_code, first.text)
first_version = first.json()["version"]
second = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={
"publishing_yaml": "target: demo\nbranch: main\n",
"transform_script": "export function transformArticle(a){return {...a, changed:true};}\n",
"diff": {"summary": "v2 create"},
"rollback_target_version_id": first_version["id"],
"activate": True,
},
)
self.assertEqual(201, second.status_code, second.text)
second_version = second.json()["version"]
rollback = self.client.post(
f"/api/sites/{site_id}/publishing-config/versions/{first_version['id']}/rollback",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, rollback.status_code, rollback.text)
audit_response = self.client.get(
f"/api/sites/{site_id}/publishing-config/audit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
events = audit_response.json()["events"]
self.assertGreaterEqual(len(events), 3)
for event in events:
self.assertIn("diff", event)
self.assertIn("rollback_target_version_id", event)
rollback_events = [event for event in events if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"]
self.assertEqual(1, len(rollback_events))
rollback_event = rollback_events[0]
self.assertEqual(second_version["id"], rollback_event["rollback_target_version_id"])
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return response.json()["jobs"]
def _complete_job(
self,
job_id: str,
*,
output: dict[str, Any],
stdout: str = "fake section scaffolding runner\n",
stderr: str = "",
exit_code: int = 0,
) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_ms": 2,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
def _first_site_id(self) -> str:
response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()[0]["site"]["id"]
def _prepare_article_for_parallel_production(self) -> str:
article_id = self._create_article_with_approved_plan()
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
return article_id
def _create_article_with_approved_plan(self) -> str:
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": self._first_site_id(),
"brief_description": "Observe failed job history and retry eligibility.",
"working_title": "Observability Retry Audit",
"content_type": "longform_guide",
"primary_keyword": "observability retry audit",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
article_id = article_response.json()["article"]["id"]
questions = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
).json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id
if __name__ == "__main__":
unittest.main()
@@ -119,6 +119,8 @@ class ScriptConfigAuditTest(unittest.TestCase):
activate: bool = False,
rollback_target_version_id: UUID | None = None,
) -> ScriptConfigVersionCreateRequest:
from src.domain.contracts import ScriptConfigVersionCreateRequest
payload = ScriptConfigVersionCreateRequest(
publishing_yaml="target:\n repo: audit\n",
transform_script="export function transformArticle(article) { return article; }",
@@ -4,11 +4,16 @@ type DynamicArticleDetailPageProps = {
params: {
articleId: string;
};
searchParams?: {
role?: string;
};
};
export default async function DynamicArticleDetailPage({
params,
searchParams,
}: DynamicArticleDetailPageProps) {
const { articleId } = params;
return <ArticleDetailPage articleId={articleId} />;
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
}
+18
View File
@@ -344,6 +344,24 @@ main {
padding: 10px 0;
}
.timelineMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.timelineSource {
border: 1px solid #d0d5dd;
border-radius: 6px;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
background: #ffffff;
font-size: 12px;
}
.draftEditor {
margin-top: 28px;
}
@@ -1,4 +1,5 @@
import type {
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionSummary,
} from "@pipeline/shared";
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
rollbackTargetVersionId: string;
};
export type AdminScriptAuditRow = {
id: string;
eventType: string;
version: number | null;
createdAt: string;
actorUserId: string | null;
diff: string;
rollbackTargetVersionId: string | null;
};
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
@@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record<string, unknown> | null {
}
return null;
}
export function buildAdminScriptAuditRows(
events: readonly ScriptConfigVersionAuditEventSummary[],
): AdminScriptAuditRow[] {
return events
.map((event) => ({
id: event.id,
eventType: event.event_type,
version: event.version ?? null,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
diff: JSON.stringify(event.diff ?? {}),
rollbackTargetVersionId: event.rollback_target_version_id ?? null,
}))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
@@ -2,10 +2,11 @@
import { useMemo, useState, type FormEvent } from "react";
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import type { ScriptConfigVersionAuditEventSummary, ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import {
buildAdminScriptAuditRows,
buildAdminScriptVersionRows,
buildCreateScriptVersionPayload,
defaultScriptVersionDraft,
@@ -17,6 +18,7 @@ import {
type AdminScriptVersionsPanelProps = {
site: TargetSiteConfig;
initialVersions: readonly ScriptConfigVersionSummary[];
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
};
function parseOptionalError(value: string): string | null {
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
export function AdminScriptVersionsPanel({
site,
initialVersions,
initialAuditEvents,
}: AdminScriptVersionsPanelProps) {
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
initialVersions,
);
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
initialAuditEvents,
);
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
defaultScriptVersionDraft(),
);
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
buildAdminScriptVersionRows(versions),
[versions],
);
const auditRows = useMemo(
() => buildAdminScriptAuditRows(auditEvents),
[auditEvents],
);
const hasVersions = rows.length > 0;
const canSubmit = !isSubmitting;
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
async function refreshVersions() {
const latest = await fetchScriptConfigVersions(
const [latest, audit] = await Promise.all([
fetchScriptConfigVersions(
site.id,
DEMO_ADMIN_EMAIL,
);
),
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
]);
setVersions(latest.versions);
setAuditEvents(audit.events ?? []);
}
function updateField<K extends keyof NewAdminScriptVersionDraft>(
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
</tbody>
</table>
)}
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Version</th>
<th>Created</th>
<th>Actor</th>
<th>Diff</th>
<th>Rollback target</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{event.version ?? "—"}</td>
<td>{new Date(event.createdAt).toLocaleString()}</td>
<td>{event.actorUserId ?? "—"}</td>
<td>{event.diff}</td>
<td>{event.rollbackTargetVersionId ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
@@ -1,10 +1,42 @@
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
import type {
AgentJobSummary,
ArticleDetailResponse,
ObservabilityTimelineEventSummary,
Role,
} from "@pipeline/shared";
export type ArticleTimelineItem = Pick<
WorkflowEventSummary,
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
> & {
export type ArticleTimelineItem = {
id: string;
articleId: string;
entryType: string;
source: string;
eventType: string;
fromStatus: string | null;
toStatus: string | null;
actorUserId: string | null;
jobId: string | null;
jobType: string | null;
jobStatus: string | null;
retryEligible: boolean;
cancelEligible: boolean;
safeFailureSummary: string | null;
createdAt: string;
};
export type JobDetailRow = {
jobId: string;
jobType: string;
status: string;
attempt: number;
errorCategory: string | null;
errorMessage: string | null;
lastSuccessfulStep: string | null;
retryEligible: boolean;
retryBlockReason: string | null;
cancelEligible: boolean;
safeFailureSummary: string | null;
stdout: string;
stderr: string;
};
export type DetailSummary = {
@@ -15,35 +47,53 @@ export type DetailSummary = {
briefDescription: string;
targetSite: string;
updatedAt: string;
viewerRole: Role;
viewerEmail: string;
timeline: ArticleTimelineItem[];
productionArtifacts: ProductionArtifactRow[];
jobDetails: JobDetailRow[];
};
export type ProductionArtifactRow = {
jobId: string;
artifactKey: string;
artifactLabel: string;
status: string;
attempt: number;
jobType: string;
usedEvidenceIds: string[];
unsupportedClaims: string[];
errorMessage: string | null;
export type JobControlState = {
showRetry: boolean;
showCancel: boolean;
showAdminLogs: boolean;
showSafeSummary: boolean;
};
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
return (detail.workflow_events ?? []).map((event) => ({
id: event.id,
event_type: event.event_type,
to_status: event.to_status,
from_status: event.from_status,
actor_user_id: event.actor_user_id,
created_at: event.created_at,
articleId: event.article_id,
}));
const timeline = detail.timeline ?? [];
if (timeline.length > 0) {
return timeline
.map((entry) => normalizeTimelineEntry(entry))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
return (detail.workflow_events ?? [])
.map((event) => ({
id: event.id,
articleId: event.article_id,
entryType: "WORKFLOW_EVENT",
source: event.actor_user_id ? "USER" : "SYSTEM",
eventType: event.event_type,
fromStatus: event.from_status ?? null,
toStatus: event.to_status ?? null,
actorUserId: event.actor_user_id ?? null,
jobId: null,
jobType: null,
jobStatus: null,
retryEligible: false,
cancelEligible: false,
safeFailureSummary: null,
createdAt: event.created_at,
}))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
export function buildDetailSummary(
detail: ArticleDetailResponse,
options: { viewerRole: Role; viewerEmail: string },
): DetailSummary {
const { viewerRole, viewerEmail } = options;
return {
articleId: detail.article.id,
status: detail.article.status,
@@ -52,8 +102,47 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown",
updatedAt: detail.article.updated_at,
viewerRole,
viewerEmail,
timeline: buildArticleTimeline(detail),
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []),
jobDetails: buildJobDetailRows(detail.agent_jobs ?? []),
};
}
export function buildJobDetailRows(jobs: readonly AgentJobSummary[]): JobDetailRow[] {
return jobs
.map((job) => {
const payload = (job.payload ?? {}) as Record<string, unknown>;
const lastSuccessfulStep = stringValue(payload.last_successful_step)
|| stringValue(payload.artifact_label)
|| stringValue(payload.heading)
|| null;
return {
jobId: job.id,
jobType: job.job_type,
status: job.status,
attempt: job.attempt ?? 1,
errorCategory: job.error_category ?? null,
errorMessage: job.error_message ?? null,
lastSuccessfulStep,
retryEligible: Boolean(job.retry_eligible),
retryBlockReason: job.retry_block_reason ?? null,
cancelEligible: Boolean(job.cancel_eligible),
safeFailureSummary: job.safe_failure_summary ?? null,
stdout: job.stdout ?? "",
stderr: job.stderr ?? "",
};
})
.sort((left, right) => left.jobType.localeCompare(right.jobType));
}
export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobControlState {
const isAdmin = viewerRole === "ADMIN";
return {
showRetry: isAdmin && job.retryEligible,
showCancel: isAdmin && job.cancelEligible,
showAdminLogs: isAdmin,
showSafeSummary: !isAdmin,
};
}
@@ -71,60 +160,26 @@ function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string
return "Best-effort content-shape validation only.";
}
export function buildProductionArtifactRows(
jobs: readonly AgentJobSummary[],
): ProductionArtifactRow[] {
return jobs
.filter((job) => job.job_type === "SECTION_SCAFFOLD")
.map((job) => {
const payload = (job.payload ?? {}) as Record<string, unknown>;
const artifactKey = stringValue(payload.artifact_key) || `job:${job.id}`;
const artifactLabel =
stringValue(payload.artifact_label)
|| stringValue(payload.heading)
|| artifactKey;
return {
jobId: job.id,
artifactKey,
artifactLabel,
status: job.status,
attempt: job.attempt ?? 1,
jobType: job.job_type,
usedEvidenceIds: stringArray(payload.used_evidence_ids),
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
errorMessage: job.error_message ?? null,
};
})
.sort((left, right) => left.artifactLabel.localeCompare(right.artifactLabel));
function normalizeTimelineEntry(entry: ObservabilityTimelineEventSummary): ArticleTimelineItem {
return {
id: entry.id,
articleId: entry.article_id,
entryType: entry.entry_type,
source: entry.source,
eventType: entry.event_type,
fromStatus: entry.from_status ?? null,
toStatus: entry.to_status ?? null,
actorUserId: entry.actor_user_id ?? null,
jobId: entry.job_id ?? null,
jobType: entry.job_type ?? null,
jobStatus: entry.job_status ?? null,
retryEligible: Boolean(entry.retry_eligible),
cancelEligible: Boolean(entry.cancel_eligible),
safeFailureSummary: entry.safe_failure_summary ?? null,
createdAt: entry.created_at,
};
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === "string");
}
function unsupportedClaims(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const claims: string[] = [];
for (const item of value) {
if (typeof item === "string") {
claims.push(item);
continue;
}
if (typeof item === "object" && item !== null && "claim_text" in item) {
const claimText = item.claim_text;
if (typeof claimText === "string") {
claims.push(claimText);
}
}
}
return claims;
}
+120 -32
View File
@@ -1,5 +1,11 @@
import Link from "next/link";
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ApiError, cancelAgentJob, retryAgentJob } from "@/shared/pipeline-api";
import { buildJobControlState } from "./model";
import type { DetailSummary } from "./model";
type DetailShellProps = {
@@ -7,8 +13,46 @@ type DetailShellProps = {
};
export function ArticleDetailShell({ summary }: DetailShellProps) {
const router = useRouter();
const [actionError, setActionError] = useState("");
const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline;
const productionArtifacts = summary.productionArtifacts;
const jobDetails = summary.jobDetails;
const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await retryAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to retry job.");
} else {
setActionError("Unable to retry job.");
}
} finally {
setBusyJobId("");
}
}
async function handleCancel(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await cancelAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to cancel job.");
} else {
setActionError("Unable to cancel job.");
}
} finally {
setBusyJobId("");
}
}
return (
<section>
@@ -64,49 +108,93 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<ul className="timeline">
{timeline.map((event) => (
<li key={event.id}>
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
<div className="timelineMeta">
<strong>{event.eventType}</strong>
<span className="timelineSource">{event.source}</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
</div>
<div>
from {event.fromStatus ?? "—"} to {event.toStatus ?? "—"}
{event.actorUserId ? <span> • actor {event.actorUserId}</span> : null}
{event.jobType ? <span> • {event.jobType}</span> : null}
{event.jobStatus ? <span> • {event.jobStatus}</span> : null}
</div>
</li>
))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul>
<h3>Production artifacts</h3>
<h3>Job failures and controls</h3>
{actionError ? <p className="formError">Error: {actionError}</p> : null}
<table className="table">
<thead>
<tr>
<th>Artifact</th>
<th>Job type</th>
<th>Status</th>
<th>Attempt</th>
<th>Used evidence IDs</th>
<th>Unsupported claims</th>
<th>Error category</th>
<th>Error message</th>
<th>Last successful step</th>
<th>Controls</th>
<th>{isAdmin ? "Logs" : "Safe summary"}</th>
</tr>
</thead>
<tbody>
{productionArtifacts.map((artifact) => (
<tr key={artifact.jobId}>
<td>
<div>{artifact.artifactLabel}</div>
<div>{artifact.artifactKey}</div>
</td>
<td>{artifact.status}</td>
<td>{artifact.attempt}</td>
<td>
{artifact.usedEvidenceIds.length
? artifact.usedEvidenceIds.join(", ")
: "—"}
</td>
<td>
{artifact.unsupportedClaims.length
? artifact.unsupportedClaims.join("; ")
: artifact.errorMessage ?? "—"}
</td>
</tr>
))}
{productionArtifacts.length === 0 ? (
{jobDetails.map((job) => {
const isBusy = busyJobId === job.jobId;
const controls = buildJobControlState(job, summary.viewerRole);
return (
<tr key={job.jobId}>
<td>
<div>{job.jobType}</div>
<div>attempt {job.attempt}</div>
</td>
<td>{job.status}</td>
<td>{job.errorCategory ?? "—"}</td>
<td>
{job.errorMessage ?? "—"}
{!job.errorMessage && job.retryBlockReason ? (
<div>{job.retryBlockReason}</div>
) : null}
</td>
<td>{job.lastSuccessfulStep ?? "—"}</td>
<td>
{controls.showRetry ? (
<button
type="button"
onClick={() => handleRetry(job.jobId)}
disabled={isBusy}
>
Retry
</button>
) : null}
{controls.showCancel ? (
<button
type="button"
onClick={() => handleCancel(job.jobId)}
disabled={isBusy}
className="tableAction"
>
Cancel
</button>
) : null}
{!controls.showRetry && !controls.showCancel ? "—" : null}
</td>
<td>
{controls.showAdminLogs ? (
<>
<div>{job.stdout || "stdout: —"}</div>
<div>{job.stderr || "stderr: —"}</div>
</>
) : (
job.safeFailureSummary ?? "—"
)}
</td>
</tr>
);
})}
{jobDetails.length === 0 ? (
<tr>
<td colSpan={5}>No production artifacts yet.</td>
<td colSpan={7}>No agent jobs yet.</td>
</tr>
) : null}
</tbody>
@@ -1,6 +1,6 @@
import Link from "next/link";
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
import { DEMO_ADMIN_EMAIL, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
@@ -40,10 +40,13 @@ export default async function AdminScriptsPage({
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
const activeSite = selectedResponse.site;
const versionsResponse = await fetchScriptConfigVersions(
selectedSite.id,
DEMO_ADMIN_EMAIL,
);
const [versionsResponse, auditResponse] = await Promise.all([
fetchScriptConfigVersions(
selectedSite.id,
DEMO_ADMIN_EMAIL,
),
fetchScriptConfigAuditEvents(selectedSite.id, DEMO_ADMIN_EMAIL),
]);
return (
<main>
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
<AdminScriptVersionsPanel
site={activeSite}
initialVersions={versionsResponse.versions}
initialAuditEvents={auditResponse.events ?? []}
/>
</section>
</main>
@@ -9,22 +9,30 @@ import { DraftEditorPanel } from "@/features/article-draft/ui";
import { ArticleReviewPanel } from "@/features/article-review/ui";
import { MediaLibraryPanel } from "@/features/article-assets/ui";
import { FinalApprovalPanel } from "@/features/final-approval/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { DEMO_ADMIN_EMAIL, DEMO_EDITOR_EMAIL, fetchArticleDetail, fetchCurrentUser, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = {
articleId: string;
viewerRoleHint?: "admin" | "editor";
};
export default async function ArticleDetailPage({
articleId,
viewerRoleHint,
}: ArticleDetailPageProps) {
const selectedEmail = viewerRoleHint === "admin" ? DEMO_ADMIN_EMAIL : DEMO_EDITOR_EMAIL;
try {
const [detail, draftsResponse] = await Promise.all([
fetchArticleDetail(articleId),
fetchDrafts(articleId),
const [currentUserResponse, detail, draftsResponse] = await Promise.all([
fetchCurrentUser(selectedEmail),
fetchArticleDetail(articleId, selectedEmail),
fetchDrafts(articleId, selectedEmail),
]);
const summary = buildDetailSummary(detail);
const viewer = currentUserResponse.user;
const summary = buildDetailSummary(detail, {
viewerRole: viewer.role,
viewerEmail: selectedEmail,
});
return (
<main>
@@ -35,7 +43,7 @@ export default async function ArticleDetailPage({
</div>
<Link href="/">Back to dashboard</Link>
</header>
<RoleNavigation role="EDITOR" />
<RoleNavigation role={summary.viewerRole} />
<section className="panel">
<ArticleDetailShell summary={summary} />
<MediaLibraryPanel assets={detail.assets ?? []} />
+22 -5
View File
@@ -43,6 +43,7 @@ import type {
SeoReviewReportResponse,
SeoReviewRunResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
TargetSiteConfigResponse,
@@ -183,8 +184,10 @@ export function apiPatch<TRequest, TResponse>(
);
}
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
return apiGet<CurrentUserResponse>("/api/me");
export function fetchCurrentUser(
userEmail = DEMO_EDITOR_EMAIL,
): Promise<CurrentUserResponse> {
return apiGet<CurrentUserResponse>("/api/me", { userEmail });
}
export function fetchAgentJobs(
@@ -266,6 +269,16 @@ export function fetchScriptConfigVersions(
);
}
export function fetchScriptConfigAuditEvents(
siteId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<ScriptConfigVersionAuditEventListResponse> {
return apiGet<ScriptConfigVersionAuditEventListResponse>(
`/api/sites/${siteId}/publishing-config/audit`,
{ userEmail },
);
}
export function createScriptConfigVersion(
siteId: string,
request: ScriptConfigVersionCreateRequest,
@@ -308,8 +321,9 @@ export function fetchArticles(): Promise<ArticleListResponse> {
export function fetchArticleDetail(
articleId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<ArticleDetailResponse> {
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`, { userEmail });
}
export function createArticle(
@@ -455,8 +469,11 @@ export function assembleDraft(articleId: string): Promise<DraftAssembleResponse>
);
}
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
export function fetchDrafts(
articleId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<DraftListResponse> {
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`, { userEmail });
}
export function fetchDraft(
@@ -20,6 +20,7 @@ new Function("exports", compiled.outputText)(moduleExports);
const {
buildAdminScriptVersionRows,
buildAdminScriptAuditRows,
defaultScriptVersionDraft,
buildCreateScriptVersionPayload,
parseJsonObject,
@@ -53,3 +54,21 @@ assert.equal(payload.rollback_target_version_id, null);
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
assert.equal(parseJsonObject("[]"), null);
assert.deepEqual(parseJsonObject(""), {});
const auditRows = buildAdminScriptAuditRows([
{
id: "evt-1",
target_site_id: "site-1",
version_id: "ver-1",
event_type: "SCRIPT_CONFIG_VERSION_ROLLBACK",
actor_user_id: "user-1",
payload: { action: "rollback" },
created_at: "2026-05-21T12:00:00Z",
version: 2,
diff: { summary: "rollback event" },
rollback_target_version_id: "ver-0",
},
]);
assert.equal(auditRows.length, 1);
assert.equal(auditRows[0].eventType, "SCRIPT_CONFIG_VERSION_ROLLBACK");
assert.equal(auditRows[0].rollbackTargetVersionId, "ver-0");
+104 -110
View File
@@ -15,100 +15,14 @@ const compiled = ts.transpileModule(source, {
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildDetailSummary, buildProductionArtifactRows } = moduleExports;
const { buildArticleTimeline, buildJobDetailRows, buildDetailSummary, buildJobControlState } = moduleExports;
const rows = buildProductionArtifactRows([
{
id: "job-section-1",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold",
status: "SUCCEEDED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {
artifact_key: "section:1",
artifact_label: "Context and scope",
used_evidence_ids: ["e1", "e2"],
unsupported_claims: [],
},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 3,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
{
id: "job-section-2",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold",
status: "FAILED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {
artifact_key: "section:2",
artifact_label: "Implementation workflow",
used_evidence_ids: ["e2"],
unsupported_claims: [{ claim_text: "Unverified claim" }],
},
error_category: "UNSUPPORTED_CLAIMS_FOUND",
error_message: "Unsupported claims introduced during scaffolding: 1",
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 3,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
{
id: "job-non-artifact",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "TEST_CODEX",
agent_profile: "fake-codex",
status: "SUCCEEDED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 0,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
]);
assert.equal(rows.length, 2);
assert.equal(rows[0].artifactLabel, "Context and scope");
assert.equal(rows[0].status, "SUCCEEDED");
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
assert.equal(rows[1].status, "FAILED");
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
const detailSummary = buildDetailSummary({
const detail = {
article: {
id: "a1",
target_site_id: "site1",
status: "PUBLISH_COMMIT_CREATED",
publishing_status: "PUBLISH_COMMIT_CREATED",
status: "PARALLEL_PRODUCTION_RUNNING",
publishing_status: "PUBLISH_NOT_STARTED",
brief_description: "desc",
language: "en",
content_type: "longform_guide",
@@ -116,26 +30,106 @@ const detailSummary = buildDetailSummary({
updated_at: "2026-05-21T00:00:00Z",
},
target_site: { name: "Demo Site" },
workflow_events: [],
agent_jobs: [],
publish_commit: {
id: "pc1",
article_id: "a1",
target_site_id: "site1",
repository_url: "/tmp/repo.git",
branch: "main",
commit_sha: "abc123",
status: "PUBLISH_COMMIT_CREATED",
created_at: "2026-05-21T00:00:00Z",
content_bundle_manifest: {
validation: {
label: "Best-effort content-shape validation only.",
},
timeline: [
{
id: "2",
article_id: "a1",
entry_type: "AGENT_JOB",
source: "AGENT",
event_type: "SECTION_SCAFFOLD_FAILED",
from_status: null,
to_status: null,
actor_user_id: null,
job_id: "job2",
job_type: "SECTION_SCAFFOLD",
job_status: "FAILED",
retry_eligible: true,
cancel_eligible: false,
safe_failure_summary: "Section failed.",
payload: {},
created_at: "2026-05-21T00:00:02Z",
},
},
});
{
id: "1",
article_id: "a1",
entry_type: "WORKFLOW_EVENT",
source: "USER",
event_type: "PLAN_APPROVED",
from_status: "PLAN_REVIEW_REQUIRED",
to_status: "RESEARCH_RUNNING",
actor_user_id: "u1",
job_id: null,
job_type: null,
job_status: null,
retry_eligible: false,
cancel_eligible: false,
safe_failure_summary: null,
payload: {},
created_at: "2026-05-21T00:00:01Z",
},
],
workflow_events: [],
agent_jobs: [
{
id: "job2",
article_id: "a1",
parent_job_id: null,
attempt: 1,
job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold",
status: "FAILED",
workspace_path: null,
input_files: [],
output_files: [],
payload: { last_successful_step: "Outline written" },
error_category: "UNSUPPORTED_CLAIMS_FOUND",
error_message: "Unsupported claims introduced during scaffolding: 1",
stdout: "token=[REDACTED]",
stderr: "",
exit_code: 1,
duration_ms: 5,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
retry_eligible: true,
retry_block_reason: null,
cancel_eligible: false,
safe_failure_summary: "Section failed safely.",
},
],
publish_commit: null,
};
assert.equal(
detailSummary.publishingValidationLabel,
"Best-effort content-shape validation only.",
const timeline = buildArticleTimeline(detail);
assert.equal(timeline.length, 2);
assert.equal(timeline[0].eventType, "PLAN_APPROVED");
assert.equal(timeline[1].eventType, "SECTION_SCAFFOLD_FAILED");
assert.equal(timeline[1].source, "AGENT");
assert.equal(timeline[1].retryEligible, true);
const jobRows = buildJobDetailRows(detail.agent_jobs);
assert.equal(jobRows.length, 1);
assert.equal(jobRows[0].jobType, "SECTION_SCAFFOLD");
assert.equal(jobRows[0].status, "FAILED");
assert.equal(jobRows[0].errorCategory, "UNSUPPORTED_CLAIMS_FOUND");
assert.equal(jobRows[0].errorMessage, "Unsupported claims introduced during scaffolding: 1");
assert.equal(jobRows[0].lastSuccessfulStep, "Outline written");
assert.equal(jobRows[0].retryEligible, true);
assert.deepEqual(
buildJobControlState(jobRows[0], "ADMIN"),
{ showRetry: true, showCancel: false, showAdminLogs: true, showSafeSummary: false },
);
assert.deepEqual(
buildJobControlState(jobRows[0], "EDITOR"),
{ showRetry: false, showCancel: false, showAdminLogs: false, showSafeSummary: true },
);
const summary = buildDetailSummary(detail, {
viewerRole: "EDITOR",
viewerEmail: "editor@example.com",
});
assert.equal(summary.viewerRole, "EDITOR");
assert.equal(summary.viewerEmail, "editor@example.com");
assert.equal(summary.targetSite, "Demo Site");
assert.equal(summary.timeline.length, 2);
assert.equal(summary.jobDetails.length, 1);
+344
View File
@@ -142,6 +142,11 @@
"title": "Attempt",
"type": "integer"
},
"cancel_eligible": {
"default": false,
"title": "Cancel Eligible",
"type": "boolean"
},
"duration_ms": {
"anyOf": [
{
@@ -248,6 +253,35 @@
"title": "Queued At",
"type": "string"
},
"retry_block_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Retry Block Reason"
},
"retry_eligible": {
"default": false,
"title": "Retry Eligible",
"type": "boolean"
},
"safe_failure_summary": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Safe Failure Summary"
},
"started_at": {
"anyOf": [
{
@@ -509,6 +543,13 @@
],
"default": null
},
"timeline": {
"items": {
"$ref": "#/components/schemas/ObservabilityTimelineEventSummary"
},
"title": "Timeline",
"type": "array"
},
"workflow_events": {
"items": {
"$ref": "#/components/schemas/WorkflowEventSummary"
@@ -2441,6 +2482,148 @@
"title": "LanguageReviewRunResponse",
"type": "object"
},
"ObservabilityTimelineEventSummary": {
"additionalProperties": false,
"properties": {
"actor_user_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Actor User Id"
},
"article_id": {
"format": "uuid",
"title": "Article Id",
"type": "string"
},
"cancel_eligible": {
"default": false,
"title": "Cancel Eligible",
"type": "boolean"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"entry_type": {
"minLength": 1,
"title": "Entry Type",
"type": "string"
},
"event_type": {
"minLength": 1,
"title": "Event Type",
"type": "string"
},
"from_status": {
"anyOf": [
{
"$ref": "#/components/schemas/ArticleWorkflowStatus"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"job_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Job Id"
},
"job_status": {
"anyOf": [
{
"$ref": "#/components/schemas/AgentJobStatus"
},
{
"type": "null"
}
],
"default": null
},
"job_type": {
"anyOf": [
{
"$ref": "#/components/schemas/AgentJobType"
},
{
"type": "null"
}
],
"default": null
},
"payload": {
"additionalProperties": true,
"title": "Payload",
"type": "object"
},
"retry_eligible": {
"default": false,
"title": "Retry Eligible",
"type": "boolean"
},
"safe_failure_summary": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Safe Failure Summary"
},
"source": {
"minLength": 1,
"title": "Source",
"type": "string"
},
"to_status": {
"anyOf": [
{
"$ref": "#/components/schemas/ArticleWorkflowStatus"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"id",
"article_id",
"entry_type",
"source",
"event_type",
"created_at"
],
"title": "ObservabilityTimelineEventSummary",
"type": "object"
},
"PlanListResponse": {
"additionalProperties": false,
"properties": {
@@ -3591,6 +3774,108 @@
"title": "RunnerFileRef",
"type": "object"
},
"ScriptConfigVersionAuditEventListResponse": {
"additionalProperties": false,
"properties": {
"events": {
"items": {
"$ref": "#/components/schemas/ScriptConfigVersionAuditEventSummary"
},
"title": "Events",
"type": "array"
}
},
"title": "ScriptConfigVersionAuditEventListResponse",
"type": "object"
},
"ScriptConfigVersionAuditEventSummary": {
"additionalProperties": false,
"properties": {
"actor_user_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Actor User Id"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"diff": {
"additionalProperties": true,
"title": "Diff",
"type": "object"
},
"event_type": {
"minLength": 1,
"title": "Event Type",
"type": "string"
},
"id": {
"format": "uuid",
"title": "Id",
"type": "string"
},
"payload": {
"additionalProperties": true,
"title": "Payload",
"type": "object"
},
"rollback_target_version_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Rollback Target Version Id"
},
"target_site_id": {
"format": "uuid",
"title": "Target Site Id",
"type": "string"
},
"version": {
"anyOf": [
{
"minimum": 1,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Version"
},
"version_id": {
"format": "uuid",
"title": "Version Id",
"type": "string"
}
},
"required": [
"id",
"target_site_id",
"version_id",
"event_type",
"created_at"
],
"title": "ScriptConfigVersionAuditEventSummary",
"type": "object"
},
"ScriptConfigVersionCreateRequest": {
"additionalProperties": false,
"properties": {
@@ -8012,6 +8297,65 @@
]
}
},
"/api/sites/{site_id}/publishing-config/audit": {
"get": {
"operationId": "get_script_config_audit_events_api_sites__site_id__publishing_config_audit_get",
"parameters": [
{
"in": "path",
"name": "site_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Site Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScriptConfigVersionAuditEventListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Script Config Audit Events",
"tags": [
"sites"
]
}
},
"/api/sites/{site_id}/publishing-config/versions": {
"get": {
"operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get",
+41
View File
@@ -26,6 +26,7 @@ export type AgentJobSummary = {
agent_profile: string;
article_id?: string | null;
attempt?: number;
cancel_eligible?: boolean;
duration_ms?: number | null;
error_category?: AgentJobErrorCategory | null;
error_message?: string | null;
@@ -38,6 +39,9 @@ export type AgentJobSummary = {
parent_job_id?: string | null;
payload?: Record<string, unknown>;
queued_at: string;
retry_block_reason?: string | null;
retry_eligible?: boolean;
safe_failure_summary?: string | null;
started_at?: string | null;
status: AgentJobStatus;
stderr?: string;
@@ -78,6 +82,7 @@ export type ArticleDetailResponse = {
research_manifests?: ResearchArtifactManifestSummary[];
reviews?: ReviewSummary[];
target_site?: TargetSiteConfig | null;
timeline?: ObservabilityTimelineEventSummary[];
workflow_events?: WorkflowEventSummary[];
};
@@ -420,6 +425,25 @@ export type LanguageReviewRunResponse = {
report: ContentReviewReportSummary;
};
export type ObservabilityTimelineEventSummary = {
actor_user_id?: string | null;
article_id: string;
cancel_eligible?: boolean;
created_at: string;
entry_type: string;
event_type: string;
from_status?: ArticleWorkflowStatus | null;
id: string;
job_id?: string | null;
job_status?: AgentJobStatus | null;
job_type?: AgentJobType | null;
payload?: Record<string, unknown>;
retry_eligible?: boolean;
safe_failure_summary?: string | null;
source: string;
to_status?: ArticleWorkflowStatus | null;
};
export type PlanListResponse = {
plans: PlanSummary[];
};
@@ -610,6 +634,23 @@ export type RunnerFileRef = {
path: string;
};
export type ScriptConfigVersionAuditEventListResponse = {
events?: ScriptConfigVersionAuditEventSummary[];
};
export type ScriptConfigVersionAuditEventSummary = {
actor_user_id?: string | null;
created_at: string;
diff?: Record<string, unknown>;
event_type: string;
id: string;
payload?: Record<string, unknown>;
rollback_target_version_id?: string | null;
target_site_id: string;
version?: number | null;
version_id: string;
};
export type ScriptConfigVersionCreateRequest = {
activate?: boolean;
diff?: Record<string, unknown>;
+47 -14
View File
@@ -44,14 +44,14 @@ Development description: Build the operational layer for workflow history, job l
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
- [ ] Article detail page shows user, system, and agent events in order.
- [ ] Job logs are stored and displayed with sensitive values redacted.
- [ ] Retry buttons appear only where retry is allowed.
- [ ] Cancelling queued/running jobs prevents article state mutation.
- [ ] Publish commit retry is blocked once a publish commit exists.
- [ ] Admin can see detailed logs; Editor sees safe failure summary.
- [ ] All Admin script/config changes are audit-visible with diff and rollback target.
- [x] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
- [x] Article detail page shows user, system, and agent events in order.
- [x] Job logs are stored and displayed with sensitive values redacted.
- [x] Retry buttons appear only where retry is allowed.
- [x] Cancelling queued/running jobs prevents article state mutation.
- [x] Publish commit retry is blocked once a publish commit exists.
- [x] Admin can see detailed logs; Editor sees safe failure summary.
- [x] All Admin script/config changes are audit-visible with diff and rollback target.
## Verification
@@ -62,9 +62,42 @@ Development description: Build the operational layer for workflow history, job l
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Implemented and verified (GREEN).
- Green evidence:
- Backend observability implemented:
- role-aware job projection with `retry_eligible`, `retry_block_reason`, `cancel_eligible`, `safe_failure_summary`,
- redaction for payload/log/error message,
- mixed timeline (`USER`/`SYSTEM`/`AGENT`) sorted by event time.
- Retry policy enforced server-side:
- retry allowed only for configured job types,
- publish commit retry blocked if `PUBLISH_COMMIT_CREATED` already exists.
- Cancel behavior enforced:
- cancelled queued/running jobs stay `CANCELLED`,
- completion callback after cancellation does not mutate article workflow state.
- Admin/editor visibility:
- admin sees redacted `stdout`/`stderr`,
- editor sees safe failure summary and no raw logs.
- Script/config audit visibility:
- added `GET /api/sites/{site_id}/publishing-config/audit`,
- audit list includes `event_type`, `diff`, and `rollback_target_version_id`.
- Frontend:
- article detail timeline renders user/system/agent entries in order,
- failure table shows job type/status/error category/error message/last successful step,
- retry/cancel controls rendered only when policy allows,
- role-based diagnostics panel (admin logs vs editor summary),
- admin scripts page now shows audit trail with diff + rollback target.
- Contracts regenerated:
- `packages/shared/openapi.json`,
- `packages/shared/src/api-types.ts`.
- Refactor notes:
- Introduced focused backend module `apps/backend/src/application/observability.py` to keep redaction, retry policy, and timeline projection out of route handlers and domain-agnostic application services.
- Reused policy projection in both article detail and agent-job endpoints so UI and action gates stay consistent.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py`
- `Ran 7 tests in 0.704s` -> `OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_agent_job_queue_public_api.py apps/backend/tests/integration/test_parallel_production_public_api.py apps/backend/tests/integration/test_publishing_git_flow_public_api.py apps/backend/tests/integration/test_final_approval_gate_public_api.py apps/backend/tests/integration/test_script_config_audit.py`
- `Ran 23 tests in 4.656s` -> `OK`
- `node apps/frontend/tests/article_detail.model.test.mjs && node apps/frontend/tests/admin_script_versions.model.test.mjs`
- exit code `0` (pass)
- `pnpm --dir apps/frontend typecheck`
- `tsc --noEmit` -> pass