Compare commits

...
5 Commits
50 changed files with 6654 additions and 287 deletions
+15
View File
@@ -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)
+4
View File
@@ -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
+165 -12
View File
@@ -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,
)
+18 -1
View File
@@ -12,6 +12,10 @@ from src.domain.contracts import (
CurrentUser,
PublishingStatus,
)
from src.application.observability import (
build_observability_timeline,
project_job_for_view,
)
def create_article(
@@ -54,6 +58,7 @@ def list_articles(repository: object) -> ArticleListResponse:
def get_article_detail(
repository: object,
article_id: UUID,
current_user: CurrentUser,
) -> ArticleDetailResponse:
article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id)
@@ -66,6 +71,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 ""
+37
View File
@@ -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
+776
View File
@@ -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)
+19 -5
View File
@@ -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",
+109
View File
@@ -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"),
+4
View File
@@ -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} />;
}
+18
View File
@@ -344,6 +344,24 @@ main {
padding: 10px 0;
}
.timelineMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.timelineSource {
border: 1px solid #d0d5dd;
border-radius: 6px;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
background: #ffffff;
font-size: 12px;
}
.draftEditor {
margin-top: 28px;
}
@@ -1,4 +1,5 @@
import type {
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionSummary,
} from "@pipeline/shared";
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
rollbackTargetVersionId: string;
};
export type AdminScriptAuditRow = {
id: string;
eventType: string;
version: number | null;
createdAt: string;
actorUserId: string | null;
diff: string;
rollbackTargetVersionId: string | null;
};
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
@@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record<string, unknown> | null {
}
return null;
}
export function buildAdminScriptAuditRows(
events: readonly ScriptConfigVersionAuditEventSummary[],
): AdminScriptAuditRow[] {
return events
.map((event) => ({
id: event.id,
eventType: event.event_type,
version: event.version ?? null,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
diff: JSON.stringify(event.diff ?? {}),
rollbackTargetVersionId: event.rollback_target_version_id ?? null,
}))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
@@ -2,10 +2,11 @@
import { useMemo, useState, type FormEvent } from "react";
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import type { ScriptConfigVersionAuditEventSummary, ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import {
buildAdminScriptAuditRows,
buildAdminScriptVersionRows,
buildCreateScriptVersionPayload,
defaultScriptVersionDraft,
@@ -17,6 +18,7 @@ import {
type AdminScriptVersionsPanelProps = {
site: TargetSiteConfig;
initialVersions: readonly ScriptConfigVersionSummary[];
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
};
function parseOptionalError(value: string): string | null {
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
export function AdminScriptVersionsPanel({
site,
initialVersions,
initialAuditEvents,
}: AdminScriptVersionsPanelProps) {
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
initialVersions,
);
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
initialAuditEvents,
);
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
defaultScriptVersionDraft(),
);
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
buildAdminScriptVersionRows(versions),
[versions],
);
const auditRows = useMemo(
() => buildAdminScriptAuditRows(auditEvents),
[auditEvents],
);
const hasVersions = rows.length > 0;
const canSubmit = !isSubmitting;
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
async function refreshVersions() {
const latest = await fetchScriptConfigVersions(
const [latest, audit] = await Promise.all([
fetchScriptConfigVersions(
site.id,
DEMO_ADMIN_EMAIL,
);
),
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
]);
setVersions(latest.versions);
setAuditEvents(audit.events ?? []);
}
function updateField<K extends keyof NewAdminScriptVersionDraft>(
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
</tbody>
</table>
)}
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Version</th>
<th>Created</th>
<th>Actor</th>
<th>Diff</th>
<th>Rollback target</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{event.version ?? "—"}</td>
<td>{new Date(event.createdAt).toLocaleString()}</td>
<td>{event.actorUserId ?? "—"}</td>
<td>{event.diff}</td>
<td>{event.rollbackTargetVersionId ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
@@ -1,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;
}
+124 -32
View File
@@ -1,5 +1,11 @@
import Link from "next/link";
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ApiError, cancelAgentJob, retryAgentJob } from "@/shared/pipeline-api";
import { buildJobControlState } from "./model";
import type { DetailSummary } from "./model";
type DetailShellProps = {
@@ -7,8 +13,46 @@ type DetailShellProps = {
};
export function ArticleDetailShell({ summary }: DetailShellProps) {
const router = useRouter();
const [actionError, setActionError] = useState("");
const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline;
const productionArtifacts = summary.productionArtifacts;
const jobDetails = summary.jobDetails;
const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await retryAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to retry job.");
} else {
setActionError("Unable to retry job.");
}
} finally {
setBusyJobId("");
}
}
async function handleCancel(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await cancelAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to cancel job.");
} else {
setActionError("Unable to cancel job.");
}
} finally {
setBusyJobId("");
}
}
return (
<section>
@@ -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>
);
+55 -5
View File
@@ -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");
+114 -83
View File
@@ -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);
+5
View File
@@ -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:
+97
View File
@@ -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
+109
View File
@@ -26,6 +26,7 @@ export type AgentJobSummary = {
agent_profile: string;
article_id?: string | null;
attempt?: number;
cancel_eligible?: boolean;
duration_ms?: number | null;
error_category?: AgentJobErrorCategory | null;
error_message?: string | null;
@@ -38,6 +39,9 @@ export type AgentJobSummary = {
parent_job_id?: string | null;
payload?: Record<string, unknown>;
queued_at: string;
retry_block_reason?: string | null;
retry_eligible?: boolean;
safe_failure_summary?: string | null;
started_at?: string | null;
status: AgentJobStatus;
stderr?: string;
@@ -78,6 +82,7 @@ export type ArticleDetailResponse = {
research_manifests?: ResearchArtifactManifestSummary[];
reviews?: ReviewSummary[];
target_site?: TargetSiteConfig | null;
timeline?: ObservabilityTimelineEventSummary[];
workflow_events?: WorkflowEventSummary[];
};
@@ -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>;
+37 -14
View File
@@ -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
+65 -16
View File
@@ -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)
+47 -14
View File
@@ -44,14 +44,14 @@ Development description: Build the operational layer for workflow history, job l
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
- [ ] Article detail page shows user, system, and agent events in order.
- [ ] Job logs are stored and displayed with sensitive values redacted.
- [ ] Retry buttons appear only where retry is allowed.
- [ ] Cancelling queued/running jobs prevents article state mutation.
- [ ] Publish commit retry is blocked once a publish commit exists.
- [ ] Admin can see detailed logs; Editor sees safe failure summary.
- [ ] All Admin script/config changes are audit-visible with diff and rollback target.
- [x] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
- [x] Article detail page shows user, system, and agent events in order.
- [x] Job logs are stored and displayed with sensitive values redacted.
- [x] Retry buttons appear only where retry is allowed.
- [x] Cancelling queued/running jobs prevents article state mutation.
- [x] Publish commit retry is blocked once a publish commit exists.
- [x] Admin can see detailed logs; Editor sees safe failure summary.
- [x] All Admin script/config changes are audit-visible with diff and rollback target.
## Verification
@@ -62,9 +62,42 @@ Development description: Build the operational layer for workflow history, job l
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Implemented and verified (GREEN).
- Green evidence:
- Backend observability implemented:
- role-aware job projection with `retry_eligible`, `retry_block_reason`, `cancel_eligible`, `safe_failure_summary`,
- redaction for payload/log/error message,
- mixed timeline (`USER`/`SYSTEM`/`AGENT`) sorted by event time.
- Retry policy enforced server-side:
- retry allowed only for configured job types,
- publish commit retry blocked if `PUBLISH_COMMIT_CREATED` already exists.
- Cancel behavior enforced:
- cancelled queued/running jobs stay `CANCELLED`,
- completion callback after cancellation does not mutate article workflow state.
- Admin/editor visibility:
- admin sees redacted `stdout`/`stderr`,
- editor sees safe failure summary and no raw logs.
- Script/config audit visibility:
- added `GET /api/sites/{site_id}/publishing-config/audit`,
- audit list includes `event_type`, `diff`, and `rollback_target_version_id`.
- Frontend:
- article detail timeline renders user/system/agent entries in order,
- failure table shows job type/status/error category/error message/last successful step,
- retry/cancel controls rendered only when policy allows,
- role-based diagnostics panel (admin logs vs editor summary),
- admin scripts page now shows audit trail with diff + rollback target.
- Contracts regenerated:
- `packages/shared/openapi.json`,
- `packages/shared/src/api-types.ts`.
- Refactor notes:
- Introduced focused backend module `apps/backend/src/application/observability.py` to keep redaction, retry policy, and timeline projection out of route handlers and domain-agnostic application services.
- Reused policy projection in both article detail and agent-job endpoints so UI and action gates stay consistent.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py`
- `Ran 7 tests in 0.704s` -> `OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_agent_job_queue_public_api.py apps/backend/tests/integration/test_parallel_production_public_api.py apps/backend/tests/integration/test_publishing_git_flow_public_api.py apps/backend/tests/integration/test_final_approval_gate_public_api.py apps/backend/tests/integration/test_script_config_audit.py`
- `Ran 23 tests in 4.656s` -> `OK`
- `node apps/frontend/tests/article_detail.model.test.mjs && node apps/frontend/tests/admin_script_versions.model.test.mjs`
- exit code `0` (pass)
- `pnpm --dir apps/frontend typecheck`
- `tsc --noEmit` -> pass
+48 -15
View File
@@ -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.