feat(task-018): add observability retry cancel and audit trail
This commit is contained in:
@@ -12,9 +12,12 @@ from src.domain.contracts import (
|
|||||||
AgentJobOutput,
|
AgentJobOutput,
|
||||||
AgentJobResponse,
|
AgentJobResponse,
|
||||||
AgentJobStatus,
|
AgentJobStatus,
|
||||||
|
AgentJobSummary,
|
||||||
AgentJobTestCodexRequest,
|
AgentJobTestCodexRequest,
|
||||||
AgentJobType,
|
AgentJobType,
|
||||||
|
Role,
|
||||||
)
|
)
|
||||||
|
from src.application.observability import evaluate_retry_policy, project_job_for_view
|
||||||
|
|
||||||
|
|
||||||
VALID_FAKE_AGENT_PROFILE = "fake-codex"
|
VALID_FAKE_AGENT_PROFILE = "fake-codex"
|
||||||
@@ -44,15 +47,27 @@ def create_test_codex_job(
|
|||||||
|
|
||||||
|
|
||||||
def list_agent_jobs(repository: object) -> AgentJobListResponse:
|
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:
|
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)
|
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(
|
retry = repository.agent_jobs.create(
|
||||||
article_id=parent.article_id,
|
article_id=parent.article_id,
|
||||||
parent_job_id=parent.id,
|
parent_job_id=parent.id,
|
||||||
@@ -62,13 +77,42 @@ def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
|||||||
status=AgentJobStatus.QUEUED,
|
status=AgentJobStatus.QUEUED,
|
||||||
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
|
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
|
||||||
payload=parent.payload,
|
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:
|
def cancel_agent_job(
|
||||||
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now()))
|
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:
|
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
|
||||||
@@ -93,6 +137,7 @@ def complete_agent_job(
|
|||||||
try:
|
try:
|
||||||
validated_output = AgentJobOutput.model_validate(output)
|
validated_output = AgentJobOutput.model_validate(output)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
|
finished_at = _now()
|
||||||
job = repository.agent_jobs.complete(
|
job = repository.agent_jobs.complete(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
status=AgentJobStatus.FAILED,
|
status=AgentJobStatus.FAILED,
|
||||||
@@ -105,9 +150,15 @@ def complete_agent_job(
|
|||||||
stderr=stderr,
|
stderr=stderr,
|
||||||
exit_code=exit_code,
|
exit_code=exit_code,
|
||||||
duration_ms=duration_ms,
|
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(
|
merged_payload = _merge_payload(
|
||||||
base_payload=existing_job.payload,
|
base_payload=existing_job.payload,
|
||||||
@@ -133,6 +184,7 @@ def complete_agent_job(
|
|||||||
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
|
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
finished_at = _now()
|
||||||
job = repository.agent_jobs.complete(
|
job = repository.agent_jobs.complete(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
status=status,
|
status=status,
|
||||||
@@ -147,9 +199,15 @@ def complete_agent_job(
|
|||||||
stderr=stderr,
|
stderr=stderr,
|
||||||
exit_code=exit_code,
|
exit_code=exit_code,
|
||||||
duration_ms=duration_ms,
|
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:
|
def _now() -> datetime:
|
||||||
@@ -164,9 +222,13 @@ def _normalize_completion_status(
|
|||||||
) -> AgentJobStatus:
|
) -> AgentJobStatus:
|
||||||
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||||
return AgentJobStatus.FAILED
|
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,
|
None,
|
||||||
0,
|
0,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return AgentJobStatus.SUCCEEDED
|
return AgentJobStatus.SUCCEEDED
|
||||||
return status
|
return status
|
||||||
@@ -201,3 +263,75 @@ def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
|
|||||||
if isinstance(claims, list):
|
if isinstance(claims, list):
|
||||||
return claims
|
return claims
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
|
||||||
|
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_completion_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
previous: AgentJobSummary,
|
||||||
|
current: AgentJobSummary,
|
||||||
|
created_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
if current.article_id is None:
|
||||||
|
return
|
||||||
|
if previous.status == current.status:
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.status == AgentJobStatus.FAILED:
|
||||||
|
retry_eligible, _ = evaluate_retry_policy(repository, current)
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=current,
|
||||||
|
event_type="AGENT_JOB_FAILED",
|
||||||
|
actor_user_id=None,
|
||||||
|
created_at=created_at,
|
||||||
|
payload={
|
||||||
|
"job_type": current.job_type.value,
|
||||||
|
"attempt": current.attempt,
|
||||||
|
"error_category": (
|
||||||
|
current.error_category.value if current.error_category is not None else None
|
||||||
|
),
|
||||||
|
"error_message": current.error_message,
|
||||||
|
"retry_eligible": retry_eligible,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.status == AgentJobStatus.SUCCEEDED:
|
||||||
|
_record_job_event(
|
||||||
|
repository,
|
||||||
|
job=current,
|
||||||
|
event_type="AGENT_JOB_SUCCEEDED",
|
||||||
|
actor_user_id=None,
|
||||||
|
created_at=created_at,
|
||||||
|
payload={
|
||||||
|
"job_type": current.job_type.value,
|
||||||
|
"attempt": current.attempt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_job_event(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
job: AgentJobSummary,
|
||||||
|
event_type: str,
|
||||||
|
actor_user_id: UUID | None,
|
||||||
|
created_at: datetime,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if job.article_id is None:
|
||||||
|
return
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=job.article_id,
|
||||||
|
event_type=event_type,
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
payload={"job_id": str(job.id), **payload},
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from src.domain.contracts import (
|
|||||||
CurrentUser,
|
CurrentUser,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
)
|
)
|
||||||
|
from src.application.observability import (
|
||||||
|
build_observability_timeline,
|
||||||
|
project_job_for_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_article(
|
def create_article(
|
||||||
@@ -54,6 +58,7 @@ def list_articles(repository: object) -> ArticleListResponse:
|
|||||||
def get_article_detail(
|
def get_article_detail(
|
||||||
repository: object,
|
repository: object,
|
||||||
article_id: UUID,
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
) -> ArticleDetailResponse:
|
) -> ArticleDetailResponse:
|
||||||
article = repository.articles.get(article_id)
|
article = repository.articles.get(article_id)
|
||||||
target_site = repository.target_sites.get_by_id(article.target_site_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)
|
claims = repository.claims.list_for_article(article_id)
|
||||||
assets = repository.assets.list_for_article(article_id)
|
assets = repository.assets.list_for_article(article_id)
|
||||||
agent_jobs = repository.agent_jobs.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)
|
publish_commit = repository.publish_commits.latest_for_article(article_id)
|
||||||
return ArticleDetailResponse(
|
return ArticleDetailResponse(
|
||||||
article=article,
|
article=article,
|
||||||
@@ -77,7 +91,8 @@ def get_article_detail(
|
|||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
claims=claims,
|
claims=claims,
|
||||||
assets=assets,
|
assets=assets,
|
||||||
agent_jobs=agent_jobs,
|
agent_jobs=projected_jobs,
|
||||||
|
timeline=timeline,
|
||||||
research_manifests=research_manifests,
|
research_manifests=research_manifests,
|
||||||
publish_commit=publish_commit,
|
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 (
|
from src.domain.contracts import (
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
@@ -231,6 +233,49 @@ def list_script_config_versions(
|
|||||||
return [_script_version_summary(row) for row in rows]
|
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:
|
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
|
||||||
return ScriptConfigVersionSummary(
|
return ScriptConfigVersionSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from .models import (
|
|||||||
BoundaryQuestionSummary,
|
BoundaryQuestionSummary,
|
||||||
BoundaryQuestionUpdateRequest,
|
BoundaryQuestionUpdateRequest,
|
||||||
WorkflowEventSummary,
|
WorkflowEventSummary,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
AssetGenerateSpecsResponse,
|
AssetGenerateSpecsResponse,
|
||||||
AssetListResponse,
|
AssetListResponse,
|
||||||
AssetResponse,
|
AssetResponse,
|
||||||
@@ -92,6 +93,8 @@ from .models import (
|
|||||||
FinalReviewIssueSummary,
|
FinalReviewIssueSummary,
|
||||||
FinalReviewIssuesResponse,
|
FinalReviewIssuesResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
@@ -129,6 +132,7 @@ __all__ = [
|
|||||||
"BoundaryQuestionSummary",
|
"BoundaryQuestionSummary",
|
||||||
"BoundaryQuestionUpdateRequest",
|
"BoundaryQuestionUpdateRequest",
|
||||||
"WorkflowEventSummary",
|
"WorkflowEventSummary",
|
||||||
|
"ObservabilityTimelineEventSummary",
|
||||||
"AssetGenerateSpecsResponse",
|
"AssetGenerateSpecsResponse",
|
||||||
"AssetListResponse",
|
"AssetListResponse",
|
||||||
"AssetResponse",
|
"AssetResponse",
|
||||||
@@ -199,6 +203,8 @@ __all__ = [
|
|||||||
"ReviewType",
|
"ReviewType",
|
||||||
"Role",
|
"Role",
|
||||||
"RunnerFileRef",
|
"RunnerFileRef",
|
||||||
|
"ScriptConfigVersionAuditEventListResponse",
|
||||||
|
"ScriptConfigVersionAuditEventSummary",
|
||||||
"SeoReviewRunResponse",
|
"SeoReviewRunResponse",
|
||||||
"SeoReviewReportResponse",
|
"SeoReviewReportResponse",
|
||||||
"ScriptConfigVersionCreateRequest",
|
"ScriptConfigVersionCreateRequest",
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ class ScriptConfigVersionListResponse(ContractModel):
|
|||||||
versions: list[ScriptConfigVersionSummary]
|
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):
|
class ArticleCreateRequest(ContractModel):
|
||||||
target_site_id: UUID
|
target_site_id: UUID
|
||||||
brief_description: str = Field(min_length=1)
|
brief_description: str = Field(min_length=1)
|
||||||
@@ -185,6 +202,25 @@ class WorkflowEventSummary(ContractModel):
|
|||||||
created_at: datetime
|
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):
|
class BoundaryQuestionSummary(ContractModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
article_id: UUID
|
article_id: UUID
|
||||||
@@ -493,6 +529,10 @@ class AgentJobSummary(ContractModel):
|
|||||||
queued_at: datetime
|
queued_at: datetime
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
finished_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):
|
class AgentJobTestCodexRequest(ContractModel):
|
||||||
@@ -709,6 +749,7 @@ class ArticleDetailResponse(ContractModel):
|
|||||||
assets: list[AssetSummary] = Field(default_factory=list)
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
reviews: list[ReviewSummary] = Field(default_factory=list)
|
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||||
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||||
|
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
|
||||||
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||||
default_factory=list
|
default_factory=list
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from .models import (
|
|||||||
BoundaryQuestionResponse,
|
BoundaryQuestionResponse,
|
||||||
BoundaryQuestionSummary,
|
BoundaryQuestionSummary,
|
||||||
BoundaryQuestionUpdateRequest,
|
BoundaryQuestionUpdateRequest,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
AssetGenerateSpecsResponse,
|
AssetGenerateSpecsResponse,
|
||||||
AssetListResponse,
|
AssetListResponse,
|
||||||
AssetResponse,
|
AssetResponse,
|
||||||
@@ -98,6 +99,8 @@ from .models import (
|
|||||||
SeoReviewReportResponse,
|
SeoReviewReportResponse,
|
||||||
SeoReviewRunResponse,
|
SeoReviewRunResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
@@ -143,11 +146,14 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
ArticleCreateResponse,
|
ArticleCreateResponse,
|
||||||
ArticleListResponse,
|
ArticleListResponse,
|
||||||
WorkflowEventSummary,
|
WorkflowEventSummary,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
BoundaryQuestionSummary,
|
BoundaryQuestionSummary,
|
||||||
BoundaryQuestionUpdateRequest,
|
BoundaryQuestionUpdateRequest,
|
||||||
BoundaryQuestionResponse,
|
BoundaryQuestionResponse,
|
||||||
|
|||||||
@@ -81,23 +81,33 @@ def get_agent_job_route(
|
|||||||
)
|
)
|
||||||
def post_retry_agent_job(
|
def post_retry_agent_job(
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
repository: BackendRepository = Depends(get_repository),
|
repository: BackendRepository = Depends(get_repository),
|
||||||
) -> AgentJobResponse:
|
) -> AgentJobResponse:
|
||||||
try:
|
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:
|
except LookupError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from 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)
|
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
|
||||||
def post_cancel_agent_job(
|
def post_cancel_agent_job(
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||||
repository: BackendRepository = Depends(get_repository),
|
repository: BackendRepository = Depends(get_repository),
|
||||||
) -> AgentJobResponse:
|
) -> AgentJobResponse:
|
||||||
try:
|
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:
|
except LookupError as error:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from 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(
|
def get_article(
|
||||||
article_id: UUID,
|
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),
|
repository: BackendRepository = Depends(get_repository),
|
||||||
) -> ArticleDetailResponse:
|
) -> ArticleDetailResponse:
|
||||||
try:
|
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:
|
except LookupError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from src.application.site_config import (
|
|||||||
create_script_config_version,
|
create_script_config_version,
|
||||||
create_target_site,
|
create_target_site,
|
||||||
get_target_site,
|
get_target_site,
|
||||||
|
list_script_config_audit_events,
|
||||||
list_script_config_versions,
|
list_script_config_versions,
|
||||||
rollback_script_config_version,
|
rollback_script_config_version,
|
||||||
update_target_site,
|
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.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||||
from src.domain.contracts import (
|
from src.domain.contracts import (
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
@@ -92,6 +94,21 @@ def get_script_config_versions(
|
|||||||
return ScriptConfigVersionListResponse(versions=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(
|
@router.post(
|
||||||
"/sites/{site_id}/publishing-config/versions",
|
"/sites/{site_id}/publishing-config/versions",
|
||||||
response_model=ScriptConfigVersionResponse,
|
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,
|
activate: bool = False,
|
||||||
rollback_target_version_id: UUID | None = None,
|
rollback_target_version_id: UUID | None = None,
|
||||||
) -> ScriptConfigVersionCreateRequest:
|
) -> ScriptConfigVersionCreateRequest:
|
||||||
|
from src.domain.contracts import ScriptConfigVersionCreateRequest
|
||||||
|
|
||||||
payload = ScriptConfigVersionCreateRequest(
|
payload = ScriptConfigVersionCreateRequest(
|
||||||
publishing_yaml="target:\n repo: audit\n",
|
publishing_yaml="target:\n repo: audit\n",
|
||||||
transform_script="export function transformArticle(article) { return article; }",
|
transform_script="export function transformArticle(article) { return article; }",
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ type DynamicArticleDetailPageProps = {
|
|||||||
params: {
|
params: {
|
||||||
articleId: string;
|
articleId: string;
|
||||||
};
|
};
|
||||||
|
searchParams?: {
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DynamicArticleDetailPage({
|
export default async function DynamicArticleDetailPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: DynamicArticleDetailPageProps) {
|
}: DynamicArticleDetailPageProps) {
|
||||||
const { articleId } = params;
|
const { articleId } = params;
|
||||||
return <ArticleDetailPage articleId={articleId} />;
|
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
|
||||||
|
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,24 @@ main {
|
|||||||
padding: 10px 0;
|
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 {
|
.draftEditor {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionSummary,
|
ScriptConfigVersionSummary,
|
||||||
} from "@pipeline/shared";
|
} from "@pipeline/shared";
|
||||||
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
|
|||||||
rollbackTargetVersionId: string;
|
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>>;
|
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
|
||||||
|
|
||||||
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
|
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;
|
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 { 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 {
|
import {
|
||||||
|
buildAdminScriptAuditRows,
|
||||||
buildAdminScriptVersionRows,
|
buildAdminScriptVersionRows,
|
||||||
buildCreateScriptVersionPayload,
|
buildCreateScriptVersionPayload,
|
||||||
defaultScriptVersionDraft,
|
defaultScriptVersionDraft,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
type AdminScriptVersionsPanelProps = {
|
type AdminScriptVersionsPanelProps = {
|
||||||
site: TargetSiteConfig;
|
site: TargetSiteConfig;
|
||||||
initialVersions: readonly ScriptConfigVersionSummary[];
|
initialVersions: readonly ScriptConfigVersionSummary[];
|
||||||
|
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseOptionalError(value: string): string | null {
|
function parseOptionalError(value: string): string | null {
|
||||||
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
|
|||||||
export function AdminScriptVersionsPanel({
|
export function AdminScriptVersionsPanel({
|
||||||
site,
|
site,
|
||||||
initialVersions,
|
initialVersions,
|
||||||
|
initialAuditEvents,
|
||||||
}: AdminScriptVersionsPanelProps) {
|
}: AdminScriptVersionsPanelProps) {
|
||||||
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
||||||
initialVersions,
|
initialVersions,
|
||||||
);
|
);
|
||||||
|
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
|
||||||
|
initialAuditEvents,
|
||||||
|
);
|
||||||
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
||||||
defaultScriptVersionDraft(),
|
defaultScriptVersionDraft(),
|
||||||
);
|
);
|
||||||
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
|
|||||||
buildAdminScriptVersionRows(versions),
|
buildAdminScriptVersionRows(versions),
|
||||||
[versions],
|
[versions],
|
||||||
);
|
);
|
||||||
|
const auditRows = useMemo(
|
||||||
|
() => buildAdminScriptAuditRows(auditEvents),
|
||||||
|
[auditEvents],
|
||||||
|
);
|
||||||
|
|
||||||
const hasVersions = rows.length > 0;
|
const hasVersions = rows.length > 0;
|
||||||
const canSubmit = !isSubmitting;
|
const canSubmit = !isSubmitting;
|
||||||
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
||||||
|
|
||||||
async function refreshVersions() {
|
async function refreshVersions() {
|
||||||
const latest = await fetchScriptConfigVersions(
|
const [latest, audit] = await Promise.all([
|
||||||
|
fetchScriptConfigVersions(
|
||||||
site.id,
|
site.id,
|
||||||
DEMO_ADMIN_EMAIL,
|
DEMO_ADMIN_EMAIL,
|
||||||
);
|
),
|
||||||
|
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
|
||||||
|
]);
|
||||||
setVersions(latest.versions);
|
setVersions(latest.versions);
|
||||||
|
setAuditEvents(audit.events ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
||||||
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</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<
|
export type ArticleTimelineItem = {
|
||||||
WorkflowEventSummary,
|
id: string;
|
||||||
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
|
|
||||||
> & {
|
|
||||||
articleId: 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 = {
|
export type DetailSummary = {
|
||||||
@@ -15,35 +47,53 @@ export type DetailSummary = {
|
|||||||
briefDescription: string;
|
briefDescription: string;
|
||||||
targetSite: string;
|
targetSite: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
viewerRole: Role;
|
||||||
|
viewerEmail: string;
|
||||||
timeline: ArticleTimelineItem[];
|
timeline: ArticleTimelineItem[];
|
||||||
productionArtifacts: ProductionArtifactRow[];
|
jobDetails: JobDetailRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProductionArtifactRow = {
|
export type JobControlState = {
|
||||||
jobId: string;
|
showRetry: boolean;
|
||||||
artifactKey: string;
|
showCancel: boolean;
|
||||||
artifactLabel: string;
|
showAdminLogs: boolean;
|
||||||
status: string;
|
showSafeSummary: boolean;
|
||||||
attempt: number;
|
|
||||||
jobType: string;
|
|
||||||
usedEvidenceIds: string[];
|
|
||||||
unsupportedClaims: string[];
|
|
||||||
errorMessage: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
|
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
|
||||||
return (detail.workflow_events ?? []).map((event) => ({
|
const timeline = detail.timeline ?? [];
|
||||||
id: event.id,
|
if (timeline.length > 0) {
|
||||||
event_type: event.event_type,
|
return timeline
|
||||||
to_status: event.to_status,
|
.map((entry) => normalizeTimelineEntry(entry))
|
||||||
from_status: event.from_status,
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||||
actor_user_id: event.actor_user_id,
|
|
||||||
created_at: event.created_at,
|
|
||||||
articleId: event.article_id,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
|
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,
|
||||||
|
options: { viewerRole: Role; viewerEmail: string },
|
||||||
|
): DetailSummary {
|
||||||
|
const { viewerRole, viewerEmail } = options;
|
||||||
return {
|
return {
|
||||||
articleId: detail.article.id,
|
articleId: detail.article.id,
|
||||||
status: detail.article.status,
|
status: detail.article.status,
|
||||||
@@ -52,8 +102,47 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
|
|||||||
briefDescription: detail.article.brief_description,
|
briefDescription: detail.article.brief_description,
|
||||||
targetSite: detail.target_site?.name ?? "Unknown",
|
targetSite: detail.target_site?.name ?? "Unknown",
|
||||||
updatedAt: detail.article.updated_at,
|
updatedAt: detail.article.updated_at,
|
||||||
|
viewerRole,
|
||||||
|
viewerEmail,
|
||||||
timeline: buildArticleTimeline(detail),
|
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.";
|
return "Best-effort content-shape validation only.";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProductionArtifactRows(
|
function normalizeTimelineEntry(entry: ObservabilityTimelineEventSummary): ArticleTimelineItem {
|
||||||
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 {
|
return {
|
||||||
jobId: job.id,
|
id: entry.id,
|
||||||
artifactKey,
|
articleId: entry.article_id,
|
||||||
artifactLabel,
|
entryType: entry.entry_type,
|
||||||
status: job.status,
|
source: entry.source,
|
||||||
attempt: job.attempt ?? 1,
|
eventType: entry.event_type,
|
||||||
jobType: job.job_type,
|
fromStatus: entry.from_status ?? null,
|
||||||
usedEvidenceIds: stringArray(payload.used_evidence_ids),
|
toStatus: entry.to_status ?? null,
|
||||||
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
|
actorUserId: entry.actor_user_id ?? null,
|
||||||
errorMessage: job.error_message ?? 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,
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.sort((left, right) => left.artifactLabel.localeCompare(right.artifactLabel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringValue(value: unknown): string {
|
function stringValue(value: unknown): string {
|
||||||
return typeof value === "string" ? value : "";
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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";
|
import type { DetailSummary } from "./model";
|
||||||
|
|
||||||
type DetailShellProps = {
|
type DetailShellProps = {
|
||||||
@@ -7,8 +13,46 @@ type DetailShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ArticleDetailShell({ summary }: DetailShellProps) {
|
export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [busyJobId, setBusyJobId] = useState("");
|
||||||
const timeline = summary.timeline;
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -64,49 +108,93 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
|||||||
<ul className="timeline">
|
<ul className="timeline">
|
||||||
{timeline.map((event) => (
|
{timeline.map((event) => (
|
||||||
<li key={event.id}>
|
<li key={event.id}>
|
||||||
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
|
<div className="timelineMeta">
|
||||||
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
|
<strong>{event.eventType}</strong>
|
||||||
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
|
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Production artifacts</h3>
|
<h3>Job failures and controls</h3>
|
||||||
|
{actionError ? <p className="formError">Error: {actionError}</p> : null}
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Artifact</th>
|
<th>Job type</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Attempt</th>
|
<th>Error category</th>
|
||||||
<th>Used evidence IDs</th>
|
<th>Error message</th>
|
||||||
<th>Unsupported claims</th>
|
<th>Last successful step</th>
|
||||||
|
<th>Controls</th>
|
||||||
|
<th>{isAdmin ? "Logs" : "Safe summary"}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{productionArtifacts.map((artifact) => (
|
{jobDetails.map((job) => {
|
||||||
<tr key={artifact.jobId}>
|
const isBusy = busyJobId === job.jobId;
|
||||||
|
const controls = buildJobControlState(job, summary.viewerRole);
|
||||||
|
return (
|
||||||
|
<tr key={job.jobId}>
|
||||||
<td>
|
<td>
|
||||||
<div>{artifact.artifactLabel}</div>
|
<div>{job.jobType}</div>
|
||||||
<div>{artifact.artifactKey}</div>
|
<div>attempt {job.attempt}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{artifact.status}</td>
|
<td>{job.status}</td>
|
||||||
<td>{artifact.attempt}</td>
|
<td>{job.errorCategory ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
{artifact.usedEvidenceIds.length
|
{job.errorMessage ?? "—"}
|
||||||
? artifact.usedEvidenceIds.join(", ")
|
{!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>
|
||||||
<td>
|
<td>
|
||||||
{artifact.unsupportedClaims.length
|
{controls.showAdminLogs ? (
|
||||||
? artifact.unsupportedClaims.join("; ")
|
<>
|
||||||
: artifact.errorMessage ?? "—"}
|
<div>{job.stdout || "stdout: —"}</div>
|
||||||
|
<div>{job.stderr || "stderr: —"}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
job.safeFailureSummary ?? "—"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
{productionArtifacts.length === 0 ? (
|
})}
|
||||||
|
{jobDetails.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>No production artifacts yet.</td>
|
<td colSpan={7}>No agent jobs yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
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 { RoleNavigation } from "@/widgets/role-navigation";
|
||||||
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
|
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 selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
|
||||||
const activeSite = selectedResponse.site;
|
const activeSite = selectedResponse.site;
|
||||||
const versionsResponse = await fetchScriptConfigVersions(
|
const [versionsResponse, auditResponse] = await Promise.all([
|
||||||
|
fetchScriptConfigVersions(
|
||||||
selectedSite.id,
|
selectedSite.id,
|
||||||
DEMO_ADMIN_EMAIL,
|
DEMO_ADMIN_EMAIL,
|
||||||
);
|
),
|
||||||
|
fetchScriptConfigAuditEvents(selectedSite.id, DEMO_ADMIN_EMAIL),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
|
|||||||
<AdminScriptVersionsPanel
|
<AdminScriptVersionsPanel
|
||||||
site={activeSite}
|
site={activeSite}
|
||||||
initialVersions={versionsResponse.versions}
|
initialVersions={versionsResponse.versions}
|
||||||
|
initialAuditEvents={auditResponse.events ?? []}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -9,22 +9,30 @@ import { DraftEditorPanel } from "@/features/article-draft/ui";
|
|||||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||||
import { FinalApprovalPanel } from "@/features/final-approval/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";
|
import { ApiError } from "@/shared/pipeline-api";
|
||||||
|
|
||||||
type ArticleDetailPageProps = {
|
type ArticleDetailPageProps = {
|
||||||
articleId: string;
|
articleId: string;
|
||||||
|
viewerRoleHint?: "admin" | "editor";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ArticleDetailPage({
|
export default async function ArticleDetailPage({
|
||||||
articleId,
|
articleId,
|
||||||
|
viewerRoleHint,
|
||||||
}: ArticleDetailPageProps) {
|
}: ArticleDetailPageProps) {
|
||||||
|
const selectedEmail = viewerRoleHint === "admin" ? DEMO_ADMIN_EMAIL : DEMO_EDITOR_EMAIL;
|
||||||
try {
|
try {
|
||||||
const [detail, draftsResponse] = await Promise.all([
|
const [currentUserResponse, detail, draftsResponse] = await Promise.all([
|
||||||
fetchArticleDetail(articleId),
|
fetchCurrentUser(selectedEmail),
|
||||||
fetchDrafts(articleId),
|
fetchArticleDetail(articleId, selectedEmail),
|
||||||
|
fetchDrafts(articleId, selectedEmail),
|
||||||
]);
|
]);
|
||||||
const summary = buildDetailSummary(detail);
|
const viewer = currentUserResponse.user;
|
||||||
|
const summary = buildDetailSummary(detail, {
|
||||||
|
viewerRole: viewer.role,
|
||||||
|
viewerEmail: selectedEmail,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -35,7 +43,7 @@ export default async function ArticleDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
<Link href="/">Back to dashboard</Link>
|
<Link href="/">Back to dashboard</Link>
|
||||||
</header>
|
</header>
|
||||||
<RoleNavigation role="EDITOR" />
|
<RoleNavigation role={summary.viewerRole} />
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<ArticleDetailShell summary={summary} />
|
<ArticleDetailShell summary={summary} />
|
||||||
<MediaLibraryPanel assets={detail.assets ?? []} />
|
<MediaLibraryPanel assets={detail.assets ?? []} />
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type {
|
|||||||
SeoReviewReportResponse,
|
SeoReviewReportResponse,
|
||||||
SeoReviewRunResponse,
|
SeoReviewRunResponse,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
TargetSiteConfigResponse,
|
TargetSiteConfigResponse,
|
||||||
@@ -183,8 +184,10 @@ export function apiPatch<TRequest, TResponse>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
|
export function fetchCurrentUser(
|
||||||
return apiGet<CurrentUserResponse>("/api/me");
|
userEmail = DEMO_EDITOR_EMAIL,
|
||||||
|
): Promise<CurrentUserResponse> {
|
||||||
|
return apiGet<CurrentUserResponse>("/api/me", { userEmail });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAgentJobs(
|
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(
|
export function createScriptConfigVersion(
|
||||||
siteId: string,
|
siteId: string,
|
||||||
request: ScriptConfigVersionCreateRequest,
|
request: ScriptConfigVersionCreateRequest,
|
||||||
@@ -308,8 +321,9 @@ export function fetchArticles(): Promise<ArticleListResponse> {
|
|||||||
|
|
||||||
export function fetchArticleDetail(
|
export function fetchArticleDetail(
|
||||||
articleId: string,
|
articleId: string,
|
||||||
|
userEmail = DEMO_EDITOR_EMAIL,
|
||||||
): Promise<ArticleDetailResponse> {
|
): Promise<ArticleDetailResponse> {
|
||||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
|
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`, { userEmail });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createArticle(
|
export function createArticle(
|
||||||
@@ -455,8 +469,11 @@ export function assembleDraft(articleId: string): Promise<DraftAssembleResponse>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
|
export function fetchDrafts(
|
||||||
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
|
articleId: string,
|
||||||
|
userEmail = DEMO_EDITOR_EMAIL,
|
||||||
|
): Promise<DraftListResponse> {
|
||||||
|
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`, { userEmail });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDraft(
|
export function fetchDraft(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ new Function("exports", compiled.outputText)(moduleExports);
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
buildAdminScriptVersionRows,
|
buildAdminScriptVersionRows,
|
||||||
|
buildAdminScriptAuditRows,
|
||||||
defaultScriptVersionDraft,
|
defaultScriptVersionDraft,
|
||||||
buildCreateScriptVersionPayload,
|
buildCreateScriptVersionPayload,
|
||||||
parseJsonObject,
|
parseJsonObject,
|
||||||
@@ -53,3 +54,21 @@ assert.equal(payload.rollback_target_version_id, null);
|
|||||||
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
||||||
assert.equal(parseJsonObject("[]"), null);
|
assert.equal(parseJsonObject("[]"), null);
|
||||||
assert.deepEqual(parseJsonObject(""), {});
|
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");
|
||||||
|
|||||||
@@ -15,38 +15,63 @@ const compiled = ts.transpileModule(source, {
|
|||||||
const moduleExports = {};
|
const moduleExports = {};
|
||||||
new Function("exports", compiled.outputText)(moduleExports);
|
new Function("exports", compiled.outputText)(moduleExports);
|
||||||
|
|
||||||
const { buildDetailSummary, buildProductionArtifactRows } = moduleExports;
|
const { buildArticleTimeline, buildJobDetailRows, buildDetailSummary, buildJobControlState } = moduleExports;
|
||||||
|
|
||||||
const rows = buildProductionArtifactRows([
|
const detail = {
|
||||||
|
article: {
|
||||||
|
id: "a1",
|
||||||
|
target_site_id: "site1",
|
||||||
|
status: "PARALLEL_PRODUCTION_RUNNING",
|
||||||
|
publishing_status: "PUBLISH_NOT_STARTED",
|
||||||
|
brief_description: "desc",
|
||||||
|
language: "en",
|
||||||
|
content_type: "longform_guide",
|
||||||
|
created_at: "2026-05-21T00:00:00Z",
|
||||||
|
updated_at: "2026-05-21T00:00:00Z",
|
||||||
|
},
|
||||||
|
target_site: { name: "Demo Site" },
|
||||||
|
timeline: [
|
||||||
{
|
{
|
||||||
id: "job-section-1",
|
id: "2",
|
||||||
article_id: "a1",
|
article_id: "a1",
|
||||||
parent_job_id: null,
|
entry_type: "AGENT_JOB",
|
||||||
attempt: 1,
|
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_type: "SECTION_SCAFFOLD",
|
||||||
agent_profile: "fake-section-scaffold",
|
job_status: "FAILED",
|
||||||
status: "SUCCEEDED",
|
retry_eligible: true,
|
||||||
workspace_path: null,
|
cancel_eligible: false,
|
||||||
input_files: [],
|
safe_failure_summary: "Section failed.",
|
||||||
output_files: [],
|
payload: {},
|
||||||
payload: {
|
created_at: "2026-05-21T00:00:02Z",
|
||||||
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",
|
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",
|
article_id: "a1",
|
||||||
parent_job_id: null,
|
parent_job_id: null,
|
||||||
attempt: 1,
|
attempt: 1,
|
||||||
@@ -56,86 +81,55 @@ const rows = buildProductionArtifactRows([
|
|||||||
workspace_path: null,
|
workspace_path: null,
|
||||||
input_files: [],
|
input_files: [],
|
||||||
output_files: [],
|
output_files: [],
|
||||||
payload: {
|
payload: { last_successful_step: "Outline written" },
|
||||||
artifact_key: "section:2",
|
|
||||||
artifact_label: "Implementation workflow",
|
|
||||||
used_evidence_ids: ["e2"],
|
|
||||||
unsupported_claims: [{ claim_text: "Unverified claim" }],
|
|
||||||
},
|
|
||||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||||
stdout: "",
|
stdout: "token=[REDACTED]",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
exit_code: 0,
|
exit_code: 1,
|
||||||
duration_ms: 3,
|
duration_ms: 5,
|
||||||
queued_at: "2026-05-21T00:00:00Z",
|
queued_at: "2026-05-21T00:00:00Z",
|
||||||
started_at: "2026-05-21T00:00:01Z",
|
started_at: "2026-05-21T00:00:01Z",
|
||||||
finished_at: "2026-05-21T00:00:02Z",
|
finished_at: "2026-05-21T00:00:02Z",
|
||||||
|
retry_eligible: true,
|
||||||
|
retry_block_reason: null,
|
||||||
|
cancel_eligible: false,
|
||||||
|
safe_failure_summary: "Section failed safely.",
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
id: "job-non-artifact",
|
publish_commit: null,
|
||||||
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);
|
const timeline = buildArticleTimeline(detail);
|
||||||
assert.equal(rows[0].artifactLabel, "Context and scope");
|
assert.equal(timeline.length, 2);
|
||||||
assert.equal(rows[0].status, "SUCCEEDED");
|
assert.equal(timeline[0].eventType, "PLAN_APPROVED");
|
||||||
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
|
assert.equal(timeline[1].eventType, "SECTION_SCAFFOLD_FAILED");
|
||||||
assert.equal(rows[1].status, "FAILED");
|
assert.equal(timeline[1].source, "AGENT");
|
||||||
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
|
assert.equal(timeline[1].retryEligible, true);
|
||||||
|
|
||||||
const detailSummary = buildDetailSummary({
|
const jobRows = buildJobDetailRows(detail.agent_jobs);
|
||||||
article: {
|
assert.equal(jobRows.length, 1);
|
||||||
id: "a1",
|
assert.equal(jobRows[0].jobType, "SECTION_SCAFFOLD");
|
||||||
target_site_id: "site1",
|
assert.equal(jobRows[0].status, "FAILED");
|
||||||
status: "PUBLISH_COMMIT_CREATED",
|
assert.equal(jobRows[0].errorCategory, "UNSUPPORTED_CLAIMS_FOUND");
|
||||||
publishing_status: "PUBLISH_COMMIT_CREATED",
|
assert.equal(jobRows[0].errorMessage, "Unsupported claims introduced during scaffolding: 1");
|
||||||
brief_description: "desc",
|
assert.equal(jobRows[0].lastSuccessfulStep, "Outline written");
|
||||||
language: "en",
|
assert.equal(jobRows[0].retryEligible, true);
|
||||||
content_type: "longform_guide",
|
assert.deepEqual(
|
||||||
created_at: "2026-05-21T00:00:00Z",
|
buildJobControlState(jobRows[0], "ADMIN"),
|
||||||
updated_at: "2026-05-21T00:00:00Z",
|
{ showRetry: true, showCancel: false, showAdminLogs: true, showSafeSummary: false },
|
||||||
},
|
|
||||||
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.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
detailSummary.publishingValidationLabel,
|
|
||||||
"Best-effort content-shape validation only.",
|
|
||||||
);
|
);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
"title": "Attempt",
|
"title": "Attempt",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"cancel_eligible": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Cancel Eligible",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"duration_ms": {
|
"duration_ms": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -248,6 +253,35 @@
|
|||||||
"title": "Queued At",
|
"title": "Queued At",
|
||||||
"type": "string"
|
"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": {
|
"started_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -509,6 +543,13 @@
|
|||||||
],
|
],
|
||||||
"default": null
|
"default": null
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ObservabilityTimelineEventSummary"
|
||||||
|
},
|
||||||
|
"title": "Timeline",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"workflow_events": {
|
"workflow_events": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/WorkflowEventSummary"
|
"$ref": "#/components/schemas/WorkflowEventSummary"
|
||||||
@@ -2441,6 +2482,148 @@
|
|||||||
"title": "LanguageReviewRunResponse",
|
"title": "LanguageReviewRunResponse",
|
||||||
"type": "object"
|
"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": {
|
"PlanListResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3591,6 +3774,108 @@
|
|||||||
"title": "RunnerFileRef",
|
"title": "RunnerFileRef",
|
||||||
"type": "object"
|
"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": {
|
"ScriptConfigVersionCreateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"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": {
|
"/api/sites/{site_id}/publishing-config/versions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get",
|
"operationId": "get_script_config_versions_api_sites__site_id__publishing_config_versions_get",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type AgentJobSummary = {
|
|||||||
agent_profile: string;
|
agent_profile: string;
|
||||||
article_id?: string | null;
|
article_id?: string | null;
|
||||||
attempt?: number;
|
attempt?: number;
|
||||||
|
cancel_eligible?: boolean;
|
||||||
duration_ms?: number | null;
|
duration_ms?: number | null;
|
||||||
error_category?: AgentJobErrorCategory | null;
|
error_category?: AgentJobErrorCategory | null;
|
||||||
error_message?: string | null;
|
error_message?: string | null;
|
||||||
@@ -38,6 +39,9 @@ export type AgentJobSummary = {
|
|||||||
parent_job_id?: string | null;
|
parent_job_id?: string | null;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
queued_at: string;
|
queued_at: string;
|
||||||
|
retry_block_reason?: string | null;
|
||||||
|
retry_eligible?: boolean;
|
||||||
|
safe_failure_summary?: string | null;
|
||||||
started_at?: string | null;
|
started_at?: string | null;
|
||||||
status: AgentJobStatus;
|
status: AgentJobStatus;
|
||||||
stderr?: string;
|
stderr?: string;
|
||||||
@@ -78,6 +82,7 @@ export type ArticleDetailResponse = {
|
|||||||
research_manifests?: ResearchArtifactManifestSummary[];
|
research_manifests?: ResearchArtifactManifestSummary[];
|
||||||
reviews?: ReviewSummary[];
|
reviews?: ReviewSummary[];
|
||||||
target_site?: TargetSiteConfig | null;
|
target_site?: TargetSiteConfig | null;
|
||||||
|
timeline?: ObservabilityTimelineEventSummary[];
|
||||||
workflow_events?: WorkflowEventSummary[];
|
workflow_events?: WorkflowEventSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,6 +425,25 @@ export type LanguageReviewRunResponse = {
|
|||||||
report: ContentReviewReportSummary;
|
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 = {
|
export type PlanListResponse = {
|
||||||
plans: PlanSummary[];
|
plans: PlanSummary[];
|
||||||
};
|
};
|
||||||
@@ -610,6 +634,23 @@ export type RunnerFileRef = {
|
|||||||
path: string;
|
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 = {
|
export type ScriptConfigVersionCreateRequest = {
|
||||||
activate?: boolean;
|
activate?: boolean;
|
||||||
diff?: Record<string, unknown>;
|
diff?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ Development description: Build the operational layer for workflow history, job l
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## 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`.
|
- [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`.
|
||||||
- [ ] Article detail page shows user, system, and agent events in order.
|
- [x] Article detail page shows user, system, and agent events in order.
|
||||||
- [ ] Job logs are stored and displayed with sensitive values redacted.
|
- [x] Job logs are stored and displayed with sensitive values redacted.
|
||||||
- [ ] Retry buttons appear only where retry is allowed.
|
- [x] Retry buttons appear only where retry is allowed.
|
||||||
- [ ] Cancelling queued/running jobs prevents article state mutation.
|
- [x] Cancelling queued/running jobs prevents article state mutation.
|
||||||
- [ ] Publish commit retry is blocked once a publish commit exists.
|
- [x] Publish commit retry is blocked once a publish commit exists.
|
||||||
- [ ] Admin can see detailed logs; Editor sees safe failure summary.
|
- [x] Admin can see detailed logs; Editor sees safe failure summary.
|
||||||
- [ ] All Admin script/config changes are audit-visible with diff and rollback target.
|
- [x] All Admin script/config changes are audit-visible with diff and rollback target.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -62,9 +62,42 @@ Development description: Build the operational layer for workflow history, job l
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Implemented and verified (GREEN).
|
||||||
- TDD plan: To be filled during execution.
|
- Green evidence:
|
||||||
- Red evidence: To be filled during execution.
|
- Backend observability implemented:
|
||||||
- Green evidence: To be filled during execution.
|
- role-aware job projection with `retry_eligible`, `retry_block_reason`, `cancel_eligible`, `safe_failure_summary`,
|
||||||
- Refactor notes: To be filled during execution.
|
- redaction for payload/log/error message,
|
||||||
- Verification output: To be filled during execution.
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user