Compare commits
5
Commits
6dcbe0e36c
...
5ea0d5978a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea0d5978a | ||
|
|
7667819c46 | ||
|
|
95fec3ba26 | ||
|
|
318ae790ef | ||
|
|
d3990b4019 |
@@ -39,3 +39,18 @@ make smoke
|
||||
The smoke test starts Docker Compose, checks frontend/backend/runner health
|
||||
endpoints, and verifies that the backend can connect to Postgres, Redis, and
|
||||
S3-compatible object storage.
|
||||
|
||||
## End-to-End Demo
|
||||
|
||||
Run full deterministic demo stack:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The Compose profile is configured for demo mode and does not require external
|
||||
credentials for Codex/search/WHOIS/cloud storage/GitHub.
|
||||
|
||||
Detailed steps (roles, happy path, failure/retry path, artifact inspection):
|
||||
|
||||
- [Demo Stack Guide](docs/demo-stack-guide.md)
|
||||
|
||||
@@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY apps/backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -12,8 +12,15 @@ from src.domain.contracts import (
|
||||
AgentJobOutput,
|
||||
AgentJobResponse,
|
||||
AgentJobStatus,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
AgentJobType,
|
||||
Role,
|
||||
)
|
||||
from src.application.observability import evaluate_retry_policy, project_job_for_view
|
||||
from src.application.demo_runtime import (
|
||||
build_demo_section_completion_payload,
|
||||
should_auto_retry_section_job,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,15 +51,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 +81,57 @@ 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,
|
||||
},
|
||||
)
|
||||
if should_auto_retry_section_job(retry):
|
||||
completion = build_demo_section_completion_payload(
|
||||
payload=retry.payload,
|
||||
failed=False,
|
||||
)
|
||||
return complete_agent_job(
|
||||
repository,
|
||||
job_id=retry.id,
|
||||
workspace_path=f"/tmp/demo/{retry.id}",
|
||||
stdout="deterministic demo section retry succeeded\n",
|
||||
stderr="",
|
||||
exit_code=0,
|
||||
duration_ms=8,
|
||||
output=completion,
|
||||
)
|
||||
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 +156,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 +169,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 +203,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 +218,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 +241,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 +282,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,16 @@ 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,
|
||||
target_site=target_site,
|
||||
@@ -76,8 +91,10 @@ 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,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.domain.contracts import AgentJobSummary
|
||||
|
||||
|
||||
_DEMO_MODE_ENV = "PIPELINE_DEMO_MODE"
|
||||
_DEMO_REPOSITORY_ENV = "PIPELINE_DEMO_PUBLISH_REPO_PATH"
|
||||
_DEFAULT_DEMO_REPOSITORY_PATH = "/tmp/pipeline-demo-site.git"
|
||||
|
||||
|
||||
def is_demo_mode() -> bool:
|
||||
value = os.environ.get(_DEMO_MODE_ENV, "").strip().lower()
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def resolve_demo_repository_path() -> Path:
|
||||
configured = os.environ.get(_DEMO_REPOSITORY_ENV, _DEFAULT_DEMO_REPOSITORY_PATH)
|
||||
return Path(configured).expanduser().resolve()
|
||||
|
||||
|
||||
def ensure_demo_bare_repository(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
_run_git(["init", "--bare", str(path)])
|
||||
if _has_main_branch(path):
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="demo-site-seed-") as temp_dir:
|
||||
worktree = Path(temp_dir) / "seed"
|
||||
worktree.mkdir(parents=True, exist_ok=True)
|
||||
_run_git(["init"], cwd=worktree)
|
||||
_run_git(["config", "user.name", "Pipeline Demo Bot"], cwd=worktree)
|
||||
_run_git(["config", "user.email", "pipeline-demo-bot@example.com"], cwd=worktree)
|
||||
(worktree / "README.md").write_text("# Pipeline Demo Site\n", encoding="utf-8")
|
||||
_run_git(["add", "README.md"], cwd=worktree)
|
||||
_run_git(["commit", "-m", "seed demo repository"], cwd=worktree)
|
||||
_run_git(["branch", "-M", "main"], cwd=worktree)
|
||||
_run_git(["remote", "add", "origin", str(path)], cwd=worktree)
|
||||
_run_git(["push", "origin", "main"], cwd=worktree)
|
||||
|
||||
|
||||
def should_auto_run_section_jobs() -> bool:
|
||||
return is_demo_mode()
|
||||
|
||||
|
||||
def should_auto_retry_section_job(job: AgentJobSummary) -> bool:
|
||||
if not is_demo_mode():
|
||||
return False
|
||||
if job.payload.get("demo_fail_once") is True and job.attempt > 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_demo_section_completion_payload(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
failed: bool,
|
||||
) -> dict[str, Any]:
|
||||
heading = _string_value(payload.get("heading")) or "Section"
|
||||
section_id = _string_value(payload.get("section_id")) or "section"
|
||||
draft_markdown = (
|
||||
f"## {heading}\n\n"
|
||||
f"This demo section is generated deterministically for `{section_id}`.\n\n"
|
||||
"Read the related architecture guide: [internal reference](/guides/pipeline-demo).\n"
|
||||
)
|
||||
if failed:
|
||||
return {
|
||||
"status": "FAILED",
|
||||
"error_category": "CLI_EXIT_CODE_FAILURE",
|
||||
"error_message": "Deterministic demo failure for retry walkthrough.",
|
||||
"payload": {
|
||||
"section_id": section_id,
|
||||
"heading": heading,
|
||||
"artifact_label": heading,
|
||||
"last_successful_step": "outline generated",
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": "",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": f"outputs/{section_id}.md"}],
|
||||
"payload": {
|
||||
"section_id": section_id,
|
||||
"heading": heading,
|
||||
"artifact_label": heading,
|
||||
"unsupported_claims": [],
|
||||
"last_successful_step": "section scaffold complete",
|
||||
"suggested_visuals": [f"{heading} diagram"],
|
||||
"draft_markdown": draft_markdown,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||
return normalized or "article"
|
||||
|
||||
|
||||
def _has_main_branch(path: Path) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(path), "rev-parse", "--verify", "refs/heads/main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _run_git(command: list[str], *, cwd: Path | None = None) -> None:
|
||||
subprocess.run(
|
||||
["git", *command] if command[0] != "git" else command,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
@@ -3,6 +3,11 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from src.application.agent_jobs import complete_agent_job
|
||||
from src.application.demo_runtime import (
|
||||
build_demo_section_completion_payload,
|
||||
should_auto_run_section_jobs,
|
||||
)
|
||||
from src.domain.contracts import (
|
||||
AgentJobListResponse,
|
||||
AgentJobStatus,
|
||||
@@ -189,6 +194,7 @@ def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse
|
||||
jobs = []
|
||||
for section in approved_plan.sections:
|
||||
used_evidence_ids = claim_evidence_by_section.get(str(section.id), [])
|
||||
demo_fail_once = should_auto_run_section_jobs() and len(jobs) == 0
|
||||
jobs.append(
|
||||
repository.agent_jobs.create(
|
||||
article_id=article_id,
|
||||
@@ -212,11 +218,16 @@ def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse
|
||||
"unsupported_claims": [],
|
||||
"suggested_visuals": [],
|
||||
"draft_markdown": "",
|
||||
"demo_fail_once": demo_fail_once,
|
||||
},
|
||||
queued_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
if should_auto_run_section_jobs():
|
||||
_auto_complete_demo_section_jobs(repository, jobs)
|
||||
jobs = repository.agent_jobs.list_for_article(article_id)
|
||||
|
||||
return AgentJobListResponse(jobs=jobs)
|
||||
|
||||
|
||||
@@ -313,3 +324,29 @@ def _insufficient_reasons(claims: list[object]) -> list[str]:
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _auto_complete_demo_section_jobs(
|
||||
repository: object,
|
||||
jobs: list[object],
|
||||
) -> None:
|
||||
for index, job in enumerate(jobs):
|
||||
failed = index == 0
|
||||
output = build_demo_section_completion_payload(
|
||||
payload=job.payload,
|
||||
failed=failed,
|
||||
)
|
||||
complete_agent_job(
|
||||
repository,
|
||||
job_id=job.id,
|
||||
workspace_path=f"/tmp/demo/{job.id}",
|
||||
stdout=(
|
||||
"deterministic demo failure; retry to continue\n"
|
||||
if failed
|
||||
else "deterministic demo section scaffold succeeded\n"
|
||||
),
|
||||
stderr="" if not failed else "exit code 1 simulated\n",
|
||||
exit_code=1 if failed else 0,
|
||||
duration_ms=12,
|
||||
output=output,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from src.domain.contracts import (
|
||||
AgentJobType,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ArticleWorkflowStatus,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
ContentReviewKind,
|
||||
FinalApprovalChecklistItemSummary,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
PlanReviewStatus,
|
||||
)
|
||||
|
||||
|
||||
def get_final_approval_checklist(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
request: FinalApprovalRequest | None = None,
|
||||
) -> FinalApprovalChecklistResponse:
|
||||
repository.articles.get(article_id)
|
||||
latest_draft = repository.article_drafts.latest_for_article(article_id)
|
||||
approved_plan = _approved_plan(repository, article_id=article_id)
|
||||
evidence = repository.evidence_items.list_for_article(article_id)
|
||||
assets = repository.assets.list_for_article(article_id)
|
||||
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||
high_risk_unsupported = _high_risk_unsupported_claims(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
draft=latest_draft,
|
||||
)
|
||||
settings = request.publishing_settings if request is not None else None
|
||||
requested_draft_version = request.draft_version if request is not None else None
|
||||
|
||||
checklist_items: list[FinalApprovalChecklistItemSummary] = []
|
||||
blockers: list[str] = []
|
||||
completed_items: list[str] = []
|
||||
|
||||
def add_item(key: str, title: str, completed: bool, blocker: str | None = None) -> None:
|
||||
checklist_items.append(
|
||||
FinalApprovalChecklistItemSummary(
|
||||
key=key,
|
||||
title=title,
|
||||
completed=completed,
|
||||
blocker=blocker,
|
||||
)
|
||||
)
|
||||
if completed:
|
||||
completed_items.append(title)
|
||||
elif blocker:
|
||||
blockers.append(blocker)
|
||||
|
||||
has_draft = latest_draft is not None
|
||||
add_item(
|
||||
key="exact_draft_version",
|
||||
title="Exact draft version selected",
|
||||
completed=has_draft and requested_draft_version == latest_draft.version,
|
||||
blocker=(
|
||||
None
|
||||
if has_draft and requested_draft_version == latest_draft.version
|
||||
else (
|
||||
"Draft version is required for final approval."
|
||||
if not has_draft
|
||||
else f"Final approval requires latest draft version {latest_draft.version}."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
add_item(
|
||||
key="plan_followed",
|
||||
title="Plan followed",
|
||||
completed=has_draft and _draft_contains_approved_plan(approved_plan, latest_draft.body_markdown),
|
||||
blocker="Approved plan sections are not fully reflected in the assembled draft.",
|
||||
)
|
||||
add_item(
|
||||
key="evidence_reviewed",
|
||||
title="Evidence reviewed",
|
||||
completed=bool(evidence) and all(item.review_status != "PENDING" for item in evidence),
|
||||
blocker="Evidence review is incomplete (there are pending evidence items).",
|
||||
)
|
||||
add_item(
|
||||
key="no_high_risk_unsupported_claims",
|
||||
title="No high-risk unsupported claims remain",
|
||||
completed=not high_risk_unsupported,
|
||||
blocker=(
|
||||
None
|
||||
if not high_risk_unsupported
|
||||
else (
|
||||
"High-risk unsupported claims must be resolved: "
|
||||
+ "; ".join(high_risk_unsupported[:3])
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
add_item(
|
||||
key="seo_metadata_approved",
|
||||
title="SEO metadata approved",
|
||||
completed=has_draft and _has_seo_metadata(latest_draft) and not _has_unresolved_meta_issues(unresolved),
|
||||
blocker="SEO metadata is incomplete or has unresolved review issues.",
|
||||
)
|
||||
|
||||
required_assets = [asset for asset in assets if asset.status != AssetStatus.REJECTED]
|
||||
unapproved_required_assets = [
|
||||
asset
|
||||
for asset in required_assets
|
||||
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||
]
|
||||
add_item(
|
||||
key="images_assets_approved",
|
||||
title="Images/assets approved",
|
||||
completed=bool(required_assets) and not unapproved_required_assets,
|
||||
blocker=(
|
||||
"Required assets are missing."
|
||||
if not required_assets
|
||||
else f"{len(unapproved_required_assets)} required assets are not approved."
|
||||
),
|
||||
)
|
||||
|
||||
required_table_diagrams = [
|
||||
asset
|
||||
for asset in required_assets
|
||||
if asset.asset_type in {
|
||||
AssetType.DIAGRAM,
|
||||
AssetType.TABLE,
|
||||
AssetType.FLOWCHART,
|
||||
AssetType.COMPARISON_MATRIX,
|
||||
AssetType.ARCHITECTURE_DIAGRAM,
|
||||
}
|
||||
]
|
||||
unapproved_table_diagrams = [
|
||||
asset
|
||||
for asset in required_table_diagrams
|
||||
if asset.status != AssetStatus.APPROVED or not asset.file_url
|
||||
]
|
||||
add_item(
|
||||
key="tables_and_diagrams_approved",
|
||||
title="Tables and diagrams approved",
|
||||
completed=bool(required_table_diagrams) and not unapproved_table_diagrams,
|
||||
blocker=(
|
||||
"Required table/diagram assets are missing."
|
||||
if not required_table_diagrams
|
||||
else f"{len(unapproved_table_diagrams)} table/diagram assets are not approved."
|
||||
),
|
||||
)
|
||||
|
||||
has_internal_links = has_draft and _has_internal_links(latest_draft.body_markdown)
|
||||
add_item(
|
||||
key="internal_links_approved",
|
||||
title="Internal links approved",
|
||||
completed=bool(has_internal_links) and not _has_unresolved_internal_link_issue(unresolved),
|
||||
blocker="Draft must include approved internal links.",
|
||||
)
|
||||
|
||||
frontmatter = settings.frontmatter if settings is not None else None
|
||||
add_item(
|
||||
key="frontmatter_fields_selected",
|
||||
title="Frontmatter fields selected",
|
||||
completed=isinstance(frontmatter, dict) and bool(frontmatter),
|
||||
blocker="Required publishing settings are missing: frontmatter fields.",
|
||||
)
|
||||
content_path = settings.content_path if settings is not None else None
|
||||
add_item(
|
||||
key="content_path_selected",
|
||||
title="Content path selected",
|
||||
completed=bool(content_path and content_path.strip()),
|
||||
blocker="Required publishing settings are missing: content path.",
|
||||
)
|
||||
author = settings.author if settings is not None else None
|
||||
add_item(
|
||||
key="author_selected",
|
||||
title="Author selected",
|
||||
completed=bool(author and author.strip()),
|
||||
blocker="Required publishing settings are missing: author.",
|
||||
)
|
||||
publishing_mode = settings.publishing_mode if settings is not None else None
|
||||
add_item(
|
||||
key="publishing_mode_selected",
|
||||
title="Publishing mode selected",
|
||||
completed=bool(publishing_mode and publishing_mode.strip()),
|
||||
blocker="Required publishing settings are missing: publishing mode.",
|
||||
)
|
||||
|
||||
can_approve = len(blockers) == 0
|
||||
return FinalApprovalChecklistResponse(
|
||||
article_id=article_id,
|
||||
draft_version=latest_draft.version if latest_draft is not None else None,
|
||||
requested_draft_version=requested_draft_version,
|
||||
can_approve=can_approve,
|
||||
completed_items=completed_items,
|
||||
blockers=blockers,
|
||||
items=checklist_items,
|
||||
)
|
||||
|
||||
|
||||
def approve_final_article(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
request: FinalApprovalRequest,
|
||||
actor_user_id: UUID,
|
||||
) -> FinalApprovalResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
checklist = get_final_approval_checklist(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
request=request,
|
||||
)
|
||||
if not checklist.can_approve:
|
||||
raise PermissionError("; ".join(checklist.blockers))
|
||||
|
||||
now = _now()
|
||||
updated = repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article_id,
|
||||
event_type="FINAL_APPROVAL_GRANTED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={
|
||||
"draft_version": checklist.draft_version,
|
||||
"publishing_settings": (
|
||||
request.publishing_settings.model_dump(mode="json")
|
||||
if request.publishing_settings is not None
|
||||
else {}
|
||||
),
|
||||
"completed_items": checklist.completed_items,
|
||||
},
|
||||
created_at=now,
|
||||
)
|
||||
return FinalApprovalResponse(article=updated, checklist=checklist)
|
||||
|
||||
|
||||
def request_final_revision(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
request: FinalRevisionRequest,
|
||||
actor_user_id: UUID,
|
||||
) -> FinalRevisionRequestResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
checklist_request = FinalApprovalRequest(
|
||||
draft_version=request.draft_version,
|
||||
publishing_settings=None,
|
||||
)
|
||||
checklist = get_final_approval_checklist(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
request=checklist_request,
|
||||
)
|
||||
now = _now()
|
||||
updated = repository.articles.update_status(
|
||||
article_id=article_id,
|
||||
status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article_id,
|
||||
event_type="FINAL_REVISION_REQUESTED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={
|
||||
"reason": (request.reason or "").strip(),
|
||||
"draft_version": checklist.draft_version,
|
||||
"blockers": checklist.blockers,
|
||||
"completed_items": checklist.completed_items,
|
||||
},
|
||||
created_at=now,
|
||||
)
|
||||
return FinalRevisionRequestResponse(article=updated, checklist=checklist)
|
||||
|
||||
|
||||
def _approved_plan(repository: object, *, article_id: UUID) -> object | None:
|
||||
plans = repository.article_plans.list_for_article(article_id)
|
||||
approved_plans = [plan for plan in plans if plan.status == PlanReviewStatus.APPROVED]
|
||||
if not approved_plans:
|
||||
return None
|
||||
approved_plans.sort(key=lambda plan: plan.version)
|
||||
return approved_plans[-1]
|
||||
|
||||
|
||||
def _draft_contains_approved_plan(approved_plan: object | None, body_markdown: str) -> bool:
|
||||
if approved_plan is None or not approved_plan.sections:
|
||||
return False
|
||||
for section in approved_plan.sections:
|
||||
if f"## {section.heading}" not in body_markdown:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _has_seo_metadata(draft: object) -> bool:
|
||||
return bool(draft.meta_title and draft.meta_title.strip()) and bool(
|
||||
draft.meta_description and draft.meta_description.strip()
|
||||
)
|
||||
|
||||
|
||||
def _has_unresolved_meta_issues(unresolved: list[object]) -> bool:
|
||||
return any(
|
||||
item.review_kind == ContentReviewKind.SEO
|
||||
and item.location in {"meta_title", "meta_description", "title"}
|
||||
for item in unresolved
|
||||
)
|
||||
|
||||
|
||||
def _has_internal_links(markdown: str) -> bool:
|
||||
return re.search(r"\]\(/[^)]+\)", markdown) is not None
|
||||
|
||||
|
||||
def _has_unresolved_internal_link_issue(unresolved: list[object]) -> bool:
|
||||
for item in unresolved:
|
||||
if item.suggestion_key == "internal-link":
|
||||
return True
|
||||
if "internal link" in item.message.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _high_risk_unsupported_claims(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
draft: object | None,
|
||||
) -> list[str]:
|
||||
claims: list[str] = []
|
||||
for claim in repository.claims.list_for_article(article_id):
|
||||
if (
|
||||
claim.support_status == ClaimSupportStatus.UNSUPPORTED
|
||||
and claim.risk_level == ClaimRiskLevel.HIGH
|
||||
):
|
||||
claims.append(claim.claim_text)
|
||||
|
||||
for job in repository.agent_jobs.list_for_article(article_id):
|
||||
if job.job_type != AgentJobType.SECTION_SCAFFOLD:
|
||||
continue
|
||||
raw_claims = job.payload.get("unsupported_claims")
|
||||
if not isinstance(raw_claims, list):
|
||||
continue
|
||||
for raw in raw_claims:
|
||||
if isinstance(raw, dict):
|
||||
risk_level = str(raw.get("risk_level", "")).lower()
|
||||
claim_text = str(raw.get("claim_text", "")).strip()
|
||||
if risk_level == "high" and claim_text:
|
||||
claims.append(claim_text)
|
||||
|
||||
if draft is not None:
|
||||
for warning in draft.unsupported_claim_warnings:
|
||||
if "high-risk" in warning.lower():
|
||||
claims.append(warning)
|
||||
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for claim in claims:
|
||||
normalized = claim.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped.append(normalized)
|
||||
return deduped
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -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
|
||||
@@ -0,0 +1,776 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from src.infrastructure.object_storage import open_object_storage_client
|
||||
from src.domain.contracts import (
|
||||
ArticleSummary,
|
||||
ArticleWorkflowStatus,
|
||||
PublishCommitCreateResponse,
|
||||
PublishCommitListResponse,
|
||||
PublishCommitSummary,
|
||||
PublishingDryRunResponse,
|
||||
PublishingStatus,
|
||||
PublishingStatusResponse,
|
||||
)
|
||||
|
||||
|
||||
_VALIDATION_LABEL = "Best-effort content-shape validation only."
|
||||
_FINAL_APPROVAL_EVENT = "FINAL_APPROVAL_GRANTED"
|
||||
_RUNTIME_DIR = ".pipeline-runtime"
|
||||
_PUBLISHING_YAML_PATH = f"{_RUNTIME_DIR}/publishing.yaml"
|
||||
_TRANSFORM_SCRIPT_PATH = f"{_RUNTIME_DIR}/transform.mjs"
|
||||
_TRANSFORM_RUNNER_PATH = f"{_RUNTIME_DIR}/run-transform.mjs"
|
||||
_TRANSFORM_INPUT_PATH = f"{_RUNTIME_DIR}/transform-input.json"
|
||||
_TRANSFORM_OUTPUT_PATH = f"{_RUNTIME_DIR}/transform-output.json"
|
||||
|
||||
|
||||
def run_publishing_dry_run(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
actor_user_id: UUID,
|
||||
) -> PublishingDryRunResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
final_approval_event = _require_final_approval(repository, article_id=article_id)
|
||||
if article.status not in {
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
}:
|
||||
raise PermissionError("Publishing dry run is allowed only after final approval.")
|
||||
|
||||
bundle_context = _load_bundle_context(
|
||||
repository,
|
||||
article=article,
|
||||
final_approval_event=final_approval_event,
|
||||
)
|
||||
validation_errors = _validate_content_shape(bundle_context["markdown_body"])
|
||||
content_shape_valid = len(validation_errors) == 0
|
||||
now = _now()
|
||||
status = (
|
||||
PublishingStatus.PUBLISH_COMMIT_READY
|
||||
if content_shape_valid
|
||||
else PublishingStatus.PUBLISH_DRY_RUN_FAILED
|
||||
)
|
||||
manifest = _build_manifest(
|
||||
bundle_context=bundle_context,
|
||||
status=status,
|
||||
validation_errors=validation_errors,
|
||||
)
|
||||
|
||||
publish_commit = repository.publish_commits.create(
|
||||
article_id=article.id,
|
||||
target_site_id=article.target_site_id,
|
||||
repository_url=bundle_context["repository_url"],
|
||||
branch=bundle_context["branch"],
|
||||
commit_sha=None,
|
||||
content_bundle_manifest=manifest,
|
||||
status=status,
|
||||
deployment_status=None,
|
||||
created_at=now,
|
||||
)
|
||||
updated = _update_article_for_dry_run_result(
|
||||
repository,
|
||||
article=article,
|
||||
content_shape_valid=content_shape_valid,
|
||||
actor_user_id=actor_user_id,
|
||||
validation_errors=validation_errors,
|
||||
created_at=now,
|
||||
)
|
||||
return PublishingDryRunResponse(
|
||||
article=updated,
|
||||
publish_commit=publish_commit,
|
||||
content_shape_valid=content_shape_valid,
|
||||
validation_label=_VALIDATION_LABEL,
|
||||
errors=validation_errors,
|
||||
)
|
||||
|
||||
|
||||
def create_publish_commit(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
actor_user_id: UUID,
|
||||
) -> PublishCommitCreateResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
_require_final_approval(repository, article_id=article_id)
|
||||
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
|
||||
article_id,
|
||||
statuses={PublishingStatus.PUBLISH_COMMIT_READY},
|
||||
)
|
||||
if latest_dry_run is None:
|
||||
raise PermissionError("Publish commit requires successful dry run.")
|
||||
if article.status != ArticleWorkflowStatus.PUBLISH_COMMIT_READY:
|
||||
raise PermissionError("Publish commit can run only from PUBLISH_COMMIT_READY status.")
|
||||
|
||||
manifest = dict(latest_dry_run.content_bundle_manifest or {})
|
||||
git_info = manifest.get("git") if isinstance(manifest.get("git"), dict) else {}
|
||||
expected_base_head_sha = git_info.get("base_head_sha")
|
||||
repository_url = latest_dry_run.repository_url
|
||||
branch = latest_dry_run.branch
|
||||
current_head_sha = _resolve_remote_branch_head_sha(repository_url, branch)
|
||||
if expected_base_head_sha and current_head_sha != expected_base_head_sha:
|
||||
_record_publish_failure(
|
||||
repository,
|
||||
article=article,
|
||||
actor_user_id=actor_user_id,
|
||||
repository_url=repository_url,
|
||||
branch=branch,
|
||||
previous_manifest=manifest,
|
||||
detail=(
|
||||
"Non-fast-forward detected: remote branch advanced since dry run; "
|
||||
"re-run dry run before creating commit."
|
||||
),
|
||||
)
|
||||
raise PermissionError(
|
||||
"Non-fast-forward detected: remote branch advanced since dry run."
|
||||
)
|
||||
|
||||
final_approval_event = _require_final_approval(repository, article_id=article_id)
|
||||
bundle_context = _load_bundle_context(
|
||||
repository,
|
||||
article=article,
|
||||
final_approval_event=final_approval_event,
|
||||
)
|
||||
commit_sha = _create_and_push_commit(bundle_context)
|
||||
now = _now()
|
||||
publish_commit = repository.publish_commits.create(
|
||||
article_id=article.id,
|
||||
target_site_id=article.target_site_id,
|
||||
repository_url=repository_url,
|
||||
branch=branch,
|
||||
commit_sha=commit_sha,
|
||||
content_bundle_manifest=_build_manifest(
|
||||
bundle_context=bundle_context,
|
||||
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||
validation_errors=[],
|
||||
commit_sha=commit_sha,
|
||||
),
|
||||
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||
deployment_status="PENDING",
|
||||
created_at=now,
|
||||
)
|
||||
updated = repository.articles.update_status(
|
||||
article_id=article.id,
|
||||
status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
updated_at=now,
|
||||
)
|
||||
updated = repository.articles.update_publishing_status(
|
||||
article_id=article.id,
|
||||
publishing_status=PublishingStatus.PUBLISH_COMMIT_CREATED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article.id,
|
||||
event_type="PUBLISH_COMMIT_CREATED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={
|
||||
"publish_commit_id": str(publish_commit.id),
|
||||
"repository_url": repository_url,
|
||||
"branch": branch,
|
||||
"commit_sha": commit_sha,
|
||||
},
|
||||
created_at=now,
|
||||
)
|
||||
return PublishCommitCreateResponse(article=updated, publish_commit=publish_commit)
|
||||
|
||||
|
||||
def get_publishing_status(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> PublishingStatusResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
|
||||
article_id,
|
||||
statuses={PublishingStatus.PUBLISH_COMMIT_READY, PublishingStatus.PUBLISH_DRY_RUN_FAILED},
|
||||
)
|
||||
latest_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
|
||||
article_id,
|
||||
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
|
||||
)
|
||||
return PublishingStatusResponse(
|
||||
article=article,
|
||||
latest_dry_run=latest_dry_run,
|
||||
latest_publish_commit=latest_publish_commit,
|
||||
validation_label=_VALIDATION_LABEL,
|
||||
)
|
||||
|
||||
|
||||
def list_publish_commits(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
) -> PublishCommitListResponse:
|
||||
repository.articles.get(article_id)
|
||||
return PublishCommitListResponse(commits=repository.publish_commits.list_for_article(article_id))
|
||||
|
||||
|
||||
def _load_bundle_context(
|
||||
repository: object,
|
||||
*,
|
||||
article: ArticleSummary,
|
||||
final_approval_event: object,
|
||||
) -> dict[str, Any]:
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
if target_site.active_script_config_version_id is None:
|
||||
raise PermissionError("Active script config version is required for publishing.")
|
||||
script_config_version = repository.script_config_versions.get_by_id(
|
||||
target_site.active_script_config_version_id
|
||||
)
|
||||
draft = repository.article_drafts.latest_for_article(article.id)
|
||||
if draft is None:
|
||||
raise PermissionError("Latest draft is required for publishing.")
|
||||
|
||||
settings_payload = final_approval_event.payload.get("publishing_settings", {})
|
||||
if not isinstance(settings_payload, dict):
|
||||
settings_payload = {}
|
||||
content_path = _string_value(settings_payload.get("content_path"))
|
||||
if not content_path:
|
||||
raise PermissionError("Final approval publishing settings are missing content_path.")
|
||||
author = _string_value(settings_payload.get("author"))
|
||||
if not author:
|
||||
raise PermissionError("Final approval publishing settings are missing author.")
|
||||
frontmatter_input = (
|
||||
settings_payload.get("frontmatter")
|
||||
if isinstance(settings_payload.get("frontmatter"), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
slug = _slug_from_content_path(content_path) or _slugify(draft.title or article.working_title or "article")
|
||||
content_rel_path = _apply_template(
|
||||
target_site.publishing_rules.content_path_template,
|
||||
{
|
||||
"slug": slug,
|
||||
"article_id": str(article.id),
|
||||
"content_path": content_path.lstrip("/"),
|
||||
"format": target_site.publishing_rules.content_format,
|
||||
"language": article.language,
|
||||
},
|
||||
)
|
||||
frontmatter = _build_frontmatter(
|
||||
frontmatter_mapping=target_site.publishing_rules.frontmatter_mapping,
|
||||
frontmatter_input=frontmatter_input,
|
||||
author=author,
|
||||
draft=draft,
|
||||
published_at=_now().isoformat(),
|
||||
)
|
||||
markdown_with_frontmatter = _compose_markdown(frontmatter, draft.body_markdown)
|
||||
object_storage = open_object_storage_client()
|
||||
assets = _collect_assets(
|
||||
repository,
|
||||
article_id=article.id,
|
||||
slug=slug,
|
||||
asset_path_template=target_site.publishing_rules.asset_path_template,
|
||||
object_storage=object_storage,
|
||||
)
|
||||
|
||||
return {
|
||||
"article": article,
|
||||
"draft": draft,
|
||||
"target_site": target_site,
|
||||
"script_config_version": script_config_version,
|
||||
"slug": slug,
|
||||
"content_rel_path": content_rel_path,
|
||||
"frontmatter": frontmatter,
|
||||
"markdown_body": draft.body_markdown,
|
||||
"markdown_with_frontmatter": markdown_with_frontmatter,
|
||||
"assets": assets,
|
||||
"repository_url": target_site.publishing_rules.repository_url,
|
||||
"branch": target_site.publishing_rules.production_branch,
|
||||
"content_format": target_site.publishing_rules.content_format,
|
||||
}
|
||||
|
||||
|
||||
def _build_frontmatter(
|
||||
*,
|
||||
frontmatter_mapping: dict[str, Any],
|
||||
frontmatter_input: dict[str, Any],
|
||||
author: str,
|
||||
draft: object,
|
||||
published_at: str,
|
||||
) -> dict[str, Any]:
|
||||
source = {
|
||||
**frontmatter_input,
|
||||
"title": frontmatter_input.get("title") or getattr(draft, "title", None),
|
||||
"meta_description": getattr(draft, "meta_description", None),
|
||||
"author": author,
|
||||
"published_at": published_at,
|
||||
}
|
||||
mapped: dict[str, Any] = {}
|
||||
for output_key, source_key in frontmatter_mapping.items():
|
||||
if not isinstance(output_key, str) or not output_key:
|
||||
continue
|
||||
if not isinstance(source_key, str) or not source_key:
|
||||
continue
|
||||
if source_key in source and source[source_key] is not None:
|
||||
mapped[output_key] = source[source_key]
|
||||
|
||||
for key, value in frontmatter_input.items():
|
||||
if key not in mapped:
|
||||
mapped[key] = value
|
||||
if "author" not in mapped:
|
||||
mapped["author"] = author
|
||||
return mapped
|
||||
|
||||
|
||||
def _collect_assets(
|
||||
repository: object,
|
||||
*,
|
||||
article_id: UUID,
|
||||
slug: str,
|
||||
asset_path_template: str,
|
||||
object_storage: object,
|
||||
) -> list[dict[str, Any]]:
|
||||
assets = repository.assets.list_for_article(article_id)
|
||||
output: list[dict[str, Any]] = []
|
||||
for asset in assets:
|
||||
if asset.status.value != "APPROVED":
|
||||
continue
|
||||
if not asset.file_url:
|
||||
continue
|
||||
source_file_name = _asset_filename(asset)
|
||||
source_content = _load_asset_bytes(
|
||||
asset=asset,
|
||||
object_storage=object_storage,
|
||||
)
|
||||
if source_content is None:
|
||||
raise PermissionError(f"Approved asset file is unavailable: {asset.file_url}")
|
||||
target_path = _apply_template(
|
||||
asset_path_template,
|
||||
{
|
||||
"slug": slug,
|
||||
"filename": source_file_name,
|
||||
"article_id": str(article_id),
|
||||
"asset_id": str(asset.id),
|
||||
},
|
||||
)
|
||||
output.append(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"asset_type": asset.asset_type.value,
|
||||
"source_url": asset.file_url,
|
||||
"source_content": source_content,
|
||||
"target_path": target_path,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _create_and_push_commit(bundle_context: dict[str, Any]) -> str:
|
||||
repository_url = bundle_context["repository_url"]
|
||||
branch = bundle_context["branch"]
|
||||
with tempfile.TemporaryDirectory(prefix="publish-commit-") as tmp_dir:
|
||||
workspace = Path(tmp_dir) / "site"
|
||||
_run_cmd(
|
||||
["git", "clone", "--branch", branch, "--single-branch", repository_url, str(workspace)],
|
||||
cwd=None,
|
||||
error_prefix="GIT_CHECKOUT_FAILED",
|
||||
)
|
||||
runtime_dir = workspace / _RUNTIME_DIR
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
script_config = bundle_context["script_config_version"]
|
||||
(workspace / _PUBLISHING_YAML_PATH).write_text(
|
||||
str(script_config["publishing_yaml"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace / _TRANSFORM_SCRIPT_PATH).write_text(
|
||||
str(script_config["transform_script"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
transform_assets = [
|
||||
{
|
||||
"asset_id": asset["asset_id"],
|
||||
"asset_type": asset["asset_type"],
|
||||
"source_url": asset["source_url"],
|
||||
"target_path": asset["target_path"],
|
||||
}
|
||||
for asset in bundle_context["assets"]
|
||||
]
|
||||
transform_input = {
|
||||
"frontmatter": bundle_context["frontmatter"],
|
||||
"body": bundle_context["markdown_body"],
|
||||
"assets": transform_assets,
|
||||
"content_path": bundle_context["content_rel_path"],
|
||||
}
|
||||
(workspace / _TRANSFORM_INPUT_PATH).write_text(
|
||||
json.dumps(transform_input, ensure_ascii=True, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace / _TRANSFORM_RUNNER_PATH).write_text(
|
||||
_transform_runner_script(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_run_cmd(
|
||||
[
|
||||
"node",
|
||||
_TRANSFORM_RUNNER_PATH,
|
||||
_TRANSFORM_SCRIPT_PATH,
|
||||
_TRANSFORM_INPUT_PATH,
|
||||
_TRANSFORM_OUTPUT_PATH,
|
||||
],
|
||||
cwd=workspace,
|
||||
error_prefix="PUBLISH_DRY_RUN_FAILED",
|
||||
)
|
||||
|
||||
content_file = workspace / bundle_context["content_rel_path"]
|
||||
content_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content_file.write_text(bundle_context["markdown_with_frontmatter"], encoding="utf-8")
|
||||
for asset in bundle_context["assets"]:
|
||||
target_path = workspace / str(asset["target_path"])
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(bytes(asset["source_content"]))
|
||||
shutil.rmtree(runtime_dir, ignore_errors=True)
|
||||
|
||||
_run_cmd(
|
||||
["git", "config", "user.name", "Pipeline Bot"],
|
||||
cwd=workspace,
|
||||
error_prefix="GIT_COMMIT_FAILED",
|
||||
)
|
||||
_run_cmd(
|
||||
["git", "config", "user.email", "pipeline-bot@example.com"],
|
||||
cwd=workspace,
|
||||
error_prefix="GIT_COMMIT_FAILED",
|
||||
)
|
||||
_run_cmd(["git", "add", "."], cwd=workspace, error_prefix="GIT_COMMIT_FAILED")
|
||||
commit_message = (
|
||||
f"Publish article {bundle_context['article'].id}: {bundle_context['draft'].title}"
|
||||
)
|
||||
_run_cmd(
|
||||
["git", "commit", "--allow-empty", "-m", commit_message],
|
||||
cwd=workspace,
|
||||
error_prefix="GIT_COMMIT_FAILED",
|
||||
)
|
||||
try:
|
||||
_run_cmd(
|
||||
["git", "push", "origin", branch],
|
||||
cwd=workspace,
|
||||
error_prefix="GIT_PUSH_NON_FAST_FORWARD",
|
||||
)
|
||||
except RuntimeError as error:
|
||||
if "non-fast-forward" in str(error) or "[rejected]" in str(error):
|
||||
raise PermissionError(
|
||||
"Non-fast-forward push rejected. Re-run dry run and retry commit."
|
||||
) from error
|
||||
raise
|
||||
|
||||
commit_sha = _run_cmd(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=workspace,
|
||||
error_prefix="GIT_COMMIT_FAILED",
|
||||
).strip()
|
||||
return commit_sha
|
||||
|
||||
|
||||
def _update_article_for_dry_run_result(
|
||||
repository: object,
|
||||
*,
|
||||
article: ArticleSummary,
|
||||
content_shape_valid: bool,
|
||||
actor_user_id: UUID,
|
||||
validation_errors: list[str],
|
||||
created_at: datetime,
|
||||
) -> ArticleSummary:
|
||||
if content_shape_valid:
|
||||
updated = repository.articles.update_status(
|
||||
article_id=article.id,
|
||||
status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
updated_at=created_at,
|
||||
)
|
||||
updated = repository.articles.update_publishing_status(
|
||||
article_id=article.id,
|
||||
publishing_status=PublishingStatus.PUBLISH_COMMIT_READY,
|
||||
updated_at=created_at,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article.id,
|
||||
event_type="PUBLISH_DRY_RUN_SUCCEEDED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"validation_label": _VALIDATION_LABEL},
|
||||
created_at=created_at,
|
||||
)
|
||||
return updated
|
||||
|
||||
updated = repository.articles.update_status(
|
||||
article_id=article.id,
|
||||
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
updated_at=created_at,
|
||||
)
|
||||
updated = repository.articles.update_publishing_status(
|
||||
article_id=article.id,
|
||||
publishing_status=PublishingStatus.PUBLISH_DRY_RUN_FAILED,
|
||||
updated_at=created_at,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article.id,
|
||||
event_type="PUBLISH_DRY_RUN_FAILED",
|
||||
from_status=article.status,
|
||||
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={
|
||||
"validation_label": _VALIDATION_LABEL,
|
||||
"errors": validation_errors,
|
||||
},
|
||||
created_at=created_at,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def _record_publish_failure(
|
||||
repository: object,
|
||||
*,
|
||||
article: ArticleSummary,
|
||||
actor_user_id: UUID,
|
||||
repository_url: str,
|
||||
branch: str,
|
||||
previous_manifest: dict[str, Any],
|
||||
detail: str,
|
||||
) -> None:
|
||||
now = _now()
|
||||
failed_manifest = dict(previous_manifest)
|
||||
failed_manifest["failure"] = detail
|
||||
repository.publish_commits.create(
|
||||
article_id=article.id,
|
||||
target_site_id=article.target_site_id,
|
||||
repository_url=repository_url,
|
||||
branch=branch,
|
||||
commit_sha=None,
|
||||
content_bundle_manifest=failed_manifest,
|
||||
status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
|
||||
deployment_status="FAILED",
|
||||
created_at=now,
|
||||
)
|
||||
repository.articles.update_publishing_status(
|
||||
article_id=article.id,
|
||||
publishing_status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
|
||||
updated_at=now,
|
||||
)
|
||||
repository.articles.create_workflow_event(
|
||||
article_id=article.id,
|
||||
event_type="PUBLISH_COMMIT_FAILED",
|
||||
from_status=article.status,
|
||||
to_status=article.status,
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"detail": detail},
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
*,
|
||||
bundle_context: dict[str, Any],
|
||||
status: PublishingStatus,
|
||||
validation_errors: list[str],
|
||||
commit_sha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base_head_sha = _resolve_remote_branch_head_sha(
|
||||
bundle_context["repository_url"], bundle_context["branch"]
|
||||
)
|
||||
script_config_version = bundle_context["script_config_version"]
|
||||
return {
|
||||
"status": status.value,
|
||||
"generated_at": _now().isoformat(),
|
||||
"validation": {
|
||||
"label": _VALIDATION_LABEL,
|
||||
"content_shape_valid": len(validation_errors) == 0,
|
||||
"errors": validation_errors,
|
||||
},
|
||||
"content": {
|
||||
"format": bundle_context["content_format"],
|
||||
"path": bundle_context["content_rel_path"],
|
||||
"slug": bundle_context["slug"],
|
||||
},
|
||||
"frontmatter": bundle_context["frontmatter"],
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": asset["asset_id"],
|
||||
"asset_type": asset["asset_type"],
|
||||
"source_url": asset["source_url"],
|
||||
"target_path": asset["target_path"],
|
||||
}
|
||||
for asset in bundle_context["assets"]
|
||||
],
|
||||
"config_version": {
|
||||
"version_id": str(script_config_version["id"]),
|
||||
"version": int(script_config_version["version"]),
|
||||
"publishing_yaml_hash": str(script_config_version["publishing_yaml_hash"]),
|
||||
"transform_script_hash": str(script_config_version["transform_script_hash"]),
|
||||
},
|
||||
"git": {
|
||||
"repository_url": bundle_context["repository_url"],
|
||||
"branch": bundle_context["branch"],
|
||||
"base_head_sha": base_head_sha,
|
||||
"commit_sha": commit_sha,
|
||||
},
|
||||
"draft_version": int(bundle_context["draft"].version),
|
||||
}
|
||||
|
||||
|
||||
def _validate_content_shape(markdown: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not markdown.strip():
|
||||
errors.append("Markdown body is empty.")
|
||||
if re.search(r"^#{1,6}\s+\S", markdown, re.MULTILINE) is None:
|
||||
errors.append("Markdown body must include at least one heading.")
|
||||
if markdown.count("```") % 2 != 0:
|
||||
errors.append("Markdown body has unbalanced fenced code blocks.")
|
||||
return errors
|
||||
|
||||
|
||||
def _compose_markdown(frontmatter: dict[str, Any], markdown_body: str) -> str:
|
||||
lines = ["---"]
|
||||
for key in sorted(frontmatter):
|
||||
lines.append(f"{key}: {json.dumps(frontmatter[key], ensure_ascii=True)}")
|
||||
lines.extend(["---", "", markdown_body.strip(), ""])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_remote_branch_head_sha(repository_url: str, branch: str) -> str | None:
|
||||
output = _run_cmd(
|
||||
["git", "ls-remote", repository_url, f"refs/heads/{branch}"],
|
||||
cwd=None,
|
||||
error_prefix="GIT_CHECKOUT_FAILED",
|
||||
)
|
||||
line = output.strip()
|
||||
if not line:
|
||||
return None
|
||||
return line.split("\t", 1)[0]
|
||||
|
||||
|
||||
def _run_cmd(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None,
|
||||
error_prefix: str,
|
||||
) -> str:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip() or "command failed"
|
||||
raise RuntimeError(f"{error_prefix}: {message}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _apply_template(template: str, values: dict[str, Any]) -> str:
|
||||
class _SafeValues(dict[str, Any]):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
rendered = template.format_map(_SafeValues(values))
|
||||
return rendered.lstrip("/")
|
||||
|
||||
|
||||
def _local_path_from_file_url(file_url: str) -> Path | None:
|
||||
parsed = urlparse(file_url)
|
||||
if parsed.scheme != "file":
|
||||
return None
|
||||
return Path(parsed.path)
|
||||
|
||||
|
||||
def _asset_filename(asset: object) -> str:
|
||||
source_path = _local_path_from_file_url(_string_value(getattr(asset, "file_url", None)))
|
||||
if source_path is not None:
|
||||
return source_path.name
|
||||
object_key = _string_value(getattr(asset, "object_key", None))
|
||||
if object_key:
|
||||
return Path(object_key).name
|
||||
return "asset.bin"
|
||||
|
||||
|
||||
def _load_asset_bytes(*, asset: object, object_storage: object) -> bytes | None:
|
||||
file_url = _string_value(getattr(asset, "file_url", None))
|
||||
local_path = _local_path_from_file_url(file_url)
|
||||
if local_path is not None:
|
||||
if not local_path.exists():
|
||||
return None
|
||||
return local_path.read_bytes()
|
||||
|
||||
object_key = _string_value(getattr(asset, "object_key", None))
|
||||
parsed = urlparse(file_url)
|
||||
if parsed.scheme == "s3" and not object_key:
|
||||
object_key = parsed.path.lstrip("/")
|
||||
if not object_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
return object_storage.get_bytes(object_key=object_key)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_final_approval(repository: object, *, article_id: UUID) -> object:
|
||||
events = repository.articles.list_workflow_events(article_id)
|
||||
for event in reversed(events):
|
||||
if event.event_type == _FINAL_APPROVAL_EVENT:
|
||||
return event
|
||||
raise PermissionError("Final approval is required before publishing.")
|
||||
|
||||
|
||||
def _slug_from_content_path(content_path: str) -> str:
|
||||
value = content_path.strip().strip("/")
|
||||
if not value:
|
||||
return ""
|
||||
tail = value.split("/")[-1]
|
||||
if "." in tail:
|
||||
tail = tail.rsplit(".", 1)[0]
|
||||
return _slugify(tail)
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
||||
return normalized or "article"
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _transform_runner_script() -> str:
|
||||
return """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [scriptPath, inputPath, outputPath] = process.argv.slice(2);
|
||||
const scriptUrl = pathToFileURL(scriptPath).href;
|
||||
const moduleRef = await import(scriptUrl + `?t=${Date.now()}`);
|
||||
const transform =
|
||||
typeof moduleRef.transformArticle === "function"
|
||||
? moduleRef.transformArticle
|
||||
: typeof moduleRef.default === "function"
|
||||
? moduleRef.default
|
||||
: null;
|
||||
|
||||
if (!transform) {
|
||||
throw new Error("Transform script must export transformArticle(article).");
|
||||
}
|
||||
|
||||
const raw = readFileSync(inputPath, "utf8");
|
||||
const article = JSON.parse(raw);
|
||||
const transformed = await transform(article);
|
||||
const output = transformed ?? article;
|
||||
writeFileSync(outputPath, JSON.stringify(output, null, 2), "utf8");
|
||||
""".strip()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -4,6 +4,11 @@ from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
from src.application.demo_runtime import (
|
||||
ensure_demo_bare_repository,
|
||||
is_demo_mode,
|
||||
resolve_demo_repository_path,
|
||||
)
|
||||
from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
Role,
|
||||
@@ -44,8 +49,9 @@ def seed_reference_data(repository: object) -> None:
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
repository_url = _repository_url_for_seed()
|
||||
publishing_rules = PublishingRules(
|
||||
repository_url="git@github.com:example/site.git",
|
||||
repository_url=repository_url,
|
||||
production_branch="main",
|
||||
content_format="mdx",
|
||||
content_path_template="content/articles/{slug}.mdx",
|
||||
@@ -85,15 +91,15 @@ def seed_reference_data(repository: object) -> None:
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
publishing_yaml = """target: b2b_saas_blog
|
||||
publishing_yaml = f"""target: b2b_saas_blog
|
||||
repository:
|
||||
url: git@github.com:example/site.git
|
||||
url: {repository_url}
|
||||
branch: main
|
||||
content:
|
||||
format: mdx
|
||||
path_template: content/articles/{slug}.mdx
|
||||
path_template: content/articles/{{slug}}.mdx
|
||||
assets:
|
||||
path_template: public/articles/{slug}/{filename}
|
||||
path_template: public/articles/{{slug}}/{{filename}}
|
||||
"""
|
||||
transform_script = """export function transformArticle(article) {
|
||||
return {
|
||||
@@ -121,6 +127,14 @@ assets:
|
||||
)
|
||||
|
||||
|
||||
def _repository_url_for_seed() -> str:
|
||||
if not is_demo_mode():
|
||||
return "git@github.com:example/site.git"
|
||||
demo_repo_path = resolve_demo_repository_path()
|
||||
ensure_demo_bare_repository(demo_repo_path)
|
||||
return str(demo_repo_path)
|
||||
|
||||
|
||||
def _stable_uuid(value: str) -> UUID:
|
||||
return uuid5(NAMESPACE_URL, f"ai-content-pipeline:{value}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -57,6 +58,11 @@ from .models import (
|
||||
EvidenceMatrixResponse,
|
||||
EvidenceResponse,
|
||||
EvidenceUpdateRequest,
|
||||
FinalApprovalChecklistItemSummary,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalPublishingSettings,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
PlanSectionSummary,
|
||||
PlanSectionEditRequest,
|
||||
PlanListResponse,
|
||||
@@ -65,6 +71,10 @@ from .models import (
|
||||
PlanSummary,
|
||||
PlanUpdateRequest,
|
||||
PublishCommitSummary,
|
||||
PublishCommitCreateResponse,
|
||||
PublishCommitListResponse,
|
||||
PublishingDryRunResponse,
|
||||
PublishingStatusResponse,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
@@ -78,9 +88,13 @@ from .models import (
|
||||
SeoReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
LanguageReviewReportResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -118,6 +132,7 @@ __all__ = [
|
||||
"BoundaryQuestionSummary",
|
||||
"BoundaryQuestionUpdateRequest",
|
||||
"WorkflowEventSummary",
|
||||
"ObservabilityTimelineEventSummary",
|
||||
"AssetGenerateSpecsResponse",
|
||||
"AssetListResponse",
|
||||
"AssetResponse",
|
||||
@@ -149,6 +164,13 @@ __all__ = [
|
||||
"EvidenceMatrixResponse",
|
||||
"EvidenceResponse",
|
||||
"EvidenceUpdateRequest",
|
||||
"FinalApprovalChecklistItemSummary",
|
||||
"FinalApprovalChecklistResponse",
|
||||
"FinalApprovalPublishingSettings",
|
||||
"FinalApprovalRequest",
|
||||
"FinalApprovalResponse",
|
||||
"FinalRevisionRequest",
|
||||
"FinalRevisionRequestResponse",
|
||||
"FinalReviewIssueSummary",
|
||||
"FinalReviewIssuesResponse",
|
||||
"LanguageReviewRunResponse",
|
||||
@@ -161,8 +183,12 @@ __all__ = [
|
||||
"PlanSectionSummary",
|
||||
"PlanSummary",
|
||||
"PlanUpdateRequest",
|
||||
"PublishCommitCreateResponse",
|
||||
"PublishCommitListResponse",
|
||||
"PublishCommitSummary",
|
||||
"PublishingDryRunResponse",
|
||||
"PublishingRules",
|
||||
"PublishingStatusResponse",
|
||||
"PublishingStatus",
|
||||
"ResearchArtifactManifestSummary",
|
||||
"ResearchArtifactSummary",
|
||||
@@ -177,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
|
||||
@@ -443,6 +479,30 @@ class PublishCommitSummary(ContractModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublishingDryRunResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
publish_commit: PublishCommitSummary
|
||||
content_shape_valid: bool
|
||||
validation_label: str = Field(min_length=1)
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PublishCommitCreateResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
publish_commit: PublishCommitSummary
|
||||
|
||||
|
||||
class PublishingStatusResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
latest_dry_run: PublishCommitSummary | None = None
|
||||
latest_publish_commit: PublishCommitSummary | None = None
|
||||
validation_label: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PublishCommitListResponse(ContractModel):
|
||||
commits: list[PublishCommitSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunnerFileRef(ContractModel):
|
||||
path: str = Field(min_length=1)
|
||||
content_hash: str | None = None
|
||||
@@ -469,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):
|
||||
@@ -598,6 +662,50 @@ class FinalReviewIssuesResponse(ContractModel):
|
||||
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FinalApprovalPublishingSettings(ContractModel):
|
||||
content_path: str | None = None
|
||||
author: str | None = None
|
||||
publishing_mode: str | None = None
|
||||
frontmatter: JsonObject | None = None
|
||||
|
||||
|
||||
class FinalApprovalRequest(ContractModel):
|
||||
draft_version: int | None = Field(default=None, ge=1)
|
||||
publishing_settings: FinalApprovalPublishingSettings | None = None
|
||||
|
||||
|
||||
class FinalRevisionRequest(ContractModel):
|
||||
reason: str | None = None
|
||||
draft_version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class FinalApprovalChecklistItemSummary(ContractModel):
|
||||
key: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
completed: bool
|
||||
blocker: str | None = None
|
||||
|
||||
|
||||
class FinalApprovalChecklistResponse(ContractModel):
|
||||
article_id: UUID
|
||||
draft_version: int | None = Field(default=None, ge=1)
|
||||
requested_draft_version: int | None = Field(default=None, ge=1)
|
||||
can_approve: bool
|
||||
completed_items: list[str] = Field(default_factory=list)
|
||||
blockers: list[str] = Field(default_factory=list)
|
||||
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FinalApprovalResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
checklist: FinalApprovalChecklistResponse
|
||||
|
||||
|
||||
class FinalRevisionRequestResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
checklist: FinalApprovalChecklistResponse
|
||||
|
||||
|
||||
class AssetGenerateSpecsResponse(ContractModel):
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
|
||||
@@ -641,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,
|
||||
@@ -58,6 +59,11 @@ from .models import (
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
FinalApprovalChecklistItemSummary,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalPublishingSettings,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
EvidenceSummary,
|
||||
EvidenceCreateRequest,
|
||||
EvidenceMatrixResponse,
|
||||
@@ -70,7 +76,11 @@ from .models import (
|
||||
PlanRevisionRequest,
|
||||
PlanSummary,
|
||||
PlanUpdateRequest,
|
||||
PublishCommitCreateResponse,
|
||||
PublishCommitListResponse,
|
||||
PublishCommitSummary,
|
||||
PublishingDryRunResponse,
|
||||
PublishingStatusResponse,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
@@ -84,9 +94,13 @@ from .models import (
|
||||
ReviewSuggestionEditRequest,
|
||||
ReviewActionResponse,
|
||||
ReviewSummary,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -132,11 +146,14 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
ArticleListResponse,
|
||||
WorkflowEventSummary,
|
||||
ObservabilityTimelineEventSummary,
|
||||
BoundaryQuestionSummary,
|
||||
BoundaryQuestionUpdateRequest,
|
||||
BoundaryQuestionResponse,
|
||||
@@ -167,6 +184,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewSuggestionSummary,
|
||||
FinalApprovalChecklistItemSummary,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalPublishingSettings,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
FinalReviewIssueSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
@@ -186,6 +210,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
PublishCommitSummary,
|
||||
PublishCommitCreateResponse,
|
||||
PublishCommitListResponse,
|
||||
PublishingDryRunResponse,
|
||||
PublishingStatusResponse,
|
||||
RunnerFileRef,
|
||||
AgentJobSummary,
|
||||
AgentJobTestCodexRequest,
|
||||
|
||||
@@ -8,6 +8,9 @@ class ObjectStorageClient:
|
||||
def put_text(self, *, object_key: str, content: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_bytes(self, *, object_key: str) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
*,
|
||||
@@ -41,6 +44,10 @@ class LocalObjectStorageClient(ObjectStorageClient):
|
||||
path.write_bytes(content)
|
||||
return f"file://{path}"
|
||||
|
||||
def get_bytes(self, *, object_key: str) -> bytes:
|
||||
path = self.root / object_key
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
class S3ObjectStorageClient(ObjectStorageClient):
|
||||
def __init__(self) -> None:
|
||||
@@ -64,6 +71,16 @@ class S3ObjectStorageClient(ObjectStorageClient):
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
def get_bytes(self, *, object_key: str) -> bytes:
|
||||
response = self.client.get_object(
|
||||
Bucket=self.bucket,
|
||||
Key=object_key,
|
||||
)
|
||||
body = response.get("Body")
|
||||
if body is None:
|
||||
raise RuntimeError(f"Object storage response missing Body for key: {object_key}")
|
||||
return body.read()
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -32,6 +32,7 @@ from src.domain.contracts import (
|
||||
PlanReviewStatus,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
PublishingStatus,
|
||||
ResearchArtifactManifestSummary,
|
||||
@@ -67,6 +68,7 @@ class BackendRepository:
|
||||
self.content_reviews = ContentReviewsRepository(self)
|
||||
self.assets = AssetsRepository(self)
|
||||
self.research_manifests = ResearchManifestsRepository(self)
|
||||
self.publish_commits = PublishCommitsRepository(self)
|
||||
self.evidence_items = EvidenceItemsRepository(self)
|
||||
self.claims = ClaimsRepository(self)
|
||||
self.agent_jobs = AgentJobsRepository(self)
|
||||
@@ -568,6 +570,26 @@ class ArticlesRepository:
|
||||
|
||||
return self.get(article_id)
|
||||
|
||||
def update_publishing_status(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
publishing_status: PublishingStatus,
|
||||
updated_at: datetime,
|
||||
) -> ArticleSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
connection.execute(
|
||||
f"""
|
||||
UPDATE articles
|
||||
SET publishing_status = {placeholder}, updated_at = {placeholder}
|
||||
WHERE id = {placeholder}
|
||||
""",
|
||||
(publishing_status.value, _datetime_value(updated_at), str(article_id)),
|
||||
)
|
||||
|
||||
return self.get(article_id)
|
||||
|
||||
def create_workflow_event(
|
||||
self,
|
||||
*,
|
||||
@@ -2049,6 +2071,150 @@ class ResearchManifestsRepository:
|
||||
return _research_manifest_from_row(row)
|
||||
|
||||
|
||||
class PublishCommitsRepository:
|
||||
def __init__(self, repository: BackendRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
article_id: UUID,
|
||||
target_site_id: UUID,
|
||||
repository_url: str,
|
||||
branch: str,
|
||||
commit_sha: str | None,
|
||||
content_bundle_manifest: JsonObject,
|
||||
status: PublishingStatus,
|
||||
deployment_status: str | None,
|
||||
created_at: datetime,
|
||||
) -> PublishCommitSummary:
|
||||
publish_commit_id = uuid4()
|
||||
placeholder = self._repository.placeholder()
|
||||
json_cast = self._repository.json_cast()
|
||||
with self._repository.connection() as connection:
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO publish_commits (
|
||||
id,
|
||||
article_id,
|
||||
target_site_id,
|
||||
repository_url,
|
||||
branch,
|
||||
commit_sha,
|
||||
content_bundle_manifest,
|
||||
status,
|
||||
deployment_status,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}
|
||||
)
|
||||
""",
|
||||
(
|
||||
str(publish_commit_id),
|
||||
str(article_id),
|
||||
str(target_site_id),
|
||||
repository_url,
|
||||
branch,
|
||||
commit_sha,
|
||||
_json_value(content_bundle_manifest),
|
||||
status.value,
|
||||
deployment_status,
|
||||
_datetime_value(created_at),
|
||||
),
|
||||
)
|
||||
|
||||
return self.get(publish_commit_id)
|
||||
|
||||
def get(self, publish_commit_id: UUID) -> PublishCommitSummary:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT {self._select_columns()}
|
||||
FROM publish_commits
|
||||
WHERE id = {placeholder}
|
||||
""",
|
||||
(str(publish_commit_id),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise LookupError(f"Publish commit not found: {publish_commit_id}")
|
||||
return _publish_commit_from_row(row)
|
||||
|
||||
def list_for_article(self, article_id: UUID) -> list[PublishCommitSummary]:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT {self._select_columns()}
|
||||
FROM publish_commits
|
||||
WHERE article_id = {placeholder}
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""",
|
||||
(str(article_id),),
|
||||
).fetchall()
|
||||
|
||||
return [_publish_commit_from_row(row) for row in rows]
|
||||
|
||||
def latest_for_article(self, article_id: UUID) -> PublishCommitSummary | None:
|
||||
commits = self.list_for_article(article_id)
|
||||
if not commits:
|
||||
return None
|
||||
return commits[0]
|
||||
|
||||
def latest_for_article_with_statuses(
|
||||
self,
|
||||
article_id: UUID,
|
||||
*,
|
||||
statuses: set[PublishingStatus],
|
||||
) -> PublishCommitSummary | None:
|
||||
status_values = [status.value for status in statuses]
|
||||
if not status_values:
|
||||
return None
|
||||
placeholder = self._repository.placeholder()
|
||||
status_placeholders = ", ".join([placeholder] * len(status_values))
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT {self._select_columns()}
|
||||
FROM publish_commits
|
||||
WHERE article_id = {placeholder}
|
||||
AND status IN ({status_placeholders})
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(article_id), *status_values),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return _publish_commit_from_row(row)
|
||||
|
||||
def _select_columns(self) -> str:
|
||||
return """
|
||||
id,
|
||||
article_id,
|
||||
target_site_id,
|
||||
repository_url,
|
||||
branch,
|
||||
commit_sha,
|
||||
content_bundle_manifest,
|
||||
status,
|
||||
deployment_status,
|
||||
created_at
|
||||
"""
|
||||
|
||||
|
||||
class EvidenceItemsRepository:
|
||||
def __init__(self, repository: BackendRepository) -> None:
|
||||
self._repository = repository
|
||||
@@ -3066,6 +3232,21 @@ def _research_manifest_from_row(row: Any) -> ResearchArtifactManifestSummary:
|
||||
)
|
||||
|
||||
|
||||
def _publish_commit_from_row(row: Any) -> PublishCommitSummary:
|
||||
return PublishCommitSummary(
|
||||
id=_row_value(row, "id"),
|
||||
article_id=_row_value(row, "article_id"),
|
||||
target_site_id=_row_value(row, "target_site_id"),
|
||||
repository_url=_row_value(row, "repository_url"),
|
||||
branch=_row_value(row, "branch"),
|
||||
commit_sha=_row_value(row, "commit_sha"),
|
||||
content_bundle_manifest=_json_from_row(row, "content_bundle_manifest"),
|
||||
status=_row_value(row, "status"),
|
||||
deployment_status=_row_value(row, "deployment_status"),
|
||||
created_at=_row_value(row, "created_at"),
|
||||
)
|
||||
|
||||
|
||||
def _evidence_from_row(row: Any) -> EvidenceSummary:
|
||||
return EvidenceSummary(
|
||||
id=_row_value(row, "id"),
|
||||
|
||||
@@ -15,7 +15,9 @@ from src.presentation.routes.auth import router as auth_router
|
||||
from src.presentation.routes.boundary_questions import router as boundary_questions_router
|
||||
from src.presentation.routes.drafts import router as drafts_router
|
||||
from src.presentation.routes.evidence import router as evidence_router
|
||||
from src.presentation.routes.final_approval import router as final_approval_router
|
||||
from src.presentation.routes.plans import router as plans_router
|
||||
from src.presentation.routes.publishing import router as publishing_router
|
||||
from src.presentation.routes.reviews import router as reviews_router
|
||||
from src.presentation.routes.sites import router as sites_router
|
||||
|
||||
@@ -29,6 +31,8 @@ app.include_router(plans_router)
|
||||
app.include_router(evidence_router)
|
||||
app.include_router(drafts_router)
|
||||
app.include_router(reviews_router)
|
||||
app.include_router(final_approval_router)
|
||||
app.include_router(publishing_router)
|
||||
app.include_router(agent_jobs_router)
|
||||
app.include_router(internal_agent_jobs_router)
|
||||
app.include_router(sites_router)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.final_approval import (
|
||||
approve_final_article,
|
||||
get_final_approval_checklist,
|
||||
request_final_revision,
|
||||
)
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["final-approval"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/final-approval/checklist",
|
||||
response_model=FinalApprovalChecklistResponse,
|
||||
)
|
||||
def get_article_final_approval_checklist(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> FinalApprovalChecklistResponse:
|
||||
try:
|
||||
return get_final_approval_checklist(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
request=None,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/final-approval",
|
||||
response_model=FinalApprovalResponse,
|
||||
)
|
||||
def post_article_final_approval(
|
||||
article_id: UUID,
|
||||
request: FinalApprovalRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> FinalApprovalResponse:
|
||||
try:
|
||||
return approve_final_article(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
request=request,
|
||||
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(
|
||||
"/articles/{article_id}/final-revision-request",
|
||||
response_model=FinalRevisionRequestResponse,
|
||||
)
|
||||
def post_article_final_revision_request(
|
||||
article_id: UUID,
|
||||
request: FinalRevisionRequest,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> FinalRevisionRequestResponse:
|
||||
try:
|
||||
return request_final_revision(
|
||||
repository,
|
||||
article_id=article_id,
|
||||
request=request,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.application.publishing import (
|
||||
create_publish_commit,
|
||||
get_publishing_status,
|
||||
list_publish_commits,
|
||||
run_publishing_dry_run,
|
||||
)
|
||||
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||
from src.domain.contracts import (
|
||||
CurrentUser,
|
||||
PublishCommitCreateResponse,
|
||||
PublishCommitListResponse,
|
||||
PublishingDryRunResponse,
|
||||
PublishingStatusResponse,
|
||||
)
|
||||
from src.infrastructure.repositories import BackendRepository
|
||||
from src.presentation.dependencies import get_repository, require_roles
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["publishing"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/publishing/dry-run",
|
||||
response_model=PublishingDryRunResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_publishing_dry_run(
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PublishingDryRunResponse:
|
||||
try:
|
||||
return run_publishing_dry_run(
|
||||
repository,
|
||||
article_id=article_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, ValueError, RuntimeError) as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles/{article_id}/publishing/create-commit",
|
||||
response_model=PublishCommitCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def post_publishing_create_commit(
|
||||
article_id: UUID,
|
||||
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PublishCommitCreateResponse:
|
||||
try:
|
||||
return create_publish_commit(
|
||||
repository,
|
||||
article_id=article_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, ValueError, RuntimeError) as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/publishing/status",
|
||||
response_model=PublishingStatusResponse,
|
||||
)
|
||||
def get_article_publishing_status(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PublishingStatusResponse:
|
||||
try:
|
||||
return get_publishing_status(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/articles/{article_id}/publishing/commits",
|
||||
response_model=PublishCommitListResponse,
|
||||
)
|
||||
def get_article_publishing_commits(
|
||||
article_id: UUID,
|
||||
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||
repository: BackendRepository = Depends(get_repository),
|
||||
) -> PublishCommitListResponse:
|
||||
try:
|
||||
return list_publish_commits(repository, article_id=article_id)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||
@@ -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,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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.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 EndToEndDemoStackSmokePublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.object_root = Path(self.tmp_dir.name) / "objects"
|
||||
self.demo_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
|
||||
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.object_root)
|
||||
os.environ["PIPELINE_DEMO_MODE"] = "1"
|
||||
os.environ["PIPELINE_DEMO_PUBLISH_REPO_PATH"] = str(self.demo_repo_path)
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-stack-smoke.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)
|
||||
os.environ.pop("PIPELINE_DEMO_MODE", None)
|
||||
os.environ.pop("PIPELINE_DEMO_PUBLISH_REPO_PATH", None)
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_demo_happy_path_reaches_publish_commit_created_with_failure_retry(self) -> None:
|
||||
site_id = self._fetch_first_site_id()
|
||||
article_id = self._create_article(site_id)
|
||||
|
||||
self._generate_submit_boundary_answers(article_id)
|
||||
plan = self._generate_and_approve_plan(article_id)
|
||||
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||
|
||||
research = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, research.status_code, research.text)
|
||||
|
||||
research_listing = self.client.get(
|
||||
f"/api/articles/{article_id}/research",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, research_listing.status_code, research_listing.text)
|
||||
manifests = research_listing.json()["manifests"]
|
||||
self.assertTrue(manifests)
|
||||
self._assert_research_artifacts_exist(manifests)
|
||||
|
||||
evidence = self.client.get(
|
||||
f"/api/articles/{article_id}/evidence",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, evidence.status_code, evidence.text)
|
||||
self.assertEqual("EVIDENCE_MATRIX_READY", evidence.json()["article"]["status"])
|
||||
self._approve_all_evidence(article_id, evidence.json()["evidence"])
|
||||
|
||||
start_draft = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(202, start_draft.status_code, start_draft.text)
|
||||
started_jobs = start_draft.json()["jobs"]
|
||||
section_jobs = [job for job in started_jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||
self.assertTrue(section_jobs)
|
||||
failed_jobs = [job for job in section_jobs if job["status"] == "FAILED"]
|
||||
self.assertTrue(failed_jobs, "Demo path must include visible failure before retry.")
|
||||
|
||||
retry = self.client.post(
|
||||
f"/api/agent-jobs/{failed_jobs[0]['id']}/retry",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, retry.status_code, retry.text)
|
||||
self.assertEqual("SUCCEEDED", retry.json()["job"]["status"])
|
||||
|
||||
assemble = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/assemble",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assemble.status_code, assemble.text)
|
||||
draft = assemble.json()["draft"]
|
||||
|
||||
assets = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/generate-specs",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assets.status_code, assets.text)
|
||||
generated_assets = assets.json()["assets"]
|
||||
self.assertTrue(generated_assets)
|
||||
self.assertTrue(
|
||||
any(asset["asset_type"] in {"diagram", "table", "flowchart", "comparison_matrix", "architecture_diagram"} for asset in generated_assets)
|
||||
)
|
||||
self._approve_assets(article_id, generated_assets)
|
||||
|
||||
seo = self.client.post(
|
||||
f"/api/articles/{article_id}/seo/review",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, seo.status_code, seo.text)
|
||||
language = self.client.post(
|
||||
f"/api/articles/{article_id}/language/review",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, language.status_code, language.text)
|
||||
self._accept_all_review_suggestions(article_id, seo.json()["report"]["issues"], kind="seo")
|
||||
self._accept_all_review_suggestions(article_id, language.json()["report"]["issues"], kind="language")
|
||||
|
||||
issues = self.client.get(
|
||||
f"/api/articles/{article_id}/final-review/issues",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, issues.status_code, issues.text)
|
||||
self.assertEqual(0, issues.json()["unresolved_count"])
|
||||
|
||||
drafts = self.client.get(
|
||||
f"/api/articles/{article_id}/drafts",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, drafts.status_code, drafts.text)
|
||||
latest_draft = max(drafts.json()["drafts"], key=lambda item: item["version"])
|
||||
|
||||
approval = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"draft_version": latest_draft["version"],
|
||||
"publishing_settings": {
|
||||
"content_path": "/guides/task-019-demo-stack",
|
||||
"author": "Demo Editor",
|
||||
"publishing_mode": "MANUAL",
|
||||
"frontmatter": {
|
||||
"title": latest_draft["title"],
|
||||
"category": "Demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, approval.status_code, approval.text)
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval.json()["article"]["status"])
|
||||
|
||||
dry_run = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run.status_code, dry_run.text)
|
||||
self.assertTrue(dry_run.json()["content_shape_valid"])
|
||||
self.assertEqual("PUBLISH_COMMIT_READY", dry_run.json()["article"]["status"])
|
||||
|
||||
commit = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, commit.status_code, commit.text)
|
||||
publish_commit = commit.json()["publish_commit"]
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", publish_commit["status"])
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", commit.json()["article"]["status"])
|
||||
self.assertEqual(str(self.demo_repo_path.resolve()), publish_commit["repository_url"])
|
||||
|
||||
self._assert_commit_payload_valid(publish_commit)
|
||||
self._assert_timeline_contains_major_actions(article_id)
|
||||
|
||||
def _fetch_first_site_id(self) -> str:
|
||||
site_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, site_response.status_code, site_response.text)
|
||||
site = site_response.json()[0]["site"]
|
||||
self.assertEqual(
|
||||
str(self.demo_repo_path.resolve()),
|
||||
site["publishing_rules"]["repository_url"],
|
||||
)
|
||||
return site["id"]
|
||||
|
||||
def _create_article(self, site_id: str) -> str:
|
||||
article_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": site_id,
|
||||
"brief_description": "Demo-stack shortest public happy path toward publish flow.",
|
||||
"working_title": "Task 019 Demo Stack Smoke",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "demo stack smoke path",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||
return article_response.json()["article"]["id"]
|
||||
|
||||
def _generate_submit_boundary_answers(self, article_id: str) -> None:
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
questions = questions_response.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)
|
||||
|
||||
def _generate_and_approve_plan(self, article_id: str) -> dict[str, object]:
|
||||
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 plan
|
||||
|
||||
def _approve_assets(self, article_id: str, assets: list[dict[str, object]]) -> None:
|
||||
fake_image = base64.b64encode(b"fake-image-bytes").decode("utf-8")
|
||||
for asset in assets:
|
||||
upload = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"filename": f"{asset['id']}.png",
|
||||
"content_base64": fake_image,
|
||||
"content_type": "image/png",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, upload.status_code, upload.text)
|
||||
approve = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve.status_code, approve.text)
|
||||
|
||||
def _approve_all_evidence(self, article_id: str, evidence_rows: list[dict[str, object]]) -> None:
|
||||
for evidence in evidence_rows:
|
||||
response = self.client.patch(
|
||||
f"/api/articles/{article_id}/evidence/{evidence['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"review_status": "APPROVED"},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def _accept_all_review_suggestions(
|
||||
self,
|
||||
article_id: str,
|
||||
issues: list[dict[str, object]],
|
||||
*,
|
||||
kind: str,
|
||||
) -> None:
|
||||
for issue in issues:
|
||||
suggestion_id = issue["suggestion_id"]
|
||||
endpoint = f"/api/articles/{article_id}/{kind}/suggestions/{suggestion_id}/accept"
|
||||
response = self.client.post(
|
||||
endpoint,
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def _assert_research_artifacts_exist(self, manifests: list[dict[str, object]]) -> None:
|
||||
for manifest in manifests:
|
||||
artifacts = manifest.get("artifacts", [])
|
||||
self.assertTrue(artifacts)
|
||||
for artifact in artifacts:
|
||||
object_key = artifact["object_key"]
|
||||
path = self.object_root / object_key
|
||||
self.assertTrue(path.exists(), f"Missing research artifact: {path}")
|
||||
|
||||
def _assert_commit_payload_valid(self, publish_commit: dict[str, object]) -> None:
|
||||
manifest = publish_commit["content_bundle_manifest"]
|
||||
content_path = manifest["content"]["path"]
|
||||
assets = manifest["assets"]
|
||||
commit_sha = publish_commit["commit_sha"]
|
||||
|
||||
markdown = self._git_show(commit_sha, content_path)
|
||||
self.assertIn("---", markdown)
|
||||
self.assertIn("title:", markdown)
|
||||
self.assertIn("author:", markdown)
|
||||
self.assertIn("##", markdown)
|
||||
|
||||
self.assertTrue(assets)
|
||||
for asset in assets:
|
||||
target = asset["target_path"]
|
||||
blob = self._git_show(commit_sha, target)
|
||||
self.assertTrue(blob)
|
||||
|
||||
def _assert_timeline_contains_major_actions(self, article_id: str) -> None:
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, detail.status_code, detail.text)
|
||||
timeline = detail.json()["timeline"]
|
||||
event_types = [event["event_type"] for event in timeline]
|
||||
for required in (
|
||||
"ARTICLE_CREATED",
|
||||
"BOUNDARY_QUESTIONS_GENERATED",
|
||||
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||
"PLAN_APPROVED",
|
||||
"EVIDENCE_MATRIX_READY",
|
||||
"PARALLEL_PRODUCTION_STARTED",
|
||||
"AGENT_JOB_FAILED",
|
||||
"AGENT_JOB_RETRIED",
|
||||
"DRAFT_ASSEMBLED",
|
||||
"SEO_REVIEW_COMPLETED",
|
||||
"LANGUAGE_REVIEW_COMPLETED",
|
||||
"FINAL_APPROVAL_GRANTED",
|
||||
"PUBLISH_DRY_RUN_SUCCEEDED",
|
||||
"PUBLISH_COMMIT_CREATED",
|
||||
):
|
||||
self.assertIn(required, event_types)
|
||||
|
||||
def _git_show(self, commit_sha: str, relative_path: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(self.demo_repo_path), "show", f"{commit_sha}:{relative_path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr or result.stdout)
|
||||
return result.stdout
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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.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 FinalApprovalGatePublicApiTest(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) / 'final-approval-gate.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_high_risk_unsupported_claims_block_final_approval(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval(
|
||||
include_high_risk_unsupported=True,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("high-risk unsupported claims", response.text.lower())
|
||||
|
||||
def test_final_approval_requires_exact_draft_version(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=self._final_approval_payload(draft_version=draft["version"] + 1),
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("latest draft version", response.text.lower())
|
||||
|
||||
def test_missing_required_publishing_settings_block_final_approval(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"draft_version": draft["version"],
|
||||
"publishing_settings": {
|
||||
"content_path": "",
|
||||
"author": "",
|
||||
"publishing_mode": "",
|
||||
"frontmatter": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("required publishing settings are missing", response.text.lower())
|
||||
|
||||
def test_unapproved_required_assets_block_final_approval(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=self._final_approval_payload(draft_version=draft["version"]),
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("required assets", response.text.lower())
|
||||
|
||||
def test_successful_final_approval_writes_event_and_moves_to_publish_dry_run_required(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
actor = self.client.get(
|
||||
"/api/me",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()["user"]
|
||||
payload = self._final_approval_payload(draft_version=draft["version"])
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json=payload,
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||
self.assertTrue(body["checklist"]["can_approve"])
|
||||
self.assertFalse(body["checklist"]["blockers"])
|
||||
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()
|
||||
events = [
|
||||
event
|
||||
for event in detail["workflow_events"]
|
||||
if event["event_type"] == "FINAL_APPROVAL_GRANTED"
|
||||
]
|
||||
self.assertTrue(events, "Expected FINAL_APPROVAL_GRANTED event")
|
||||
latest = events[-1]
|
||||
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||
self.assertTrue(latest["created_at"])
|
||||
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||
self.assertEqual(payload["publishing_settings"], latest["payload"]["publishing_settings"])
|
||||
|
||||
def test_revision_request_writes_event_and_moves_article_to_final_revision_required(self) -> None:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
actor = self.client.get(
|
||||
"/api/me",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()["user"]
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-revision-request",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"reason": "Need factual clarification in section 2.",
|
||||
"draft_version": draft["version"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("FINAL_REVISION_REQUIRED", body["article"]["status"])
|
||||
|
||||
detail = self.client.get(
|
||||
f"/api/articles/{article_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()
|
||||
events = [
|
||||
event
|
||||
for event in detail["workflow_events"]
|
||||
if event["event_type"] == "FINAL_REVISION_REQUESTED"
|
||||
]
|
||||
self.assertTrue(events, "Expected FINAL_REVISION_REQUESTED event")
|
||||
latest = events[-1]
|
||||
self.assertEqual(actor["id"], latest["actor_user_id"])
|
||||
self.assertEqual("Need factual clarification in section 2.", latest["payload"]["reason"])
|
||||
self.assertEqual(draft["version"], latest["payload"]["draft_version"])
|
||||
|
||||
def test_checklist_endpoint_returns_blockers_and_completed_items(self) -> None:
|
||||
article_id, _ = self._prepare_article_ready_for_final_approval(approve_assets=False)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/articles/{article_id}/final-approval/checklist",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
checklist = response.json()
|
||||
self.assertFalse(checklist["can_approve"])
|
||||
self.assertTrue(checklist["blockers"])
|
||||
self.assertTrue(checklist["completed_items"])
|
||||
self.assertTrue(any(item["completed"] for item in checklist["items"]))
|
||||
self.assertTrue(any(not item["completed"] for item in checklist["items"]))
|
||||
|
||||
def _final_approval_payload(self, *, draft_version: int) -> dict[str, Any]:
|
||||
return {
|
||||
"draft_version": draft_version,
|
||||
"publishing_settings": {
|
||||
"content_path": "/guides/final-approval-gate",
|
||||
"author": "Editorial Team",
|
||||
"publishing_mode": "MANUAL",
|
||||
"frontmatter": {
|
||||
"category": "Guides",
|
||||
"tags": ["workflow", "approval"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _prepare_article_ready_for_final_approval(
|
||||
self,
|
||||
*,
|
||||
include_high_risk_unsupported: bool = False,
|
||||
approve_assets: bool = True,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
article_id = self._create_article_with_approved_plan()
|
||||
self._ensure_evidence_ready(article_id)
|
||||
self._approve_all_evidence(article_id)
|
||||
|
||||
section_jobs = self._start_parallel_production(article_id)
|
||||
self.assertGreaterEqual(len(section_jobs), 1)
|
||||
first_job = section_jobs[0]
|
||||
|
||||
if include_high_risk_unsupported:
|
||||
failed = self._complete_job(
|
||||
first_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/high-risk.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [
|
||||
{
|
||||
"claim_text": "Unverified migration benchmark for market leadership.",
|
||||
"risk_level": "high",
|
||||
}
|
||||
],
|
||||
"draft_markdown": "Section draft with high-risk unsupported claim.",
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("FAILED", failed["status"])
|
||||
retry_response = self.client.post(
|
||||
f"/api/agent-jobs/{first_job['id']}/retry",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, retry_response.status_code, retry_response.text)
|
||||
retry_job = retry_response.json()["job"]
|
||||
retried = self._complete_job(
|
||||
retry_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/retry-clean.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": retry_job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": (
|
||||
f"## {retry_job['payload']['heading']}\n\n"
|
||||
"Clean retry section with [internal guide](/guides/internal)."
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", retried["status"])
|
||||
else:
|
||||
completed = self._complete_job(
|
||||
first_job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/section-1.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": first_job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": (
|
||||
f"## {first_job['payload']['heading']}\n\n"
|
||||
"Use [internal guide](/guides/internal) for implementation details."
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", completed["status"])
|
||||
|
||||
for index, job in enumerate(section_jobs[1:], start=2):
|
||||
completed = self._complete_job(
|
||||
job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": f"## {job['payload']['heading']}\n\nSection {index} ready.",
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", completed["status"])
|
||||
|
||||
assemble_response = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/assemble",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||
draft = assemble_response.json()["draft"]
|
||||
|
||||
assets_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/generate-specs",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||
assets = assets_response.json()["assets"]
|
||||
self.assertTrue(assets)
|
||||
|
||||
if approve_assets:
|
||||
for asset in assets:
|
||||
upload_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"filename": f"{asset['id']}.png",
|
||||
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||
"content_type": "image/png",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
|
||||
return article_id, draft
|
||||
|
||||
def _approve_all_evidence(self, article_id: str) -> None:
|
||||
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)
|
||||
evidence = evidence_response.json()["evidence"]
|
||||
self.assertTrue(evidence)
|
||||
for item in evidence:
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"review_status": "APPROVED"},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.client.post(
|
||||
f"/internal/agent-jobs/{job_id}/complete",
|
||||
json={
|
||||
"workspace_path": f"/tmp/{job_id}",
|
||||
"stdout": "fake section scaffolding runner\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()["job"]
|
||||
|
||||
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 [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||
|
||||
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||
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"])
|
||||
|
||||
def _create_article_with_approved_plan(self) -> str:
|
||||
site = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
).json()[0]["site"]
|
||||
article = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": site["id"],
|
||||
"brief_description": "Validate final approval gate checklist and transitions.",
|
||||
"working_title": "Final Approval Gate",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "final approval gate",
|
||||
},
|
||||
).json()["article"]
|
||||
article_id = 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"]
|
||||
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,464 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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.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 PublishingGitFlowPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.objects_root = Path(self.tmp_dir.name) / "objects"
|
||||
self.bare_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
|
||||
self.seed_repo_path = Path(self.tmp_dir.name) / "demo-site-seed"
|
||||
|
||||
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
|
||||
self._prepare_local_bare_git_repo()
|
||||
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'publishing-git-flow.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_publishing_dry_run_blocked_before_final_approval(self) -> None:
|
||||
article_id, _ = self._prepare_article_ready_for_final_approval()
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("final approval", response.text.lower())
|
||||
|
||||
def test_publishing_dry_run_validation_failure_sets_failed_status(self) -> None:
|
||||
article_id, draft = self._prepare_final_approved_article()
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/drafts/{draft['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"body_markdown": "Only plain text, no markdown heading present."},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertFalse(body["content_shape_valid"])
|
||||
self.assertIn("best-effort", body["validation_label"].lower())
|
||||
self.assertIn("heading", " ".join(body["errors"]).lower())
|
||||
self.assertEqual("PUBLISH_DRY_RUN_FAILED", body["article"]["publishing_status"])
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||
|
||||
def test_publish_commit_blocked_before_successful_dry_run(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("dry run", response.text.lower())
|
||||
|
||||
def test_final_approved_article_can_create_commit_in_local_git_repository(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
self.assertTrue(dry_run_response.json()["content_shape_valid"])
|
||||
self.assertEqual("PUBLISH_COMMIT_READY", dry_run_response.json()["article"]["status"])
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||
body = commit_response.json()
|
||||
commit = body["publish_commit"]
|
||||
commit_sha = commit["commit_sha"]
|
||||
self.assertTrue(commit_sha)
|
||||
self.assertEqual(str(self.bare_repo_path), commit["repository_url"])
|
||||
self.assertEqual("main", commit["branch"])
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", commit["status"])
|
||||
|
||||
check = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo_path), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, check.returncode, check.stderr)
|
||||
|
||||
def test_non_fast_forward_conflict_fails_without_rebase(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
self._push_remote_commit("competing remote update")
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, commit_response.status_code, commit_response.text)
|
||||
self.assertIn("non-fast-forward", commit_response.text.lower())
|
||||
|
||||
def test_publishing_dry_run_supports_s3_asset_urls_with_object_keys(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
self._rewrite_asset_urls_to_s3(article_id)
|
||||
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
body = dry_run_response.json()
|
||||
self.assertTrue(body["content_shape_valid"])
|
||||
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["status"])
|
||||
self.assertEqual("PUBLISH_COMMIT_READY", body["article"]["publishing_status"])
|
||||
|
||||
def test_status_and_commits_endpoints_expose_required_metadata(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||
|
||||
status_response = self.client.get(
|
||||
f"/api/articles/{article_id}/publishing/status",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, status_response.status_code, status_response.text)
|
||||
status_body = status_response.json()
|
||||
self.assertIn("best-effort", status_body["validation_label"].lower())
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["status"])
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["publishing_status"])
|
||||
self.assertIsNotNone(status_body["latest_dry_run"])
|
||||
self.assertIsNotNone(status_body["latest_publish_commit"])
|
||||
|
||||
commits_response = self.client.get(
|
||||
f"/api/articles/{article_id}/publishing/commits",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, commits_response.status_code, commits_response.text)
|
||||
commits = commits_response.json()["commits"]
|
||||
self.assertGreaterEqual(len(commits), 2)
|
||||
|
||||
commit_rows = {row["status"]: row for row in commits}
|
||||
self.assertIn("PUBLISH_COMMIT_READY", commit_rows)
|
||||
self.assertIn("PUBLISH_COMMIT_CREATED", commit_rows)
|
||||
|
||||
created = commit_rows["PUBLISH_COMMIT_CREATED"]
|
||||
self.assertEqual(str(self.bare_repo_path), created["repository_url"])
|
||||
self.assertEqual("main", created["branch"])
|
||||
self.assertTrue(created["commit_sha"])
|
||||
manifest = created["content_bundle_manifest"]
|
||||
self.assertIn("content", manifest)
|
||||
self.assertIn("frontmatter", manifest)
|
||||
self.assertIn("assets", manifest)
|
||||
self.assertIn("config_version", manifest)
|
||||
self.assertIn("git", manifest)
|
||||
self.assertEqual("main", manifest["git"]["branch"])
|
||||
self.assertEqual(str(self.bare_repo_path), manifest["git"]["repository_url"])
|
||||
self.assertIn("best-effort", manifest["validation"]["label"].lower())
|
||||
self.assertEqual("content/articles/git-publishing-flow.mdx", manifest["content"]["path"])
|
||||
|
||||
def _prepare_local_bare_git_repo(self) -> None:
|
||||
self._run_git(["init", "--bare", str(self.bare_repo_path)])
|
||||
self.seed_repo_path.mkdir(parents=True, exist_ok=True)
|
||||
self._run_git(["init"], cwd=self.seed_repo_path)
|
||||
self._run_git(["config", "user.name", "Pipeline Bot"], cwd=self.seed_repo_path)
|
||||
self._run_git(["config", "user.email", "pipeline-bot@example.com"], cwd=self.seed_repo_path)
|
||||
(self.seed_repo_path / "README.md").write_text("# Demo Site\n", encoding="utf-8")
|
||||
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||
self._run_git(["commit", "-m", "seed"], cwd=self.seed_repo_path)
|
||||
self._run_git(["branch", "-M", "main"], cwd=self.seed_repo_path)
|
||||
self._run_git(["remote", "add", "origin", str(self.bare_repo_path)], cwd=self.seed_repo_path)
|
||||
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||
|
||||
def _rewrite_asset_urls_to_s3(self, article_id: str) -> None:
|
||||
assets = self.repository.assets.list_for_article(UUID(article_id))
|
||||
now = self.repository.articles.get(UUID(article_id)).updated_at
|
||||
for asset in assets:
|
||||
if asset.status.value != "APPROVED":
|
||||
continue
|
||||
self.assertTrue(asset.object_key)
|
||||
self.repository.assets.update(
|
||||
article_id=UUID(article_id),
|
||||
asset_id=asset.id,
|
||||
section_id=asset.section_id,
|
||||
title=asset.title,
|
||||
prompt=asset.prompt,
|
||||
object_key=asset.object_key,
|
||||
file_url=f"s3://pipeline-local/{asset.object_key}",
|
||||
alt_text=asset.alt_text,
|
||||
caption=asset.caption,
|
||||
status=asset.status,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def _run_git(self, args: list[str], *, cwd: Path | None = None) -> None:
|
||||
subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _push_remote_commit(self, message: str) -> None:
|
||||
readme_path = self.seed_repo_path / "README.md"
|
||||
readme_path.write_text(
|
||||
readme_path.read_text(encoding="utf-8") + f"\n{message}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||
self._run_git(["commit", "-m", message], cwd=self.seed_repo_path)
|
||||
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||
|
||||
def _prepare_final_approved_article(self) -> tuple[str, dict[str, Any]]:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
self._submit_final_approval(article_id, draft=draft)
|
||||
return article_id, draft
|
||||
|
||||
def _prepare_article_ready_for_final_approval(self) -> tuple[str, dict[str, Any]]:
|
||||
site_id = self._configure_local_git_repository_for_site()
|
||||
article_id = self._create_article_with_approved_plan(site_id)
|
||||
self._ensure_evidence_ready(article_id)
|
||||
self._approve_all_evidence(article_id)
|
||||
|
||||
section_jobs = self._start_parallel_production(article_id)
|
||||
self.assertGreaterEqual(len(section_jobs), 1)
|
||||
for index, job in enumerate(section_jobs, start=1):
|
||||
completed = self._complete_job(
|
||||
job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": (
|
||||
f"## {job['payload']['heading']}\n\n"
|
||||
"Publishing-ready section with [internal link](/guides/internal)."
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", completed["status"])
|
||||
|
||||
assemble_response = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/assemble",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||
draft = assemble_response.json()["draft"]
|
||||
|
||||
assets_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/generate-specs",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||
assets = assets_response.json()["assets"]
|
||||
self.assertTrue(assets)
|
||||
for asset in assets:
|
||||
upload_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"filename": f"{asset['id']}.png",
|
||||
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||
"content_type": "image/png",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
|
||||
return article_id, draft
|
||||
|
||||
def _submit_final_approval(self, article_id: str, *, draft: dict[str, Any]) -> None:
|
||||
approval_response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"draft_version": draft["version"],
|
||||
"publishing_settings": {
|
||||
"content_path": "/guides/git-publishing-flow",
|
||||
"author": "Editorial Team",
|
||||
"publishing_mode": "MANUAL",
|
||||
"frontmatter": {"title": draft["title"], "category": "Guides"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, approval_response.status_code, approval_response.text)
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval_response.json()["article"]["status"])
|
||||
|
||||
def _configure_local_git_repository_for_site(self) -> UUID:
|
||||
sites_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||
site = sites_response.json()[0]["site"]
|
||||
|
||||
publishing_rules = dict(site["publishing_rules"])
|
||||
publishing_rules["repository_url"] = str(self.bare_repo_path)
|
||||
publishing_rules["production_branch"] = "main"
|
||||
|
||||
patch_response = self.client.patch(
|
||||
f"/api/sites/{site['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={"publishing_rules": publishing_rules},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
return UUID(site["id"])
|
||||
|
||||
def _create_article_with_approved_plan(self, site_id: UUID) -> str:
|
||||
article_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": str(site_id),
|
||||
"brief_description": "Publish final-approved content into local git repository.",
|
||||
"working_title": "Git Publishing Dry Run And Commit",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "git publishing dry run",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||
article_id = article_response.json()["article"]["id"]
|
||||
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
questions = questions_response.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"]
|
||||
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||
|
||||
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
|
||||
|
||||
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||
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"])
|
||||
|
||||
def _approve_all_evidence(self, article_id: str) -> None:
|
||||
response = self.client.get(
|
||||
f"/api/articles/{article_id}/evidence",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
for item in response.json()["evidence"]:
|
||||
patch = self.client.patch(
|
||||
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"review_status": "APPROVED"},
|
||||
)
|
||||
self.assertEqual(200, patch.status_code, patch.text)
|
||||
|
||||
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 [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||
|
||||
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.client.post(
|
||||
f"/internal/agent-jobs/{job_id}/complete",
|
||||
json={
|
||||
"workspace_path": f"/tmp/{job_id}",
|
||||
"stdout": "fake section scaffolding runner\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()["job"]
|
||||
|
||||
|
||||
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,114 +1,185 @@
|
||||
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 = {
|
||||
articleId: string;
|
||||
status: string;
|
||||
publishingStatus: string;
|
||||
publishingValidationLabel: string;
|
||||
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,
|
||||
publishingStatus: detail.article.publishing_status,
|
||||
publishingValidationLabel: resolvePublishingValidationLabel(detail),
|
||||
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 buildProductionArtifactRows(
|
||||
jobs: readonly AgentJobSummary[],
|
||||
): ProductionArtifactRow[] {
|
||||
export function buildJobDetailRows(jobs: readonly AgentJobSummary[]): JobDetailRow[] {
|
||||
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)
|
||||
const lastSuccessfulStep = stringValue(payload.last_successful_step)
|
||||
|| stringValue(payload.artifact_label)
|
||||
|| stringValue(payload.heading)
|
||||
|| artifactKey;
|
||||
|| null;
|
||||
return {
|
||||
jobId: job.id,
|
||||
artifactKey,
|
||||
artifactLabel,
|
||||
jobType: job.job_type,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
jobType: job.job_type,
|
||||
usedEvidenceIds: stringArray(payload.used_evidence_ids),
|
||||
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
|
||||
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.artifactLabel.localeCompare(right.artifactLabel));
|
||||
.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,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
|
||||
const manifest = detail.publish_commit?.content_bundle_manifest;
|
||||
if (manifest && typeof manifest === "object") {
|
||||
const validation = (manifest as Record<string, unknown>).validation;
|
||||
if (validation && typeof validation === "object") {
|
||||
const label = (validation as Record<string, unknown>).label;
|
||||
if (typeof label === "string" && label.trim()) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Best-effort content-shape validation only.";
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -26,6 +70,10 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
<dt>Publishing status</dt>
|
||||
<dd>{summary.publishingStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Validation</dt>
|
||||
<dd>{summary.publishingValidationLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target site</dt>
|
||||
<dd>{summary.targetSite}</dd>
|
||||
@@ -60,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>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
FinalApprovalChecklistItemSummary,
|
||||
FinalApprovalChecklistResponse,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type FinalApprovalChecklistItemViewModel = {
|
||||
key: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
blocker: string;
|
||||
};
|
||||
|
||||
export type FinalApprovalViewModel = {
|
||||
canApprove: boolean;
|
||||
completedItems: string[];
|
||||
blockers: string[];
|
||||
draftVersion: number | null;
|
||||
requestedDraftVersion: number | null;
|
||||
items: FinalApprovalChecklistItemViewModel[];
|
||||
};
|
||||
|
||||
export function buildFinalApprovalViewModel(
|
||||
checklist: FinalApprovalChecklistResponse | null,
|
||||
): FinalApprovalViewModel {
|
||||
if (!checklist) {
|
||||
return {
|
||||
canApprove: false,
|
||||
completedItems: [],
|
||||
blockers: [],
|
||||
draftVersion: null,
|
||||
requestedDraftVersion: null,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
canApprove: checklist.can_approve,
|
||||
completedItems: checklist.completed_items ?? [],
|
||||
blockers: checklist.blockers ?? [],
|
||||
draftVersion: checklist.draft_version ?? null,
|
||||
requestedDraftVersion: checklist.requested_draft_version ?? null,
|
||||
items: (checklist.items ?? []).map(mapChecklistItem),
|
||||
};
|
||||
}
|
||||
|
||||
function mapChecklistItem(
|
||||
item: FinalApprovalChecklistItemSummary,
|
||||
): FinalApprovalChecklistItemViewModel {
|
||||
return {
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
completed: item.completed,
|
||||
blocker: item.blocker ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { FinalApprovalChecklistResponse } from "@pipeline/shared";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
fetchFinalApprovalChecklist,
|
||||
submitFinalApproval,
|
||||
submitFinalRevisionRequest,
|
||||
} from "@/shared/pipeline-api";
|
||||
import { buildFinalApprovalViewModel } from "./model";
|
||||
|
||||
type FinalApprovalPanelProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}';
|
||||
|
||||
export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) {
|
||||
const [checklist, setChecklist] = useState<FinalApprovalChecklistResponse | null>(null);
|
||||
const [draftVersionInput, setDraftVersionInput] = useState("");
|
||||
const [contentPath, setContentPath] = useState("/guides/final-approval-gate");
|
||||
const [author, setAuthor] = useState("Editorial Team");
|
||||
const [publishingMode, setPublishingMode] = useState("MANUAL");
|
||||
const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER);
|
||||
const [revisionReason, setRevisionReason] = useState("Need final refinements before approval.");
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const viewModel = useMemo(
|
||||
() => buildFinalApprovalViewModel(checklist),
|
||||
[checklist],
|
||||
);
|
||||
|
||||
async function refreshChecklist() {
|
||||
setBusyKey("refresh");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await fetchFinalApprovalChecklist(articleId);
|
||||
setChecklist(response);
|
||||
if (!draftVersionInput && response.draft_version) {
|
||||
setDraftVersionInput(String(response.draft_version));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
let frontmatter: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(frontmatterText);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
setMessage("Frontmatter must be a valid JSON object.");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
|
||||
setBusyKey("approve");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await submitFinalApproval(articleId, {
|
||||
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
|
||||
publishing_settings: {
|
||||
content_path: contentPath,
|
||||
author,
|
||||
publishing_mode: publishingMode,
|
||||
frontmatter,
|
||||
},
|
||||
});
|
||||
setChecklist(response.checklist);
|
||||
setMessage(`Final approval completed. Article status: ${response.article.status}.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Final approval failed.");
|
||||
await refreshChecklist();
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRevision() {
|
||||
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
|
||||
setBusyKey("revision");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await submitFinalRevisionRequest(articleId, {
|
||||
reason: revisionReason,
|
||||
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
|
||||
});
|
||||
setChecklist(response.checklist);
|
||||
setMessage(`Revision requested. Article status: ${response.article.status}.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Failed to request revision.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="reviewPanel">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>Final approval gate</h2>
|
||||
<p>Checklist blockers: {viewModel.blockers.length}</p>
|
||||
</div>
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
|
||||
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
|
||||
|
||||
{viewModel.items.length > 0 ? (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Checklist item</th>
|
||||
<th>Status</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewModel.items.map((item) => (
|
||||
<tr key={item.key}>
|
||||
<td>{item.title}</td>
|
||||
<td>{item.completed ? "Completed" : "Blocked"}</td>
|
||||
<td>{item.completed ? "—" : item.blocker || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p>No checklist yet. Refresh to load current gate status.</p>
|
||||
)}
|
||||
|
||||
{viewModel.completedItems.length > 0 ? (
|
||||
<ul className="timeline">
|
||||
{viewModel.completedItems.map((item) => (
|
||||
<li key={item}>Completed: {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{viewModel.blockers.length > 0 ? (
|
||||
<ul className="timeline">
|
||||
{viewModel.blockers.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="reviewActions">
|
||||
<input
|
||||
value={draftVersionInput}
|
||||
onChange={(event) => setDraftVersionInput(event.target.value)}
|
||||
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<input
|
||||
value={contentPath}
|
||||
onChange={(event) => setContentPath(event.target.value)}
|
||||
placeholder="/guides/final-approval-gate"
|
||||
/>
|
||||
<input
|
||||
value={author}
|
||||
onChange={(event) => setAuthor(event.target.value)}
|
||||
placeholder="Author"
|
||||
/>
|
||||
<input
|
||||
value={publishingMode}
|
||||
onChange={(event) => setPublishingMode(event.target.value)}
|
||||
placeholder="MANUAL"
|
||||
/>
|
||||
<textarea
|
||||
value={frontmatterText}
|
||||
onChange={(event) => setFrontmatterText(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
|
||||
{busyKey === "approve" ? "Approving..." : "Approve final"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="reviewActions">
|
||||
<input
|
||||
value={revisionReason}
|
||||
onChange={(event) => setRevisionReason(event.target.value)}
|
||||
placeholder="Revision reason"
|
||||
/>
|
||||
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
|
||||
{busyKey === "revision" ? "Submitting..." : "Request revision"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -8,22 +8,31 @@ import { ArticleDetailShell } from "@/features/article-detail/ui";
|
||||
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { FinalApprovalPanel } from "@/features/final-approval/ui";
|
||||
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>
|
||||
@@ -34,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 ?? []} />
|
||||
@@ -44,6 +53,7 @@ export default async function ArticleDetailPage({
|
||||
assets={detail.assets ?? []}
|
||||
/>
|
||||
<ArticleReviewPanel articleId={articleId} />
|
||||
<FinalApprovalPanel articleId={articleId} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,11 @@ import type {
|
||||
EvidenceCreateRequest,
|
||||
EvidenceUpdateRequest,
|
||||
FinalReviewIssuesResponse,
|
||||
FinalApprovalChecklistResponse,
|
||||
FinalApprovalRequest,
|
||||
FinalApprovalResponse,
|
||||
FinalRevisionRequest,
|
||||
FinalRevisionRequestResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
PlanListResponse,
|
||||
@@ -38,6 +43,7 @@ import type {
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
@@ -178,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(
|
||||
@@ -261,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,
|
||||
@@ -303,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(
|
||||
@@ -450,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(
|
||||
@@ -622,3 +644,31 @@ export function fetchFinalReviewIssues(
|
||||
): Promise<FinalReviewIssuesResponse> {
|
||||
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
||||
}
|
||||
|
||||
export function fetchFinalApprovalChecklist(
|
||||
articleId: string,
|
||||
): Promise<FinalApprovalChecklistResponse> {
|
||||
return apiGet<FinalApprovalChecklistResponse>(
|
||||
`/api/articles/${articleId}/final-approval/checklist`,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitFinalApproval(
|
||||
articleId: string,
|
||||
request: FinalApprovalRequest,
|
||||
): Promise<FinalApprovalResponse> {
|
||||
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
|
||||
`/api/articles/${articleId}/final-approval`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitFinalRevisionRequest(
|
||||
articleId: string,
|
||||
request: FinalRevisionRequest,
|
||||
): Promise<FinalRevisionRequestResponse> {
|
||||
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
|
||||
`/api/articles/${articleId}/final-revision-request`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,90 +15,121 @@ const compiled = ts.transpileModule(source, {
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { 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: [],
|
||||
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: "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",
|
||||
},
|
||||
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" }],
|
||||
{
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
]);
|
||||
],
|
||||
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(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 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);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(testDir, "../src/features/final-approval/model.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
});
|
||||
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { buildFinalApprovalViewModel } = moduleExports;
|
||||
|
||||
const viewModel = buildFinalApprovalViewModel({
|
||||
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
draft_version: 3,
|
||||
requested_draft_version: 2,
|
||||
can_approve: false,
|
||||
completed_items: ["Plan followed", "Evidence reviewed"],
|
||||
blockers: [
|
||||
"Final approval requires latest draft version 3.",
|
||||
"Required publishing settings are missing: content path.",
|
||||
],
|
||||
items: [
|
||||
{
|
||||
key: "exact_draft_version",
|
||||
title: "Exact draft version selected",
|
||||
completed: false,
|
||||
blocker: "Final approval requires latest draft version 3.",
|
||||
},
|
||||
{
|
||||
key: "plan_followed",
|
||||
title: "Plan followed",
|
||||
completed: true,
|
||||
blocker: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(viewModel.canApprove, false);
|
||||
assert.equal(viewModel.draftVersion, 3);
|
||||
assert.equal(viewModel.requestedDraftVersion, 2);
|
||||
assert.equal(viewModel.completedItems.length, 2);
|
||||
assert.equal(viewModel.blockers.length, 2);
|
||||
assert.equal(viewModel.items.length, 2);
|
||||
assert.equal(viewModel.items[0].completed, false);
|
||||
assert.equal(viewModel.items[1].completed, true);
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
context: .
|
||||
dockerfile: apps/backend/Dockerfile
|
||||
environment:
|
||||
PIPELINE_DEMO_MODE: "1"
|
||||
PIPELINE_DEMO_PUBLISH_REPO_PATH: /demo/pipeline-demo-site.git
|
||||
POSTGRES_DSN: postgresql://${POSTGRES_USER:-pipeline}:${POSTGRES_PASSWORD:-pipeline_local}@postgres:5432/${POSTGRES_DB:-pipeline}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
OBJECT_STORAGE_ENDPOINT: http://minio:9000
|
||||
@@ -25,6 +27,8 @@ services:
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-minio_local_password}
|
||||
OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:-pipeline-local}
|
||||
OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-us-east-1}
|
||||
volumes:
|
||||
- demo-publish-repo:/demo
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
depends_on:
|
||||
@@ -109,3 +113,4 @@ volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
demo-publish-repo:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Demo Stack Guide (Task 019)
|
||||
|
||||
This guide runs a deterministic end-to-end demo from article brief to `PUBLISH_COMMIT_CREATED` with no external credentials.
|
||||
|
||||
## 1. Setup
|
||||
|
||||
From repository root:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The backend runs in demo mode in Compose:
|
||||
|
||||
- `PIPELINE_DEMO_MODE=1`
|
||||
- local Git target: `/demo/pipeline-demo-site.git`
|
||||
- fake deterministic section runtime with one fail-then-retry branch
|
||||
- local infra only (Postgres/Redis/MinIO)
|
||||
|
||||
No real Codex, search, WHOIS, cloud S3, or GitHub credentials are required.
|
||||
|
||||
## 2. Role Selection
|
||||
|
||||
The app uses demo users via backend header selection:
|
||||
|
||||
- Editor flow (default): `editor@example.com`
|
||||
- Admin actions: `admin@example.com`
|
||||
|
||||
For the article detail page, role hint query is available:
|
||||
|
||||
- Editor: `/articles/<articleId>?role=editor`
|
||||
- Admin: `/articles/<articleId>?role=admin`
|
||||
|
||||
## 3. Happy Path
|
||||
|
||||
1. Open frontend: `http://localhost:3000`.
|
||||
2. Create article from dashboard (`New article`).
|
||||
3. Complete boundary questions and submit.
|
||||
4. Generate plan and approve.
|
||||
5. Start research and open evidence matrix.
|
||||
6. Start draft production.
|
||||
7. Open article detail:
|
||||
- one section job is expected to fail (deterministic demo failure),
|
||||
- retry it from Admin view.
|
||||
8. Assemble draft.
|
||||
9. Run SEO and language review, resolve suggestions.
|
||||
10. Generate assets, upload files, approve assets.
|
||||
11. Final approval with publishing settings.
|
||||
12. Run publishing dry run.
|
||||
13. Create publish commit.
|
||||
|
||||
Expected final status: `PUBLISH_COMMIT_CREATED`.
|
||||
|
||||
## 4. Failure Path (Visible)
|
||||
|
||||
Demo mode always creates one deterministic failed section scaffold attempt on draft start:
|
||||
|
||||
- `SECTION_SCAFFOLD` attempt 1 -> `FAILED`
|
||||
- retry from `/api/agent-jobs/{job_id}/retry` -> auto-completes `SUCCEEDED`
|
||||
|
||||
This exposes failure/retry timeline behavior and retry policy UI controls.
|
||||
|
||||
## 5. Artifact Inspection
|
||||
|
||||
### Workflow timeline and job logs
|
||||
|
||||
- Open article detail page.
|
||||
- Verify mixed timeline entries for user/system/agent events.
|
||||
- Admin sees detailed redacted `stdout/stderr`.
|
||||
- Editor sees safe failure summary only.
|
||||
|
||||
### Object storage (research artifacts/manifests)
|
||||
|
||||
In local integration-style runs with `OBJECT_STORAGE_LOCAL_ROOT`, artifacts are under:
|
||||
|
||||
- `research/<article_id>/<run_id>/source-*/section.json`
|
||||
- `research/<article_id>/<run_id>/source-*/metadata.json`
|
||||
|
||||
In Compose (MinIO), inspect bucket `${OBJECT_STORAGE_BUCKET:-pipeline-local}`.
|
||||
|
||||
### Final Git commit payload
|
||||
|
||||
The publish commit manifest contains:
|
||||
|
||||
- `content.path` (markdown/mdx),
|
||||
- `frontmatter`,
|
||||
- `assets[*].target_path`.
|
||||
|
||||
Inspect repo in Compose:
|
||||
|
||||
```sh
|
||||
docker compose exec backend git --git-dir /demo/pipeline-demo-site.git log --oneline -n 5
|
||||
docker compose exec backend git --git-dir /demo/pipeline-demo-site.git show <commit_sha>:<content_path>
|
||||
```
|
||||
|
||||
For local integration tests, repository path comes from
|
||||
`PIPELINE_DEMO_PUBLISH_REPO_PATH` (the smoke test sets a temp path).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ export type AgentJobSummary = {
|
||||
agent_profile: string;
|
||||
article_id?: string | null;
|
||||
attempt?: number;
|
||||
cancel_eligible?: boolean;
|
||||
duration_ms?: number | null;
|
||||
error_category?: AgentJobErrorCategory | null;
|
||||
error_message?: string | null;
|
||||
@@ -38,6 +39,9 @@ export type AgentJobSummary = {
|
||||
parent_job_id?: string | null;
|
||||
payload?: Record<string, unknown>;
|
||||
queued_at: string;
|
||||
retry_block_reason?: string | null;
|
||||
retry_eligible?: boolean;
|
||||
safe_failure_summary?: string | null;
|
||||
started_at?: string | null;
|
||||
status: AgentJobStatus;
|
||||
stderr?: string;
|
||||
@@ -78,6 +82,7 @@ export type ArticleDetailResponse = {
|
||||
research_manifests?: ResearchArtifactManifestSummary[];
|
||||
reviews?: ReviewSummary[];
|
||||
target_site?: TargetSiteConfig | null;
|
||||
timeline?: ObservabilityTimelineEventSummary[];
|
||||
workflow_events?: WorkflowEventSummary[];
|
||||
};
|
||||
|
||||
@@ -349,6 +354,40 @@ export type EvidenceUpdateRequest = {
|
||||
review_status?: string | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalChecklistItemSummary = {
|
||||
blocker?: string | null;
|
||||
completed: boolean;
|
||||
key: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type FinalApprovalChecklistResponse = {
|
||||
article_id: string;
|
||||
blockers?: string[];
|
||||
can_approve: boolean;
|
||||
completed_items?: string[];
|
||||
draft_version?: number | null;
|
||||
items?: FinalApprovalChecklistItemSummary[];
|
||||
requested_draft_version?: number | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalPublishingSettings = {
|
||||
author?: string | null;
|
||||
content_path?: string | null;
|
||||
frontmatter?: Record<string, unknown> | null;
|
||||
publishing_mode?: string | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalRequest = {
|
||||
draft_version?: number | null;
|
||||
publishing_settings?: FinalApprovalPublishingSettings | null;
|
||||
};
|
||||
|
||||
export type FinalApprovalResponse = {
|
||||
article: ArticleSummary;
|
||||
checklist: FinalApprovalChecklistResponse;
|
||||
};
|
||||
|
||||
export type FinalReviewIssueSummary = {
|
||||
location: string;
|
||||
message: string;
|
||||
@@ -364,6 +403,16 @@ export type FinalReviewIssuesResponse = {
|
||||
unresolved_issues?: FinalReviewIssueSummary[];
|
||||
};
|
||||
|
||||
export type FinalRevisionRequest = {
|
||||
draft_version?: number | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type FinalRevisionRequestResponse = {
|
||||
article: ArticleSummary;
|
||||
checklist: FinalApprovalChecklistResponse;
|
||||
};
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: ValidationError[];
|
||||
};
|
||||
@@ -376,6 +425,25 @@ export type LanguageReviewRunResponse = {
|
||||
report: ContentReviewReportSummary;
|
||||
};
|
||||
|
||||
export type ObservabilityTimelineEventSummary = {
|
||||
actor_user_id?: string | null;
|
||||
article_id: string;
|
||||
cancel_eligible?: boolean;
|
||||
created_at: string;
|
||||
entry_type: string;
|
||||
event_type: string;
|
||||
from_status?: ArticleWorkflowStatus | null;
|
||||
id: string;
|
||||
job_id?: string | null;
|
||||
job_status?: AgentJobStatus | null;
|
||||
job_type?: AgentJobType | null;
|
||||
payload?: Record<string, unknown>;
|
||||
retry_eligible?: boolean;
|
||||
safe_failure_summary?: string | null;
|
||||
source: string;
|
||||
to_status?: ArticleWorkflowStatus | null;
|
||||
};
|
||||
|
||||
export type PlanListResponse = {
|
||||
plans: PlanSummary[];
|
||||
};
|
||||
@@ -452,6 +520,15 @@ export type PlanUpdateRequest = {
|
||||
visual_needs?: string[] | null;
|
||||
};
|
||||
|
||||
export type PublishCommitCreateResponse = {
|
||||
article: ArticleSummary;
|
||||
publish_commit: PublishCommitSummary;
|
||||
};
|
||||
|
||||
export type PublishCommitListResponse = {
|
||||
commits?: PublishCommitSummary[];
|
||||
};
|
||||
|
||||
export type PublishCommitSummary = {
|
||||
article_id: string;
|
||||
branch: string;
|
||||
@@ -465,6 +542,14 @@ export type PublishCommitSummary = {
|
||||
target_site_id: string;
|
||||
};
|
||||
|
||||
export type PublishingDryRunResponse = {
|
||||
article: ArticleSummary;
|
||||
content_shape_valid: boolean;
|
||||
errors?: string[];
|
||||
publish_commit: PublishCommitSummary;
|
||||
validation_label: string;
|
||||
};
|
||||
|
||||
export type PublishingRules = {
|
||||
asset_path_template: string;
|
||||
content_format?: string;
|
||||
@@ -478,6 +563,13 @@ export type PublishingRules = {
|
||||
|
||||
export type PublishingStatus = "PUBLISH_NOT_STARTED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_DRY_RUN_RUNNING" | "PUBLISH_DRY_RUN_FAILED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED" | "PUBLISH_VERIFICATION_FAILED";
|
||||
|
||||
export type PublishingStatusResponse = {
|
||||
article: ArticleSummary;
|
||||
latest_dry_run?: PublishCommitSummary | null;
|
||||
latest_publish_commit?: PublishCommitSummary | null;
|
||||
validation_label: string;
|
||||
};
|
||||
|
||||
export type ResearchArtifactManifestSummary = {
|
||||
agent_job_id: string;
|
||||
article_id: string;
|
||||
@@ -542,6 +634,23 @@ export type RunnerFileRef = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ScriptConfigVersionAuditEventListResponse = {
|
||||
events?: ScriptConfigVersionAuditEventSummary[];
|
||||
};
|
||||
|
||||
export type ScriptConfigVersionAuditEventSummary = {
|
||||
actor_user_id?: string | null;
|
||||
created_at: string;
|
||||
diff?: Record<string, unknown>;
|
||||
event_type: string;
|
||||
id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
rollback_target_version_id?: string | null;
|
||||
target_site_id: string;
|
||||
version?: number | null;
|
||||
version_id: string;
|
||||
};
|
||||
|
||||
export type ScriptConfigVersionCreateRequest = {
|
||||
activate?: boolean;
|
||||
diff?: Record<string, unknown>;
|
||||
|
||||
@@ -34,14 +34,14 @@ Development description: Implement the final review gate that prevents publishin
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
||||
- [ ] Final approval requires an exact draft version.
|
||||
- [ ] High-risk unsupported claims block approval.
|
||||
- [ ] Missing required publishing settings block approval.
|
||||
- [ ] Unapproved required assets block approval.
|
||||
- [ ] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
||||
- [ ] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
||||
- [ ] UI shows blockers and completed checklist items.
|
||||
- [x] TDD pre-requirement: before implementation, write one failing behavior test proving high-risk unsupported claims block final approval; proceed one checklist behavior at a time and record evidence in `Result`.
|
||||
- [x] Final approval requires an exact draft version.
|
||||
- [x] High-risk unsupported claims block approval.
|
||||
- [x] Missing required publishing settings block approval.
|
||||
- [x] Unapproved required assets block approval.
|
||||
- [x] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
|
||||
- [x] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
|
||||
- [x] UI shows blockers and completed checklist items.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -51,9 +51,32 @@ Development description: Implement the final review gate that prevents publishin
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Pending execution.
|
||||
- TDD plan: To be filled during execution.
|
||||
- Red evidence: To be filled during execution.
|
||||
- Green evidence: To be filled during execution.
|
||||
- Refactor notes: To be filled during execution.
|
||||
- Verification output: To be filled during execution.
|
||||
- Status: Done (TDD RED -> GREEN completed).
|
||||
- TDD progression:
|
||||
1. Pre-requirement RED test created for high-risk unsupported claims blocker.
|
||||
2. Implemented final approval gate service with checklist evaluation on assembled draft + evidence + assets + review state.
|
||||
3. Added public endpoints:
|
||||
- `POST /api/articles/{article_id}/final-approval`
|
||||
- `POST /api/articles/{article_id}/final-revision-request`
|
||||
- `GET /api/articles/{article_id}/final-approval/checklist` (for UI checklist visibility).
|
||||
4. Added workflow events:
|
||||
- `FINAL_APPROVAL_GRANTED` -> `PUBLISH_DRY_RUN_REQUIRED`
|
||||
- `FINAL_REVISION_REQUESTED` -> `FINAL_REVISION_REQUIRED`
|
||||
5. Added frontend final-approval feature (FSD) to show blockers/completed checklist items and invoke approval/revision endpoints.
|
||||
|
||||
- Implemented behavior coverage:
|
||||
- Exact draft version is required and validated against latest immutable draft.
|
||||
- High-risk unsupported claims (claims + section scaffold history) block approval.
|
||||
- Missing required publishing settings (`frontmatter`, `content_path`, `author`, `publishing_mode`) block approval.
|
||||
- Unapproved required assets block approval.
|
||||
- Successful approval writes event payload with actor/timestamp/draft version/publishing settings and moves state to `PUBLISH_DRY_RUN_REQUIRED`.
|
||||
- Revision request writes event payload and moves state to `FINAL_REVISION_REQUIRED`.
|
||||
- UI renders checklist rows, blockers, and completed items.
|
||||
|
||||
- Verification output:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_final_approval_gate_public_api.py` -> OK (7 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_seo_language_review_public_api.py` -> OK (6 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> OK (7 tests)
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> OK (5 tests)
|
||||
- `node apps/frontend/tests/final_approval.model.test.mjs` -> OK
|
||||
- `pnpm --dir apps/frontend typecheck` -> OK
|
||||
|
||||
@@ -34,16 +34,16 @@ Development description: Implement the Git-backed publishing path that builds a
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TDD pre-requirement: before implementation, write one failing integration test against a temporary local Git repository proving a final-approved article can create a commit; add dry-run and failure tests one behavior at a time and record evidence in `Result`.
|
||||
- [ ] Publishing cannot dry-run before final approval.
|
||||
- [ ] Dry run fails if generic Markdown/MDX content-shape validation fails.
|
||||
- [ ] Publish commit cannot run until dry run passes.
|
||||
- [ ] Content bundle uses site-configured path templates and frontmatter mapping.
|
||||
- [ ] Active YAML/script config version is included in publish logs/manifest.
|
||||
- [ ] Publish writes commit to configured production branch in a test repository.
|
||||
- [ ] Non-fast-forward push or conflict fails without automatic rebase.
|
||||
- [ ] Publish commit record stores repository URL, branch, commit SHA, bundle manifest, and status.
|
||||
- [ ] UI clearly labels validation as best-effort content-shape validation only.
|
||||
- [x] TDD pre-requirement: before implementation, write one failing integration test against a temporary local Git repository proving a final-approved article can create a commit; add dry-run and failure tests one behavior at a time and record evidence in `Result`.
|
||||
- [x] Publishing cannot dry-run before final approval.
|
||||
- [x] Dry run fails if generic Markdown/MDX content-shape validation fails.
|
||||
- [x] Publish commit cannot run until dry run passes.
|
||||
- [x] Content bundle uses site-configured path templates and frontmatter mapping.
|
||||
- [x] Active YAML/script config version is included in publish logs/manifest.
|
||||
- [x] Publish writes commit to configured production branch in a test repository.
|
||||
- [x] Non-fast-forward push or conflict fails without automatic rebase.
|
||||
- [x] Publish commit record stores repository URL, branch, commit SHA, bundle manifest, and status.
|
||||
- [x] UI clearly labels validation as best-effort content-shape validation only.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -53,9 +53,58 @@ Development description: Implement the Git-backed publishing path that builds a
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Pending execution.
|
||||
- TDD plan: To be filled during execution.
|
||||
- Red evidence: To be filled during execution.
|
||||
- Green evidence: To be filled during execution.
|
||||
- Refactor notes: To be filled during execution.
|
||||
- Verification output: To be filled during execution.
|
||||
- Status: Done (TDD RED -> GREEN completed).
|
||||
- TDD progression:
|
||||
1. Restored and expanded `apps/backend/tests/integration/test_publishing_git_flow_public_api.py` from RED baseline.
|
||||
2. Added incremental behavior coverage:
|
||||
- dry-run blocked before final approval,
|
||||
- dry-run content-shape validation failure path,
|
||||
- create-commit blocked without successful dry-run,
|
||||
- successful publish commit into local bare repo `main`,
|
||||
- non-fast-forward conflict fail without auto rebase,
|
||||
- publishing status/commits metadata assertions.
|
||||
3. Implemented publishing backend flow:
|
||||
- endpoints:
|
||||
- `POST /api/articles/{article_id}/publishing/dry-run`
|
||||
- `POST /api/articles/{article_id}/publishing/create-commit`
|
||||
- `GET /api/articles/{article_id}/publishing/status`
|
||||
- `GET /api/articles/{article_id}/publishing/commits`
|
||||
- gates:
|
||||
- dry-run only after final approval,
|
||||
- create-commit only after successful dry-run.
|
||||
- content-shape checks:
|
||||
- non-empty markdown,
|
||||
- at least one markdown heading,
|
||||
- balanced fenced code blocks.
|
||||
- bundle/manifest:
|
||||
- site-configured `content_path_template` and `asset_path_template`,
|
||||
- frontmatter mapping usage,
|
||||
- active script config version id/hash metadata,
|
||||
- repository/branch/base head/commit sha metadata.
|
||||
- git publish:
|
||||
- direct commit+push to configured production branch,
|
||||
- non-fast-forward detection and fail path without rebase.
|
||||
4. Added frontend detail-model mapping for validation label and surfaced label in article detail UI.
|
||||
5. Regenerated shared OpenAPI contracts.
|
||||
|
||||
- Green evidence:
|
||||
- Integration suite passes with all required publishing behaviors in one flow-oriented file:
|
||||
`apps/backend/tests/integration/test_publishing_git_flow_public_api.py` (6 tests, all green).
|
||||
- Commit object is present in bare repo (`git cat-file -e <sha>^{commit}` in test).
|
||||
- Manifest metadata includes `config_version` and `git` blocks and best-effort validation label.
|
||||
|
||||
- Refactor notes:
|
||||
- Added dedicated `PublishCommitsRepository` with list/latest helpers and status filtering.
|
||||
- Added `articles.update_publishing_status(...)` for explicit workflow/publishing-state sync.
|
||||
- Kept implementation scoped to task 017 API/domain/repository/frontend model changes; no unrelated workflow rewrites.
|
||||
|
||||
- Verification output:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_publishing_git_flow_public_api.py` -> `Ran 6 tests ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_final_approval_gate_public_api.py` -> `Ran 7 tests ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> `Ran 5 tests ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> `Ran 7 tests ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 scripts/generate_openapi_contracts.py` -> wrote:
|
||||
- `packages/shared/openapi.json`
|
||||
- `packages/shared/src/api-types.ts`
|
||||
- `node apps/frontend/tests/article_detail.model.test.mjs` -> `OK` (exit 0)
|
||||
- `pnpm --dir apps/frontend typecheck` -> `tsc --noEmit` completed successfully (exit 0)
|
||||
|
||||
@@ -44,14 +44,14 @@ Development description: Build the operational layer for workflow history, job l
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
|
||||
- [ ] Article detail page shows user, system, and agent events in order.
|
||||
- [ ] Job logs are stored and displayed with sensitive values redacted.
|
||||
- [ ] Retry buttons appear only where retry is allowed.
|
||||
- [ ] Cancelling queued/running jobs prevents article state mutation.
|
||||
- [ ] Publish commit retry is blocked once a publish commit exists.
|
||||
- [ ] Admin can see detailed logs; Editor sees safe failure summary.
|
||||
- [ ] All Admin script/config changes are audit-visible with diff and rollback target.
|
||||
- [x] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
|
||||
- [x] Article detail page shows user, system, and agent events in order.
|
||||
- [x] Job logs are stored and displayed with sensitive values redacted.
|
||||
- [x] Retry buttons appear only where retry is allowed.
|
||||
- [x] Cancelling queued/running jobs prevents article state mutation.
|
||||
- [x] Publish commit retry is blocked once a publish commit exists.
|
||||
- [x] Admin can see detailed logs; Editor sees safe failure summary.
|
||||
- [x] All Admin script/config changes are audit-visible with diff and rollback target.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -62,9 +62,42 @@ Development description: Build the operational layer for workflow history, job l
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Pending execution.
|
||||
- TDD plan: To be filled during execution.
|
||||
- Red evidence: To be filled during execution.
|
||||
- Green evidence: To be filled during execution.
|
||||
- Refactor notes: To be filled during execution.
|
||||
- Verification output: To be filled during execution.
|
||||
- Status: Implemented and verified (GREEN).
|
||||
- Green evidence:
|
||||
- Backend observability implemented:
|
||||
- role-aware job projection with `retry_eligible`, `retry_block_reason`, `cancel_eligible`, `safe_failure_summary`,
|
||||
- redaction for payload/log/error message,
|
||||
- mixed timeline (`USER`/`SYSTEM`/`AGENT`) sorted by event time.
|
||||
- Retry policy enforced server-side:
|
||||
- retry allowed only for configured job types,
|
||||
- publish commit retry blocked if `PUBLISH_COMMIT_CREATED` already exists.
|
||||
- Cancel behavior enforced:
|
||||
- cancelled queued/running jobs stay `CANCELLED`,
|
||||
- completion callback after cancellation does not mutate article workflow state.
|
||||
- Admin/editor visibility:
|
||||
- admin sees redacted `stdout`/`stderr`,
|
||||
- editor sees safe failure summary and no raw logs.
|
||||
- Script/config audit visibility:
|
||||
- added `GET /api/sites/{site_id}/publishing-config/audit`,
|
||||
- audit list includes `event_type`, `diff`, and `rollback_target_version_id`.
|
||||
- Frontend:
|
||||
- article detail timeline renders user/system/agent entries in order,
|
||||
- failure table shows job type/status/error category/error message/last successful step,
|
||||
- retry/cancel controls rendered only when policy allows,
|
||||
- role-based diagnostics panel (admin logs vs editor summary),
|
||||
- admin scripts page now shows audit trail with diff + rollback target.
|
||||
- Contracts regenerated:
|
||||
- `packages/shared/openapi.json`,
|
||||
- `packages/shared/src/api-types.ts`.
|
||||
- Refactor notes:
|
||||
- Introduced focused backend module `apps/backend/src/application/observability.py` to keep redaction, retry policy, and timeline projection out of route handlers and domain-agnostic application services.
|
||||
- Reused policy projection in both article detail and agent-job endpoints so UI and action gates stay consistent.
|
||||
- Verification output:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py`
|
||||
- `Ran 7 tests in 0.704s` -> `OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_agent_job_queue_public_api.py apps/backend/tests/integration/test_parallel_production_public_api.py apps/backend/tests/integration/test_publishing_git_flow_public_api.py apps/backend/tests/integration/test_final_approval_gate_public_api.py apps/backend/tests/integration/test_script_config_audit.py`
|
||||
- `Ran 23 tests in 4.656s` -> `OK`
|
||||
- `node apps/frontend/tests/article_detail.model.test.mjs && node apps/frontend/tests/admin_script_versions.model.test.mjs`
|
||||
- exit code `0` (pass)
|
||||
- `pnpm --dir apps/frontend typecheck`
|
||||
- `tsc --noEmit` -> pass
|
||||
|
||||
@@ -37,15 +37,15 @@ Development description: Assemble and verify a complete demo-ready Docker Compos
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TDD pre-requirement: before implementation, write one failing end-to-end smoke test for the shortest happy path through public UI/API boundaries; add failure-path checks one behavior at a time and record evidence in `Result`.
|
||||
- [ ] `docker compose up --build` starts the complete demo stack.
|
||||
- [ ] Demo requires no real Codex, search, WHOIS, cloud S3, or GitHub credentials.
|
||||
- [ ] Editor can complete the happy path from article brief to `PUBLISH_COMMIT_CREATED`.
|
||||
- [ ] Final commit contains Markdown/MDX, frontmatter, and referenced assets according to site config.
|
||||
- [ ] Object storage contains research source-section artifacts and manifests.
|
||||
- [ ] Workflow timeline shows all major actions.
|
||||
- [ ] Demo includes at least one visible failure/retry scenario.
|
||||
- [ ] README/demo guide is accurate and enough for a new developer to run the product.
|
||||
- [x] TDD pre-requirement: before implementation, write one failing end-to-end smoke test for the shortest happy path through public UI/API boundaries; add failure-path checks one behavior at a time and record evidence in `Result`.
|
||||
- [x] `docker compose up --build` starts the complete demo stack.
|
||||
- [x] Demo requires no real Codex, search, WHOIS, cloud S3, or GitHub credentials.
|
||||
- [x] Editor can complete the happy path from article brief to `PUBLISH_COMMIT_CREATED`.
|
||||
- [x] Final commit contains Markdown/MDX, frontmatter, and referenced assets according to site config.
|
||||
- [x] Object storage contains research source-section artifacts and manifests.
|
||||
- [x] Workflow timeline shows all major actions.
|
||||
- [x] Demo includes at least one visible failure/retry scenario.
|
||||
- [x] README/demo guide is accurate and enough for a new developer to run the product.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -56,9 +56,42 @@ Development description: Assemble and verify a complete demo-ready Docker Compos
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Pending execution.
|
||||
- TDD plan: To be filled during execution.
|
||||
- Red evidence: To be filled during execution.
|
||||
- Green evidence: To be filled during execution.
|
||||
- Refactor notes: To be filled during execution.
|
||||
- Verification output: To be filled during execution.
|
||||
- Status: Implemented and verified (GREEN).
|
||||
- Delivered:
|
||||
1. Deterministic demo runtime (`PIPELINE_DEMO_MODE`) with local bare Git publish target bootstrap.
|
||||
2. Demo seed data now points to local repository path in demo mode.
|
||||
3. Deterministic section scaffold fail-once behavior with visible retry success path.
|
||||
4. End-to-end smoke test now covers full public happy path to `PUBLISH_COMMIT_CREATED` plus failure/retry.
|
||||
5. README + dedicated demo guide with setup, roles, happy/failure path, and artifact inspection.
|
||||
- Red evidence (pre-requirement):
|
||||
- `apps/backend/tests/integration/test_end_to_end_demo_stack_smoke_public_api.py` initially failed during RED phase as documented above.
|
||||
- Green evidence:
|
||||
- `test_demo_happy_path_reaches_publish_commit_created_with_failure_retry` is green and verifies:
|
||||
- article path from brief to publish commit,
|
||||
- deterministic failure (`AGENT_JOB_FAILED`) and retry (`AGENT_JOB_RETRIED`),
|
||||
- commit payload contains frontmatter/markdown and assets in target repository,
|
||||
- research manifests/artifacts exist in object storage,
|
||||
- timeline includes major workflow events through `PUBLISH_COMMIT_CREATED`.
|
||||
- Refactor notes:
|
||||
- Introduced `demo_runtime.py` to isolate demo-only behavior and avoid scattering env checks.
|
||||
- Reused existing job completion pipeline (`complete_agent_job`) for demo auto-completion to preserve observability/audit behavior.
|
||||
- Fixed publish YAML template brace escaping in seed data.
|
||||
- Verification output:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_end_to_end_demo_stack_smoke_public_api.py`
|
||||
- `Ran 1 test ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py apps/backend/tests/integration/test_evidence_matrix_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_agent_job_queue_public_api.py apps/backend/tests/integration/test_parallel_production_public_api.py apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py apps/backend/tests/integration/test_boundary_questions_public_api.py`
|
||||
- `Ran 37 tests ... OK`
|
||||
- `node apps/frontend/tests/article_detail.model.test.mjs`
|
||||
- exit code `0`
|
||||
- `node apps/frontend/tests/admin_script_versions.model.test.mjs`
|
||||
- exit code `0`
|
||||
- `node apps/frontend/tests/draft_editor.model.test.mjs`
|
||||
- exit code `0`
|
||||
- `node apps/frontend/tests/final_approval.model.test.mjs`
|
||||
- exit code `0`
|
||||
- `pnpm --dir apps/frontend typecheck`
|
||||
- `tsc --noEmit` completed successfully
|
||||
- `docker compose up --build -d`
|
||||
- completed successfully; all services started.
|
||||
- `docker compose ps`
|
||||
- backend/frontend/runner/minio/postgres/redis are up (postgres/redis healthy).
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Task 020: Demo Publishing S3 Assets Compatibility
|
||||
|
||||
Development description: Fix publish dry-run/commit flow so approved assets stored in object storage (`s3://...`) are correctly bundled for Git publishing in demo Compose mode.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Root cause:
|
||||
- Publishing bundle loader accepted only `file://` asset URLs.
|
||||
- In Compose demo mode, approved assets are stored in MinIO and exposed as `s3://bucket/key`.
|
||||
- Required backend changes:
|
||||
- Extend object storage client with read capability (`get_bytes`).
|
||||
- Update publishing asset collector to support both `file://` and `s3://` sources.
|
||||
- Use `asset.object_key` (or URL-derived key fallback) for object-storage fetch.
|
||||
- Preserve existing manifest structure and publish workflow statuses.
|
||||
- Validation:
|
||||
- Add regression test proving dry-run works when approved assets use `s3://` URLs with valid object keys.
|
||||
|
||||
## Public Interface
|
||||
|
||||
- No API contract changes.
|
||||
- Existing endpoints must behave identically, except they no longer fail on `s3://` approved assets:
|
||||
- `POST /api/articles/{article_id}/publishing/dry-run`
|
||||
- `POST /api/articles/{article_id}/publishing/create-commit`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Dry-run no longer fails with `Approved asset file is unavailable: s3://...` when asset object exists.
|
||||
- [x] Publish bundle copies approved assets from object storage into target repo workspace.
|
||||
- [x] Existing file-based publishing tests continue to pass.
|
||||
- [x] New regression test covers `s3://` asset URL compatibility.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run publishing integration suite:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_publishing_git_flow_public_api.py`
|
||||
- Run task 019 demo smoke:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_end_to_end_demo_stack_smoke_public_api.py`
|
||||
- Run clean compose checklist manually (`down -v` -> `up --build`) and verify publish dry-run/commit steps.
|
||||
|
||||
## Result
|
||||
|
||||
- Status: Implemented.
|
||||
- Files changed:
|
||||
- `apps/backend/src/infrastructure/object_storage.py`
|
||||
- `apps/backend/src/application/publishing.py`
|
||||
- `apps/backend/tests/integration/test_publishing_git_flow_public_api.py`
|
||||
- Green evidence:
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_publishing_git_flow_public_api.py` -> `Ran 7 tests ... OK`
|
||||
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_end_to_end_demo_stack_smoke_public_api.py` -> `Ran 1 test ... OK`
|
||||
- Clean compose check (`docker compose down -v` -> `docker compose up --build -d`) + manual public API flow:
|
||||
- final approval: `200 PUBLISH_DRY_RUN_REQUIRED`
|
||||
- publishing dry-run: `201 PUBLISH_COMMIT_READY`
|
||||
- create-commit: `201 PUBLISH_COMMIT_CREATED`
|
||||
- Refactor notes:
|
||||
- Added storage read path to reuse existing object storage abstractions instead of introducing publish-specific S3 calls.
|
||||
Reference in New Issue
Block a user