From 95fec3ba26e9752798f0d0f46ff1a0f1df283ddf Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 22 May 2026 01:23:07 +0300 Subject: [PATCH] feat(task-018): add observability retry cancel and audit trail --- apps/backend/src/application/agent_jobs.py | 158 +++++- apps/backend/src/application/articles.py | 17 +- apps/backend/src/application/observability.py | 239 +++++++++ apps/backend/src/application/site_config.py | 45 ++ apps/backend/src/domain/contracts/__init__.py | 6 + apps/backend/src/domain/contracts/models.py | 41 ++ apps/backend/src/domain/contracts/openapi.py | 6 + .../src/presentation/routes/agent_jobs.py | 18 +- .../src/presentation/routes/articles.py | 8 +- apps/backend/src/presentation/routes/sites.py | 17 + ...rvability_retry_cancel_audit_public_api.py | 493 ++++++++++++++++++ .../integration/test_script_config_audit.py | 2 + .../src/app/articles/[articleId]/page.tsx | 7 +- apps/frontend/src/app/globals.css | 18 + .../src/features/admin-scripts/model.ts | 27 + .../src/features/admin-scripts/ui.tsx | 52 +- .../src/features/article-detail/model.ts | 213 +++++--- .../src/features/article-detail/ui.tsx | 152 ++++-- .../src/pages/admin-scripts/index.tsx | 14 +- .../src/pages/article-detail/index.tsx | 20 +- apps/frontend/src/shared/pipeline-api.ts | 27 +- .../admin_script_versions.model.test.mjs | 19 + .../tests/article_detail.model.test.mjs | 214 ++++---- packages/shared/openapi.json | 344 ++++++++++++ packages/shared/src/api-types.ts | 41 ++ tasks/018-observability-retry-cancel-audit.md | 61 ++- 26 files changed, 1984 insertions(+), 275 deletions(-) create mode 100644 apps/backend/src/application/observability.py create mode 100644 apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py diff --git a/apps/backend/src/application/agent_jobs.py b/apps/backend/src/application/agent_jobs.py index 581c74f..99f85e5 100644 --- a/apps/backend/src/application/agent_jobs.py +++ b/apps/backend/src/application/agent_jobs.py @@ -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, + ) diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index a820e81..d0aa722 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -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, ) diff --git a/apps/backend/src/application/observability.py b/apps/backend/src/application/observability.py new file mode 100644 index 0000000..ae53ae8 --- /dev/null +++ b/apps/backend/src/application/observability.py @@ -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 diff --git a/apps/backend/src/application/site_config.py b/apps/backend/src/application/site_config.py index bc6bdb0..aceb06f 100644 --- a/apps/backend/src/application/site_config.py +++ b/apps/backend/src/application/site_config.py @@ -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"], diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index b3c1664..35a38e5 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -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", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index 34fac50..48f1e71 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -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 ) diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index b305123..12bf753 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -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, diff --git a/apps/backend/src/presentation/routes/agent_jobs.py b/apps/backend/src/presentation/routes/agent_jobs.py index 056d32c..c97c265 100644 --- a/apps/backend/src/presentation/routes/agent_jobs.py +++ b/apps/backend/src/presentation/routes/agent_jobs.py @@ -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 diff --git a/apps/backend/src/presentation/routes/articles.py b/apps/backend/src/presentation/routes/articles.py index 529c963..1791c05 100644 --- a/apps/backend/src/presentation/routes/articles.py +++ b/apps/backend/src/presentation/routes/articles.py @@ -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, diff --git a/apps/backend/src/presentation/routes/sites.py b/apps/backend/src/presentation/routes/sites.py index 70bace9..6364a57 100644 --- a/apps/backend/src/presentation/routes/sites.py +++ b/apps/backend/src/presentation/routes/sites.py @@ -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, diff --git a/apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py b/apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py new file mode 100644 index 0000000..9fbee55 --- /dev/null +++ b/apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py @@ -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() diff --git a/apps/backend/tests/integration/test_script_config_audit.py b/apps/backend/tests/integration/test_script_config_audit.py index 2988d89..088af39 100644 --- a/apps/backend/tests/integration/test_script_config_audit.py +++ b/apps/backend/tests/integration/test_script_config_audit.py @@ -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; }", diff --git a/apps/frontend/src/app/articles/[articleId]/page.tsx b/apps/frontend/src/app/articles/[articleId]/page.tsx index e545f7a..837208a 100644 --- a/apps/frontend/src/app/articles/[articleId]/page.tsx +++ b/apps/frontend/src/app/articles/[articleId]/page.tsx @@ -4,11 +4,16 @@ type DynamicArticleDetailPageProps = { params: { articleId: string; }; + searchParams?: { + role?: string; + }; }; export default async function DynamicArticleDetailPage({ params, + searchParams, }: DynamicArticleDetailPageProps) { const { articleId } = params; - return ; + const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor"; + return ; } diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 3719022..1822b15 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -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; } diff --git a/apps/frontend/src/features/admin-scripts/model.ts b/apps/frontend/src/features/admin-scripts/model.ts index 80f9bd4..a693633 100644 --- a/apps/frontend/src/features/admin-scripts/model.ts +++ b/apps/frontend/src/features/admin-scripts/model.ts @@ -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>; const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}'; @@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record | 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)); +} diff --git a/apps/frontend/src/features/admin-scripts/ui.tsx b/apps/frontend/src/features/admin-scripts/ui.tsx index 16d3142..4729c2f 100644 --- a/apps/frontend/src/features/admin-scripts/ui.tsx +++ b/apps/frontend/src/features/admin-scripts/ui.tsx @@ -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( initialVersions, ); + const [auditEvents, setAuditEvents] = useState( + initialAuditEvents, + ); const [draft, setDraft] = useState( 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( @@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({ )} + +

Audit trail

+ {auditRows.length === 0 ? ( +

No audit events yet.

+ ) : ( + + + + + + + + + + + + + {auditRows.map((event) => ( + + + + + + + + + ))} + +
EventVersionCreatedActorDiffRollback target
{event.eventType}{event.version ?? "—"}{new Date(event.createdAt).toLocaleString()}{event.actorUserId ?? "—"}{event.diff}{event.rollbackTargetVersionId ?? "—"}
+ )} ); } diff --git a/apps/frontend/src/features/article-detail/model.ts b/apps/frontend/src/features/article-detail/model.ts index a1ebe4a..2c3705a 100644 --- a/apps/frontend/src/features/article-detail/model.ts +++ b/apps/frontend/src/features/article-detail/model.ts @@ -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; + 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; - 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; -} diff --git a/apps/frontend/src/features/article-detail/ui.tsx b/apps/frontend/src/features/article-detail/ui.tsx index 3f0bf57..cc9f546 100644 --- a/apps/frontend/src/features/article-detail/ui.tsx +++ b/apps/frontend/src/features/article-detail/ui.tsx @@ -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 (
@@ -64,49 +108,93 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
    {timeline.map((event) => (
  • - {event.event_type} from {event.from_status ?? "—"} to{" "} - {event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()} - {event.actor_user_id ? • actor {event.actor_user_id} : null} +
    + {event.eventType} + {event.source} + {new Date(event.createdAt).toLocaleString()} +
    +
    + from {event.fromStatus ?? "—"} to {event.toStatus ?? "—"} + {event.actorUserId ? • actor {event.actorUserId} : null} + {event.jobType ? • {event.jobType} : null} + {event.jobStatus ? • {event.jobStatus} : null} +
  • ))} {timeline.length === 0 ?
  • No workflow events yet.
  • : null}
-

Production artifacts

+

Job failures and controls

+ {actionError ?

Error: {actionError}

: null} - + - - - + + + + + - {productionArtifacts.map((artifact) => ( - - - - - - - - ))} - {productionArtifacts.length === 0 ? ( + {jobDetails.map((job) => { + const isBusy = busyJobId === job.jobId; + const controls = buildJobControlState(job, summary.viewerRole); + return ( + + + + + + + + + + ); + })} + {jobDetails.length === 0 ? ( - + ) : null} diff --git a/apps/frontend/src/pages/admin-scripts/index.tsx b/apps/frontend/src/pages/admin-scripts/index.tsx index e33ca1a..70ff727 100644 --- a/apps/frontend/src/pages/admin-scripts/index.tsx +++ b/apps/frontend/src/pages/admin-scripts/index.tsx @@ -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 (
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
diff --git a/apps/frontend/src/pages/article-detail/index.tsx b/apps/frontend/src/pages/article-detail/index.tsx index a42d002..9c1b3d1 100644 --- a/apps/frontend/src/pages/article-detail/index.tsx +++ b/apps/frontend/src/pages/article-detail/index.tsx @@ -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 (
@@ -35,7 +43,7 @@ export default async function ArticleDetailPage({ Back to dashboard - +
diff --git a/apps/frontend/src/shared/pipeline-api.ts b/apps/frontend/src/shared/pipeline-api.ts index 2defeb1..a865bd5 100644 --- a/apps/frontend/src/shared/pipeline-api.ts +++ b/apps/frontend/src/shared/pipeline-api.ts @@ -43,6 +43,7 @@ import type { SeoReviewReportResponse, SeoReviewRunResponse, ScriptConfigVersionCreateRequest, + ScriptConfigVersionAuditEventListResponse, ScriptConfigVersionListResponse, ScriptConfigVersionResponse, TargetSiteConfigResponse, @@ -183,8 +184,10 @@ export function apiPatch( ); } -export function fetchCurrentUser(): Promise { - return apiGet("/api/me"); +export function fetchCurrentUser( + userEmail = DEMO_EDITOR_EMAIL, +): Promise { + return apiGet("/api/me", { userEmail }); } export function fetchAgentJobs( @@ -266,6 +269,16 @@ export function fetchScriptConfigVersions( ); } +export function fetchScriptConfigAuditEvents( + siteId: string, + userEmail = DEMO_EDITOR_EMAIL, +): Promise { + return apiGet( + `/api/sites/${siteId}/publishing-config/audit`, + { userEmail }, + ); +} + export function createScriptConfigVersion( siteId: string, request: ScriptConfigVersionCreateRequest, @@ -308,8 +321,9 @@ export function fetchArticles(): Promise { export function fetchArticleDetail( articleId: string, + userEmail = DEMO_EDITOR_EMAIL, ): Promise { - return apiGet(`/api/articles/${articleId}`); + return apiGet(`/api/articles/${articleId}`, { userEmail }); } export function createArticle( @@ -455,8 +469,11 @@ export function assembleDraft(articleId: string): Promise ); } -export function fetchDrafts(articleId: string): Promise { - return apiGet(`/api/articles/${articleId}/drafts`); +export function fetchDrafts( + articleId: string, + userEmail = DEMO_EDITOR_EMAIL, +): Promise { + return apiGet(`/api/articles/${articleId}/drafts`, { userEmail }); } export function fetchDraft( diff --git a/apps/frontend/tests/admin_script_versions.model.test.mjs b/apps/frontend/tests/admin_script_versions.model.test.mjs index bf3d695..d0ceb47 100644 --- a/apps/frontend/tests/admin_script_versions.model.test.mjs +++ b/apps/frontend/tests/admin_script_versions.model.test.mjs @@ -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"); diff --git a/apps/frontend/tests/article_detail.model.test.mjs b/apps/frontend/tests/article_detail.model.test.mjs index 19f5094..1fb01d2 100644 --- a/apps/frontend/tests/article_detail.model.test.mjs +++ b/apps/frontend/tests/article_detail.model.test.mjs @@ -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); diff --git a/packages/shared/openapi.json b/packages/shared/openapi.json index d81b518..5d16c4b 100644 --- a/packages/shared/openapi.json +++ b/packages/shared/openapi.json @@ -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", diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index 9b7aaea..95a0704 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -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; 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; + 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; + event_type: string; + id: string; + payload?: Record; + rollback_target_version_id?: string | null; + target_site_id: string; + version?: number | null; + version_id: string; +}; + export type ScriptConfigVersionCreateRequest = { activate?: boolean; diff?: Record; diff --git a/tasks/018-observability-retry-cancel-audit.md b/tasks/018-observability-retry-cancel-audit.md index 0b231f1..c8800e5 100644 --- a/tasks/018-observability-retry-cancel-audit.md +++ b/tasks/018-observability-retry-cancel-audit.md @@ -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
ArtifactJob type StatusAttemptUsed evidence IDsUnsupported claimsError categoryError messageLast successful stepControls{isAdmin ? "Logs" : "Safe summary"}
-
{artifact.artifactLabel}
-
{artifact.artifactKey}
-
{artifact.status}{artifact.attempt} - {artifact.usedEvidenceIds.length - ? artifact.usedEvidenceIds.join(", ") - : "—"} - - {artifact.unsupportedClaims.length - ? artifact.unsupportedClaims.join("; ") - : artifact.errorMessage ?? "—"} -
+
{job.jobType}
+
attempt {job.attempt}
+
{job.status}{job.errorCategory ?? "—"} + {job.errorMessage ?? "—"} + {!job.errorMessage && job.retryBlockReason ? ( +
{job.retryBlockReason}
+ ) : null} +
{job.lastSuccessfulStep ?? "—"} + {controls.showRetry ? ( + + ) : null} + {controls.showCancel ? ( + + ) : null} + {!controls.showRetry && !controls.showCancel ? "—" : null} + + {controls.showAdminLogs ? ( + <> +
{job.stdout || "stdout: —"}
+
{job.stderr || "stderr: —"}
+ + ) : ( + job.safeFailureSummary ?? "—" + )} +
No production artifacts yet.No agent jobs yet.