feat(task-018): add observability retry cancel and audit trail
This commit is contained in:
@@ -12,9 +12,12 @@ from src.domain.contracts import (
|
||||
AgentJobOutput,
|
||||
AgentJobResponse,
|
||||
AgentJobStatus,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
AgentJobType,
|
||||
Role,
|
||||
)
|
||||
from src.application.observability import evaluate_retry_policy, project_job_for_view
|
||||
|
||||
|
||||
VALID_FAKE_AGENT_PROFILE = "fake-codex"
|
||||
@@ -44,15 +47,27 @@ def create_test_codex_job(
|
||||
|
||||
|
||||
def list_agent_jobs(repository: object) -> AgentJobListResponse:
|
||||
return AgentJobListResponse(jobs=repository.agent_jobs.list())
|
||||
jobs = [_project_admin_view(repository, job) for job in repository.agent_jobs.list()]
|
||||
return AgentJobListResponse(jobs=jobs)
|
||||
|
||||
|
||||
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
return AgentJobResponse(job=repository.agent_jobs.get(job_id))
|
||||
job = repository.agent_jobs.get(job_id)
|
||||
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||
|
||||
|
||||
def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
def retry_agent_job(
|
||||
repository: object,
|
||||
job_id: UUID,
|
||||
*,
|
||||
actor_user_id: UUID | None = None,
|
||||
) -> AgentJobResponse:
|
||||
parent = repository.agent_jobs.get(job_id)
|
||||
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, parent)
|
||||
if not retry_eligible:
|
||||
raise PermissionError(retry_block_reason or "Retry is blocked for this job.")
|
||||
|
||||
now = _now()
|
||||
retry = repository.agent_jobs.create(
|
||||
article_id=parent.article_id,
|
||||
parent_job_id=parent.id,
|
||||
@@ -62,13 +77,42 @@ def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
|
||||
payload=parent.payload,
|
||||
queued_at=_now(),
|
||||
queued_at=now,
|
||||
)
|
||||
return AgentJobResponse(job=retry)
|
||||
_record_job_event(
|
||||
repository,
|
||||
job=retry,
|
||||
event_type="AGENT_JOB_RETRIED",
|
||||
actor_user_id=actor_user_id,
|
||||
created_at=now,
|
||||
payload={
|
||||
"parent_job_id": str(parent.id),
|
||||
"attempt": retry.attempt,
|
||||
"job_type": retry.job_type.value,
|
||||
},
|
||||
)
|
||||
return AgentJobResponse(job=_project_admin_view(repository, retry))
|
||||
|
||||
|
||||
def cancel_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
|
||||
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now()))
|
||||
def cancel_agent_job(
|
||||
repository: object,
|
||||
job_id: UUID,
|
||||
*,
|
||||
actor_user_id: UUID | None = None,
|
||||
) -> AgentJobResponse:
|
||||
now = _now()
|
||||
existing = repository.agent_jobs.get(job_id)
|
||||
job = repository.agent_jobs.cancel(job_id=job_id, finished_at=now)
|
||||
if existing.status != job.status and job.status == AgentJobStatus.CANCELLED:
|
||||
_record_job_event(
|
||||
repository,
|
||||
job=job,
|
||||
event_type="AGENT_JOB_CANCELLED",
|
||||
actor_user_id=actor_user_id,
|
||||
created_at=now,
|
||||
payload={"job_type": job.job_type.value, "attempt": job.attempt},
|
||||
)
|
||||
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||
|
||||
|
||||
def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
|
||||
@@ -93,6 +137,7 @@ def complete_agent_job(
|
||||
try:
|
||||
validated_output = AgentJobOutput.model_validate(output)
|
||||
except ValidationError as error:
|
||||
finished_at = _now()
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job_id,
|
||||
status=AgentJobStatus.FAILED,
|
||||
@@ -105,9 +150,15 @@ def complete_agent_job(
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration_ms,
|
||||
finished_at=_now(),
|
||||
finished_at=finished_at,
|
||||
)
|
||||
return AgentJobResponse(job=job)
|
||||
_record_completion_event(
|
||||
repository,
|
||||
previous=existing_job,
|
||||
current=job,
|
||||
created_at=finished_at,
|
||||
)
|
||||
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||
|
||||
merged_payload = _merge_payload(
|
||||
base_payload=existing_job.payload,
|
||||
@@ -133,6 +184,7 @@ def complete_agent_job(
|
||||
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
|
||||
)
|
||||
|
||||
finished_at = _now()
|
||||
job = repository.agent_jobs.complete(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
@@ -147,9 +199,15 @@ def complete_agent_job(
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration_ms,
|
||||
finished_at=_now(),
|
||||
finished_at=finished_at,
|
||||
)
|
||||
return AgentJobResponse(job=job)
|
||||
_record_completion_event(
|
||||
repository,
|
||||
previous=existing_job,
|
||||
current=job,
|
||||
created_at=finished_at,
|
||||
)
|
||||
return AgentJobResponse(job=_project_admin_view(repository, job))
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -164,9 +222,13 @@ def _normalize_completion_status(
|
||||
) -> AgentJobStatus:
|
||||
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
|
||||
return AgentJobStatus.FAILED
|
||||
if error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE and exit_code in (
|
||||
if (
|
||||
status == AgentJobStatus.SUCCEEDED
|
||||
and error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
|
||||
and exit_code in (
|
||||
None,
|
||||
0,
|
||||
)
|
||||
):
|
||||
return AgentJobStatus.SUCCEEDED
|
||||
return status
|
||||
@@ -201,3 +263,75 @@ def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
|
||||
if isinstance(claims, list):
|
||||
return claims
|
||||
return []
|
||||
|
||||
|
||||
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
|
||||
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
|
||||
|
||||
|
||||
def _record_completion_event(
|
||||
repository: object,
|
||||
*,
|
||||
previous: AgentJobSummary,
|
||||
current: AgentJobSummary,
|
||||
created_at: datetime,
|
||||
) -> None:
|
||||
if current.article_id is None:
|
||||
return
|
||||
if previous.status == current.status:
|
||||
return
|
||||
|
||||
if current.status == AgentJobStatus.FAILED:
|
||||
retry_eligible, _ = evaluate_retry_policy(repository, current)
|
||||
_record_job_event(
|
||||
repository,
|
||||
job=current,
|
||||
event_type="AGENT_JOB_FAILED",
|
||||
actor_user_id=None,
|
||||
created_at=created_at,
|
||||
payload={
|
||||
"job_type": current.job_type.value,
|
||||
"attempt": current.attempt,
|
||||
"error_category": (
|
||||
current.error_category.value if current.error_category is not None else None
|
||||
),
|
||||
"error_message": current.error_message,
|
||||
"retry_eligible": retry_eligible,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if current.status == AgentJobStatus.SUCCEEDED:
|
||||
_record_job_event(
|
||||
repository,
|
||||
job=current,
|
||||
event_type="AGENT_JOB_SUCCEEDED",
|
||||
actor_user_id=None,
|
||||
created_at=created_at,
|
||||
payload={
|
||||
"job_type": current.job_type.value,
|
||||
"attempt": current.attempt,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_job_event(
|
||||
repository: object,
|
||||
*,
|
||||
job: AgentJobSummary,
|
||||
event_type: str,
|
||||
actor_user_id: UUID | None,
|
||||
created_at: datetime,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
if job.article_id is None:
|
||||
return
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=job.article_id,
|
||||
event_type=event_type,
|
||||
from_status=None,
|
||||
to_status=None,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"job_id": str(job.id), **payload},
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,10 @@ from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
PublishingStatus,
|
||||
)
|
||||
from src.application.observability import (
|
||||
build_observability_timeline,
|
||||
project_job_for_view,
|
||||
)
|
||||
|
||||
|
||||
def create_article(
|
||||
@@ -54,6 +58,7 @@ def list_articles(repository: object) -> ArticleListResponse:
|
||||
def get_article_detail(
|
||||
repository: object,
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
) -> ArticleDetailResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
@@ -66,6 +71,15 @@ def get_article_detail(
|
||||
claims = repository.claims.list_for_article(article_id)
|
||||
assets = repository.assets.list_for_article(article_id)
|
||||
agent_jobs = repository.agent_jobs.list_for_article(article_id)
|
||||
projected_jobs = [
|
||||
project_job_for_view(
|
||||
repository,
|
||||
job,
|
||||
viewer_role=current_user.role,
|
||||
)
|
||||
for job in agent_jobs
|
||||
]
|
||||
timeline = build_observability_timeline(workflow_events, projected_jobs)
|
||||
publish_commit = repository.publish_commits.latest_for_article(article_id)
|
||||
return ArticleDetailResponse(
|
||||
article=article,
|
||||
@@ -77,7 +91,8 @@ def get_article_detail(
|
||||
evidence=evidence,
|
||||
claims=claims,
|
||||
assets=assets,
|
||||
agent_jobs=agent_jobs,
|
||||
agent_jobs=projected_jobs,
|
||||
timeline=timeline,
|
||||
research_manifests=research_manifests,
|
||||
publish_commit=publish_commit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobStatus,
|
||||
AgentJobSummary,
|
||||
AgentJobType,
|
||||
PublishingStatus,
|
||||
Role,
|
||||
WorkflowEventSummary,
|
||||
)
|
||||
|
||||
|
||||
_RETRYABLE_JOB_TYPES = {
|
||||
AgentJobType.PLAN_GENERATION,
|
||||
AgentJobType.RESEARCH,
|
||||
AgentJobType.SECTION_SCAFFOLD,
|
||||
AgentJobType.SEO_REVIEW,
|
||||
AgentJobType.LANGUAGE_REVIEW,
|
||||
AgentJobType.PUBLISH_COMMIT,
|
||||
AgentJobType.TEST_CODEX,
|
||||
}
|
||||
|
||||
_SENSITIVE_KEY_TOKENS = (
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"session",
|
||||
)
|
||||
|
||||
_SENSITIVE_VALUE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)[^\s]+"), r"\1[REDACTED]"),
|
||||
(re.compile(r"(?i)(token\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||
(re.compile(r"(?i)(password\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||
(re.compile(r"(?i)(secret\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,;]+"), r"\1[REDACTED]"),
|
||||
(re.compile(r"\b(sk-[a-zA-Z0-9_-]{8,})\b"), "[REDACTED]"),
|
||||
(re.compile(r"\b(gh[pousr]_[a-zA-Z0-9]{8,})\b"), "[REDACTED]"),
|
||||
)
|
||||
|
||||
|
||||
def project_job_for_view(
|
||||
repository: object,
|
||||
job: AgentJobSummary,
|
||||
*,
|
||||
viewer_role: Role,
|
||||
) -> AgentJobSummary:
|
||||
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, job)
|
||||
payload = redact_json_value(job.payload)
|
||||
error_message = _nullable_redacted(job.error_message)
|
||||
stdout = redact_text(job.stdout)
|
||||
stderr = redact_text(job.stderr)
|
||||
safe_failure_summary = build_safe_failure_summary(job)
|
||||
|
||||
if viewer_role != Role.ADMIN:
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return job.model_copy(
|
||||
update={
|
||||
"payload": payload,
|
||||
"error_message": error_message,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"retry_eligible": retry_eligible,
|
||||
"retry_block_reason": retry_block_reason,
|
||||
"cancel_eligible": is_cancel_eligible(job),
|
||||
"safe_failure_summary": safe_failure_summary,
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_retry_policy(
|
||||
repository: object,
|
||||
job: AgentJobSummary,
|
||||
) -> tuple[bool, str | None]:
|
||||
if job.status != AgentJobStatus.FAILED:
|
||||
return False, "Retry is available only for failed jobs."
|
||||
if job.job_type not in _RETRYABLE_JOB_TYPES:
|
||||
return False, f"Retry is not supported for job type {job.job_type.value}."
|
||||
if job.job_type == AgentJobType.PUBLISH_COMMIT:
|
||||
if job.article_id is None:
|
||||
return False, "Publish commit retry requires article context."
|
||||
existing_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
|
||||
job.article_id,
|
||||
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
|
||||
)
|
||||
if existing_publish_commit is not None:
|
||||
return (
|
||||
False,
|
||||
"Publish commit already exists for this article. Retry is blocked.",
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def is_cancel_eligible(job: AgentJobSummary) -> bool:
|
||||
return job.status in {AgentJobStatus.QUEUED, AgentJobStatus.RUNNING}
|
||||
|
||||
|
||||
def build_safe_failure_summary(job: AgentJobSummary) -> str | None:
|
||||
if job.status != AgentJobStatus.FAILED:
|
||||
return None
|
||||
category = job.error_category.value if job.error_category is not None else "UNKNOWN"
|
||||
step = (
|
||||
_string_value(job.payload.get("last_successful_step"))
|
||||
or _string_value(job.payload.get("artifact_label"))
|
||||
or _string_value(job.payload.get("heading"))
|
||||
)
|
||||
message = redact_text(job.error_message or "").strip()
|
||||
parts = [f"{job.job_type.value} failed ({category})."]
|
||||
if step:
|
||||
parts.append(f"Last successful step: {step}.")
|
||||
if message:
|
||||
parts.append(f"Summary: {message}.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_observability_timeline(
|
||||
workflow_events: list[WorkflowEventSummary],
|
||||
agent_jobs: list[AgentJobSummary],
|
||||
) -> list[dict[str, Any]]:
|
||||
timeline: list[dict[str, Any]] = []
|
||||
|
||||
for event in workflow_events:
|
||||
source = "USER" if event.actor_user_id is not None else "SYSTEM"
|
||||
timeline.append(
|
||||
{
|
||||
"id": event.id,
|
||||
"article_id": event.article_id,
|
||||
"entry_type": "WORKFLOW_EVENT",
|
||||
"source": source,
|
||||
"event_type": event.event_type,
|
||||
"from_status": event.from_status,
|
||||
"to_status": event.to_status,
|
||||
"actor_user_id": event.actor_user_id,
|
||||
"job_id": None,
|
||||
"job_type": None,
|
||||
"job_status": None,
|
||||
"retry_eligible": False,
|
||||
"cancel_eligible": False,
|
||||
"safe_failure_summary": None,
|
||||
"payload": redact_json_value(event.payload),
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
)
|
||||
|
||||
for job in agent_jobs:
|
||||
created_at = job.finished_at or job.started_at or job.queued_at
|
||||
timeline.append(
|
||||
{
|
||||
"id": job.id,
|
||||
"article_id": job.article_id,
|
||||
"entry_type": "AGENT_JOB",
|
||||
"source": "AGENT",
|
||||
"event_type": f"{job.job_type.value}_{job.status.value}",
|
||||
"from_status": None,
|
||||
"to_status": None,
|
||||
"actor_user_id": None,
|
||||
"job_id": job.id,
|
||||
"job_type": job.job_type,
|
||||
"job_status": job.status,
|
||||
"retry_eligible": bool(job.retry_eligible),
|
||||
"cancel_eligible": bool(job.cancel_eligible),
|
||||
"safe_failure_summary": job.safe_failure_summary,
|
||||
"payload": {
|
||||
"attempt": job.attempt,
|
||||
"error_category": (
|
||||
job.error_category.value if job.error_category is not None else None
|
||||
),
|
||||
"error_message": job.error_message,
|
||||
},
|
||||
"created_at": created_at,
|
||||
}
|
||||
)
|
||||
|
||||
timeline.sort(
|
||||
key=lambda item: (
|
||||
_timeline_dt(item.get("created_at")),
|
||||
str(item.get("id")),
|
||||
)
|
||||
)
|
||||
return timeline
|
||||
|
||||
|
||||
def redact_json_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
redacted: dict[str, Any] = {}
|
||||
for key, nested in value.items():
|
||||
if _contains_sensitive_token(key):
|
||||
redacted[key] = "[REDACTED]"
|
||||
else:
|
||||
redacted[key] = redact_json_value(nested)
|
||||
return redacted
|
||||
if isinstance(value, list):
|
||||
return [redact_json_value(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
redacted = value
|
||||
for pattern, replacement in _SENSITIVE_VALUE_PATTERNS:
|
||||
redacted = pattern.sub(replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def _timeline_dt(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.min
|
||||
|
||||
|
||||
def _contains_sensitive_token(value: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
return any(token in normalized for token in _SENSITIVE_KEY_TOKENS)
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _nullable_redacted(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
redacted = redact_text(value).strip()
|
||||
if not redacted:
|
||||
return None
|
||||
return redacted
|
||||
@@ -6,6 +6,8 @@ from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionStatus,
|
||||
@@ -231,6 +233,49 @@ def list_script_config_versions(
|
||||
return [_script_version_summary(row) for row in rows]
|
||||
|
||||
|
||||
def list_script_config_audit_events(
|
||||
repository: object,
|
||||
site_id: UUID,
|
||||
) -> ScriptConfigVersionAuditEventListResponse:
|
||||
repository.target_sites.get_by_id(site_id)
|
||||
version_rows = repository.script_config_versions.list_for_site(site_id)
|
||||
version_by_id = {str(row["id"]): row for row in version_rows}
|
||||
events = repository.script_config_version_events.list_for_site(site_id)
|
||||
|
||||
summaries: list[ScriptConfigVersionAuditEventSummary] = []
|
||||
for event in events:
|
||||
version_row = version_by_id.get(str(event["version_id"]))
|
||||
payload = event["payload"] if isinstance(event["payload"], dict) else {}
|
||||
summaries.append(
|
||||
ScriptConfigVersionAuditEventSummary(
|
||||
id=event["id"],
|
||||
target_site_id=event["target_site_id"],
|
||||
version_id=event["version_id"],
|
||||
event_type=event["event_type"],
|
||||
actor_user_id=event["actor_user_id"],
|
||||
payload=payload,
|
||||
created_at=event["created_at"],
|
||||
version=(
|
||||
int(version_row["version"])
|
||||
if version_row is not None and version_row.get("version") is not None
|
||||
else None
|
||||
),
|
||||
diff=(
|
||||
version_row["diff"]
|
||||
if version_row is not None and isinstance(version_row.get("diff"), dict)
|
||||
else {}
|
||||
),
|
||||
rollback_target_version_id=(
|
||||
version_row["rollback_target_version_id"]
|
||||
if version_row is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return ScriptConfigVersionAuditEventListResponse(events=summaries)
|
||||
|
||||
|
||||
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
|
||||
return ScriptConfigVersionSummary(
|
||||
id=row["id"],
|
||||
|
||||
@@ -32,6 +32,7 @@ from .models import (
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
WorkflowEventSummary,
|
||||
ObservabilityTimelineEventSummary,
|
||||
AssetGenerateSpecsResponse,
|
||||
AssetListResponse,
|
||||
AssetResponse,
|
||||
@@ -92,6 +93,8 @@ from .models import (
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -129,6 +132,7 @@ __all__ = [
|
||||
"BoundaryQuestionSummary",
|
||||
"BoundaryQuestionUpdateRequest",
|
||||
"WorkflowEventSummary",
|
||||
"ObservabilityTimelineEventSummary",
|
||||
"AssetGenerateSpecsResponse",
|
||||
"AssetListResponse",
|
||||
"AssetResponse",
|
||||
@@ -199,6 +203,8 @@ __all__ = [
|
||||
"ReviewType",
|
||||
"Role",
|
||||
"RunnerFileRef",
|
||||
"ScriptConfigVersionAuditEventListResponse",
|
||||
"ScriptConfigVersionAuditEventSummary",
|
||||
"SeoReviewRunResponse",
|
||||
"SeoReviewReportResponse",
|
||||
"ScriptConfigVersionCreateRequest",
|
||||
|
||||
@@ -141,6 +141,23 @@ class ScriptConfigVersionListResponse(ContractModel):
|
||||
versions: list[ScriptConfigVersionSummary]
|
||||
|
||||
|
||||
class ScriptConfigVersionAuditEventSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
version_id: UUID
|
||||
event_type: str = Field(min_length=1)
|
||||
actor_user_id: UUID | None = None
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
diff: JsonObject = Field(default_factory=dict)
|
||||
rollback_target_version_id: UUID | None = None
|
||||
|
||||
|
||||
class ScriptConfigVersionAuditEventListResponse(ContractModel):
|
||||
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ArticleCreateRequest(ContractModel):
|
||||
target_site_id: UUID
|
||||
brief_description: str = Field(min_length=1)
|
||||
@@ -185,6 +202,25 @@ class WorkflowEventSummary(ContractModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ObservabilityTimelineEventSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
entry_type: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
event_type: str = Field(min_length=1)
|
||||
from_status: ArticleWorkflowStatus | None = None
|
||||
to_status: ArticleWorkflowStatus | None = None
|
||||
actor_user_id: UUID | None = None
|
||||
job_id: UUID | None = None
|
||||
job_type: AgentJobType | None = None
|
||||
job_status: AgentJobStatus | None = None
|
||||
retry_eligible: bool = False
|
||||
cancel_eligible: bool = False
|
||||
safe_failure_summary: str | None = None
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BoundaryQuestionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
@@ -493,6 +529,10 @@ class AgentJobSummary(ContractModel):
|
||||
queued_at: datetime
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
retry_eligible: bool = False
|
||||
retry_block_reason: str | None = None
|
||||
cancel_eligible: bool = False
|
||||
safe_failure_summary: str | None = None
|
||||
|
||||
|
||||
class AgentJobTestCodexRequest(ContractModel):
|
||||
@@ -709,6 +749,7 @@ class ArticleDetailResponse(ContractModel):
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
|
||||
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ from .models import (
|
||||
BoundaryQuestionResponse,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
ObservabilityTimelineEventSummary,
|
||||
AssetGenerateSpecsResponse,
|
||||
AssetListResponse,
|
||||
AssetResponse,
|
||||
@@ -98,6 +99,8 @@ from .models import (
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -143,11 +146,14 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
ArticleListResponse,
|
||||
WorkflowEventSummary,
|
||||
ObservabilityTimelineEventSummary,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
BoundaryQuestionResponse,
|
||||
|
||||
@@ -81,23 +81,33 @@ def get_agent_job_route(
|
||||
)
|
||||
def post_retry_agent_job(
|
||||
job_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return retry_agent_job(repository, job_id)
|
||||
return retry_agent_job(
|
||||
repository,
|
||||
job_id,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
except PermissionError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
|
||||
def post_cancel_agent_job(
|
||||
job_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> AgentJobResponse:
|
||||
try:
|
||||
return cancel_agent_job(repository, job_id)
|
||||
return cancel_agent_job(
|
||||
repository,
|
||||
job_id,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@@ -61,11 +61,15 @@ def list_articles_route(
|
||||
)
|
||||
def get_article(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ArticleDetailResponse:
|
||||
try:
|
||||
return get_article_detail(repository=repository, article_id=article_id)
|
||||
return get_article_detail(
|
||||
repository=repository,
|
||||
article_id=article_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.application.site_config import (
|
||||
create_script_config_version,
|
||||
create_target_site,
|
||||
get_target_site,
|
||||
list_script_config_audit_events,
|
||||
list_script_config_versions,
|
||||
rollback_script_config_version,
|
||||
update_target_site,
|
||||
@@ -16,6 +17,7 @@ from src.application.site_config import (
|
||||
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -92,6 +94,21 @@ def get_script_config_versions(
|
||||
return ScriptConfigVersionListResponse(versions=versions)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sites/{site_id}/publishing-config/audit",
|
||||
response_model=ScriptConfigVersionAuditEventListResponse,
|
||||
)
|
||||
def get_script_config_audit_events(
|
||||
site_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> ScriptConfigVersionAuditEventListResponse:
|
||||
try:
|
||||
return list_script_config_audit_events(repository, site_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sites/{site_id}/publishing-config/versions",
|
||||
response_model=ScriptConfigVersionResponse,
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.domain.contracts import AgentJobStatus, AgentJobType, PublishingStatus # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class ObservabilityRetryCancelAuditPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(Path(self.tmp_dir.name) / "objects")
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'observability.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_failed_job_appears_in_article_history_with_retry_eligibility(self) -> None:
|
||||
article_id = self._prepare_article_for_parallel_production()
|
||||
created_jobs = self._start_parallel_production(article_id)
|
||||
section_job = [job for job in created_jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
|
||||
|
||||
failed_job = self._complete_job(
|
||||
section_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||
"payload": {
|
||||
"unsupported_claims": [
|
||||
{"claim_text": "Unverified claim", "risk_level": "high"}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("FAILED", failed_job["status"])
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||
article_jobs = detail_response.json()["agent_jobs"]
|
||||
history_failed_job = [job for job in article_jobs if job["id"] == failed_job["id"]][0]
|
||||
self.assertIn("retry_eligible", history_failed_job)
|
||||
self.assertTrue(history_failed_job["retry_eligible"])
|
||||
self.assertIsNone(history_failed_job["retry_block_reason"])
|
||||
|
||||
def test_article_timeline_mixes_user_system_and_agent_events_in_order(self) -> None:
|
||||
article_id = self._prepare_article_for_parallel_production()
|
||||
section_job = self._start_parallel_production(article_id)[0]
|
||||
failed = self._complete_job(
|
||||
section_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||
"payload": {
|
||||
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("FAILED", failed["status"])
|
||||
|
||||
retry_response = self.client.post(
|
||||
f"/api/agent-jobs/{failed['id']}/retry",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, detail_response.status_code, detail_response.text)
|
||||
timeline = detail_response.json()["timeline"]
|
||||
self.assertGreaterEqual(len(timeline), 3)
|
||||
|
||||
created_at_values = [entry["created_at"] for entry in timeline]
|
||||
self.assertEqual(created_at_values, sorted(created_at_values))
|
||||
|
||||
sources = {entry["source"] for entry in timeline}
|
||||
self.assertIn("USER", sources)
|
||||
self.assertIn("SYSTEM", sources)
|
||||
self.assertIn("AGENT", sources)
|
||||
|
||||
failed_job_entry = [
|
||||
entry
|
||||
for entry in timeline
|
||||
if entry["entry_type"] == "AGENT_JOB"
|
||||
and entry.get("job_id") == failed["id"]
|
||||
][0]
|
||||
self.assertTrue(failed_job_entry["retry_eligible"])
|
||||
self.assertEqual("FAILED", failed_job_entry["job_status"])
|
||||
|
||||
def test_sensitive_logs_are_redacted_and_editor_sees_only_safe_failure_summary(self) -> None:
|
||||
article_id = self._prepare_article_for_parallel_production()
|
||||
section_job = self._start_parallel_production(article_id)[0]
|
||||
|
||||
failed_job = self._complete_job(
|
||||
section_job["id"],
|
||||
exit_code=1,
|
||||
stdout=(
|
||||
"token=topsecret123 password=hunter2 "
|
||||
"Authorization: Bearer very-secret-token\n"
|
||||
),
|
||||
stderr="api_key=prod-key-123\n",
|
||||
output={
|
||||
"status": "FAILED",
|
||||
"error_message": "runner failed with sk-1234567890abcdef",
|
||||
"payload": {"last_successful_step": "outline written"},
|
||||
},
|
||||
)
|
||||
self.assertEqual("FAILED", failed_job["status"])
|
||||
|
||||
admin_detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
).json()
|
||||
editor_detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()
|
||||
|
||||
admin_job = [job for job in admin_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
|
||||
editor_job = [job for job in editor_detail["agent_jobs"] if job["id"] == failed_job["id"]][0]
|
||||
|
||||
self.assertIn("[REDACTED]", admin_job["stdout"])
|
||||
self.assertIn("[REDACTED]", admin_job["stderr"])
|
||||
self.assertNotIn("topsecret123", admin_job["stdout"])
|
||||
self.assertNotIn("hunter2", admin_job["stdout"])
|
||||
self.assertNotIn("very-secret-token", admin_job["stdout"])
|
||||
self.assertNotIn("prod-key-123", admin_job["stderr"])
|
||||
self.assertNotIn("sk-1234567890abcdef", admin_job["error_message"] or "")
|
||||
|
||||
self.assertEqual("", editor_job["stdout"])
|
||||
self.assertEqual("", editor_job["stderr"])
|
||||
self.assertIn("failed", editor_job["safe_failure_summary"].lower())
|
||||
self.assertNotIn("topsecret123", editor_job["safe_failure_summary"])
|
||||
|
||||
def test_retry_eligibility_and_cancel_eligibility_follow_policy(self) -> None:
|
||||
article_id = self._prepare_article_for_parallel_production()
|
||||
jobs = self._start_parallel_production(article_id)
|
||||
section_job = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"][0]
|
||||
|
||||
section_failed = self._complete_job(
|
||||
section_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/section-failed.md"}],
|
||||
"payload": {
|
||||
"unsupported_claims": [{"claim_text": "unsupported claim", "risk_level": "high"}]
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("FAILED", section_failed["status"])
|
||||
|
||||
now = datetime.now(UTC)
|
||||
evidence_job = self.repository.agent_jobs.create(
|
||||
article_id=UUID(article_id),
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.EVIDENCE_MATRIX,
|
||||
agent_profile="manual-test",
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[{"path": "inputs/evidence.json"}],
|
||||
queued_at=now,
|
||||
)
|
||||
evidence_failed = self.repository.agent_jobs.complete(
|
||||
job_id=evidence_job.id,
|
||||
status=AgentJobStatus.FAILED,
|
||||
workspace_path="/tmp/evidence",
|
||||
output_files=[],
|
||||
payload={"phase": "collect"},
|
||||
error_category=None,
|
||||
error_message="temporary failure",
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=1,
|
||||
duration_ms=2,
|
||||
finished_at=now,
|
||||
)
|
||||
self.assertEqual(AgentJobStatus.FAILED, evidence_failed.status)
|
||||
|
||||
queued_section = [job for job in jobs if job["id"] != section_job["id"]][0]
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
).json()
|
||||
|
||||
failed_section_view = [job for job in detail["agent_jobs"] if job["id"] == section_failed["id"]][0]
|
||||
evidence_view = [job for job in detail["agent_jobs"] if job["id"] == str(evidence_failed.id)][0]
|
||||
queued_view = [job for job in detail["agent_jobs"] if job["id"] == queued_section["id"]][0]
|
||||
|
||||
self.assertTrue(failed_section_view["retry_eligible"])
|
||||
self.assertIsNone(failed_section_view["retry_block_reason"])
|
||||
self.assertFalse(failed_section_view["cancel_eligible"])
|
||||
|
||||
self.assertFalse(evidence_view["retry_eligible"])
|
||||
self.assertIn("not supported", evidence_view["retry_block_reason"].lower())
|
||||
self.assertFalse(evidence_view["cancel_eligible"])
|
||||
|
||||
self.assertFalse(queued_view["retry_eligible"])
|
||||
self.assertTrue(queued_view["cancel_eligible"])
|
||||
|
||||
def test_cancelling_running_or_queued_job_prevents_article_state_mutation(self) -> None:
|
||||
article_id = self._prepare_article_for_parallel_production()
|
||||
jobs = self._start_parallel_production(article_id)
|
||||
first_job = jobs[0]
|
||||
|
||||
cancel_response = self.client.post(
|
||||
f"/api/agent-jobs/{first_job['id']}/cancel",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, cancel_response.status_code, cancel_response.text)
|
||||
self.assertEqual("CANCELLED", cancel_response.json()["job"]["status"])
|
||||
|
||||
complete_response = self.client.post(
|
||||
f"/internal/agent-jobs/{first_job['id']}/complete",
|
||||
json={
|
||||
"workspace_path": f"/tmp/{first_job['id']}",
|
||||
"stdout": "completion after cancellation\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 2,
|
||||
"output": {
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/cancelled.md"}],
|
||||
"payload": {"draft_markdown": "should never apply"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||
self.assertEqual("CANCELLED", complete_response.json()["job"]["status"])
|
||||
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()
|
||||
self.assertEqual("PARALLEL_PRODUCTION_RUNNING", detail["article"]["status"])
|
||||
cancelled_view = [job for job in detail["agent_jobs"] if job["id"] == first_job["id"]][0]
|
||||
self.assertEqual("CANCELLED", cancelled_view["status"])
|
||||
|
||||
def test_publish_commit_retry_blocked_after_publish_commit_exists(self) -> None:
|
||||
article = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": self._first_site_id(),
|
||||
"brief_description": "Publish retry policy test",
|
||||
"working_title": "Publish Retry Policy",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "publish retry policy",
|
||||
},
|
||||
).json()["article"]
|
||||
article_id = article["id"]
|
||||
|
||||
now = datetime.now(UTC)
|
||||
publish_job = self.repository.agent_jobs.create(
|
||||
article_id=UUID(article_id),
|
||||
parent_job_id=None,
|
||||
attempt=1,
|
||||
job_type=AgentJobType.PUBLISH_COMMIT,
|
||||
agent_profile="manual-publish-commit",
|
||||
status=AgentJobStatus.QUEUED,
|
||||
input_files=[{"path": "inputs/publish.json"}],
|
||||
queued_at=now,
|
||||
)
|
||||
publish_job = self.repository.agent_jobs.complete(
|
||||
job_id=publish_job.id,
|
||||
status=AgentJobStatus.FAILED,
|
||||
workspace_path="/tmp/publish",
|
||||
output_files=[],
|
||||
payload={"stage": "git push"},
|
||||
error_category=None,
|
||||
error_message="git push failed",
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=1,
|
||||
duration_ms=5,
|
||||
finished_at=now,
|
||||
)
|
||||
|
||||
self.repository.publish_commits.create(
|
||||
article_id=UUID(article_id),
|
||||
target_site_id=UUID(self._first_site_id()),
|
||||
repository_url="git@example.com/site.git",
|
||||
branch="main",
|
||||
commit_sha="abc123",
|
||||
content_bundle_manifest={"validation": {"label": "ok"}},
|
||||
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||
deployment_status=None,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
retry_response = self.client.post(
|
||||
f"/api/agent-jobs/{publish_job.id}/retry",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, retry_response.status_code, retry_response.text)
|
||||
self.assertIn("already exists", retry_response.text.lower())
|
||||
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
).json()
|
||||
publish_job_view = [job for job in detail["agent_jobs"] if job["id"] == str(publish_job.id)][0]
|
||||
self.assertFalse(publish_job_view["retry_eligible"])
|
||||
self.assertIn("already exists", publish_job_view["retry_block_reason"].lower())
|
||||
|
||||
def test_script_config_changes_are_audit_visible_with_diff_and_rollback_target(self) -> None:
|
||||
site_id = self._first_site_id()
|
||||
first = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={
|
||||
"publishing_yaml": "target: demo\n",
|
||||
"transform_script": "export function transformArticle(a){return a;}\n",
|
||||
"diff": {"summary": "v1 create"},
|
||||
"activate": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, first.status_code, first.text)
|
||||
first_version = first.json()["version"]
|
||||
|
||||
second = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={
|
||||
"publishing_yaml": "target: demo\nbranch: main\n",
|
||||
"transform_script": "export function transformArticle(a){return {...a, changed:true};}\n",
|
||||
"diff": {"summary": "v2 create"},
|
||||
"rollback_target_version_id": first_version["id"],
|
||||
"activate": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, second.status_code, second.text)
|
||||
second_version = second.json()["version"]
|
||||
|
||||
rollback = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{first_version['id']}/rollback",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, rollback.status_code, rollback.text)
|
||||
|
||||
audit_response = self.client.get(
|
||||
f"/api/sites/{site_id}/publishing-config/audit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, audit_response.status_code, audit_response.text)
|
||||
events = audit_response.json()["events"]
|
||||
self.assertGreaterEqual(len(events), 3)
|
||||
for event in events:
|
||||
self.assertIn("diff", event)
|
||||
self.assertIn("rollback_target_version_id", event)
|
||||
|
||||
rollback_events = [event for event in events if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"]
|
||||
self.assertEqual(1, len(rollback_events))
|
||||
rollback_event = rollback_events[0]
|
||||
self.assertEqual(second_version["id"], rollback_event["rollback_target_version_id"])
|
||||
|
||||
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(202, response.status_code, response.text)
|
||||
return response.json()["jobs"]
|
||||
|
||||
def _complete_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
output: dict[str, Any],
|
||||
stdout: str = "fake section scaffolding runner\n",
|
||||
stderr: str = "",
|
||||
exit_code: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
response = self.client.post(
|
||||
f"/internal/agent-jobs/{job_id}/complete",
|
||||
json={
|
||||
"workspace_path": f"/tmp/{job_id}",
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": 2,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()["job"]
|
||||
|
||||
def _first_site_id(self) -> str:
|
||||
response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()[0]["site"]["id"]
|
||||
|
||||
def _prepare_article_for_parallel_production(self) -> str:
|
||||
article_id = self._create_article_with_approved_plan()
|
||||
research_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||
|
||||
evidence_response = self.client.get(
|
||||
f"/api/articles/{article_id}/evidence",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||
return article_id
|
||||
|
||||
def _create_article_with_approved_plan(self) -> str:
|
||||
article_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": self._first_site_id(),
|
||||
"brief_description": "Observe failed job history and retry eligibility.",
|
||||
"working_title": "Observability Retry Audit",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "observability retry audit",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||
article_id = article_response.json()["article"]["id"]
|
||||
|
||||
questions = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()["questions"]
|
||||
for question in questions:
|
||||
if question["is_required"]:
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Answer for {question['category']}"},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
submit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||
|
||||
plan_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plan/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||
plan = plan_response.json()["plan"]
|
||||
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
return article_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -119,6 +119,8 @@ class ScriptConfigAuditTest(unittest.TestCase):
|
||||
activate: bool = False,
|
||||
rollback_target_version_id: UUID | None = None,
|
||||
) -> ScriptConfigVersionCreateRequest:
|
||||
from src.domain.contracts import ScriptConfigVersionCreateRequest
|
||||
|
||||
payload = ScriptConfigVersionCreateRequest(
|
||||
publishing_yaml="target:\n repo: audit\n",
|
||||
transform_script="export function transformArticle(article) { return article; }",
|
||||
|
||||
@@ -4,11 +4,16 @@ type DynamicArticleDetailPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
searchParams?: {
|
||||
role?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticleDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: DynamicArticleDetailPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticleDetailPage articleId={articleId} />;
|
||||
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
|
||||
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||
}
|
||||
|
||||
@@ -344,6 +344,24 @@ main {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.timelineMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timelineSource {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.draftEditor {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionSummary,
|
||||
} from "@pipeline/shared";
|
||||
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
|
||||
rollbackTargetVersionId: string;
|
||||
};
|
||||
|
||||
export type AdminScriptAuditRow = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
version: number | null;
|
||||
createdAt: string;
|
||||
actorUserId: string | null;
|
||||
diff: string;
|
||||
rollbackTargetVersionId: string | null;
|
||||
};
|
||||
|
||||
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
|
||||
|
||||
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
|
||||
@@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAdminScriptAuditRows(
|
||||
events: readonly ScriptConfigVersionAuditEventSummary[],
|
||||
): AdminScriptAuditRow[] {
|
||||
return events
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
eventType: event.event_type,
|
||||
version: event.version ?? null,
|
||||
createdAt: event.created_at,
|
||||
actorUserId: event.actor_user_id ?? null,
|
||||
diff: JSON.stringify(event.diff ?? {}),
|
||||
rollbackTargetVersionId: event.rollback_target_version_id ?? null,
|
||||
}))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
import type { ScriptConfigVersionAuditEventSummary, ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildAdminScriptAuditRows,
|
||||
buildAdminScriptVersionRows,
|
||||
buildCreateScriptVersionPayload,
|
||||
defaultScriptVersionDraft,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type AdminScriptVersionsPanelProps = {
|
||||
site: TargetSiteConfig;
|
||||
initialVersions: readonly ScriptConfigVersionSummary[];
|
||||
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
|
||||
};
|
||||
|
||||
function parseOptionalError(value: string): string | null {
|
||||
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
|
||||
export function AdminScriptVersionsPanel({
|
||||
site,
|
||||
initialVersions,
|
||||
initialAuditEvents,
|
||||
}: AdminScriptVersionsPanelProps) {
|
||||
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
||||
initialVersions,
|
||||
);
|
||||
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
|
||||
initialAuditEvents,
|
||||
);
|
||||
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
||||
defaultScriptVersionDraft(),
|
||||
);
|
||||
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
|
||||
buildAdminScriptVersionRows(versions),
|
||||
[versions],
|
||||
);
|
||||
const auditRows = useMemo(
|
||||
() => buildAdminScriptAuditRows(auditEvents),
|
||||
[auditEvents],
|
||||
);
|
||||
|
||||
const hasVersions = rows.length > 0;
|
||||
const canSubmit = !isSubmitting;
|
||||
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
||||
|
||||
async function refreshVersions() {
|
||||
const latest = await fetchScriptConfigVersions(
|
||||
const [latest, audit] = await Promise.all([
|
||||
fetchScriptConfigVersions(
|
||||
site.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
),
|
||||
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
setVersions(latest.versions);
|
||||
setAuditEvents(audit.events ?? []);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
||||
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Audit trail</h3>
|
||||
{auditRows.length === 0 ? (
|
||||
<p className="emptyState">No audit events yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Version</th>
|
||||
<th>Created</th>
|
||||
<th>Actor</th>
|
||||
<th>Diff</th>
|
||||
<th>Rollback target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditRows.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td>{event.eventType}</td>
|
||||
<td>{event.version ?? "—"}</td>
|
||||
<td>{new Date(event.createdAt).toLocaleString()}</td>
|
||||
<td>{event.actorUserId ?? "—"}</td>
|
||||
<td>{event.diff}</td>
|
||||
<td>{event.rollbackTargetVersionId ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
|
||||
import type {
|
||||
AgentJobSummary,
|
||||
ArticleDetailResponse,
|
||||
ObservabilityTimelineEventSummary,
|
||||
Role,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type ArticleTimelineItem = Pick<
|
||||
WorkflowEventSummary,
|
||||
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
|
||||
> & {
|
||||
export type ArticleTimelineItem = {
|
||||
id: string;
|
||||
articleId: string;
|
||||
entryType: string;
|
||||
source: string;
|
||||
eventType: string;
|
||||
fromStatus: string | null;
|
||||
toStatus: string | null;
|
||||
actorUserId: string | null;
|
||||
jobId: string | null;
|
||||
jobType: string | null;
|
||||
jobStatus: string | null;
|
||||
retryEligible: boolean;
|
||||
cancelEligible: boolean;
|
||||
safeFailureSummary: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type JobDetailRow = {
|
||||
jobId: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
attempt: number;
|
||||
errorCategory: string | null;
|
||||
errorMessage: string | null;
|
||||
lastSuccessfulStep: string | null;
|
||||
retryEligible: boolean;
|
||||
retryBlockReason: string | null;
|
||||
cancelEligible: boolean;
|
||||
safeFailureSummary: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type DetailSummary = {
|
||||
@@ -15,35 +47,53 @@ export type DetailSummary = {
|
||||
briefDescription: string;
|
||||
targetSite: string;
|
||||
updatedAt: string;
|
||||
viewerRole: Role;
|
||||
viewerEmail: string;
|
||||
timeline: ArticleTimelineItem[];
|
||||
productionArtifacts: ProductionArtifactRow[];
|
||||
jobDetails: JobDetailRow[];
|
||||
};
|
||||
|
||||
export type ProductionArtifactRow = {
|
||||
jobId: string;
|
||||
artifactKey: string;
|
||||
artifactLabel: string;
|
||||
status: string;
|
||||
attempt: number;
|
||||
jobType: string;
|
||||
usedEvidenceIds: string[];
|
||||
unsupportedClaims: string[];
|
||||
errorMessage: string | null;
|
||||
export type JobControlState = {
|
||||
showRetry: boolean;
|
||||
showCancel: boolean;
|
||||
showAdminLogs: boolean;
|
||||
showSafeSummary: boolean;
|
||||
};
|
||||
|
||||
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
|
||||
return (detail.workflow_events ?? []).map((event) => ({
|
||||
id: event.id,
|
||||
event_type: event.event_type,
|
||||
to_status: event.to_status,
|
||||
from_status: event.from_status,
|
||||
actor_user_id: event.actor_user_id,
|
||||
created_at: event.created_at,
|
||||
articleId: event.article_id,
|
||||
}));
|
||||
const timeline = detail.timeline ?? [];
|
||||
if (timeline.length > 0) {
|
||||
return timeline
|
||||
.map((entry) => normalizeTimelineEntry(entry))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
return (detail.workflow_events ?? [])
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
articleId: event.article_id,
|
||||
entryType: "WORKFLOW_EVENT",
|
||||
source: event.actor_user_id ? "USER" : "SYSTEM",
|
||||
eventType: event.event_type,
|
||||
fromStatus: event.from_status ?? null,
|
||||
toStatus: event.to_status ?? null,
|
||||
actorUserId: event.actor_user_id ?? null,
|
||||
jobId: null,
|
||||
jobType: null,
|
||||
jobStatus: null,
|
||||
retryEligible: false,
|
||||
cancelEligible: false,
|
||||
safeFailureSummary: null,
|
||||
createdAt: event.created_at,
|
||||
}))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
|
||||
export function buildDetailSummary(
|
||||
detail: ArticleDetailResponse,
|
||||
options: { viewerRole: Role; viewerEmail: string },
|
||||
): DetailSummary {
|
||||
const { viewerRole, viewerEmail } = options;
|
||||
return {
|
||||
articleId: detail.article.id,
|
||||
status: detail.article.status,
|
||||
@@ -52,8 +102,47 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
|
||||
briefDescription: detail.article.brief_description,
|
||||
targetSite: detail.target_site?.name ?? "Unknown",
|
||||
updatedAt: detail.article.updated_at,
|
||||
viewerRole,
|
||||
viewerEmail,
|
||||
timeline: buildArticleTimeline(detail),
|
||||
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []),
|
||||
jobDetails: buildJobDetailRows(detail.agent_jobs ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJobDetailRows(jobs: readonly AgentJobSummary[]): JobDetailRow[] {
|
||||
return jobs
|
||||
.map((job) => {
|
||||
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
||||
const lastSuccessfulStep = stringValue(payload.last_successful_step)
|
||||
|| stringValue(payload.artifact_label)
|
||||
|| stringValue(payload.heading)
|
||||
|| null;
|
||||
return {
|
||||
jobId: job.id,
|
||||
jobType: job.job_type,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
errorCategory: job.error_category ?? null,
|
||||
errorMessage: job.error_message ?? null,
|
||||
lastSuccessfulStep,
|
||||
retryEligible: Boolean(job.retry_eligible),
|
||||
retryBlockReason: job.retry_block_reason ?? null,
|
||||
cancelEligible: Boolean(job.cancel_eligible),
|
||||
safeFailureSummary: job.safe_failure_summary ?? null,
|
||||
stdout: job.stdout ?? "",
|
||||
stderr: job.stderr ?? "",
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.jobType.localeCompare(right.jobType));
|
||||
}
|
||||
|
||||
export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobControlState {
|
||||
const isAdmin = viewerRole === "ADMIN";
|
||||
return {
|
||||
showRetry: isAdmin && job.retryEligible,
|
||||
showCancel: isAdmin && job.cancelEligible,
|
||||
showAdminLogs: isAdmin,
|
||||
showSafeSummary: !isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,60 +160,26 @@ function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string
|
||||
return "Best-effort content-shape validation only.";
|
||||
}
|
||||
|
||||
export function buildProductionArtifactRows(
|
||||
jobs: readonly AgentJobSummary[],
|
||||
): ProductionArtifactRow[] {
|
||||
return jobs
|
||||
.filter((job) => job.job_type === "SECTION_SCAFFOLD")
|
||||
.map((job) => {
|
||||
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
||||
const artifactKey = stringValue(payload.artifact_key) || `job:${job.id}`;
|
||||
const artifactLabel =
|
||||
stringValue(payload.artifact_label)
|
||||
|| stringValue(payload.heading)
|
||||
|| artifactKey;
|
||||
return {
|
||||
jobId: job.id,
|
||||
artifactKey,
|
||||
artifactLabel,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
jobType: job.job_type,
|
||||
usedEvidenceIds: stringArray(payload.used_evidence_ids),
|
||||
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
|
||||
errorMessage: job.error_message ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.artifactLabel.localeCompare(right.artifactLabel));
|
||||
function normalizeTimelineEntry(entry: ObservabilityTimelineEventSummary): ArticleTimelineItem {
|
||||
return {
|
||||
id: entry.id,
|
||||
articleId: entry.article_id,
|
||||
entryType: entry.entry_type,
|
||||
source: entry.source,
|
||||
eventType: entry.event_type,
|
||||
fromStatus: entry.from_status ?? null,
|
||||
toStatus: entry.to_status ?? null,
|
||||
actorUserId: entry.actor_user_id ?? null,
|
||||
jobId: entry.job_id ?? null,
|
||||
jobType: entry.job_type ?? null,
|
||||
jobStatus: entry.job_status ?? null,
|
||||
retryEligible: Boolean(entry.retry_eligible),
|
||||
cancelEligible: Boolean(entry.cancel_eligible),
|
||||
safeFailureSummary: entry.safe_failure_summary ?? null,
|
||||
createdAt: entry.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
function unsupportedClaims(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const claims: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
claims.push(item);
|
||||
continue;
|
||||
}
|
||||
if (typeof item === "object" && item !== null && "claim_text" in item) {
|
||||
const claimText = item.claim_text;
|
||||
if (typeof claimText === "string") {
|
||||
claims.push(claimText);
|
||||
}
|
||||
}
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError, cancelAgentJob, retryAgentJob } from "@/shared/pipeline-api";
|
||||
import { buildJobControlState } from "./model";
|
||||
import type { DetailSummary } from "./model";
|
||||
|
||||
type DetailShellProps = {
|
||||
@@ -7,8 +13,46 @@ type DetailShellProps = {
|
||||
};
|
||||
|
||||
export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [busyJobId, setBusyJobId] = useState("");
|
||||
const timeline = summary.timeline;
|
||||
const productionArtifacts = summary.productionArtifacts;
|
||||
const jobDetails = summary.jobDetails;
|
||||
const isAdmin = summary.viewerRole === "ADMIN";
|
||||
|
||||
async function handleRetry(jobId: string) {
|
||||
setActionError("");
|
||||
setBusyJobId(jobId);
|
||||
try {
|
||||
await retryAgentJob(jobId, summary.viewerEmail);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setActionError(error.message || "Unable to retry job.");
|
||||
} else {
|
||||
setActionError("Unable to retry job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(jobId: string) {
|
||||
setActionError("");
|
||||
setBusyJobId(jobId);
|
||||
try {
|
||||
await cancelAgentJob(jobId, summary.viewerEmail);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setActionError(error.message || "Unable to cancel job.");
|
||||
} else {
|
||||
setActionError("Unable to cancel job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -64,49 +108,93 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
<ul className="timeline">
|
||||
{timeline.map((event) => (
|
||||
<li key={event.id}>
|
||||
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
|
||||
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
|
||||
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
|
||||
<div className="timelineMeta">
|
||||
<strong>{event.eventType}</strong>
|
||||
<span className="timelineSource">{event.source}</span>
|
||||
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
from {event.fromStatus ?? "—"} to {event.toStatus ?? "—"}
|
||||
{event.actorUserId ? <span> • actor {event.actorUserId}</span> : null}
|
||||
{event.jobType ? <span> • {event.jobType}</span> : null}
|
||||
{event.jobStatus ? <span> • {event.jobStatus}</span> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
|
||||
</ul>
|
||||
|
||||
<h3>Production artifacts</h3>
|
||||
<h3>Job failures and controls</h3>
|
||||
{actionError ? <p className="formError">Error: {actionError}</p> : null}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artifact</th>
|
||||
<th>Job type</th>
|
||||
<th>Status</th>
|
||||
<th>Attempt</th>
|
||||
<th>Used evidence IDs</th>
|
||||
<th>Unsupported claims</th>
|
||||
<th>Error category</th>
|
||||
<th>Error message</th>
|
||||
<th>Last successful step</th>
|
||||
<th>Controls</th>
|
||||
<th>{isAdmin ? "Logs" : "Safe summary"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{productionArtifacts.map((artifact) => (
|
||||
<tr key={artifact.jobId}>
|
||||
<td>
|
||||
<div>{artifact.artifactLabel}</div>
|
||||
<div>{artifact.artifactKey}</div>
|
||||
</td>
|
||||
<td>{artifact.status}</td>
|
||||
<td>{artifact.attempt}</td>
|
||||
<td>
|
||||
{artifact.usedEvidenceIds.length
|
||||
? artifact.usedEvidenceIds.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{artifact.unsupportedClaims.length
|
||||
? artifact.unsupportedClaims.join("; ")
|
||||
: artifact.errorMessage ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{productionArtifacts.length === 0 ? (
|
||||
{jobDetails.map((job) => {
|
||||
const isBusy = busyJobId === job.jobId;
|
||||
const controls = buildJobControlState(job, summary.viewerRole);
|
||||
return (
|
||||
<tr key={job.jobId}>
|
||||
<td>
|
||||
<div>{job.jobType}</div>
|
||||
<div>attempt {job.attempt}</div>
|
||||
</td>
|
||||
<td>{job.status}</td>
|
||||
<td>{job.errorCategory ?? "—"}</td>
|
||||
<td>
|
||||
{job.errorMessage ?? "—"}
|
||||
{!job.errorMessage && job.retryBlockReason ? (
|
||||
<div>{job.retryBlockReason}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{job.lastSuccessfulStep ?? "—"}</td>
|
||||
<td>
|
||||
{controls.showRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(job.jobId)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
{controls.showCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancel(job.jobId)}
|
||||
disabled={isBusy}
|
||||
className="tableAction"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
{!controls.showRetry && !controls.showCancel ? "—" : null}
|
||||
</td>
|
||||
<td>
|
||||
{controls.showAdminLogs ? (
|
||||
<>
|
||||
<div>{job.stdout || "stdout: —"}</div>
|
||||
<div>{job.stderr || "stderr: —"}</div>
|
||||
</>
|
||||
) : (
|
||||
job.safeFailureSummary ?? "—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{jobDetails.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}>No production artifacts yet.</td>
|
||||
<td colSpan={7}>No agent jobs yet.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
|
||||
|
||||
@@ -40,10 +40,13 @@ export default async function AdminScriptsPage({
|
||||
|
||||
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
|
||||
const activeSite = selectedResponse.site;
|
||||
const versionsResponse = await fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
const [versionsResponse, auditResponse] = await Promise.all([
|
||||
fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
),
|
||||
fetchScriptConfigAuditEvents(selectedSite.id, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
|
||||
<AdminScriptVersionsPanel
|
||||
site={activeSite}
|
||||
initialVersions={versionsResponse.versions}
|
||||
initialAuditEvents={auditResponse.events ?? []}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -9,22 +9,30 @@ import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||
import { FinalApprovalPanel } from "@/features/final-approval/ui";
|
||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { DEMO_ADMIN_EMAIL, DEMO_EDITOR_EMAIL, fetchArticleDetail, fetchCurrentUser, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { ApiError } from "@/shared/pipeline-api";
|
||||
|
||||
type ArticleDetailPageProps = {
|
||||
articleId: string;
|
||||
viewerRoleHint?: "admin" | "editor";
|
||||
};
|
||||
|
||||
export default async function ArticleDetailPage({
|
||||
articleId,
|
||||
viewerRoleHint,
|
||||
}: ArticleDetailPageProps) {
|
||||
const selectedEmail = viewerRoleHint === "admin" ? DEMO_ADMIN_EMAIL : DEMO_EDITOR_EMAIL;
|
||||
try {
|
||||
const [detail, draftsResponse] = await Promise.all([
|
||||
fetchArticleDetail(articleId),
|
||||
fetchDrafts(articleId),
|
||||
const [currentUserResponse, detail, draftsResponse] = await Promise.all([
|
||||
fetchCurrentUser(selectedEmail),
|
||||
fetchArticleDetail(articleId, selectedEmail),
|
||||
fetchDrafts(articleId, selectedEmail),
|
||||
]);
|
||||
const summary = buildDetailSummary(detail);
|
||||
const viewer = currentUserResponse.user;
|
||||
const summary = buildDetailSummary(detail, {
|
||||
viewerRole: viewer.role,
|
||||
viewerEmail: selectedEmail,
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -35,7 +43,7 @@ export default async function ArticleDetailPage({
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<RoleNavigation role={summary.viewerRole} />
|
||||
<section className="panel">
|
||||
<ArticleDetailShell summary={summary} />
|
||||
<MediaLibraryPanel assets={detail.assets ?? []} />
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
@@ -183,8 +184,10 @@ export function apiPatch<TRequest, TResponse>(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
|
||||
return apiGet<CurrentUserResponse>("/api/me");
|
||||
export function fetchCurrentUser(
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<CurrentUserResponse> {
|
||||
return apiGet<CurrentUserResponse>("/api/me", { userEmail });
|
||||
}
|
||||
|
||||
export function fetchAgentJobs(
|
||||
@@ -266,6 +269,16 @@ export function fetchScriptConfigVersions(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchScriptConfigAuditEvents(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ScriptConfigVersionAuditEventListResponse> {
|
||||
return apiGet<ScriptConfigVersionAuditEventListResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/audit`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function createScriptConfigVersion(
|
||||
siteId: string,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
@@ -308,8 +321,9 @@ export function fetchArticles(): Promise<ArticleListResponse> {
|
||||
|
||||
export function fetchArticleDetail(
|
||||
articleId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ArticleDetailResponse> {
|
||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
|
||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`, { userEmail });
|
||||
}
|
||||
|
||||
export function createArticle(
|
||||
@@ -455,8 +469,11 @@ export function assembleDraft(articleId: string): Promise<DraftAssembleResponse>
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
|
||||
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
|
||||
export function fetchDrafts(
|
||||
articleId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<DraftListResponse> {
|
||||
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`, { userEmail });
|
||||
}
|
||||
|
||||
export function fetchDraft(
|
||||
|
||||
@@ -20,6 +20,7 @@ new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const {
|
||||
buildAdminScriptVersionRows,
|
||||
buildAdminScriptAuditRows,
|
||||
defaultScriptVersionDraft,
|
||||
buildCreateScriptVersionPayload,
|
||||
parseJsonObject,
|
||||
@@ -53,3 +54,21 @@ assert.equal(payload.rollback_target_version_id, null);
|
||||
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
||||
assert.equal(parseJsonObject("[]"), null);
|
||||
assert.deepEqual(parseJsonObject(""), {});
|
||||
|
||||
const auditRows = buildAdminScriptAuditRows([
|
||||
{
|
||||
id: "evt-1",
|
||||
target_site_id: "site-1",
|
||||
version_id: "ver-1",
|
||||
event_type: "SCRIPT_CONFIG_VERSION_ROLLBACK",
|
||||
actor_user_id: "user-1",
|
||||
payload: { action: "rollback" },
|
||||
created_at: "2026-05-21T12:00:00Z",
|
||||
version: 2,
|
||||
diff: { summary: "rollback event" },
|
||||
rollback_target_version_id: "ver-0",
|
||||
},
|
||||
]);
|
||||
assert.equal(auditRows.length, 1);
|
||||
assert.equal(auditRows[0].eventType, "SCRIPT_CONFIG_VERSION_ROLLBACK");
|
||||
assert.equal(auditRows[0].rollbackTargetVersionId, "ver-0");
|
||||
|
||||
@@ -15,100 +15,14 @@ const compiled = ts.transpileModule(source, {
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { buildDetailSummary, buildProductionArtifactRows } = moduleExports;
|
||||
const { buildArticleTimeline, buildJobDetailRows, buildDetailSummary, buildJobControlState } = moduleExports;
|
||||
|
||||
const rows = buildProductionArtifactRows([
|
||||
{
|
||||
id: "job-section-1",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "SUCCEEDED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {
|
||||
artifact_key: "section:1",
|
||||
artifact_label: "Context and scope",
|
||||
used_evidence_ids: ["e1", "e2"],
|
||||
unsupported_claims: [],
|
||||
},
|
||||
error_category: null,
|
||||
error_message: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 3,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "job-section-2",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "FAILED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {
|
||||
artifact_key: "section:2",
|
||||
artifact_label: "Implementation workflow",
|
||||
used_evidence_ids: ["e2"],
|
||||
unsupported_claims: [{ claim_text: "Unverified claim" }],
|
||||
},
|
||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 3,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "job-non-artifact",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "TEST_CODEX",
|
||||
agent_profile: "fake-codex",
|
||||
status: "SUCCEEDED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {},
|
||||
error_category: null,
|
||||
error_message: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 0,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].artifactLabel, "Context and scope");
|
||||
assert.equal(rows[0].status, "SUCCEEDED");
|
||||
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
|
||||
assert.equal(rows[1].status, "FAILED");
|
||||
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
|
||||
|
||||
const detailSummary = buildDetailSummary({
|
||||
const detail = {
|
||||
article: {
|
||||
id: "a1",
|
||||
target_site_id: "site1",
|
||||
status: "PUBLISH_COMMIT_CREATED",
|
||||
publishing_status: "PUBLISH_COMMIT_CREATED",
|
||||
status: "PARALLEL_PRODUCTION_RUNNING",
|
||||
publishing_status: "PUBLISH_NOT_STARTED",
|
||||
brief_description: "desc",
|
||||
language: "en",
|
||||
content_type: "longform_guide",
|
||||
@@ -116,26 +30,106 @@ const detailSummary = buildDetailSummary({
|
||||
updated_at: "2026-05-21T00:00:00Z",
|
||||
},
|
||||
target_site: { name: "Demo Site" },
|
||||
workflow_events: [],
|
||||
agent_jobs: [],
|
||||
publish_commit: {
|
||||
id: "pc1",
|
||||
article_id: "a1",
|
||||
target_site_id: "site1",
|
||||
repository_url: "/tmp/repo.git",
|
||||
branch: "main",
|
||||
commit_sha: "abc123",
|
||||
status: "PUBLISH_COMMIT_CREATED",
|
||||
created_at: "2026-05-21T00:00:00Z",
|
||||
content_bundle_manifest: {
|
||||
validation: {
|
||||
label: "Best-effort content-shape validation only.",
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
id: "2",
|
||||
article_id: "a1",
|
||||
entry_type: "AGENT_JOB",
|
||||
source: "AGENT",
|
||||
event_type: "SECTION_SCAFFOLD_FAILED",
|
||||
from_status: null,
|
||||
to_status: null,
|
||||
actor_user_id: null,
|
||||
job_id: "job2",
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
job_status: "FAILED",
|
||||
retry_eligible: true,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: "Section failed.",
|
||||
payload: {},
|
||||
created_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
id: "1",
|
||||
article_id: "a1",
|
||||
entry_type: "WORKFLOW_EVENT",
|
||||
source: "USER",
|
||||
event_type: "PLAN_APPROVED",
|
||||
from_status: "PLAN_REVIEW_REQUIRED",
|
||||
to_status: "RESEARCH_RUNNING",
|
||||
actor_user_id: "u1",
|
||||
job_id: null,
|
||||
job_type: null,
|
||||
job_status: null,
|
||||
retry_eligible: false,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: null,
|
||||
payload: {},
|
||||
created_at: "2026-05-21T00:00:01Z",
|
||||
},
|
||||
],
|
||||
workflow_events: [],
|
||||
agent_jobs: [
|
||||
{
|
||||
id: "job2",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "FAILED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: { last_successful_step: "Outline written" },
|
||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||
stdout: "token=[REDACTED]",
|
||||
stderr: "",
|
||||
exit_code: 1,
|
||||
duration_ms: 5,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
retry_eligible: true,
|
||||
retry_block_reason: null,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: "Section failed safely.",
|
||||
},
|
||||
],
|
||||
publish_commit: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
detailSummary.publishingValidationLabel,
|
||||
"Best-effort content-shape validation only.",
|
||||
const timeline = buildArticleTimeline(detail);
|
||||
assert.equal(timeline.length, 2);
|
||||
assert.equal(timeline[0].eventType, "PLAN_APPROVED");
|
||||
assert.equal(timeline[1].eventType, "SECTION_SCAFFOLD_FAILED");
|
||||
assert.equal(timeline[1].source, "AGENT");
|
||||
assert.equal(timeline[1].retryEligible, true);
|
||||
|
||||
const jobRows = buildJobDetailRows(detail.agent_jobs);
|
||||
assert.equal(jobRows.length, 1);
|
||||
assert.equal(jobRows[0].jobType, "SECTION_SCAFFOLD");
|
||||
assert.equal(jobRows[0].status, "FAILED");
|
||||
assert.equal(jobRows[0].errorCategory, "UNSUPPORTED_CLAIMS_FOUND");
|
||||
assert.equal(jobRows[0].errorMessage, "Unsupported claims introduced during scaffolding: 1");
|
||||
assert.equal(jobRows[0].lastSuccessfulStep, "Outline written");
|
||||
assert.equal(jobRows[0].retryEligible, true);
|
||||
assert.deepEqual(
|
||||
buildJobControlState(jobRows[0], "ADMIN"),
|
||||
{ showRetry: true, showCancel: false, showAdminLogs: true, showSafeSummary: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildJobControlState(jobRows[0], "EDITOR"),
|
||||
{ showRetry: false, showCancel: false, showAdminLogs: false, showSafeSummary: true },
|
||||
);
|
||||
|
||||
const summary = buildDetailSummary(detail, {
|
||||
viewerRole: "EDITOR",
|
||||
viewerEmail: "editor@example.com",
|
||||
});
|
||||
assert.equal(summary.viewerRole, "EDITOR");
|
||||
assert.equal(summary.viewerEmail, "editor@example.com");
|
||||
assert.equal(summary.targetSite, "Demo Site");
|
||||
assert.equal(summary.timeline.length, 2);
|
||||
assert.equal(summary.jobDetails.length, 1);
|
||||
|
||||
Reference in New Issue
Block a user