Compare commits

...
10 Commits
88 changed files with 13772 additions and 328 deletions
+7 -2
View File
@@ -8,6 +8,10 @@ The system accepts a short article description, asks boundary-setting questions,
v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product. v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product.
### Main principle
The workflow is a first-class Admin-managed product object, not a hard-coded backend status chain. The pipeline is gate-driven: each workflow stage defines its purpose, ordered parts, owner role, runner profile, required inputs, outputs, and acceptance criteria. Runtime execution cannot move to the next stage until the current stage is complete and every required human approval or validation gate has passed.
Chosen architecture: Chosen architecture:
```text ```text
@@ -50,6 +54,7 @@ Claude Code can be supported as an optional internal runner, but not as the main
14. Multi-site configuration 14. Multi-site configuration
15. Job history and audit trail 15. Job history and audit trail
16. Codex CLI-based agent execution 16. Codex CLI-based agent execution
17. Admin-managed workflow templates and editable workflow stages
``` ```
## Out of scope for v1 ## Out of scope for v1
@@ -76,13 +81,13 @@ The publishing step in v1 should create a **direct commit to the configured prod
## Admin ## Admin
Can configure target websites, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts. Can configure target websites, workflow templates, workflow stages, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts.
## Editor ## Editor
Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits. Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits.
Only Admins can edit upload scripts and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories. Only Admins can edit workflow configuration, upload scripts, and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories.
--- ---
+15
View File
@@ -39,3 +39,18 @@ make smoke
The smoke test starts Docker Compose, checks frontend/backend/runner health The smoke test starts Docker Compose, checks frontend/backend/runner health
endpoints, and verifies that the backend can connect to Postgres, Redis, and endpoints, and verifies that the backend can connect to Postgres, Redis, and
S3-compatible object storage. 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 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 COPY apps/backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r 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, AgentJobOutput,
AgentJobResponse, AgentJobResponse,
AgentJobStatus, AgentJobStatus,
AgentJobSummary,
AgentJobTestCodexRequest, AgentJobTestCodexRequest,
AgentJobType, 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: def list_agent_jobs(repository: object) -> AgentJobListResponse:
return AgentJobListResponse(jobs=repository.agent_jobs.list()) jobs = [_project_admin_view(repository, job) for job in repository.agent_jobs.list()]
return AgentJobListResponse(jobs=jobs)
def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse: def get_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
return AgentJobResponse(job=repository.agent_jobs.get(job_id)) job = repository.agent_jobs.get(job_id)
return AgentJobResponse(job=_project_admin_view(repository, job))
def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse: def retry_agent_job(
repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
parent = repository.agent_jobs.get(job_id) parent = repository.agent_jobs.get(job_id)
retry_eligible, retry_block_reason = evaluate_retry_policy(repository, parent)
if not retry_eligible:
raise PermissionError(retry_block_reason or "Retry is blocked for this job.")
now = _now()
retry = repository.agent_jobs.create( retry = repository.agent_jobs.create(
article_id=parent.article_id, article_id=parent.article_id,
parent_job_id=parent.id, parent_job_id=parent.id,
@@ -62,13 +81,57 @@ def retry_agent_job(repository: object, job_id: UUID) -> AgentJobResponse:
status=AgentJobStatus.QUEUED, status=AgentJobStatus.QUEUED,
input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files], input_files=[file_ref.model_dump(mode="json") for file_ref in parent.input_files],
payload=parent.payload, payload=parent.payload,
queued_at=_now(), queued_at=now,
) )
return AgentJobResponse(job=retry) _record_job_event(
repository,
job=retry,
event_type="AGENT_JOB_RETRIED",
actor_user_id=actor_user_id,
created_at=now,
payload={
"parent_job_id": str(parent.id),
"attempt": retry.attempt,
"job_type": retry.job_type.value,
},
)
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: def cancel_agent_job(
return AgentJobResponse(job=repository.agent_jobs.cancel(job_id=job_id, finished_at=_now())) repository: object,
job_id: UUID,
*,
actor_user_id: UUID | None = None,
) -> AgentJobResponse:
now = _now()
existing = repository.agent_jobs.get(job_id)
job = repository.agent_jobs.cancel(job_id=job_id, finished_at=now)
if existing.status != job.status and job.status == AgentJobStatus.CANCELLED:
_record_job_event(
repository,
job=job,
event_type="AGENT_JOB_CANCELLED",
actor_user_id=actor_user_id,
created_at=now,
payload={"job_type": job.job_type.value, "attempt": job.attempt},
)
return AgentJobResponse(job=_project_admin_view(repository, job))
def claim_next_agent_job(repository: object) -> AgentJobResponse | None: def claim_next_agent_job(repository: object) -> AgentJobResponse | None:
@@ -93,6 +156,7 @@ def complete_agent_job(
try: try:
validated_output = AgentJobOutput.model_validate(output) validated_output = AgentJobOutput.model_validate(output)
except ValidationError as error: except ValidationError as error:
finished_at = _now()
job = repository.agent_jobs.complete( job = repository.agent_jobs.complete(
job_id=job_id, job_id=job_id,
status=AgentJobStatus.FAILED, status=AgentJobStatus.FAILED,
@@ -105,9 +169,15 @@ def complete_agent_job(
stderr=stderr, stderr=stderr,
exit_code=exit_code, exit_code=exit_code,
duration_ms=duration_ms, duration_ms=duration_ms,
finished_at=_now(), finished_at=finished_at,
) )
return AgentJobResponse(job=job) _record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, job))
merged_payload = _merge_payload( merged_payload = _merge_payload(
base_payload=existing_job.payload, base_payload=existing_job.payload,
@@ -133,6 +203,7 @@ def complete_agent_job(
f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}" f"Unsupported claims introduced during scaffolding: {len(unsupported_claims)}"
) )
finished_at = _now()
job = repository.agent_jobs.complete( job = repository.agent_jobs.complete(
job_id=job_id, job_id=job_id,
status=status, status=status,
@@ -147,9 +218,15 @@ def complete_agent_job(
stderr=stderr, stderr=stderr,
exit_code=exit_code, exit_code=exit_code,
duration_ms=duration_ms, duration_ms=duration_ms,
finished_at=_now(), finished_at=finished_at,
) )
return AgentJobResponse(job=job) _record_completion_event(
repository,
previous=existing_job,
current=job,
created_at=finished_at,
)
return AgentJobResponse(job=_project_admin_view(repository, job))
def _now() -> datetime: def _now() -> datetime:
@@ -164,9 +241,13 @@ def _normalize_completion_status(
) -> AgentJobStatus: ) -> AgentJobStatus:
if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None): if status == AgentJobStatus.SUCCEEDED and exit_code not in (0, None):
return AgentJobStatus.FAILED return AgentJobStatus.FAILED
if error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE and exit_code in ( if (
status == AgentJobStatus.SUCCEEDED
and error_category == AgentJobErrorCategory.CLI_EXIT_CODE_FAILURE
and exit_code in (
None, None,
0, 0,
)
): ):
return AgentJobStatus.SUCCEEDED return AgentJobStatus.SUCCEEDED
return status return status
@@ -201,3 +282,75 @@ def _extract_unsupported_claims(payload: dict[str, Any]) -> list[Any]:
if isinstance(claims, list): if isinstance(claims, list):
return claims return claims
return [] return []
def _project_admin_view(repository: object, job: AgentJobSummary) -> AgentJobSummary:
return project_job_for_view(repository, job, viewer_role=Role.ADMIN)
def _record_completion_event(
repository: object,
*,
previous: AgentJobSummary,
current: AgentJobSummary,
created_at: datetime,
) -> None:
if current.article_id is None:
return
if previous.status == current.status:
return
if current.status == AgentJobStatus.FAILED:
retry_eligible, _ = evaluate_retry_policy(repository, current)
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_FAILED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
"error_category": (
current.error_category.value if current.error_category is not None else None
),
"error_message": current.error_message,
"retry_eligible": retry_eligible,
},
)
return
if current.status == AgentJobStatus.SUCCEEDED:
_record_job_event(
repository,
job=current,
event_type="AGENT_JOB_SUCCEEDED",
actor_user_id=None,
created_at=created_at,
payload={
"job_type": current.job_type.value,
"attempt": current.attempt,
},
)
def _record_job_event(
repository: object,
*,
job: AgentJobSummary,
event_type: str,
actor_user_id: UUID | None,
created_at: datetime,
payload: dict[str, Any],
) -> None:
if job.article_id is None:
return
repository.articles.create_workflow_event(
article_id=job.article_id,
event_type=event_type,
from_status=None,
to_status=None,
actor_user_id=actor_user_id,
payload={"job_id": str(job.id), **payload},
created_at=created_at,
)
+73 -1
View File
@@ -11,6 +11,14 @@ from src.domain.contracts import (
ArticleWorkflowStatus, ArticleWorkflowStatus,
CurrentUser, CurrentUser,
PublishingStatus, PublishingStatus,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
)
from src.application.observability import (
build_observability_timeline,
project_job_for_view,
) )
@@ -20,10 +28,16 @@ def create_article(
current_user: CurrentUser, current_user: CurrentUser,
) -> ArticleCreateResponse: ) -> ArticleCreateResponse:
repository.target_sites.get_by_id(request.target_site_id) repository.target_sites.get_by_id(request.target_site_id)
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
now = _now() now = _now()
summary = repository.articles.create( summary = repository.articles.create(
target_site_id=request.target_site_id, target_site_id=request.target_site_id,
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
workflow_template_snapshot=(
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
),
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED, status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED, publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description, brief_description=request.brief_description,
@@ -54,9 +68,13 @@ def list_articles(repository: object) -> ArticleListResponse:
def get_article_detail( def get_article_detail(
repository: object, repository: object,
article_id: UUID, article_id: UUID,
current_user: CurrentUser,
) -> ArticleDetailResponse: ) -> ArticleDetailResponse:
article = repository.articles.get(article_id) article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id) target_site = repository.target_sites.get_by_id(article.target_site_id)
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
article_id
)
workflow_events = repository.articles.list_workflow_events(article_id) workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id) boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id) plans = repository.article_plans.list_for_article(article_id)
@@ -66,9 +84,20 @@ def get_article_detail(
claims = repository.claims.list_for_article(article_id) claims = repository.claims.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id) assets = repository.assets.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id) agent_jobs = repository.agent_jobs.list_for_article(article_id)
projected_jobs = [
project_job_for_view(
repository,
job,
viewer_role=current_user.role,
)
for job in agent_jobs
]
timeline = build_observability_timeline(workflow_events, projected_jobs)
publish_commit = repository.publish_commits.latest_for_article(article_id)
return ArticleDetailResponse( return ArticleDetailResponse(
article=article, article=article,
target_site=target_site, target_site=target_site,
workflow_template_snapshot=workflow_template_snapshot,
workflow_events=workflow_events, workflow_events=workflow_events,
boundary_questions=boundary_questions, boundary_questions=boundary_questions,
plan=plans[-1] if plans else None, plan=plans[-1] if plans else None,
@@ -76,10 +105,53 @@ def get_article_detail(
evidence=evidence, evidence=evidence,
claims=claims, claims=claims,
assets=assets, assets=assets,
agent_jobs=agent_jobs, agent_jobs=projected_jobs,
timeline=timeline,
research_manifests=research_manifests, research_manifests=research_manifests,
publish_commit=publish_commit,
) )
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
def _resolve_workflow_template_snapshot(
repository: object,
request: ArticleCreateRequest,
) -> WorkflowTemplateSnapshot | None:
if request.workflow_template_id is None:
return None
try:
workflow = repository.workflow_templates.get(request.workflow_template_id)
except LookupError as error:
raise ValueError("Invalid workflow_template_id") from error
if workflow.status != WorkflowTemplateStatus.ACTIVE:
raise ValueError("Workflow template must be active")
return _workflow_template_snapshot(workflow)
def _workflow_template_snapshot(
workflow: WorkflowTemplateSummary,
) -> WorkflowTemplateSnapshot:
return WorkflowTemplateSnapshot(
id=workflow.id,
name=workflow.name,
slug=workflow.slug,
version=workflow.version,
stage_summary=[
WorkflowTemplateStageSnapshot(
id=stage.id,
stable_key=stage.stable_key,
display_name=stage.display_name,
position=stage.position,
owner_role=stage.owner_role,
runner_profile_key=stage.runner_profile_key,
requires_human_approval=stage.requires_human_approval,
)
for stage in workflow.stages
],
)
@@ -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 datetime import UTC, datetime
from uuid import UUID 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 ( from src.domain.contracts import (
AgentJobListResponse, AgentJobListResponse,
AgentJobStatus, AgentJobStatus,
@@ -189,6 +194,7 @@ def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse
jobs = [] jobs = []
for section in approved_plan.sections: for section in approved_plan.sections:
used_evidence_ids = claim_evidence_by_section.get(str(section.id), []) 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( jobs.append(
repository.agent_jobs.create( repository.agent_jobs.create(
article_id=article_id, article_id=article_id,
@@ -212,11 +218,16 @@ def start_draft(repository: object, *, article_id: UUID) -> AgentJobListResponse
"unsupported_claims": [], "unsupported_claims": [],
"suggested_visuals": [], "suggested_visuals": [],
"draft_markdown": "", "draft_markdown": "",
"demo_fail_once": demo_fail_once,
}, },
queued_at=now, 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) return AgentJobListResponse(jobs=jobs)
@@ -313,3 +324,29 @@ def _insufficient_reasons(claims: list[object]) -> list[str]:
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC) 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)
+175 -5
View File
@@ -4,10 +4,16 @@ from datetime import UTC, datetime
from hashlib import sha256 from hashlib import sha256
from uuid import NAMESPACE_URL, UUID, uuid5 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 ( from src.domain.contracts import (
PublishingRules, PublishingRules,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from src.domain.schema import ( from src.domain.schema import (
SEEDED_ADMIN_EMAIL, SEEDED_ADMIN_EMAIL,
@@ -17,6 +23,7 @@ from src.domain.schema import (
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC) SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
def seed_reference_data(repository: object) -> None: def seed_reference_data(repository: object) -> None:
@@ -44,8 +51,9 @@ def seed_reference_data(repository: object) -> None:
updated_at=SEED_TIMESTAMP, updated_at=SEED_TIMESTAMP,
) )
repository_url = _repository_url_for_seed()
publishing_rules = PublishingRules( publishing_rules = PublishingRules(
repository_url="git@github.com:example/site.git", repository_url=repository_url,
production_branch="main", production_branch="main",
content_format="mdx", content_format="mdx",
content_path_template="content/articles/{slug}.mdx", content_path_template="content/articles/{slug}.mdx",
@@ -85,15 +93,15 @@ def seed_reference_data(repository: object) -> None:
updated_at=SEED_TIMESTAMP, updated_at=SEED_TIMESTAMP,
) )
publishing_yaml = """target: b2b_saas_blog publishing_yaml = f"""target: b2b_saas_blog
repository: repository:
url: git@github.com:example/site.git url: {repository_url}
branch: main branch: main
content: content:
format: mdx format: mdx
path_template: content/articles/{slug}.mdx path_template: content/articles/{{slug}}.mdx
assets: assets:
path_template: public/articles/{slug}/{filename} path_template: public/articles/{{slug}}/{{filename}}
""" """
transform_script = """export function transformArticle(article) { transform_script = """export function transformArticle(article) {
return { return {
@@ -119,6 +127,168 @@ assets:
transform_script=transform_script, transform_script=transform_script,
transform_script_hash=_sha256(transform_script), transform_script_hash=_sha256(transform_script),
) )
_seed_demo_workflow_template(repository, admin_id=admin_id)
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
existing = next(
(
workflow
for workflow in repository.workflow_templates.list()
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
),
None,
)
if existing is not None:
return
workflow = repository.workflow_templates.create(
name="Demo article production workflow",
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
description="Active demo workflow for article intake, drafting, review, and publishing.",
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=admin_id,
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
for stage in _demo_workflow_stages():
repository.workflow_template_stages.create(
workflow_id=workflow.id,
stable_key=stage["stable_key"],
display_name=stage["display_name"],
description=stage["description"],
position=stage["position"],
owner_role=stage["owner_role"],
runner_profile_key=stage["runner_profile_key"],
required_inputs=stage["required_inputs"],
expected_outputs=stage["expected_outputs"],
acceptance_criteria=stage["acceptance_criteria"],
requires_human_approval=stage["requires_human_approval"],
retry_policy=stage["retry_policy"],
parts=stage["parts"],
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
repository.workflow_templates.activate(
workflow_id=workflow.id,
updated_by=admin_id,
activated_at=SEED_TIMESTAMP,
)
def _demo_workflow_stages() -> list[dict[str, object]]:
return [
{
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before plan generation.",
"position": 1,
"owner_role": Role.EDITOR,
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": ["article_brief", "target_site"],
"expected_outputs": ["answered_boundary_questions"],
"acceptance_criteria": ["All required boundary questions are answered."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
{
"stable_key": "plan-review",
"display_name": "Plan review",
"description": "Generate and approve the article plan before research.",
"position": 2,
"owner_role": Role.EDITOR,
"runner_profile_key": "plan-generation-agent-v1",
"required_inputs": ["answered_boundary_questions"],
"expected_outputs": ["approved_plan"],
"acceptance_criteria": ["Plan is approved by the editor."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
"parts": [],
},
{
"stable_key": "research-evidence",
"display_name": "Research and evidence",
"description": "Run research and approve evidence before production starts.",
"position": 3,
"owner_role": Role.EDITOR,
"runner_profile_key": "research-agent-v1",
"required_inputs": ["approved_plan"],
"expected_outputs": ["approved_evidence_matrix"],
"acceptance_criteria": ["Evidence matrix is ready and approved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "parallel-production",
"display_name": "Parallel production",
"description": "Create section scaffolds and draft-ready assets in parallel.",
"position": 4,
"owner_role": Role.EDITOR,
"runner_profile_key": "section-scaffold-agent-v1",
"required_inputs": ["approved_evidence_matrix"],
"expected_outputs": ["section_scaffolds", "asset_specs"],
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Assemble a draft from the approved plan and production artifacts.",
"position": 5,
"owner_role": Role.EDITOR,
"runner_profile_key": "draft-assembly-agent-v1",
"required_inputs": ["section_scaffolds", "approved_assets"],
"expected_outputs": ["article_draft"],
"acceptance_criteria": ["Draft is assembled for review."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "seo-language-review",
"display_name": "SEO and language review",
"description": "Run automated quality reviews and resolve required suggestions.",
"position": 6,
"owner_role": Role.EDITOR,
"runner_profile_key": "review-agent-v1",
"required_inputs": ["article_draft"],
"expected_outputs": ["resolved_review_suggestions"],
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "final-publishing",
"display_name": "Final approval and publishing",
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
"position": 7,
"owner_role": Role.EDITOR,
"runner_profile_key": "publishing-agent-v1",
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
"expected_outputs": ["publish_commit"],
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
]
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: def _stable_uuid(value: str) -> UUID:
@@ -6,6 +6,8 @@ from uuid import NAMESPACE_URL, UUID, uuid5
from src.domain.contracts import ( from src.domain.contracts import (
CurrentUser, CurrentUser,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
@@ -231,6 +233,49 @@ def list_script_config_versions(
return [_script_version_summary(row) for row in rows] return [_script_version_summary(row) for row in rows]
def list_script_config_audit_events(
repository: object,
site_id: UUID,
) -> ScriptConfigVersionAuditEventListResponse:
repository.target_sites.get_by_id(site_id)
version_rows = repository.script_config_versions.list_for_site(site_id)
version_by_id = {str(row["id"]): row for row in version_rows}
events = repository.script_config_version_events.list_for_site(site_id)
summaries: list[ScriptConfigVersionAuditEventSummary] = []
for event in events:
version_row = version_by_id.get(str(event["version_id"]))
payload = event["payload"] if isinstance(event["payload"], dict) else {}
summaries.append(
ScriptConfigVersionAuditEventSummary(
id=event["id"],
target_site_id=event["target_site_id"],
version_id=event["version_id"],
event_type=event["event_type"],
actor_user_id=event["actor_user_id"],
payload=payload,
created_at=event["created_at"],
version=(
int(version_row["version"])
if version_row is not None and version_row.get("version") is not None
else None
),
diff=(
version_row["diff"]
if version_row is not None and isinstance(version_row.get("diff"), dict)
else {}
),
rollback_target_version_id=(
version_row["rollback_target_version_id"]
if version_row is not None
else None
),
)
)
return ScriptConfigVersionAuditEventListResponse(events=summaries)
def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary: def _script_version_summary(row: dict[str, object]) -> ScriptConfigVersionSummary:
return ScriptConfigVersionSummary( return ScriptConfigVersionSummary(
id=row["id"], id=row["id"],
@@ -0,0 +1,372 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
CurrentUser,
Role,
WorkflowStageCreateRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
)
_TEMPLATE_CREATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_CREATED"
_TEMPLATE_UPDATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_UPDATED"
_STAGE_CREATED_EVENT_TYPE = "WORKFLOW_STAGE_CREATED"
_STAGE_UPDATED_EVENT_TYPE = "WORKFLOW_STAGE_UPDATED"
_STAGE_DELETED_EVENT_TYPE = "WORKFLOW_STAGE_DELETED"
_STAGES_REORDERED_EVENT_TYPE = "WORKFLOW_STAGES_REORDERED"
_TEMPLATE_ACTIVATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ACTIVATED"
_TEMPLATE_ARCHIVED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ARCHIVED"
def create_workflow_template(
repository: object,
*,
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
now = _now()
workflow = repository.workflow_templates.create(
name=request.name,
slug=request.slug,
description=request.description,
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=current_user.id,
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={"slug": workflow.slug, "version": workflow.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def list_workflow_templates(
repository: object,
*,
current_user: CurrentUser,
) -> WorkflowTemplateListResponse:
if current_user.role == Role.ADMIN:
workflows = repository.workflow_templates.list()
else:
workflows = repository.workflow_templates.list_active()
return WorkflowTemplateListResponse(workflows=workflows)
def get_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
if current_user.role != Role.ADMIN and workflow.status != WorkflowTemplateStatus.ACTIVE:
raise LookupError(f"Workflow template not found: {workflow_id}")
return WorkflowTemplateResponse(workflow=workflow)
def update_workflow_template(
repository: object,
*,
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
existing = repository.workflow_templates.get(workflow_id)
_require_draft(existing)
changes = request.model_dump(exclude_unset=True)
now = _now()
workflow = repository.workflow_templates.update(
workflow_id=workflow_id,
name=changes.get("name", existing.name),
slug=changes.get("slug", existing.slug),
description=changes.get("description", existing.description),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def add_workflow_stage(
repository: object,
*,
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
stage = repository.workflow_template_stages.create(
workflow_id=workflow_id,
stable_key=request.stable_key,
display_name=request.display_name,
description=request.description,
position=request.position,
owner_role=request.owner_role,
runner_profile_key=request.runner_profile_key,
required_inputs=request.required_inputs,
expected_outputs=request.expected_outputs,
acceptance_criteria=request.acceptance_criteria,
requires_human_approval=request.requires_human_approval,
retry_policy=request.retry_policy,
parts=_parts_json(request.parts),
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={
"stage_id": str(stage.id),
"stable_key": stage.stable_key,
"position": stage.position,
},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def update_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
changes = request.model_dump(exclude_unset=True)
now = _now()
stage = repository.workflow_template_stages.update(
workflow_id=workflow_id,
stage_id=stage_id,
stable_key=changes.get("stable_key", existing.stable_key),
display_name=changes.get("display_name", existing.display_name),
description=changes.get("description", existing.description),
position=changes.get("position", existing.position),
owner_role=request.owner_role or existing.owner_role,
runner_profile_key=changes.get(
"runner_profile_key",
existing.runner_profile_key,
),
required_inputs=changes.get("required_inputs", existing.required_inputs),
expected_outputs=changes.get("expected_outputs", existing.expected_outputs),
acceptance_criteria=changes.get(
"acceptance_criteria",
existing.acceptance_criteria,
),
requires_human_approval=changes.get(
"requires_human_approval",
existing.requires_human_approval,
),
retry_policy=changes.get("retry_policy", existing.retry_policy),
parts=(
_parts_json(request.parts)
if request.parts is not None
else _parts_json(existing.parts)
),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def delete_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
now = _now()
repository.workflow_template_stages.delete(
workflow_id=workflow_id,
stage_id=stage_id,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_DELETED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "stable_key": existing.stable_key},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def reorder_workflow_stages(
repository: object,
*,
workflow_id: UUID,
stage_ids: list[UUID],
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
repository.workflow_template_stages.reorder(
workflow_id=workflow_id,
stage_ids=stage_ids,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGES_REORDERED_EVENT_TYPE,
current_user=current_user,
payload={"stage_ids": [str(stage_id) for stage_id in stage_ids]},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def activate_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
activated = repository.workflow_templates.activate(
workflow_id=workflow_id,
updated_by=current_user.id,
activated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ACTIVATED_EVENT_TYPE,
current_user=current_user,
payload={"version": activated.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=activated)
def archive_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
repository.workflow_templates.get(workflow_id)
now = _now()
archived = repository.workflow_templates.archive(
workflow_id=workflow_id,
updated_by=current_user.id,
archived_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ARCHIVED_EVENT_TYPE,
current_user=current_user,
payload={"version": archived.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=archived)
def list_workflow_template_audit_events(
repository: object,
*,
workflow_id: UUID,
) -> WorkflowTemplateAuditEventListResponse:
repository.workflow_templates.get(workflow_id)
return WorkflowTemplateAuditEventListResponse(
events=repository.workflow_template_events.list_for_workflow(workflow_id)
)
def _create_event(
repository: object,
*,
workflow_id: UUID,
event_type: str,
current_user: CurrentUser,
payload: dict[str, object],
created_at: datetime,
) -> None:
repository.workflow_template_events.create(
workflow_id=workflow_id,
event_type=event_type,
actor_user_id=current_user.id,
payload=payload,
created_at=created_at,
)
def _require_draft(workflow: WorkflowTemplateSummary) -> None:
if workflow.status != WorkflowTemplateStatus.DRAFT:
raise ValueError("Only draft workflow templates can be mutated.")
def _parts_json(parts: list[object]) -> list[dict[str, object]]:
return [
part.model_dump(mode="json") if hasattr(part, "model_dump") else dict(part)
for part in parts
]
def _now() -> datetime:
return datetime.now(UTC)
@@ -15,6 +15,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from .models import ( from .models import (
AgentJobOutput, AgentJobOutput,
@@ -32,6 +33,22 @@ from .models import (
BoundaryQuestionSummary, BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest, BoundaryQuestionUpdateRequest,
WorkflowEventSummary, WorkflowEventSummary,
ObservabilityTimelineEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
AssetGenerateSpecsResponse, AssetGenerateSpecsResponse,
AssetListResponse, AssetListResponse,
AssetResponse, AssetResponse,
@@ -57,6 +74,11 @@ from .models import (
EvidenceMatrixResponse, EvidenceMatrixResponse,
EvidenceResponse, EvidenceResponse,
EvidenceUpdateRequest, EvidenceUpdateRequest,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
PlanSectionSummary, PlanSectionSummary,
PlanSectionEditRequest, PlanSectionEditRequest,
PlanListResponse, PlanListResponse,
@@ -65,6 +87,10 @@ from .models import (
PlanSummary, PlanSummary,
PlanUpdateRequest, PlanUpdateRequest,
PublishCommitSummary, PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
ResearchArtifactSummary, ResearchArtifactSummary,
@@ -78,9 +104,13 @@ from .models import (
SeoReviewReportResponse, SeoReviewReportResponse,
LanguageReviewRunResponse, LanguageReviewRunResponse,
LanguageReviewReportResponse, LanguageReviewReportResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary, FinalReviewIssueSummary,
FinalReviewIssuesResponse, FinalReviewIssuesResponse,
RunnerFileRef, RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
@@ -118,6 +148,23 @@ __all__ = [
"BoundaryQuestionSummary", "BoundaryQuestionSummary",
"BoundaryQuestionUpdateRequest", "BoundaryQuestionUpdateRequest",
"WorkflowEventSummary", "WorkflowEventSummary",
"ObservabilityTimelineEventSummary",
"WorkflowStageCreateRequest",
"WorkflowStagePart",
"WorkflowStageReorderRequest",
"WorkflowStageResponse",
"WorkflowStageSummary",
"WorkflowStageUpdateRequest",
"WorkflowTemplateAuditEventListResponse",
"WorkflowTemplateAuditEventSummary",
"WorkflowTemplateCreateRequest",
"WorkflowTemplateListResponse",
"WorkflowTemplateResponse",
"WorkflowTemplateSnapshot",
"WorkflowTemplateStageSnapshot",
"WorkflowTemplateStatus",
"WorkflowTemplateSummary",
"WorkflowTemplateUpdateRequest",
"AssetGenerateSpecsResponse", "AssetGenerateSpecsResponse",
"AssetListResponse", "AssetListResponse",
"AssetResponse", "AssetResponse",
@@ -149,6 +196,13 @@ __all__ = [
"EvidenceMatrixResponse", "EvidenceMatrixResponse",
"EvidenceResponse", "EvidenceResponse",
"EvidenceUpdateRequest", "EvidenceUpdateRequest",
"FinalApprovalChecklistItemSummary",
"FinalApprovalChecklistResponse",
"FinalApprovalPublishingSettings",
"FinalApprovalRequest",
"FinalApprovalResponse",
"FinalRevisionRequest",
"FinalRevisionRequestResponse",
"FinalReviewIssueSummary", "FinalReviewIssueSummary",
"FinalReviewIssuesResponse", "FinalReviewIssuesResponse",
"LanguageReviewRunResponse", "LanguageReviewRunResponse",
@@ -161,8 +215,12 @@ __all__ = [
"PlanSectionSummary", "PlanSectionSummary",
"PlanSummary", "PlanSummary",
"PlanUpdateRequest", "PlanUpdateRequest",
"PublishCommitCreateResponse",
"PublishCommitListResponse",
"PublishCommitSummary", "PublishCommitSummary",
"PublishingDryRunResponse",
"PublishingRules", "PublishingRules",
"PublishingStatusResponse",
"PublishingStatus", "PublishingStatus",
"ResearchArtifactManifestSummary", "ResearchArtifactManifestSummary",
"ResearchArtifactSummary", "ResearchArtifactSummary",
@@ -177,6 +235,8 @@ __all__ = [
"ReviewType", "ReviewType",
"Role", "Role",
"RunnerFileRef", "RunnerFileRef",
"ScriptConfigVersionAuditEventListResponse",
"ScriptConfigVersionAuditEventSummary",
"SeoReviewRunResponse", "SeoReviewRunResponse",
"SeoReviewReportResponse", "SeoReviewReportResponse",
"ScriptConfigVersionCreateRequest", "ScriptConfigVersionCreateRequest",
@@ -139,3 +139,9 @@ class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT" DRAFT = "DRAFT"
ACTIVE = "ACTIVE" ACTIVE = "ACTIVE"
DEPRECATED = "DEPRECATED" DEPRECATED = "DEPRECATED"
class WorkflowTemplateStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"
+246
View File
@@ -23,6 +23,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
@@ -141,8 +142,158 @@ class ScriptConfigVersionListResponse(ContractModel):
versions: list[ScriptConfigVersionSummary] versions: list[ScriptConfigVersionSummary]
class ScriptConfigVersionAuditEventSummary(ContractModel):
id: UUID
target_site_id: UUID
version_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
version: int | None = Field(default=None, ge=1)
diff: JsonObject = Field(default_factory=dict)
rollback_target_version_id: UUID | None = None
class ScriptConfigVersionAuditEventListResponse(ContractModel):
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
class WorkflowStagePart(ContractModel):
key: str = Field(min_length=1)
type: str = Field(min_length=1)
title: str = Field(min_length=1)
payload: JsonObject = Field(default_factory=dict)
acceptance_criteria: list[str] = Field(default_factory=list)
class WorkflowStageSummary(ContractModel):
id: UUID
workflow_id: UUID
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int = Field(ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class WorkflowTemplateSummary(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
status: WorkflowTemplateStatus
version: int = Field(ge=1)
created_by: UUID
updated_by: UUID | None = None
created_at: datetime
updated_at: datetime
activated_at: datetime | None = None
archived_at: datetime | None = None
stages: list[WorkflowStageSummary] = Field(default_factory=list)
class WorkflowTemplateCreateRequest(ContractModel):
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
class WorkflowTemplateUpdateRequest(ContractModel):
name: str | None = Field(default=None, min_length=1)
slug: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
class WorkflowStageCreateRequest(ContractModel):
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
class WorkflowStageUpdateRequest(ContractModel):
stable_key: str | None = Field(default=None, min_length=1)
display_name: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role | None = None
runner_profile_key: str | None = Field(default=None, min_length=1)
required_inputs: list[str] | None = None
expected_outputs: list[str] | None = None
acceptance_criteria: list[str] | None = None
requires_human_approval: bool | None = None
retry_policy: JsonObject | None = None
parts: list[WorkflowStagePart] | None = None
class WorkflowStageReorderRequest(ContractModel):
stage_ids: list[UUID] = Field(min_length=1)
class WorkflowTemplateResponse(ContractModel):
workflow: WorkflowTemplateSummary
class WorkflowTemplateListResponse(ContractModel):
workflows: list[WorkflowTemplateSummary] = Field(default_factory=list)
class WorkflowStageResponse(ContractModel):
stage: WorkflowStageSummary
class WorkflowTemplateAuditEventSummary(ContractModel):
id: UUID
workflow_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class WorkflowTemplateAuditEventListResponse(ContractModel):
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
class WorkflowTemplateStageSnapshot(ContractModel):
id: UUID
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
position: int = Field(ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
requires_human_approval: bool = False
class WorkflowTemplateSnapshot(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
version: int = Field(ge=1)
stage_summary: list[WorkflowTemplateStageSnapshot] = Field(default_factory=list)
class ArticleCreateRequest(ContractModel): class ArticleCreateRequest(ContractModel):
target_site_id: UUID target_site_id: UUID
workflow_template_id: UUID | None = None
brief_description: str = Field(min_length=1) brief_description: str = Field(min_length=1)
working_title: str | None = None working_title: str | None = None
language: str = Field(default="en", min_length=2) language: str = Field(default="en", min_length=2)
@@ -154,6 +305,8 @@ class ArticleCreateRequest(ContractModel):
class ArticleSummary(ContractModel): class ArticleSummary(ContractModel):
id: UUID id: UUID
target_site_id: UUID target_site_id: UUID
workflow_template_id: UUID | None = None
workflow_template_version: int | None = Field(default=None, ge=1)
status: ArticleWorkflowStatus status: ArticleWorkflowStatus
publishing_status: PublishingStatus publishing_status: PublishingStatus
brief_description: str brief_description: str
@@ -185,6 +338,25 @@ class WorkflowEventSummary(ContractModel):
created_at: datetime created_at: datetime
class ObservabilityTimelineEventSummary(ContractModel):
id: UUID
article_id: UUID
entry_type: str = Field(min_length=1)
source: str = Field(min_length=1)
event_type: str = Field(min_length=1)
from_status: ArticleWorkflowStatus | None = None
to_status: ArticleWorkflowStatus | None = None
actor_user_id: UUID | None = None
job_id: UUID | None = None
job_type: AgentJobType | None = None
job_status: AgentJobStatus | None = None
retry_eligible: bool = False
cancel_eligible: bool = False
safe_failure_summary: str | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
class BoundaryQuestionSummary(ContractModel): class BoundaryQuestionSummary(ContractModel):
id: UUID id: UUID
article_id: UUID article_id: UUID
@@ -443,6 +615,30 @@ class PublishCommitSummary(ContractModel):
created_at: datetime 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): class RunnerFileRef(ContractModel):
path: str = Field(min_length=1) path: str = Field(min_length=1)
content_hash: str | None = None content_hash: str | None = None
@@ -469,6 +665,10 @@ class AgentJobSummary(ContractModel):
queued_at: datetime queued_at: datetime
started_at: datetime | None = None started_at: datetime | None = None
finished_at: datetime | None = None finished_at: datetime | None = None
retry_eligible: bool = False
retry_block_reason: str | None = None
cancel_eligible: bool = False
safe_failure_summary: str | None = None
class AgentJobTestCodexRequest(ContractModel): class AgentJobTestCodexRequest(ContractModel):
@@ -598,6 +798,50 @@ class FinalReviewIssuesResponse(ContractModel):
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list) 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): class AssetGenerateSpecsResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list) assets: list[AssetSummary] = Field(default_factory=list)
@@ -632,6 +876,7 @@ class AssetUploadResponse(ContractModel):
class ArticleDetailResponse(ContractModel): class ArticleDetailResponse(ContractModel):
article: ArticleSummary article: ArticleSummary
target_site: TargetSiteConfig | None = None target_site: TargetSiteConfig | None = None
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list) workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list) boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
plan: PlanSummary | None = None plan: PlanSummary | None = None
@@ -641,6 +886,7 @@ class ArticleDetailResponse(ContractModel):
assets: list[AssetSummary] = Field(default_factory=list) assets: list[AssetSummary] = Field(default_factory=list)
reviews: list[ReviewSummary] = Field(default_factory=list) reviews: list[ReviewSummary] = Field(default_factory=list)
agent_jobs: list[AgentJobSummary] = Field(default_factory=list) agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
timeline: list[ObservabilityTimelineEventSummary] = Field(default_factory=list)
research_manifests: list[ResearchArtifactManifestSummary] = Field( research_manifests: list[ResearchArtifactManifestSummary] = Field(
default_factory=list default_factory=list
) )
@@ -22,6 +22,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from .models import ( from .models import (
AgentJobOutput, AgentJobOutput,
@@ -38,6 +39,7 @@ from .models import (
BoundaryQuestionResponse, BoundaryQuestionResponse,
BoundaryQuestionSummary, BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest, BoundaryQuestionUpdateRequest,
ObservabilityTimelineEventSummary,
AssetGenerateSpecsResponse, AssetGenerateSpecsResponse,
AssetListResponse, AssetListResponse,
AssetResponse, AssetResponse,
@@ -58,6 +60,11 @@ from .models import (
ContentReviewIssueSummary, ContentReviewIssueSummary,
ContentReviewReportSummary, ContentReviewReportSummary,
ContentReviewSuggestionSummary, ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
EvidenceSummary, EvidenceSummary,
EvidenceCreateRequest, EvidenceCreateRequest,
EvidenceMatrixResponse, EvidenceMatrixResponse,
@@ -70,7 +77,11 @@ from .models import (
PlanRevisionRequest, PlanRevisionRequest,
PlanSummary, PlanSummary,
PlanUpdateRequest, PlanUpdateRequest,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishCommitSummary, PublishCommitSummary,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
ResearchArtifactSummary, ResearchArtifactSummary,
@@ -84,9 +95,13 @@ from .models import (
ReviewSuggestionEditRequest, ReviewSuggestionEditRequest,
ReviewActionResponse, ReviewActionResponse,
ReviewSummary, ReviewSummary,
FinalRevisionRequest,
FinalRevisionRequestResponse,
SeoReviewReportResponse, SeoReviewReportResponse,
SeoReviewRunResponse, SeoReviewRunResponse,
RunnerFileRef, RunnerFileRef,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
@@ -95,6 +110,21 @@ from .models import (
TargetSiteConfigCreateRequest, TargetSiteConfigCreateRequest,
TargetSiteConfigResponse, TargetSiteConfigResponse,
WorkflowEventSummary, WorkflowEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
TargetSiteConfigUpdateRequest, TargetSiteConfigUpdateRequest,
UserSummary, UserSummary,
) )
@@ -117,6 +147,7 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
ReviewType, ReviewType,
ContentReviewKind, ContentReviewKind,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
@@ -132,11 +163,29 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionAuditEventListResponse,
WorkflowStagePart,
WorkflowStageSummary,
WorkflowTemplateSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateUpdateRequest,
WorkflowStageCreateRequest,
WorkflowStageUpdateRequest,
WorkflowStageReorderRequest,
WorkflowTemplateResponse,
WorkflowTemplateListResponse,
WorkflowStageResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSnapshot,
ArticleCreateRequest, ArticleCreateRequest,
ArticleSummary, ArticleSummary,
ArticleCreateResponse, ArticleCreateResponse,
ArticleListResponse, ArticleListResponse,
WorkflowEventSummary, WorkflowEventSummary,
ObservabilityTimelineEventSummary,
BoundaryQuestionSummary, BoundaryQuestionSummary,
BoundaryQuestionUpdateRequest, BoundaryQuestionUpdateRequest,
BoundaryQuestionResponse, BoundaryQuestionResponse,
@@ -167,6 +216,13 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ContentReviewIssueSummary, ContentReviewIssueSummary,
ContentReviewReportSummary, ContentReviewReportSummary,
ContentReviewSuggestionSummary, ContentReviewSuggestionSummary,
FinalApprovalChecklistItemSummary,
FinalApprovalChecklistResponse,
FinalApprovalPublishingSettings,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
FinalReviewIssueSummary, FinalReviewIssueSummary,
FinalReviewIssuesResponse, FinalReviewIssuesResponse,
LanguageReviewReportResponse, LanguageReviewReportResponse,
@@ -186,6 +242,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
SeoReviewReportResponse, SeoReviewReportResponse,
SeoReviewRunResponse, SeoReviewRunResponse,
PublishCommitSummary, PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
RunnerFileRef, RunnerFileRef,
AgentJobSummary, AgentJobSummary,
AgentJobTestCodexRequest, AgentJobTestCodexRequest,
+6
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
USERS_TABLE = "users" USERS_TABLE = "users"
TARGET_SITES_TABLE = "target_sites" TARGET_SITES_TABLE = "target_sites"
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions" SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
WORKFLOW_TEMPLATES_TABLE = "workflow_templates"
WORKFLOW_TEMPLATE_STAGES_TABLE = "workflow_template_stages"
WORKFLOW_TEMPLATE_EVENTS_TABLE = "workflow_template_events"
ARTICLES_TABLE = "articles" ARTICLES_TABLE = "articles"
BOUNDARY_QUESTIONS_TABLE = "boundary_questions" BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
ARTICLE_PLANS_TABLE = "article_plans" ARTICLE_PLANS_TABLE = "article_plans"
@@ -26,6 +29,9 @@ CORE_TABLES: tuple[str, ...] = (
USERS_TABLE, USERS_TABLE,
TARGET_SITES_TABLE, TARGET_SITES_TABLE,
SCRIPT_CONFIG_VERSIONS_TABLE, SCRIPT_CONFIG_VERSIONS_TABLE,
WORKFLOW_TEMPLATES_TABLE,
WORKFLOW_TEMPLATE_STAGES_TABLE,
WORKFLOW_TEMPLATE_EVENTS_TABLE,
ARTICLES_TABLE, ARTICLES_TABLE,
BOUNDARY_QUESTIONS_TABLE, BOUNDARY_QUESTIONS_TABLE,
ARTICLE_PLANS_TABLE, ARTICLE_PLANS_TABLE,
@@ -8,6 +8,9 @@ class ObjectStorageClient:
def put_text(self, *, object_key: str, content: str) -> str: def put_text(self, *, object_key: str, content: str) -> str:
raise NotImplementedError raise NotImplementedError
def get_bytes(self, *, object_key: str) -> bytes:
raise NotImplementedError
def put_bytes( def put_bytes(
self, self,
*, *,
@@ -41,6 +44,10 @@ class LocalObjectStorageClient(ObjectStorageClient):
path.write_bytes(content) path.write_bytes(content)
return f"file://{path}" return f"file://{path}"
def get_bytes(self, *, object_key: str) -> bytes:
path = self.root / object_key
return path.read_bytes()
class S3ObjectStorageClient(ObjectStorageClient): class S3ObjectStorageClient(ObjectStorageClient):
def __init__(self) -> None: def __init__(self) -> None:
@@ -64,6 +71,16 @@ class S3ObjectStorageClient(ObjectStorageClient):
content_type="application/json; charset=utf-8", 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( def put_bytes(
self, self,
*, *,
File diff suppressed because it is too large Load Diff
+141
View File
@@ -17,6 +17,7 @@ from src.domain.contracts import (
PublishingStatus, PublishingStatus,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
@@ -30,6 +31,9 @@ PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingS
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values( SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
status.value for status in ScriptConfigVersionStatus status.value for status in ScriptConfigVersionStatus
) )
WORKFLOW_TEMPLATE_STATUS_VALUES = _quoted_values(
status.value for status in WorkflowTemplateStatus
)
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus) PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values( CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
status.value for status in ClaimSupportStatus status.value for status in ClaimSupportStatus
@@ -94,9 +98,59 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
f""" f"""
CREATE TABLE IF NOT EXISTS workflow_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ({WORKFLOW_TEMPLATE_STATUS_VALUES})),
version INTEGER NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL CHECK (owner_role IN ('ADMIN', 'EDITOR')),
runner_profile_key TEXT NOT NULL,
required_inputs JSONB NOT NULL DEFAULT '[]'::jsonb,
expected_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
acceptance_criteria JSONB NOT NULL DEFAULT '[]'::jsonb,
requires_human_approval BOOLEAN NOT NULL DEFAULT false,
retry_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
parts JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workflow_template_id, stable_key)
)
""",
f"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id),
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS articles ( CREATE TABLE IF NOT EXISTS articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_site_id UUID NOT NULL REFERENCES target_sites(id), target_site_id UUID NOT NULL REFERENCES target_sites(id),
workflow_template_id UUID REFERENCES workflow_templates(id),
workflow_template_version INTEGER,
workflow_template_snapshot JSONB,
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})), status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
publishing_status TEXT NOT NULL CHECK ( publishing_status TEXT NOT NULL CHECK (
publishing_status IN ({PUBLISHING_STATUS_VALUES}) publishing_status IN ({PUBLISHING_STATUS_VALUES})
@@ -111,6 +165,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) )
""", """,
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
""" """
CREATE TABLE IF NOT EXISTS boundary_questions ( CREATE TABLE IF NOT EXISTS boundary_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -400,6 +457,19 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at DESC)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
""" """
CREATE INDEX IF NOT EXISTS idx_articles_dashboard CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at DESC) ON articles (target_site_id, status, publishing_status, updated_at DESC)
@@ -409,6 +479,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON articles (assigned_editor_id, updated_at DESC) ON articles (assigned_editor_id, updated_at DESC)
""", """,
""" """
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at) ON agent_jobs (status, queued_at)
""", """,
@@ -496,9 +570,59 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
""" """
CREATE TABLE IF NOT EXISTS workflow_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL,
version INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TEXT,
archived_at TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id TEXT PRIMARY KEY,
workflow_template_id TEXT NOT NULL,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL,
runner_profile_key TEXT NOT NULL,
required_inputs TEXT NOT NULL DEFAULT '[]',
expected_outputs TEXT NOT NULL DEFAULT '[]',
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
requires_human_approval INTEGER NOT NULL DEFAULT 0,
retry_policy TEXT NOT NULL DEFAULT '{}',
parts TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (workflow_template_id, stable_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id TEXT PRIMARY KEY,
workflow_template_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS articles ( CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL, target_site_id TEXT NOT NULL,
workflow_template_id TEXT,
workflow_template_version INTEGER,
workflow_template_snapshot TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
publishing_status TEXT NOT NULL, publishing_status TEXT NOT NULL,
brief_description TEXT NOT NULL, brief_description TEXT NOT NULL,
@@ -767,6 +891,19 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
""" """
CREATE INDEX IF NOT EXISTS idx_articles_dashboard CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at) ON articles (target_site_id, status, publishing_status, updated_at)
@@ -776,6 +913,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON articles (assigned_editor_id, updated_at) ON articles (assigned_editor_id, updated_at)
""", """,
""" """
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at) ON agent_jobs (status, queued_at)
""", """,
+23
View File
@@ -1,8 +1,10 @@
import os
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from src.domain.contracts.openapi import inject_contract_schemas from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.agent_jobs import ( from src.presentation.routes.agent_jobs import (
@@ -15,12 +17,30 @@ 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.boundary_questions import router as boundary_questions_router
from src.presentation.routes.drafts import router as drafts_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.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.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.reviews import router as reviews_router
from src.presentation.routes.sites import router as sites_router from src.presentation.routes.sites import router as sites_router
from src.presentation.routes.workflow_templates import router as workflow_templates_router
app = FastAPI(title="AI Content Pipeline Backend") app = FastAPI(title="AI Content Pipeline Backend")
_allowed_origins = [
origin.strip()
for origin in os.getenv(
"PIPELINE_CORS_ALLOW_ORIGINS",
"http://localhost:3000,http://localhost:13300",
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(articles_router) app.include_router(articles_router)
app.include_router(assets_router) app.include_router(assets_router)
@@ -29,9 +49,12 @@ app.include_router(plans_router)
app.include_router(evidence_router) app.include_router(evidence_router)
app.include_router(drafts_router) app.include_router(drafts_router)
app.include_router(reviews_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(agent_jobs_router)
app.include_router(internal_agent_jobs_router) app.include_router(internal_agent_jobs_router)
app.include_router(sites_router) app.include_router(sites_router)
app.include_router(workflow_templates_router)
@app.get("/health") @app.get("/health")
@@ -81,23 +81,33 @@ def get_agent_job_route(
) )
def post_retry_agent_job( def post_retry_agent_job(
job_id: UUID, job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)), current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository), repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse: ) -> AgentJobResponse:
try: try:
return retry_agent_job(repository, job_id) return retry_agent_job(
repository,
job_id,
actor_user_id=current_user.id,
)
except LookupError as error: except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except PermissionError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse) @router.post("/agent-jobs/{job_id}/cancel", response_model=AgentJobResponse)
def post_cancel_agent_job( def post_cancel_agent_job(
job_id: UUID, job_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)), current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository), repository: BackendRepository = Depends(get_repository),
) -> AgentJobResponse: ) -> AgentJobResponse:
try: try:
return cancel_agent_job(repository, job_id) return cancel_agent_job(
repository,
job_id,
actor_user_id=current_user.id,
)
except LookupError as error: except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -45,6 +45,11 @@ def post_article(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid target_site_id", detail="Invalid target_site_id",
) from error ) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error
@router.get("/articles", response_model=ArticleListResponse) @router.get("/articles", response_model=ArticleListResponse)
@@ -61,11 +66,15 @@ def list_articles_route(
) )
def get_article( def get_article(
article_id: UUID, article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)), current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository), repository: BackendRepository = Depends(get_repository),
) -> ArticleDetailResponse: ) -> ArticleDetailResponse:
try: try:
return get_article_detail(repository=repository, article_id=article_id) return get_article_detail(
repository=repository,
article_id=article_id,
current_user=current_user,
)
except LookupError as error: except LookupError as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -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_script_config_version,
create_target_site, create_target_site,
get_target_site, get_target_site,
list_script_config_audit_events,
list_script_config_versions, list_script_config_versions,
rollback_script_config_version, rollback_script_config_version,
update_target_site, update_target_site,
@@ -16,6 +17,7 @@ from src.application.site_config import (
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import ( from src.domain.contracts import (
CurrentUser, CurrentUser,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
@@ -92,6 +94,21 @@ def get_script_config_versions(
return ScriptConfigVersionListResponse(versions=versions) return ScriptConfigVersionListResponse(versions=versions)
@router.get(
"/sites/{site_id}/publishing-config/audit",
response_model=ScriptConfigVersionAuditEventListResponse,
)
def get_script_config_audit_events(
site_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> ScriptConfigVersionAuditEventListResponse:
try:
return list_script_config_audit_events(repository, site_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.post( @router.post(
"/sites/{site_id}/publishing-config/versions", "/sites/{site_id}/publishing-config/versions",
response_model=ScriptConfigVersionResponse, response_model=ScriptConfigVersionResponse,
@@ -0,0 +1,257 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.workflow_templates import (
activate_workflow_template,
add_workflow_stage,
archive_workflow_template,
create_workflow_template,
delete_workflow_stage,
get_workflow_template,
list_workflow_template_audit_events,
list_workflow_templates,
reorder_workflow_stages,
update_workflow_stage,
update_workflow_template,
)
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["workflow_templates"])
@router.post(
"/admin/workflows",
response_model=WorkflowTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_template(
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
return create_workflow_template(
repository,
request=request,
current_user=current_user,
)
@router.get("/admin/workflows", response_model=WorkflowTemplateListResponse)
def get_workflow_templates(
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateListResponse:
return list_workflow_templates(repository, current_user=current_user)
@router.get(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def get_workflow_template_detail(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return get_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def patch_workflow_template(
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return update_workflow_template(
repository,
workflow_id=workflow_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/stages",
response_model=WorkflowStageResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_stage(
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return add_workflow_stage(
repository,
workflow_id=workflow_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.patch(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowStageResponse,
)
def patch_workflow_stage(
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return update_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.delete(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowTemplateResponse,
)
def delete_workflow_template_stage(
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return delete_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/stages/reorder",
response_model=WorkflowTemplateResponse,
)
def post_reorder_workflow_stages(
workflow_id: UUID,
request: WorkflowStageReorderRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return reorder_workflow_stages(
repository,
workflow_id=workflow_id,
stage_ids=request.stage_ids,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/activate",
response_model=WorkflowTemplateResponse,
)
def post_activate_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return activate_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/archive",
response_model=WorkflowTemplateResponse,
)
def post_archive_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return archive_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/admin/workflows/{workflow_id}/audit",
response_model=WorkflowTemplateAuditEventListResponse,
)
def get_workflow_template_audit_events(
workflow_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateAuditEventListResponse:
try:
return list_workflow_template_audit_events(
repository,
workflow_id=workflow_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,391 @@
from __future__ import annotations
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_ADMIN_EMAIL = "admin@example.com"
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.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()
self.tmp_dir.cleanup()
def test_admin_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
create_response = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-v1"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
self.assertEqual("article-production-v1", workflow["slug"])
self.assertEqual("DRAFT", workflow["status"])
self.assertEqual(1, workflow["version"])
self.assertEqual([], workflow["stages"])
self.assertTrue(workflow["created_at"])
self.assertTrue(workflow["updated_at"])
intake_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
)
self.assertEqual(201, intake_response.status_code, intake_response.text)
intake_stage = intake_response.json()["stage"]
draft_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
)
self.assertEqual(201, draft_response.status_code, draft_response.text)
draft_stage = draft_response.json()["stage"]
edited_draft_parts = [
{
"key": "draft-outline",
"type": "outline",
"title": "Edited draft outline",
"payload": {
"prompt": "Build a sourced outline before drafting.",
"config": {
"minimum_sections": 5,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
"Claims that need evidence are marked.",
],
},
{
"key": "draft-body",
"type": "generation",
"title": "Draft body",
"payload": {
"prompt": "Generate a complete longform draft.",
"config": {
"tone": "practical",
"include_evidence_markers": True,
},
},
"acceptance_criteria": [
"Draft includes all approved outline sections.",
"Unsupported claims remain marked for review.",
],
},
]
patch_response = self.client.patch(
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
headers=admin_headers,
json={
"display_name": "Draft and evidence assembly",
"description": "Create an evidence-aware article draft.",
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v2",
"required_inputs": [
"approved_plan",
"evidence_matrix",
],
"expected_outputs": [
"article_draft",
"claim_evidence_map",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
"Evidence markers are preserved.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 120,
},
"parts": edited_draft_parts,
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
detail_response = self.client.get(
f"/api/admin/workflows/{workflow['id']}",
headers=admin_headers,
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()["workflow"]
stages = detail["stages"]
self.assertEqual([1, 2], [stage["position"] for stage in stages])
self.assertEqual(
["intake-boundary-questions", "draft-assembly"],
[stage["stable_key"] for stage in stages],
)
self.assertEqual(intake_stage["id"], stages[0]["id"])
self.assertEqual(draft_stage["id"], stages[1]["id"])
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
self.assertTrue(stages[1]["requires_human_approval"])
self.assertEqual(
{
"max_attempts": 2,
"backoff_seconds": 120,
},
stages[1]["retry_policy"],
)
self.assertEqual(edited_draft_parts, stages[1]["parts"])
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
self,
) -> None:
response = self.client.post(
"/api/admin/workflows",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._workflow_payload("editor-denied-workflow"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
workflow = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-ops"),
).json()["workflow"]
workflow_id = workflow["id"]
intake_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
).json()["stage"]
draft_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
).json()["stage"]
reorder_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages/reorder",
headers=admin_headers,
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
)
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
self.assertEqual(
["draft-assembly", "intake-boundary-questions"],
[
stage["stable_key"]
for stage in reorder_response.json()["workflow"]["stages"]
],
)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=admin_headers,
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
activated = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", activated["status"])
self.assertEqual(2, activated["version"])
editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
self.assertIn(
workflow_id,
[item["id"] for item in editor_list_response.json()["workflows"]],
)
editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
immutable_patch_response = self.client.patch(
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
headers=admin_headers,
json={"display_name": "Should not mutate active template"},
)
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
archive_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/archive",
headers=admin_headers,
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
archived_editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(
200,
archived_editor_list_response.status_code,
archived_editor_list_response.text,
)
self.assertNotIn(
workflow_id,
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
)
archived_editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(
404,
archived_editor_detail_response.status_code,
archived_editor_detail_response.text,
)
audit_response = self.client.get(
f"/api/admin/workflows/{workflow_id}/audit",
headers=admin_headers,
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
event_types = {
event["event_type"] for event in audit_response.json()["events"]
}
self.assertTrue(
{
"WORKFLOW_TEMPLATE_CREATED",
"WORKFLOW_STAGE_CREATED",
"WORKFLOW_STAGES_REORDERED",
"WORKFLOW_TEMPLATE_ACTIVATED",
"WORKFLOW_TEMPLATE_ARCHIVED",
}.issubset(event_types)
)
def _workflow_payload(self, slug: str) -> dict[str, str]:
return {
"name": "Article production workflow",
"slug": slug,
"description": "Reusable editorial workflow for longform article production.",
}
def _intake_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before planning.",
"position": 1,
"owner_role": "EDITOR",
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": [
"article_brief",
"target_site",
],
"expected_outputs": [
"answered_boundary_questions",
],
"acceptance_criteria": [
"All required boundary questions are answered.",
"Answers are tied to the source brief.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 1,
"backoff_seconds": 0,
},
"parts": [
{
"key": "required-context-checklist",
"type": "checklist",
"title": "Required context checklist",
"payload": {
"prompt": "Identify missing audience, keyword, and source constraints.",
"config": {
"required_fields": [
"audience",
"primary_keyword",
"source_rules",
],
"block_on_missing": True,
},
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
}
],
}
def _draft_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Generate a first draft from the approved plan.",
"position": 2,
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v1",
"required_inputs": [
"approved_plan",
],
"expected_outputs": [
"article_draft",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
],
"requires_human_approval": False,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 60,
},
"parts": [
{
"key": "draft-outline",
"type": "outline",
"title": "Draft outline",
"payload": {
"prompt": "Build a concise outline before drafting.",
"config": {
"minimum_sections": 4,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
if __name__ == "__main__":
unittest.main()
@@ -316,8 +316,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
) )
article_id = article_response.json()["article"]["id"] 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)
for question in questions_response.json()["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_id = plan_response.json()["plan"]["id"]
review_response = self.client.post( review_response = self.client.post(
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve", f"/api/articles/{article_id}/plans/{plan_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
) )
@@ -0,0 +1,56 @@
from __future__ import annotations
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
class DemoBrowserCorsPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.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()
self.tmp_dir.cleanup()
def test_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None:
response = self.client.options(
"/api/admin/workflows",
headers={
"Origin": "http://localhost:13300",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-Demo-User-Email",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(
"http://localhost:13300",
response.headers.get("access-control-allow-origin"),
)
self.assertIn(
"X-Demo-User-Email",
response.headers.get("access-control-allow-headers", ""),
)
if __name__ == "__main__":
unittest.main()
@@ -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, activate: bool = False,
rollback_target_version_id: UUID | None = None, rollback_target_version_id: UUID | None = None,
) -> ScriptConfigVersionCreateRequest: ) -> ScriptConfigVersionCreateRequest:
from src.domain.contracts import ScriptConfigVersionCreateRequest
payload = ScriptConfigVersionCreateRequest( payload = ScriptConfigVersionCreateRequest(
publishing_yaml="target:\n repo: audit\n", publishing_yaml="target:\n repo: audit\n",
transform_script="export function transformArticle(article) { return article; }", transform_script="export function transformArticle(article) { return article; }",
@@ -0,0 +1,288 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import uuid4
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_ADMIN_EMAIL = "admin@example.com"
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class WorkflowTemplateBindingPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-template-binding.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()
self.tmp_dir.cleanup()
def test_article_creation_stores_active_workflow_template_snapshot(self) -> None:
workflow = self._create_active_workflow("article-binding-active")
target_site = self._first_target_site()
create_response = self.client.post(
"/api/articles",
headers=self._editor_headers(),
json={
"target_site_id": target_site["id"],
"workflow_template_id": workflow["id"],
"brief_description": (
"Create an article that is governed by an Admin-managed "
f"workflow template {uuid4()}."
),
"working_title": "Workflow-bound article",
"content_type": "longform_guide",
"primary_keyword": "workflow template binding",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
article = create_response.json()["article"]
self.assertEqual(workflow["id"], article["workflow_template_id"])
self.assertEqual(workflow["version"], article["workflow_template_version"])
archive_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/archive",
headers=self._admin_headers(),
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
detail_response = self.client.get(
f"/api/articles/{article['id']}",
headers=self._editor_headers(),
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()
expected_stage_summary = [
{
"id": stage["id"],
"stable_key": stage["stable_key"],
"display_name": stage["display_name"],
"position": stage["position"],
"owner_role": stage["owner_role"],
"runner_profile_key": stage["runner_profile_key"],
"requires_human_approval": stage["requires_human_approval"],
}
for stage in workflow["stages"]
]
self.assertEqual(
{
"id": workflow["id"],
"name": workflow["name"],
"slug": workflow["slug"],
"version": workflow["version"],
"stage_summary": expected_stage_summary,
},
detail["workflow_template_snapshot"],
)
def test_article_creation_rejects_archived_workflow_template_reference(self) -> None:
workflow = self._create_active_workflow("article-binding-archived")
archive_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/archive",
headers=self._admin_headers(),
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
response = self._create_article_with_workflow_template(workflow["id"])
self.assertEqual(400, response.status_code, response.text)
self.assertEqual("Workflow template must be active", response.json()["detail"])
def test_article_creation_rejects_missing_workflow_template_reference(self) -> None:
response = self._create_article_with_workflow_template(str(uuid4()))
self.assertEqual(400, response.status_code, response.text)
self.assertEqual("Invalid workflow_template_id", response.json()["detail"])
def test_seeded_active_workflow_template_is_available_for_editor_articles(self) -> None:
workflows_response = self.client.get(
"/api/admin/workflows",
headers=self._editor_headers(),
)
self.assertEqual(200, workflows_response.status_code, workflows_response.text)
seeded_workflow = next(
workflow
for workflow in workflows_response.json()["workflows"]
if workflow["slug"] == "demo-article-production"
)
self.assertEqual("ACTIVE", seeded_workflow["status"])
self.assertGreaterEqual(len(seeded_workflow["stages"]), 7)
create_response = self._create_article_with_workflow_template(
seeded_workflow["id"]
)
self.assertEqual(201, create_response.status_code, create_response.text)
article = create_response.json()["article"]
self.assertEqual(seeded_workflow["id"], article["workflow_template_id"])
self.assertEqual(
seeded_workflow["version"],
article["workflow_template_version"],
)
def _create_article_with_workflow_template(self, workflow_template_id: str) -> Any:
target_site = self._first_target_site()
return self.client.post(
"/api/articles",
headers=self._editor_headers(),
json={
"target_site_id": target_site["id"],
"workflow_template_id": workflow_template_id,
"brief_description": (
"Create an article using the selected workflow template "
f"{uuid4()}."
),
"content_type": "longform_guide",
"primary_keyword": "workflow binding guardrail",
},
)
def _create_active_workflow(self, slug_prefix: str) -> dict[str, Any]:
create_response = self.client.post(
"/api/admin/workflows",
headers=self._admin_headers(),
json={
"name": "Article production workflow",
"slug": f"{slug_prefix}-{uuid4()}",
"description": "Reusable editorial workflow for article production.",
},
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
workflow_id = workflow["id"]
for stage_payload in (
self._intake_stage_payload(),
self._draft_stage_payload(),
):
stage_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=self._admin_headers(),
json=stage_payload,
)
self.assertEqual(201, stage_response.status_code, stage_response.text)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=self._admin_headers(),
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
workflow = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", workflow["status"])
self.assertEqual(2, workflow["version"])
return workflow
def _first_target_site(self) -> dict[str, Any]:
response = self.client.get("/api/sites", headers=self._editor_headers())
self.assertEqual(200, response.status_code, response.text)
return response.json()[0]["site"]
def _intake_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before planning.",
"position": 1,
"owner_role": "EDITOR",
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": [
"article_brief",
"target_site",
],
"expected_outputs": [
"answered_boundary_questions",
],
"acceptance_criteria": [
"All required boundary questions are answered.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 1,
"backoff_seconds": 0,
},
"parts": [
{
"key": "required-context-checklist",
"type": "checklist",
"title": "Required context checklist",
"payload": {
"prompt": "Identify missing audience and source constraints.",
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
],
}
],
}
def _draft_stage_payload(self) -> dict[str, object]:
return {
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Generate a first draft from the approved plan.",
"position": 2,
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v1",
"required_inputs": [
"approved_plan",
],
"expected_outputs": [
"article_draft",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
],
"requires_human_approval": False,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 60,
},
"parts": [
{
"key": "draft-outline",
"type": "outline",
"title": "Draft outline",
"payload": {
"prompt": "Build a concise outline before drafting.",
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
def _admin_headers(self) -> dict[str, str]:
return {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
def _editor_headers(self) -> dict[str, str]:
return {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -1,6 +1,24 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
outputFileTracingRoot: repoRoot,
async rewrites() {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
turbopack: {
root: repoRoot,
},
}; };
export default nextConfig; export default nextConfig;
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test:roles": "node tests/role-navigation.test.mjs", "test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs", "test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/admin_workflows.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminJobsPage from "@/pages/admin-jobs"; import AdminJobsPage from "@/pages/admin-jobs";
export default AdminJobsPage; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminJobsPage />;
}
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminScriptsPage from "@/pages/admin-scripts"; import AdminScriptsPage from "@/pages/admin-scripts";
export { AdminScriptsPage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminScriptsPage />;
}
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminSitesPage from "@/pages/admin-sites"; import AdminSitesPage from "@/pages/admin-sites";
export default AdminSitesPage; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminSitesPage />;
}
@@ -0,0 +1,7 @@
import AdminWorkflowsPage from "@/pages/admin-workflows";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminWorkflowsPage />;
}
@@ -1,14 +1,16 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions"; import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
export const dynamic = "force-dynamic";
type DynamicArticleBoundaryQuestionsPageProps = { type DynamicArticleBoundaryQuestionsPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleBoundaryQuestionsPage({ export default async function DynamicArticleBoundaryQuestionsPage({
params, params,
}: DynamicArticleBoundaryQuestionsPageProps) { }: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />; return <ArticleBoundaryQuestionsPage articleId={articleId} />;
} }
@@ -1,14 +1,16 @@
import ArticleEvidencePage from "@/pages/article-evidence"; import ArticleEvidencePage from "@/pages/article-evidence";
export const dynamic = "force-dynamic";
type DynamicArticleEvidencePageProps = { type DynamicArticleEvidencePageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleEvidencePage({ export default async function DynamicArticleEvidencePage({
params, params,
}: DynamicArticleEvidencePageProps) { }: DynamicArticleEvidencePageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleEvidencePage articleId={articleId} />; return <ArticleEvidencePage articleId={articleId} />;
} }
@@ -1,14 +1,22 @@
import ArticleDetailPage from "@/pages/article-detail"; import ArticleDetailPage from "@/pages/article-detail";
export const dynamic = "force-dynamic";
type DynamicArticleDetailPageProps = { type DynamicArticleDetailPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
searchParams?: Promise<{
role?: string;
}>;
}; };
export default async function DynamicArticleDetailPage({ export default async function DynamicArticleDetailPage({
params, params,
searchParams,
}: DynamicArticleDetailPageProps) { }: DynamicArticleDetailPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleDetailPage articleId={articleId} />; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
} }
@@ -1,14 +1,16 @@
import ArticlePlansPage from "@/pages/article-plans"; import ArticlePlansPage from "@/pages/article-plans";
export const dynamic = "force-dynamic";
type DynamicArticlePlansPageProps = { type DynamicArticlePlansPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticlePlansPage({ export default async function DynamicArticlePlansPage({
params, params,
}: DynamicArticlePlansPageProps) { }: DynamicArticlePlansPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticlePlansPage articleId={articleId} />; return <ArticlePlansPage articleId={articleId} />;
} }
@@ -1,14 +1,16 @@
import ArticleResearchPage from "@/pages/article-research"; import ArticleResearchPage from "@/pages/article-research";
export const dynamic = "force-dynamic";
type DynamicArticleResearchPageProps = { type DynamicArticleResearchPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleResearchPage({ export default async function DynamicArticleResearchPage({
params, params,
}: DynamicArticleResearchPageProps) { }: DynamicArticleResearchPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleResearchPage articleId={articleId} />; return <ArticleResearchPage articleId={articleId} />;
} }
+5 -1
View File
@@ -1,3 +1,7 @@
import NewArticlePage from "@/pages/article-new"; import NewArticlePage from "@/pages/article-new";
export { NewArticlePage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <NewArticlePage />;
}
+24
View File
@@ -161,6 +161,12 @@ main {
font-size: 12px; font-size: 12px;
} }
.fieldHint {
margin: 0;
color: #475467;
font-size: 12px;
}
.formSuccess { .formSuccess {
margin: 0; margin: 0;
color: #027a48; color: #027a48;
@@ -344,6 +350,24 @@ main {
padding: 10px 0; padding: 10px 0;
} }
.timelineMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.timelineSource {
border: 1px solid #d0d5dd;
border-radius: 6px;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
background: #ffffff;
font-size: 12px;
}
.draftEditor { .draftEditor {
margin-top: 28px; margin-top: 28px;
} }
+2
View File
@@ -6,6 +6,8 @@ export const metadata = {
description: "Internal editorial workflow foundation", description: "Internal editorial workflow foundation",
}; };
export const dynamic = "force-dynamic";
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
+5 -1
View File
@@ -1,3 +1,7 @@
import DashboardPage from "@/pages/dashboard"; import DashboardPage from "@/pages/dashboard";
export { DashboardPage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <DashboardPage />;
}
@@ -1,4 +1,5 @@
import type { import type {
ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionSummary, ScriptConfigVersionSummary,
} from "@pipeline/shared"; } from "@pipeline/shared";
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
rollbackTargetVersionId: string; rollbackTargetVersionId: string;
}; };
export type AdminScriptAuditRow = {
id: string;
eventType: string;
version: number | null;
createdAt: string;
actorUserId: string | null;
diff: string;
rollbackTargetVersionId: string | null;
};
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>; export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}'; const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
@@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record<string, unknown> | null {
} }
return null; return null;
} }
export function buildAdminScriptAuditRows(
events: readonly ScriptConfigVersionAuditEventSummary[],
): AdminScriptAuditRow[] {
return events
.map((event) => ({
id: event.id,
eventType: event.event_type,
version: event.version ?? null,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
diff: JSON.stringify(event.diff ?? {}),
rollbackTargetVersionId: event.rollback_target_version_id ?? null,
}))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
@@ -2,10 +2,11 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared"; import type { ScriptConfigVersionAuditEventSummary, ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api"; import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
import { import {
buildAdminScriptAuditRows,
buildAdminScriptVersionRows, buildAdminScriptVersionRows,
buildCreateScriptVersionPayload, buildCreateScriptVersionPayload,
defaultScriptVersionDraft, defaultScriptVersionDraft,
@@ -17,6 +18,7 @@ import {
type AdminScriptVersionsPanelProps = { type AdminScriptVersionsPanelProps = {
site: TargetSiteConfig; site: TargetSiteConfig;
initialVersions: readonly ScriptConfigVersionSummary[]; initialVersions: readonly ScriptConfigVersionSummary[];
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
}; };
function parseOptionalError(value: string): string | null { function parseOptionalError(value: string): string | null {
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
export function AdminScriptVersionsPanel({ export function AdminScriptVersionsPanel({
site, site,
initialVersions, initialVersions,
initialAuditEvents,
}: AdminScriptVersionsPanelProps) { }: AdminScriptVersionsPanelProps) {
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>( const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
initialVersions, initialVersions,
); );
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
initialAuditEvents,
);
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>( const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
defaultScriptVersionDraft(), defaultScriptVersionDraft(),
); );
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
buildAdminScriptVersionRows(versions), buildAdminScriptVersionRows(versions),
[versions], [versions],
); );
const auditRows = useMemo(
() => buildAdminScriptAuditRows(auditEvents),
[auditEvents],
);
const hasVersions = rows.length > 0; const hasVersions = rows.length > 0;
const canSubmit = !isSubmitting; const canSubmit = !isSubmitting;
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id); const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
async function refreshVersions() { async function refreshVersions() {
const latest = await fetchScriptConfigVersions( const [latest, audit] = await Promise.all([
fetchScriptConfigVersions(
site.id, site.id,
DEMO_ADMIN_EMAIL, DEMO_ADMIN_EMAIL,
); ),
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
]);
setVersions(latest.versions); setVersions(latest.versions);
setAuditEvents(audit.events ?? []);
} }
function updateField<K extends keyof NewAdminScriptVersionDraft>( function updateField<K extends keyof NewAdminScriptVersionDraft>(
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
</tbody> </tbody>
</table> </table>
)} )}
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Version</th>
<th>Created</th>
<th>Actor</th>
<th>Diff</th>
<th>Rollback target</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{event.version ?? "—"}</td>
<td>{new Date(event.createdAt).toLocaleString()}</td>
<td>{event.actorUserId ?? "—"}</td>
<td>{event.diff}</td>
<td>{event.rollbackTargetVersionId ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</section> </section>
); );
} }
@@ -0,0 +1,363 @@
import type {
Role,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared";
export type WorkflowDraft = {
name: string;
slug: string;
description: string;
};
export type WorkflowStageDraft = {
stableKey: string;
displayName: string;
description: string;
position: number;
ownerRole: Role;
runnerProfileKey: string;
requiredInputs: string[];
expectedOutputs: string[];
acceptanceCriteria: string[];
requiresHumanApproval: boolean;
retryPolicy: Record<string, unknown>;
parts: WorkflowStagePart[];
};
export type WorkflowStageFormPart = {
key: string;
type: string;
title: string;
payloadJson: string;
acceptanceCriteriaText: string;
};
export type WorkflowStageFormDraft = Omit<WorkflowStageDraft, "parts"> & {
requiredInputsText: string;
expectedOutputsText: string;
acceptanceCriteriaText: string;
retryPolicyJson: string;
parts: WorkflowStageFormPart[];
};
export type WorkflowStagePositionItem = {
id: string;
position: number;
};
export type AdminWorkflowRow = {
id: string;
name: string;
slug: string;
status: string;
version: number;
updatedAt: string;
stageCount: number;
isDraft: boolean;
isActive: boolean;
isArchived: boolean;
};
export type AdminWorkflowAuditRow = {
id: string;
eventType: string;
createdAt: string;
actorUserId: string | null;
payload: string;
};
export type StageDraftValidationErrors = Partial<
Record<
| "stableKey"
| "displayName"
| "description"
| "runnerProfileKey"
| "retryPolicyJson"
| "parts",
string
>
>;
export function defaultWorkflowDraft(): WorkflowDraft {
return {
name: "",
slug: "",
description: "",
};
}
export function buildCreateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateCreateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function buildUpdateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateUpdateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function defaultWorkflowStagePart(): WorkflowStagePart {
return {
key: "stage-part",
type: "instruction",
title: "Stage instruction",
payload: {
prompt: "",
config: {},
},
acceptance_criteria: [],
};
}
export function defaultWorkflowStageDraft(position = 1): WorkflowStageDraft {
return {
stableKey: "",
displayName: "",
description: "",
position,
ownerRole: "EDITOR",
runnerProfileKey: "",
requiredInputs: [],
expectedOutputs: [],
acceptanceCriteria: [],
requiresHumanApproval: false,
retryPolicy: {
max_attempts: 1,
backoff_seconds: 0,
},
parts: [],
};
}
export function buildCreateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageCreateRequest {
return {
stable_key: draft.stableKey,
display_name: draft.displayName,
description: draft.description,
position: draft.position,
owner_role: draft.ownerRole,
runner_profile_key: draft.runnerProfileKey,
required_inputs: draft.requiredInputs,
expected_outputs: draft.expectedOutputs,
acceptance_criteria: draft.acceptanceCriteria,
requires_human_approval: draft.requiresHumanApproval,
retry_policy: draft.retryPolicy,
parts: draft.parts,
};
}
export function buildUpdateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageUpdateRequest {
return buildCreateWorkflowStagePayload(draft);
}
export function moveWorkflowStage<TStage extends WorkflowStagePositionItem>(
stages: readonly TStage[],
stageId: string,
targetIndex: number,
): TStage[] {
const currentIndex = stages.findIndex((stage) => stage.id === stageId);
if (currentIndex === -1) {
return stages.map((stage, index) => ({
...stage,
position: index + 1,
}));
}
const nextStages = [...stages];
const [stage] = nextStages.splice(currentIndex, 1);
const clampedIndex = Math.max(0, Math.min(targetIndex, nextStages.length));
nextStages.splice(clampedIndex, 0, stage);
return nextStages.map((nextStage, index) => ({
...nextStage,
position: index + 1,
}));
}
export function buildReorderWorkflowStagesPayload(
stages: readonly WorkflowStagePositionItem[],
): WorkflowStageReorderRequest {
return {
stage_ids: stages.map((stage) => stage.id),
};
}
export function buildAdminWorkflowRows(
workflows: readonly WorkflowTemplateSummary[],
): AdminWorkflowRow[] {
return workflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
slug: workflow.slug,
status: workflow.status,
version: workflow.version,
updatedAt: workflow.updated_at,
stageCount: workflow.stages?.length ?? 0,
isDraft: workflow.status === "DRAFT",
isActive: workflow.status === "ACTIVE",
isArchived: workflow.status === "ARCHIVED",
}));
}
export function buildAdminWorkflowAuditRows(
events: readonly WorkflowTemplateAuditEventSummary[],
): AdminWorkflowAuditRow[] {
return events.map((event) => ({
id: event.id,
eventType: event.event_type,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
payload: JSON.stringify(event.payload ?? {}),
}));
}
export function workflowDraftFromSummary(
workflow: WorkflowTemplateSummary,
): WorkflowDraft {
return {
name: workflow.name,
slug: workflow.slug,
description: workflow.description,
};
}
export function workflowStageDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageDraft {
return {
stableKey: stage.stable_key,
displayName: stage.display_name,
description: stage.description,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiredInputs: stage.required_inputs ?? [],
expectedOutputs: stage.expected_outputs ?? [],
acceptanceCriteria: stage.acceptance_criteria ?? [],
requiresHumanApproval: stage.requires_human_approval ?? false,
retryPolicy: stage.retry_policy ?? {},
parts: stage.parts ?? [],
};
}
export function workflowStageFormDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageFormDraft {
const draft = workflowStageDraftFromSummary(stage);
return workflowStageDraftToFormDraft(draft);
}
export function workflowStageDraftToFormDraft(
draft: WorkflowStageDraft,
): WorkflowStageFormDraft {
return {
...draft,
requiredInputsText: draft.requiredInputs.join("\n"),
expectedOutputsText: draft.expectedOutputs.join("\n"),
acceptanceCriteriaText: draft.acceptanceCriteria.join("\n"),
retryPolicyJson: JSON.stringify(draft.retryPolicy, null, 2),
parts: draft.parts.map((part) => ({
key: part.key,
type: part.type,
title: part.title,
payloadJson: JSON.stringify(part.payload ?? {}, null, 2),
acceptanceCriteriaText: (part.acceptance_criteria ?? []).join("\n"),
})),
};
}
export function defaultWorkflowStageFormDraft(
position = 1,
): WorkflowStageFormDraft {
return workflowStageDraftToFormDraft(defaultWorkflowStageDraft(position));
}
export function workflowStageFormDraftToStageDraft(
draft: WorkflowStageFormDraft,
): WorkflowStageDraft | null {
const retryPolicy = parseJsonObject(draft.retryPolicyJson);
if (retryPolicy === null) {
return null;
}
const parts: WorkflowStagePart[] = [];
for (const part of draft.parts) {
const payload = parseJsonObject(part.payloadJson);
if (payload === null) {
return null;
}
parts.push({
key: part.key,
type: part.type,
title: part.title,
payload,
acceptance_criteria: splitLines(part.acceptanceCriteriaText),
});
}
return {
stableKey: draft.stableKey,
displayName: draft.displayName,
description: draft.description,
position: draft.position,
ownerRole: draft.ownerRole,
runnerProfileKey: draft.runnerProfileKey,
requiredInputs: splitLines(draft.requiredInputsText),
expectedOutputs: splitLines(draft.expectedOutputsText),
acceptanceCriteria: splitLines(draft.acceptanceCriteriaText),
requiresHumanApproval: draft.requiresHumanApproval,
retryPolicy,
parts,
};
}
export function splitLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function parseJsonObject(value: string): Record<string, unknown> | null {
if (!value.trim()) {
return {};
}
try {
const parsed = JSON.parse(value);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
parsed !== null
) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
@@ -0,0 +1,976 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import type {
Role,
WorkflowStageSummary,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import {
ApiError,
DEMO_ADMIN_EMAIL,
activateWorkflowTemplate,
addWorkflowStage,
archiveWorkflowTemplate,
createWorkflowTemplate,
deleteWorkflowStage,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
reorderWorkflowStages,
updateWorkflowStage,
updateWorkflowTemplate,
} from "@/shared/pipeline-api";
import {
buildAdminWorkflowAuditRows,
buildAdminWorkflowRows,
buildCreateWorkflowPayload,
buildCreateWorkflowStagePayload,
buildReorderWorkflowStagesPayload,
buildUpdateWorkflowPayload,
buildUpdateWorkflowStagePayload,
defaultWorkflowDraft,
defaultWorkflowStageFormDraft,
moveWorkflowStage,
parseJsonObject,
workflowDraftFromSummary,
workflowStageFormDraftFromSummary,
workflowStageFormDraftToStageDraft,
type StageDraftValidationErrors,
type WorkflowDraft,
type WorkflowStageFormDraft,
type WorkflowStageFormPart,
} from "./model";
type AdminWorkflowBuilderProps = {
initialWorkflows: readonly WorkflowTemplateSummary[];
initialWorkflow?: WorkflowTemplateSummary | null;
initialAuditEvents?: readonly WorkflowTemplateAuditEventSummary[];
};
const EMPTY_AUDIT_EVENTS: readonly WorkflowTemplateAuditEventSummary[] = [];
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
function sortedStages(
stages: readonly WorkflowStageSummary[] | undefined,
): WorkflowStageSummary[] {
return [...(stages ?? [])].sort((left, right) => left.position - right.position);
}
function createDefaultPart(index: number): WorkflowStageFormPart {
return {
key: `stage-part-${index}`,
type: "instruction",
title: "Stage instruction",
payloadJson: JSON.stringify({ prompt: "", config: {} }, null, 2),
acceptanceCriteriaText: "",
};
}
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.message || fallback;
}
return fallback;
}
export function AdminWorkflowBuilder({
initialWorkflows,
initialWorkflow = null,
initialAuditEvents = EMPTY_AUDIT_EVENTS,
}: AdminWorkflowBuilderProps) {
const [workflows, setWorkflows] = useState<readonly WorkflowTemplateSummary[]>(
initialWorkflows,
);
const [selectedWorkflow, setSelectedWorkflow] =
useState<WorkflowTemplateSummary | null>(initialWorkflow);
const [auditEvents, setAuditEvents] =
useState<readonly WorkflowTemplateAuditEventSummary[]>(initialAuditEvents);
const [createDraft, setCreateDraft] = useState<WorkflowDraft>(
defaultWorkflowDraft(),
);
const [metadataDraft, setMetadataDraft] = useState<WorkflowDraft>(
initialWorkflow ? workflowDraftFromSummary(initialWorkflow) : defaultWorkflowDraft(),
);
const [editingStageId, setEditingStageId] = useState<string | null>(null);
const [stageDraft, setStageDraft] = useState<WorkflowStageFormDraft>(
defaultWorkflowStageFormDraft(sortedStages(initialWorkflow?.stages).length + 1),
);
const [stageErrors, setStageErrors] = useState<StageDraftValidationErrors>({});
const [actionMessage, setActionMessage] = useState("");
const [formError, setFormError] = useState("");
const [pendingAction, setPendingAction] = useState("");
const rows = useMemo(() => buildAdminWorkflowRows(workflows), [workflows]);
const auditRows = useMemo(
() => buildAdminWorkflowAuditRows(auditEvents),
[auditEvents],
);
const stages = useMemo(
() => sortedStages(selectedWorkflow?.stages),
[selectedWorkflow],
);
const canEditSelected = selectedWorkflow?.status === "DRAFT";
const isBusy = pendingAction !== "";
function clearMessages() {
setActionMessage("");
setFormError("");
}
function setActiveWorkflow(
workflow: WorkflowTemplateSummary,
nextAuditEvents: readonly WorkflowTemplateAuditEventSummary[] = auditEvents,
) {
const nextStages = sortedStages(workflow.stages);
setSelectedWorkflow(workflow);
setMetadataDraft(workflowDraftFromSummary(workflow));
setAuditEvents(nextAuditEvents);
setEditingStageId(null);
setStageDraft(defaultWorkflowStageFormDraft(nextStages.length + 1));
setStageErrors({});
}
function replaceWorkflowInList(workflow: WorkflowTemplateSummary) {
setWorkflows((current) => {
const exists = current.some((item) => item.id === workflow.id);
if (!exists) {
return [workflow, ...current];
}
return current.map((item) => (item.id === workflow.id ? workflow : item));
});
}
async function refreshWorkflowList(selectedWorkflowId?: string) {
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
setWorkflows(listResponse.workflows ?? []);
if (selectedWorkflowId) {
await loadWorkflow(selectedWorkflowId, false);
}
}
async function loadWorkflow(workflowId: string, markPending = true) {
if (markPending) {
clearMessages();
setPendingAction(`load-${workflowId}`);
}
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(workflowId, DEMO_ADMIN_EMAIL),
]);
setActiveWorkflow(workflowResponse.workflow, auditResponse.events ?? []);
} catch (error) {
setFormError(errorMessage(error, "Unable to load workflow"));
} finally {
if (markPending) {
setPendingAction("");
}
}
}
async function handleRefresh() {
clearMessages();
setPendingAction("refresh-workflows");
try {
await refreshWorkflowList(selectedWorkflow?.id);
setActionMessage("Workflow list refreshed");
} catch (error) {
setFormError(errorMessage(error, "Unable to refresh workflow list"));
} finally {
setPendingAction("");
}
}
function updateCreateDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setCreateDraft((current) => ({ ...current, [field]: value }));
}
function updateMetadataDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setMetadataDraft((current) => ({ ...current, [field]: value }));
}
function updateStageDraft<K extends keyof WorkflowStageFormDraft>(
field: K,
value: WorkflowStageFormDraft[K],
) {
setStageDraft((current) => ({ ...current, [field]: value }));
}
function updateStagePart<K extends keyof WorkflowStageFormPart>(
index: number,
field: K,
value: WorkflowStageFormPart[K],
) {
setStageDraft((current) => ({
...current,
parts: current.parts.map((part, partIndex) =>
partIndex === index ? { ...part, [field]: value } : part,
),
}));
}
function addStagePart() {
setStageDraft((current) => ({
...current,
parts: [...current.parts, createDefaultPart(current.parts.length + 1)],
}));
}
function removeStagePart(index: number) {
setStageDraft((current) => ({
...current,
parts: current.parts.filter((_, partIndex) => partIndex !== index),
}));
}
function startNewStage() {
setEditingStageId(null);
setStageErrors({});
setStageDraft(defaultWorkflowStageFormDraft(stages.length + 1));
}
function startEditStage(stage: WorkflowStageSummary) {
setEditingStageId(stage.id);
setStageErrors({});
setStageDraft(workflowStageFormDraftFromSummary(stage));
}
function validateStageDraft(): ReturnType<typeof workflowStageFormDraftToStageDraft> {
const nextErrors: StageDraftValidationErrors = {};
if (!stageDraft.stableKey.trim()) {
nextErrors.stableKey = "Stable key is required";
}
if (!stageDraft.displayName.trim()) {
nextErrors.displayName = "Display name is required";
}
if (!stageDraft.description.trim()) {
nextErrors.description = "Description is required";
}
if (!stageDraft.runnerProfileKey.trim()) {
nextErrors.runnerProfileKey = "Runner profile key is required";
}
if (parseJsonObject(stageDraft.retryPolicyJson) === null) {
nextErrors.retryPolicyJson = "Invalid JSON object";
}
const hasInvalidPartPayload = stageDraft.parts.some(
(part) => parseJsonObject(part.payloadJson) === null,
);
const hasInvalidPartFields = stageDraft.parts.some(
(part) => !part.key.trim() || !part.type.trim() || !part.title.trim(),
);
if (hasInvalidPartPayload || hasInvalidPartFields) {
nextErrors.parts = hasInvalidPartPayload
? "Each part payload must be a JSON object"
: "Each part needs key, type, and title";
}
if (Object.keys(nextErrors).length > 0) {
setStageErrors(nextErrors);
return null;
}
const nextDraft = workflowStageFormDraftToStageDraft(stageDraft);
if (!nextDraft) {
setStageErrors({ parts: "Stage draft contains invalid structured data" });
return null;
}
setStageErrors({});
return nextDraft;
}
async function handleCreateWorkflow(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
clearMessages();
setPendingAction("create-workflow");
try {
const response = await createWorkflowTemplate(
buildCreateWorkflowPayload(createDraft),
DEMO_ADMIN_EMAIL,
);
setCreateDraft(defaultWorkflowDraft());
replaceWorkflowInList(response.workflow);
setActiveWorkflow(response.workflow, []);
setActionMessage("Workflow draft created");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to create workflow draft"));
} finally {
setPendingAction("");
}
}
async function handleSaveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction("save-metadata");
try {
const response = await updateWorkflowTemplate(
selectedWorkflow.id,
buildUpdateWorkflowPayload(metadataDraft),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow metadata saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow metadata"));
} finally {
setPendingAction("");
}
}
async function handleSaveStage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
const validDraft = validateStageDraft();
if (!validDraft) {
return;
}
clearMessages();
setPendingAction("save-stage");
try {
if (editingStageId) {
await updateWorkflowStage(
selectedWorkflow.id,
editingStageId,
buildUpdateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage updated");
} else {
await addWorkflowStage(
selectedWorkflow.id,
buildCreateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage added");
}
await refreshWorkflowList(selectedWorkflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleDeleteStage(stageId: string) {
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction(`delete-stage-${stageId}`);
try {
const response = await deleteWorkflowStage(
selectedWorkflow.id,
stageId,
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage deleted");
} catch (error) {
setFormError(errorMessage(error, "Unable to delete workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleReorderStage(stageId: string, targetIndex: number) {
if (!selectedWorkflow) {
return;
}
const movedStages = moveWorkflowStage(stages, stageId, targetIndex);
clearMessages();
setPendingAction(`reorder-${stageId}`);
try {
const response = await reorderWorkflowStages(
selectedWorkflow.id,
buildReorderWorkflowStagesPayload(movedStages),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage order saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to reorder workflow stages"));
} finally {
setPendingAction("");
}
}
async function handleActivateWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`activate-${workflowId}`);
try {
const response = await activateWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow activated");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to activate workflow"));
} finally {
setPendingAction("");
}
}
async function handleArchiveWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`archive-${workflowId}`);
try {
const response = await archiveWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow archived");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to archive workflow"));
} finally {
setPendingAction("");
}
}
return (
<>
{formError ? <p className="formError">Error: {formError}</p> : null}
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow templates</h2>
<p>{rows.length} templates</p>
</div>
<button type="button" onClick={handleRefresh} disabled={isBusy}>
{pendingAction === "refresh-workflows" ? "Refreshing..." : "Refresh"}
</button>
</div>
<form className="articleForm" onSubmit={handleCreateWorkflow}>
<h3>Create draft</h3>
<label htmlFor="workflowName">Name</label>
<input
id="workflowName"
value={createDraft.name}
onChange={(event) => updateCreateDraft("name", event.target.value)}
placeholder="Article production workflow"
required
/>
<label htmlFor="workflowSlug">Slug</label>
<input
id="workflowSlug"
value={createDraft.slug}
onChange={(event) => updateCreateDraft("slug", event.target.value)}
placeholder="article-production-v1"
required
/>
<label htmlFor="workflowDescription">Description</label>
<textarea
id="workflowDescription"
value={createDraft.description}
onChange={(event) => updateCreateDraft("description", event.target.value)}
required
/>
<div className="formActions">
<button type="submit" disabled={isBusy}>
{pendingAction === "create-workflow" ? "Creating..." : "Create draft"}
</button>
</div>
</form>
{rows.length === 0 ? (
<p className="emptyState">No workflow templates yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Version</th>
<th>Stages</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<strong>{row.name}</strong>
<div>{row.slug}</div>
</td>
<td>{row.status}</td>
<td>{row.version}</td>
<td>{row.stageCount}</td>
<td>{formatDate(row.updatedAt)}</td>
<td>
<button
type="button"
onClick={() => loadWorkflow(row.id)}
disabled={isBusy}
>
Open
</button>
{row.isDraft ? (
<button
type="button"
onClick={() => handleActivateWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Activate
</button>
) : null}
{!row.isArchived ? (
<button
type="button"
onClick={() => handleArchiveWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Archive
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow editor</h2>
<p>
{selectedWorkflow
? `${selectedWorkflow.name} - ${selectedWorkflow.status}`
: "Select a workflow"}
</p>
</div>
</div>
{!selectedWorkflow ? (
<p className="emptyState">Open a workflow template to edit stages.</p>
) : (
<>
<form className="articleForm" onSubmit={handleSaveMetadata}>
<h3>Metadata</h3>
{!canEditSelected ? (
<p className="emptyState">Only draft templates can be edited.</p>
) : null}
<label htmlFor="metadataName">Name</label>
<input
id="metadataName"
value={metadataDraft.name}
onChange={(event) => updateMetadataDraft("name", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataSlug">Slug</label>
<input
id="metadataSlug"
value={metadataDraft.slug}
onChange={(event) => updateMetadataDraft("slug", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataDescription">Description</label>
<textarea
id="metadataDescription"
value={metadataDraft.description}
onChange={(event) =>
updateMetadataDraft("description", event.target.value)
}
disabled={!canEditSelected || isBusy}
required
/>
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-metadata" ? "Saving..." : "Save metadata"}
</button>
</div>
</form>
<h3>Stages</h3>
{stages.length === 0 ? (
<p className="emptyState">No stages yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Order</th>
<th>Stage</th>
<th>Owner</th>
<th>Runner</th>
<th>Approval</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stages.map((stage, index) => (
<tr key={stage.id}>
<td>{stage.position}</td>
<td>
<strong>{stage.display_name}</strong>
<div>{stage.stable_key}</div>
<div>{stage.description}</div>
</td>
<td>{stage.owner_role}</td>
<td>{stage.runner_profile_key}</td>
<td>{stage.requires_human_approval ? "Required" : "None"}</td>
<td>
<button
type="button"
onClick={() => startEditStage(stage)}
disabled={isBusy}
>
Edit
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index - 1)}
disabled={!canEditSelected || isBusy || index === 0}
className="tableAction"
>
Up
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index + 1)}
disabled={!canEditSelected || isBusy || index === stages.length - 1}
className="tableAction"
>
Down
</button>
<button
type="button"
onClick={() => handleDeleteStage(stage.id)}
disabled={!canEditSelected || isBusy}
className="tableAction"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<form className="articleForm" onSubmit={handleSaveStage}>
<div className="sectionHeader">
<div>
<h3>{editingStageId ? "Edit stage" : "Add stage"}</h3>
<p>Structured inputs, outputs, retry policy, and parts.</p>
</div>
<button type="button" onClick={startNewStage} disabled={isBusy}>
New stage
</button>
</div>
<label htmlFor="stageStableKey">Stable key</label>
<input
id="stageStableKey"
value={stageDraft.stableKey}
onChange={(event) => updateStageDraft("stableKey", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.stableKey ? (
<p className="fieldError">{stageErrors.stableKey}</p>
) : null}
<label htmlFor="stageDisplayName">Display name</label>
<input
id="stageDisplayName"
value={stageDraft.displayName}
onChange={(event) => updateStageDraft("displayName", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.displayName ? (
<p className="fieldError">{stageErrors.displayName}</p>
) : null}
<label htmlFor="stageDescription">Description</label>
<textarea
id="stageDescription"
value={stageDraft.description}
onChange={(event) => updateStageDraft("description", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.description ? (
<p className="fieldError">{stageErrors.description}</p>
) : null}
<label htmlFor="stagePosition">Position</label>
<input
id="stagePosition"
type="number"
min={1}
value={stageDraft.position}
onChange={(event) =>
updateStageDraft("position", Number(event.target.value) || 1)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageOwnerRole">Owner role</label>
<select
id="stageOwnerRole"
value={stageDraft.ownerRole}
onChange={(event) =>
updateStageDraft("ownerRole", event.target.value as Role)
}
disabled={!canEditSelected || isBusy}
>
<option value="EDITOR">EDITOR</option>
<option value="ADMIN">ADMIN</option>
</select>
<label htmlFor="stageRunnerProfile">Runner profile key</label>
<input
id="stageRunnerProfile"
value={stageDraft.runnerProfileKey}
onChange={(event) =>
updateStageDraft("runnerProfileKey", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.runnerProfileKey ? (
<p className="fieldError">{stageErrors.runnerProfileKey}</p>
) : null}
<label htmlFor="stageRequiredInputs">Required inputs</label>
<textarea
id="stageRequiredInputs"
value={stageDraft.requiredInputsText}
onChange={(event) =>
updateStageDraft("requiredInputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageExpectedOutputs">Expected outputs</label>
<textarea
id="stageExpectedOutputs"
value={stageDraft.expectedOutputsText}
onChange={(event) =>
updateStageDraft("expectedOutputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageAcceptanceCriteria">Acceptance criteria</label>
<textarea
id="stageAcceptanceCriteria"
value={stageDraft.acceptanceCriteriaText}
onChange={(event) =>
updateStageDraft("acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageRequiresApproval">
<input
id="stageRequiresApproval"
type="checkbox"
checked={stageDraft.requiresHumanApproval}
onChange={(event) =>
updateStageDraft("requiresHumanApproval", event.target.checked)
}
disabled={!canEditSelected || isBusy}
/>
Requires human approval
</label>
<label htmlFor="stageRetryPolicy">Retry policy JSON</label>
<textarea
id="stageRetryPolicy"
className="codeArea"
value={stageDraft.retryPolicyJson}
onChange={(event) =>
updateStageDraft("retryPolicyJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.retryPolicyJson ? (
<p className="fieldError">{stageErrors.retryPolicyJson}</p>
) : null}
<div className="sectionHeader">
<div>
<h3>Stage parts</h3>
<p>{stageDraft.parts.length} structured parts</p>
</div>
<button
type="button"
onClick={addStagePart}
disabled={!canEditSelected || isBusy}
>
Add part
</button>
</div>
{stageErrors.parts ? <p className="fieldError">{stageErrors.parts}</p> : null}
{stageDraft.parts.length === 0 ? (
<p className="emptyState">No structured parts yet.</p>
) : null}
{stageDraft.parts.map((part, index) => (
<div
key={`${part.key}-${index}`}
style={{
display: "grid",
gap: 8,
borderTop: "1px solid #d0d5dd",
paddingTop: 12,
}}
>
<label htmlFor={`stagePartKey-${index}`}>Part key</label>
<input
id={`stagePartKey-${index}`}
value={part.key}
onChange={(event) => updateStagePart(index, "key", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartType-${index}`}>Part type</label>
<input
id={`stagePartType-${index}`}
value={part.type}
onChange={(event) => updateStagePart(index, "type", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartTitle-${index}`}>Part title</label>
<input
id={`stagePartTitle-${index}`}
value={part.title}
onChange={(event) => updateStagePart(index, "title", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartPayload-${index}`}>Payload JSON</label>
<textarea
id={`stagePartPayload-${index}`}
className="codeArea"
value={part.payloadJson}
onChange={(event) =>
updateStagePart(index, "payloadJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartCriteria-${index}`}>
Part acceptance criteria
</label>
<textarea
id={`stagePartCriteria-${index}`}
value={part.acceptanceCriteriaText}
onChange={(event) =>
updateStagePart(index, "acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<div className="formActions">
<button
type="button"
onClick={() => removeStagePart(index)}
disabled={!canEditSelected || isBusy}
>
Remove part
</button>
</div>
</div>
))}
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-stage" ? "Saving..." : "Save stage"}
</button>
</div>
</form>
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Created</th>
<th>Actor</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{formatDate(event.createdAt)}</td>
<td>{event.actorUserId ?? "none"}</td>
<td>{event.payload}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
</>
);
}
@@ -1,114 +1,232 @@
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared"; import type {
AgentJobSummary,
ArticleDetailResponse,
ObservabilityTimelineEventSummary,
Role,
} from "@pipeline/shared";
export type ArticleTimelineItem = Pick< export type ArticleTimelineItem = {
WorkflowEventSummary, id: string;
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
> & {
articleId: string; articleId: string;
entryType: string;
source: string;
eventType: string;
fromStatus: string | null;
toStatus: string | null;
actorUserId: string | null;
jobId: string | null;
jobType: string | null;
jobStatus: string | null;
retryEligible: boolean;
cancelEligible: boolean;
safeFailureSummary: string | null;
createdAt: string;
};
export type JobDetailRow = {
jobId: string;
jobType: string;
status: string;
attempt: number;
errorCategory: string | null;
errorMessage: string | null;
lastSuccessfulStep: string | null;
retryEligible: boolean;
retryBlockReason: string | null;
cancelEligible: boolean;
safeFailureSummary: string | null;
stdout: string;
stderr: string;
};
export type WorkflowTemplateStageRow = {
id: string;
stableKey: string;
displayName: string;
position: number;
ownerRole: string;
runnerProfileKey: string;
requiresHumanApproval: boolean;
};
export type WorkflowTemplateSnapshotView = {
id: string;
name: string;
slug: string;
version: number;
stages: WorkflowTemplateStageRow[];
}; };
export type DetailSummary = { export type DetailSummary = {
articleId: string; articleId: string;
status: string; status: string;
publishingStatus: string; publishingStatus: string;
publishingValidationLabel: string;
briefDescription: string; briefDescription: string;
targetSite: string; targetSite: string;
workflowTemplate: WorkflowTemplateSnapshotView | null;
updatedAt: string; updatedAt: string;
viewerRole: Role;
viewerEmail: string;
timeline: ArticleTimelineItem[]; timeline: ArticleTimelineItem[];
productionArtifacts: ProductionArtifactRow[]; jobDetails: JobDetailRow[];
}; };
export type ProductionArtifactRow = { export type JobControlState = {
jobId: string; showRetry: boolean;
artifactKey: string; showCancel: boolean;
artifactLabel: string; showAdminLogs: boolean;
status: string; showSafeSummary: boolean;
attempt: number;
jobType: string;
usedEvidenceIds: string[];
unsupportedClaims: string[];
errorMessage: string | null;
}; };
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] { export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
return (detail.workflow_events ?? []).map((event) => ({ const timeline = detail.timeline ?? [];
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, 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, 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 { return {
articleId: detail.article.id, articleId: detail.article.id,
status: detail.article.status, status: detail.article.status,
publishingStatus: detail.article.publishing_status, publishingStatus: detail.article.publishing_status,
publishingValidationLabel: resolvePublishingValidationLabel(detail),
briefDescription: detail.article.brief_description, briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown", targetSite: detail.target_site?.name ?? "Unknown",
workflowTemplate: buildWorkflowTemplateSnapshotView(detail),
updatedAt: detail.article.updated_at, updatedAt: detail.article.updated_at,
viewerRole,
viewerEmail,
timeline: buildArticleTimeline(detail), timeline: buildArticleTimeline(detail),
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []), jobDetails: buildJobDetailRows(detail.agent_jobs ?? []),
}; };
} }
export function buildProductionArtifactRows( export function buildJobDetailRows(jobs: readonly AgentJobSummary[]): JobDetailRow[] {
jobs: readonly AgentJobSummary[],
): ProductionArtifactRow[] {
return jobs return jobs
.filter((job) => job.job_type === "SECTION_SCAFFOLD")
.map((job) => { .map((job) => {
const payload = (job.payload ?? {}) as Record<string, unknown>; const payload = (job.payload ?? {}) as Record<string, unknown>;
const artifactKey = stringValue(payload.artifact_key) || `job:${job.id}`; const lastSuccessfulStep = stringValue(payload.last_successful_step)
const artifactLabel = || stringValue(payload.artifact_label)
stringValue(payload.artifact_label)
|| stringValue(payload.heading) || stringValue(payload.heading)
|| artifactKey; || null;
return { return {
jobId: job.id, jobId: job.id,
artifactKey, jobType: job.job_type,
artifactLabel,
status: job.status, status: job.status,
attempt: job.attempt ?? 1, attempt: job.attempt ?? 1,
jobType: job.job_type, errorCategory: job.error_category ?? null,
usedEvidenceIds: stringArray(payload.used_evidence_ids),
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
errorMessage: job.error_message ?? 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,
};
}
export function buildWorkflowTemplateSnapshotView(
detail: ArticleDetailResponse,
): WorkflowTemplateSnapshotView | null {
const snapshot = detail.workflow_template_snapshot;
if (!snapshot) {
return null;
}
return {
id: snapshot.id,
name: snapshot.name,
slug: snapshot.slug,
version: snapshot.version,
stages: (snapshot.stage_summary ?? [])
.map((stage) => ({
id: stage.id,
stableKey: stage.stable_key,
displayName: stage.display_name,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiresHumanApproval: Boolean(stage.requires_human_approval),
}))
.sort((left, right) => left.position - right.position),
};
}
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 { function stringValue(value: unknown): string {
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === "string");
}
function unsupportedClaims(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const claims: string[] = [];
for (const item of value) {
if (typeof item === "string") {
claims.push(item);
continue;
}
if (typeof item === "object" && item !== null && "claim_text" in item) {
const claimText = item.claim_text;
if (typeof claimText === "string") {
claims.push(claimText);
}
}
}
return claims;
}
+161 -25
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"; import type { DetailSummary } from "./model";
type DetailShellProps = { type DetailShellProps = {
@@ -7,8 +13,47 @@ type DetailShellProps = {
}; };
export function ArticleDetailShell({ summary }: DetailShellProps) { export function ArticleDetailShell({ summary }: DetailShellProps) {
const router = useRouter();
const [actionError, setActionError] = useState("");
const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline; const timeline = summary.timeline;
const productionArtifacts = summary.productionArtifacts; const jobDetails = summary.jobDetails;
const workflowTemplate = summary.workflowTemplate;
const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await retryAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to retry job.");
} else {
setActionError("Unable to retry job.");
}
} finally {
setBusyJobId("");
}
}
async function handleCancel(jobId: string) {
setActionError("");
setBusyJobId(jobId);
try {
await cancelAgentJob(jobId, summary.viewerEmail);
router.refresh();
} catch (error) {
if (error instanceof ApiError) {
setActionError(error.message || "Unable to cancel job.");
} else {
setActionError("Unable to cancel job.");
}
} finally {
setBusyJobId("");
}
}
return ( return (
<section> <section>
@@ -26,6 +71,10 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<dt>Publishing status</dt> <dt>Publishing status</dt>
<dd>{summary.publishingStatus}</dd> <dd>{summary.publishingStatus}</dd>
</div> </div>
<div>
<dt>Validation</dt>
<dd>{summary.publishingValidationLabel}</dd>
</div>
<div> <div>
<dt>Target site</dt> <dt>Target site</dt>
<dd>{summary.targetSite}</dd> <dd>{summary.targetSite}</dd>
@@ -56,53 +105,140 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
</li> </li>
</ul> </ul>
<h3>Workflow template</h3>
{workflowTemplate ? (
<>
<dl className="detailGrid">
<div>
<dt>Name</dt>
<dd>{workflowTemplate.name}</dd>
</div>
<div>
<dt>Slug</dt>
<dd>{workflowTemplate.slug}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{workflowTemplate.version}</dd>
</div>
<div>
<dt>Stages</dt>
<dd>{workflowTemplate.stages.length}</dd>
</div>
</dl>
<ol className="timeline">
{workflowTemplate.stages.map((stage) => (
<li key={stage.id}>
<div className="timelineMeta">
<strong>{stage.position}. {stage.displayName}</strong>
<span className="timelineSource">{stage.ownerRole}</span>
<span>{stage.runnerProfileKey}</span>
</div>
<div>
{stage.stableKey}
{stage.requiresHumanApproval ? (
<span> - approval required</span>
) : null}
</div>
</li>
))}
</ol>
</>
) : (
<p>No workflow template snapshot.</p>
)}
<h3>Workflow timeline</h3> <h3>Workflow timeline</h3>
<ul className="timeline"> <ul className="timeline">
{timeline.map((event) => ( {timeline.map((event) => (
<li key={event.id}> <li key={event.id}>
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "} <div className="timelineMeta">
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()} <strong>{event.eventType}</strong>
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null} <span className="timelineSource">{event.source}</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
</div>
<div>
from {event.fromStatus ?? "—"} to {event.toStatus ?? "—"}
{event.actorUserId ? <span> • actor {event.actorUserId}</span> : null}
{event.jobType ? <span> • {event.jobType}</span> : null}
{event.jobStatus ? <span> • {event.jobStatus}</span> : null}
</div>
</li> </li>
))} ))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null} {timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul> </ul>
<h3>Production artifacts</h3> <h3>Job failures and controls</h3>
{actionError ? <p className="formError">Error: {actionError}</p> : null}
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>Artifact</th> <th>Job type</th>
<th>Status</th> <th>Status</th>
<th>Attempt</th> <th>Error category</th>
<th>Used evidence IDs</th> <th>Error message</th>
<th>Unsupported claims</th> <th>Last successful step</th>
<th>Controls</th>
<th>{isAdmin ? "Logs" : "Safe summary"}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{productionArtifacts.map((artifact) => ( {jobDetails.map((job) => {
<tr key={artifact.jobId}> const isBusy = busyJobId === job.jobId;
const controls = buildJobControlState(job, summary.viewerRole);
return (
<tr key={job.jobId}>
<td> <td>
<div>{artifact.artifactLabel}</div> <div>{job.jobType}</div>
<div>{artifact.artifactKey}</div> <div>attempt {job.attempt}</div>
</td> </td>
<td>{artifact.status}</td> <td>{job.status}</td>
<td>{artifact.attempt}</td> <td>{job.errorCategory ?? "—"}</td>
<td> <td>
{artifact.usedEvidenceIds.length {job.errorMessage ?? "—"}
? artifact.usedEvidenceIds.join(", ") {!job.errorMessage && job.retryBlockReason ? (
: "—"} <div>{job.retryBlockReason}</div>
) : null}
</td>
<td>{job.lastSuccessfulStep ?? "—"}</td>
<td>
{controls.showRetry ? (
<button
type="button"
onClick={() => handleRetry(job.jobId)}
disabled={isBusy}
>
Retry
</button>
) : null}
{controls.showCancel ? (
<button
type="button"
onClick={() => handleCancel(job.jobId)}
disabled={isBusy}
className="tableAction"
>
Cancel
</button>
) : null}
{!controls.showRetry && !controls.showCancel ? "—" : null}
</td> </td>
<td> <td>
{artifact.unsupportedClaims.length {controls.showAdminLogs ? (
? artifact.unsupportedClaims.join("; ") <>
: artifact.errorMessage ?? "—"} <div>{job.stdout || "stdout: —"}</div>
<div>{job.stderr || "stderr: —"}</div>
</>
) : (
job.safeFailureSummary ?? "—"
)}
</td> </td>
</tr> </tr>
))} );
{productionArtifacts.length === 0 ? ( })}
{jobDetails.length === 0 ? (
<tr> <tr>
<td colSpan={5}>No production artifacts yet.</td> <td colSpan={7}>No agent jobs yet.</td>
</tr> </tr>
) : null} ) : null}
</tbody> </tbody>
@@ -1,13 +1,25 @@
import type { TargetSiteConfig } from "@pipeline/shared"; import type {
ArticleCreateRequest,
TargetSiteConfig,
WorkflowTemplateSummary,
} from "@pipeline/shared";
export type NewArticleDraft = { export type NewArticleDraft = {
targetSiteId: string; targetSiteId: string;
workflowTemplateId: string;
briefDescription: string; briefDescription: string;
workingTitle: string; workingTitle: string;
contentType: string; contentType: string;
primaryKeyword: string; primaryKeyword: string;
}; };
export type WorkflowTemplateOption = {
id: string;
label: string;
version: number;
stageCount: number;
};
export type FieldErrors = Record<string, string>; export type FieldErrors = Record<string, string>;
export type NewArticleFormState = { export type NewArticleFormState = {
@@ -16,9 +28,14 @@ export type NewArticleFormState = {
formError?: string; formError?: string;
}; };
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft { export function initialDraft(
sites: readonly TargetSiteConfig[],
workflows: readonly WorkflowTemplateSummary[] = [],
): NewArticleDraft {
const workflowOptions = buildWorkflowTemplateOptions(workflows);
return { return {
targetSiteId: sites.length > 0 ? sites[0].id : "", targetSiteId: sites.length > 0 ? sites[0].id : "",
workflowTemplateId: workflowOptions.length > 0 ? workflowOptions[0].id : "",
briefDescription: "", briefDescription: "",
workingTitle: "", workingTitle: "",
contentType: "longform_guide", contentType: "longform_guide",
@@ -26,6 +43,33 @@ export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraf
}; };
} }
export function buildWorkflowTemplateOptions(
workflows: readonly WorkflowTemplateSummary[],
): WorkflowTemplateOption[] {
return workflows
.filter((workflow) => workflow.status === "ACTIVE")
.map((workflow) => ({
id: workflow.id,
label: `${workflow.name} v${workflow.version}`,
version: workflow.version,
stageCount: workflow.stages?.length ?? 0,
}));
}
export function buildCreateArticlePayload(
values: NewArticleDraft,
): ArticleCreateRequest {
return {
target_site_id: values.targetSiteId,
workflow_template_id: values.workflowTemplateId || null,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
};
}
export function validateArticleDraft(values: NewArticleDraft): FieldErrors { export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
const errors: FieldErrors = {}; const errors: FieldErrors = {};
@@ -3,10 +3,12 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { TargetSiteConfig } from "@pipeline/shared"; import type { TargetSiteConfig, WorkflowTemplateSummary } from "@pipeline/shared";
import { ApiError, createArticle } from "@/shared/pipeline-api"; import { ApiError, createArticle } from "@/shared/pipeline-api";
import { import {
buildCreateArticlePayload,
buildWorkflowTemplateOptions,
initialDraft, initialDraft,
validateArticleDraft, validateArticleDraft,
type FieldErrors, type FieldErrors,
@@ -15,14 +17,24 @@ import {
type NewArticleFormProps = { type NewArticleFormProps = {
sites: readonly TargetSiteConfig[]; sites: readonly TargetSiteConfig[];
workflowTemplates: readonly WorkflowTemplateSummary[];
}; };
export function NewArticleForm({ sites }: NewArticleFormProps) { export function NewArticleForm({ sites, workflowTemplates }: NewArticleFormProps) {
const router = useRouter(); const router = useRouter();
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites)); const workflowOptions = useMemo(
() => buildWorkflowTemplateOptions(workflowTemplates),
[workflowTemplates],
);
const [values, setValues] = useState<NewArticleDraft>(() =>
initialDraft(sites, workflowTemplates),
);
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const selectedWorkflow = workflowOptions.find(
(workflow) => workflow.id === values.workflowTemplateId,
);
const canSubmit = useMemo( const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0, () => !isSubmitting && sites.length > 0,
@@ -41,14 +53,7 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
setFormError(""); setFormError("");
setSubmitting(true); setSubmitting(true);
try { try {
const response = await createArticle({ const response = await createArticle(buildCreateArticlePayload(values));
target_site_id: values.targetSiteId,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
});
router.push(`/articles/${response.article.id}`); router.push(`/articles/${response.article.id}`);
} catch (error) { } catch (error) {
if (error instanceof ApiError) { if (error instanceof ApiError) {
@@ -84,6 +89,26 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
</select> </select>
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null} {errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
<label htmlFor="workflowTemplateId">Workflow template</label>
<select
id="workflowTemplateId"
value={values.workflowTemplateId}
onChange={(event) => updateField("workflowTemplateId", event.target.value)}
disabled={workflowOptions.length === 0}
>
<option value="">No active workflow template</option>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.label} - {workflow.stageCount} stages
</option>
))}
</select>
{selectedWorkflow ? (
<p className="fieldHint">
{selectedWorkflow.label} - {selectedWorkflow.stageCount} stages
</p>
) : null}
<label htmlFor="briefDescription">Brief description</label> <label htmlFor="briefDescription">Brief description</label>
<textarea <textarea
id="briefDescription" id="briefDescription"
@@ -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 Link from "next/link";
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api"; import { DEMO_ADMIN_EMAIL, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation"; import { RoleNavigation } from "@/widgets/role-navigation";
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui"; import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
@@ -40,10 +40,13 @@ export default async function AdminScriptsPage({
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL); const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
const activeSite = selectedResponse.site; const activeSite = selectedResponse.site;
const versionsResponse = await fetchScriptConfigVersions( const [versionsResponse, auditResponse] = await Promise.all([
fetchScriptConfigVersions(
selectedSite.id, selectedSite.id,
DEMO_ADMIN_EMAIL, DEMO_ADMIN_EMAIL,
); ),
fetchScriptConfigAuditEvents(selectedSite.id, DEMO_ADMIN_EMAIL),
]);
return ( return (
<main> <main>
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
<AdminScriptVersionsPanel <AdminScriptVersionsPanel
site={activeSite} site={activeSite}
initialVersions={versionsResponse.versions} initialVersions={versionsResponse.versions}
initialAuditEvents={auditResponse.events ?? []}
/> />
</section> </section>
</main> </main>
@@ -0,0 +1,65 @@
import Link from "next/link";
import type {
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import { AdminWorkflowBuilder } from "@/features/admin-workflows/ui";
import {
DEMO_ADMIN_EMAIL,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type SearchParams = {
workflowId?: string;
};
type AdminWorkflowsPageProps = {
searchParams?: SearchParams;
};
export default async function AdminWorkflowsPage({
searchParams,
}: AdminWorkflowsPageProps) {
const workflowListResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
const selectedWorkflowId = searchParams?.workflowId;
let selectedWorkflow: WorkflowTemplateSummary | null = null;
let auditEvents: WorkflowTemplateAuditEventSummary[] = [];
if (selectedWorkflowId) {
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(selectedWorkflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(selectedWorkflowId, DEMO_ADMIN_EMAIL),
]);
selectedWorkflow = workflowResponse.workflow;
auditEvents = auditResponse.events ?? [];
} catch {
selectedWorkflow = null;
auditEvents = [];
}
}
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Workflows</h1>
<p>Create and manage reusable ordered workflow templates.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<AdminWorkflowBuilder
initialWorkflows={workflowListResponse.workflows ?? []}
initialWorkflow={selectedWorkflow}
initialAuditEvents={auditEvents}
/>
</main>
);
}
@@ -8,22 +8,31 @@ import { ArticleDetailShell } from "@/features/article-detail/ui";
import { DraftEditorPanel } from "@/features/article-draft/ui"; import { DraftEditorPanel } from "@/features/article-draft/ui";
import { ArticleReviewPanel } from "@/features/article-review/ui"; import { ArticleReviewPanel } from "@/features/article-review/ui";
import { MediaLibraryPanel } from "@/features/article-assets/ui"; import { MediaLibraryPanel } from "@/features/article-assets/ui";
import { 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"; import { ApiError } from "@/shared/pipeline-api";
type ArticleDetailPageProps = { type ArticleDetailPageProps = {
articleId: string; articleId: string;
viewerRoleHint?: "admin" | "editor";
}; };
export default async function ArticleDetailPage({ export default async function ArticleDetailPage({
articleId, articleId,
viewerRoleHint,
}: ArticleDetailPageProps) { }: ArticleDetailPageProps) {
const selectedEmail = viewerRoleHint === "admin" ? DEMO_ADMIN_EMAIL : DEMO_EDITOR_EMAIL;
try { try {
const [detail, draftsResponse] = await Promise.all([ const [currentUserResponse, detail, draftsResponse] = await Promise.all([
fetchArticleDetail(articleId), fetchCurrentUser(selectedEmail),
fetchDrafts(articleId), fetchArticleDetail(articleId, selectedEmail),
fetchDrafts(articleId, selectedEmail),
]); ]);
const summary = buildDetailSummary(detail); const viewer = currentUserResponse.user;
const summary = buildDetailSummary(detail, {
viewerRole: viewer.role,
viewerEmail: selectedEmail,
});
return ( return (
<main> <main>
@@ -34,7 +43,7 @@ export default async function ArticleDetailPage({
</div> </div>
<Link href="/">Back to dashboard</Link> <Link href="/">Back to dashboard</Link>
</header> </header>
<RoleNavigation role="EDITOR" /> <RoleNavigation role={summary.viewerRole} />
<section className="panel"> <section className="panel">
<ArticleDetailShell summary={summary} /> <ArticleDetailShell summary={summary} />
<MediaLibraryPanel assets={detail.assets ?? []} /> <MediaLibraryPanel assets={detail.assets ?? []} />
@@ -44,6 +53,7 @@ export default async function ArticleDetailPage({
assets={detail.assets ?? []} assets={detail.assets ?? []}
/> />
<ArticleReviewPanel articleId={articleId} /> <ArticleReviewPanel articleId={articleId} />
<FinalApprovalPanel articleId={articleId} />
</section> </section>
</main> </main>
); );
+14 -3
View File
@@ -3,11 +3,19 @@ import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation"; import { RoleNavigation } from "@/widgets/role-navigation";
import { NewArticleForm } from "@/features/article-intake/ui"; import { NewArticleForm } from "@/features/article-intake/ui";
import { fetchTargetSites } from "@/shared/pipeline-api"; import {
DEMO_EDITOR_EMAIL,
fetchTargetSites,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
export default async function NewArticlePage() { export default async function NewArticlePage() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites(); const [sites, workflowResponse] = await Promise.all([
fetchTargetSites(),
fetchWorkflowTemplates(DEMO_EDITOR_EMAIL),
]);
const targetSites = sites.map((site) => site.site); const targetSites = sites.map((site) => site.site);
const workflowTemplates = workflowResponse.workflows ?? [];
return ( return (
<main> <main>
@@ -20,7 +28,10 @@ export default async function NewArticlePage() {
</header> </header>
<RoleNavigation role="EDITOR" /> <RoleNavigation role="EDITOR" />
<section className="panel"> <section className="panel">
<NewArticleForm sites={targetSites} /> <NewArticleForm
sites={targetSites}
workflowTemplates={workflowTemplates}
/>
</section> </section>
</main> </main>
); );
+191 -5
View File
@@ -24,6 +24,11 @@ import type {
EvidenceCreateRequest, EvidenceCreateRequest,
EvidenceUpdateRequest, EvidenceUpdateRequest,
FinalReviewIssuesResponse, FinalReviewIssuesResponse,
FinalApprovalChecklistResponse,
FinalApprovalRequest,
FinalApprovalResponse,
FinalRevisionRequest,
FinalRevisionRequestResponse,
LanguageReviewReportResponse, LanguageReviewReportResponse,
LanguageReviewRunResponse, LanguageReviewRunResponse,
PlanListResponse, PlanListResponse,
@@ -38,10 +43,20 @@ import type {
SeoReviewReportResponse, SeoReviewReportResponse,
SeoReviewRunResponse, SeoReviewRunResponse,
ScriptConfigVersionCreateRequest, ScriptConfigVersionCreateRequest,
ScriptConfigVersionAuditEventListResponse,
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
TargetSiteConfigResponse, TargetSiteConfigResponse,
TargetSiteConfigCreateRequest, TargetSiteConfigCreateRequest,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared"; } from "@pipeline/shared";
export const DEMO_EDITOR_EMAIL = "editor@example.com"; export const DEMO_EDITOR_EMAIL = "editor@example.com";
@@ -178,8 +193,17 @@ export function apiPatch<TRequest, TResponse>(
); );
} }
export function fetchCurrentUser(): Promise<CurrentUserResponse> { export function apiDelete<TResponse>(
return apiGet<CurrentUserResponse>("/api/me"); path: string,
options?: ApiRequestConfig,
): Promise<TResponse> {
return requestJson<TResponse>(path, { method: "DELETE" }, options?.userEmail);
}
export function fetchCurrentUser(
userEmail = DEMO_EDITOR_EMAIL,
): Promise<CurrentUserResponse> {
return apiGet<CurrentUserResponse>("/api/me", { userEmail });
} }
export function fetchAgentJobs( export function fetchAgentJobs(
@@ -251,6 +275,126 @@ export function createTargetSite(
); );
} }
export function fetchWorkflowTemplates(
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateListResponse> {
return apiGet<WorkflowTemplateListResponse>("/api/admin/workflows", {
userEmail,
});
}
export function fetchWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiGet<WorkflowTemplateResponse>(`/api/admin/workflows/${workflowId}`, {
userEmail,
});
}
export function createWorkflowTemplate(
request: WorkflowTemplateCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowTemplateCreateRequest, WorkflowTemplateResponse>(
"/api/admin/workflows",
request,
{ userEmail },
);
}
export function updateWorkflowTemplate(
workflowId: string,
request: WorkflowTemplateUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPatch<WorkflowTemplateUpdateRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}`,
request,
{ userEmail },
);
}
export function addWorkflowStage(
workflowId: string,
request: WorkflowStageCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPost<WorkflowStageCreateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages`,
request,
{ userEmail },
);
}
export function updateWorkflowStage(
workflowId: string,
stageId: string,
request: WorkflowStageUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPatch<WorkflowStageUpdateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
request,
{ userEmail },
);
}
export function deleteWorkflowStage(
workflowId: string,
stageId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiDelete<WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
{ userEmail },
);
}
export function reorderWorkflowStages(
workflowId: string,
request: WorkflowStageReorderRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowStageReorderRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/reorder`,
request,
{ userEmail },
);
}
export function activateWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/activate`,
undefined,
{ userEmail },
);
}
export function archiveWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/archive`,
undefined,
{ userEmail },
);
}
export function fetchWorkflowTemplateAuditEvents(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateAuditEventListResponse> {
return apiGet<WorkflowTemplateAuditEventListResponse>(
`/api/admin/workflows/${workflowId}/audit`,
{ userEmail },
);
}
export function fetchScriptConfigVersions( export function fetchScriptConfigVersions(
siteId: string, siteId: string,
userEmail = DEMO_EDITOR_EMAIL, userEmail = DEMO_EDITOR_EMAIL,
@@ -261,6 +405,16 @@ export function fetchScriptConfigVersions(
); );
} }
export function fetchScriptConfigAuditEvents(
siteId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<ScriptConfigVersionAuditEventListResponse> {
return apiGet<ScriptConfigVersionAuditEventListResponse>(
`/api/sites/${siteId}/publishing-config/audit`,
{ userEmail },
);
}
export function createScriptConfigVersion( export function createScriptConfigVersion(
siteId: string, siteId: string,
request: ScriptConfigVersionCreateRequest, request: ScriptConfigVersionCreateRequest,
@@ -303,8 +457,9 @@ export function fetchArticles(): Promise<ArticleListResponse> {
export function fetchArticleDetail( export function fetchArticleDetail(
articleId: string, articleId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<ArticleDetailResponse> { ): Promise<ArticleDetailResponse> {
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`); return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`, { userEmail });
} }
export function createArticle( export function createArticle(
@@ -450,8 +605,11 @@ export function assembleDraft(articleId: string): Promise<DraftAssembleResponse>
); );
} }
export function fetchDrafts(articleId: string): Promise<DraftListResponse> { export function fetchDrafts(
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`); articleId: string,
userEmail = DEMO_EDITOR_EMAIL,
): Promise<DraftListResponse> {
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`, { userEmail });
} }
export function fetchDraft( export function fetchDraft(
@@ -622,3 +780,31 @@ export function fetchFinalReviewIssues(
): Promise<FinalReviewIssuesResponse> { ): Promise<FinalReviewIssuesResponse> {
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`); 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,
);
}
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
href: "/admin/scripts", href: "/admin/scripts",
roles: ["ADMIN"], roles: ["ADMIN"],
}, },
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
{ {
id: "agent-jobs", id: "agent-jobs",
label: "Agent jobs", label: "Agent jobs",
@@ -20,6 +20,7 @@ new Function("exports", compiled.outputText)(moduleExports);
const { const {
buildAdminScriptVersionRows, buildAdminScriptVersionRows,
buildAdminScriptAuditRows,
defaultScriptVersionDraft, defaultScriptVersionDraft,
buildCreateScriptVersionPayload, buildCreateScriptVersionPayload,
parseJsonObject, parseJsonObject,
@@ -53,3 +54,21 @@ assert.equal(payload.rollback_target_version_id, null);
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 }); assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
assert.equal(parseJsonObject("[]"), null); assert.equal(parseJsonObject("[]"), null);
assert.deepEqual(parseJsonObject(""), {}); assert.deepEqual(parseJsonObject(""), {});
const auditRows = buildAdminScriptAuditRows([
{
id: "evt-1",
target_site_id: "site-1",
version_id: "ver-1",
event_type: "SCRIPT_CONFIG_VERSION_ROLLBACK",
actor_user_id: "user-1",
payload: { action: "rollback" },
created_at: "2026-05-21T12:00:00Z",
version: 2,
diff: { summary: "rollback event" },
rollback_target_version_id: "ver-0",
},
]);
assert.equal(auditRows.length, 1);
assert.equal(auditRows[0].eventType, "SCRIPT_CONFIG_VERSION_ROLLBACK");
assert.equal(auditRows[0].rollbackTargetVersionId, "ver-0");
@@ -0,0 +1,411 @@
import assert from "node:assert/strict";
import { existsSync, 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 failures = [];
function loadTsModule(sourcePath, requireOverrides = {}) {
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const moduleExports = {};
const module = { exports: moduleExports };
const localRequire = (specifier) => {
if (specifier in requireOverrides) {
return requireOverrides[specifier];
}
throw new Error(`Unexpected runtime import in model test: ${specifier}`);
};
new Function("exports", "module", "require", compiled.outputText)(
moduleExports,
module,
localRequire,
);
return module.exports;
}
function loadExpectedModule(sourcePath, label, expectedExports = []) {
if (!existsSync(sourcePath)) {
const expected = expectedExports.join(", ");
failures.push(
`${label} is missing at ${path.relative(process.cwd(), sourcePath)}; expected exports: ${expected}`,
);
return null;
}
try {
return loadTsModule(sourcePath);
} catch (error) {
failures.push(`${label} failed to load: ${error.message}`);
return null;
}
}
function expectFunction(moduleExports, exportName, label) {
if (!moduleExports) {
return null;
}
const value = moduleExports?.[exportName];
if (typeof value !== "function") {
failures.push(`${label} must export ${exportName}()`);
return null;
}
return value;
}
async function recordAssertion(label, assertion) {
try {
await assertion();
} catch (error) {
failures.push(`${label}: ${error.message}`);
}
}
const workflowModelPath = path.resolve(
testDir,
"../src/features/admin-workflows/model.ts",
);
const apiPath = path.resolve(testDir, "../src/shared/pipeline-api.ts");
const navigationModelPath = path.resolve(
testDir,
"../src/widgets/role-navigation/model.ts",
);
const expectedWorkflowModelExports = [
"defaultWorkflowDraft",
"buildCreateWorkflowPayload",
"defaultWorkflowStageDraft",
"buildCreateWorkflowStagePayload",
"moveWorkflowStage",
"buildReorderWorkflowStagesPayload",
];
const workflowDraft = {
name: "Article production workflow",
slug: "article-production-v1",
description: "Reusable editorial workflow for longform article production.",
};
const workflowId = "11111111-1111-1111-1111-111111111111";
const intakeStageId = "22222222-2222-2222-2222-222222222222";
const draftStageId = "33333333-3333-3333-3333-333333333333";
const workflowPart = {
key: "required-context-checklist",
type: "checklist",
title: "Required context checklist",
payload: {
prompt: "Identify missing audience, keyword, and source constraints.",
config: {
required_fields: ["audience", "primary_keyword", "source_rules"],
block_on_missing: true,
},
},
acceptance_criteria: [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
};
const intakeStageDraft = {
stableKey: "intake-boundary-questions",
displayName: "Boundary question intake",
description: "Collect required editorial context before planning.",
position: 1,
ownerRole: "EDITOR",
runnerProfileKey: "boundary-question-agent-v1",
requiredInputs: ["article_brief", "target_site"],
expectedOutputs: ["answered_boundary_questions"],
acceptanceCriteria: ["All required boundary questions are answered."],
requiresHumanApproval: true,
retryPolicy: { max_attempts: 1, backoff_seconds: 0 },
parts: [workflowPart],
};
const expectedIntakeStagePayload = {
stable_key: "intake-boundary-questions",
display_name: "Boundary question intake",
description: "Collect required editorial context before planning.",
position: 1,
owner_role: "EDITOR",
runner_profile_key: "boundary-question-agent-v1",
required_inputs: ["article_brief", "target_site"],
expected_outputs: ["answered_boundary_questions"],
acceptance_criteria: ["All required boundary questions are answered."],
requires_human_approval: true,
retry_policy: { max_attempts: 1, backoff_seconds: 0 },
parts: [workflowPart],
};
const workflowModelExports = loadExpectedModule(
workflowModelPath,
"features/admin-workflows/model",
expectedWorkflowModelExports,
);
const apiExports = loadExpectedModule(apiPath, "shared/pipeline-api");
const navigationExports = loadExpectedModule(
navigationModelPath,
"widgets/role-navigation/model",
);
const defaultWorkflowDraft = expectFunction(
workflowModelExports,
"defaultWorkflowDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowPayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowPayload",
"features/admin-workflows/model",
);
const defaultWorkflowStageDraft = expectFunction(
workflowModelExports,
"defaultWorkflowStageDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowStagePayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowStagePayload",
"features/admin-workflows/model",
);
const moveWorkflowStage = expectFunction(
workflowModelExports,
"moveWorkflowStage",
"features/admin-workflows/model",
);
const buildReorderWorkflowStagesPayload = expectFunction(
workflowModelExports,
"buildReorderWorkflowStagesPayload",
"features/admin-workflows/model",
);
const createWorkflowTemplate = expectFunction(
apiExports,
"createWorkflowTemplate",
"shared/pipeline-api",
);
const addWorkflowStage = expectFunction(
apiExports,
"addWorkflowStage",
"shared/pipeline-api",
);
const reorderWorkflowStages = expectFunction(
apiExports,
"reorderWorkflowStages",
"shared/pipeline-api",
);
const getRoleNavigationItems = expectFunction(
navigationExports,
"getRoleNavigationItems",
"widgets/role-navigation/model",
);
if (
defaultWorkflowDraft &&
buildCreateWorkflowPayload &&
defaultWorkflowStageDraft &&
buildCreateWorkflowStagePayload &&
moveWorkflowStage &&
buildReorderWorkflowStagesPayload
) {
await recordAssertion("workflow draft payload maps model state to API request", () => {
const createPayload = buildCreateWorkflowPayload({
...defaultWorkflowDraft(),
...workflowDraft,
});
assert.deepEqual(createPayload, workflowDraft);
});
await recordAssertion("workflow stage payload preserves structured parts", () => {
const stagePayload = buildCreateWorkflowStagePayload({
...defaultWorkflowStageDraft(),
...intakeStageDraft,
});
assert.deepEqual(stagePayload, expectedIntakeStagePayload);
});
await recordAssertion("workflow stage reorder returns ordered IDs for API", () => {
const stages = [
{ id: intakeStageId, stableKey: "intake-boundary-questions", position: 1 },
{ id: draftStageId, stableKey: "draft-assembly", position: 2 },
];
const reorderedStages = moveWorkflowStage(stages, draftStageId, 0);
assert.deepEqual(
reorderedStages.map((stage) => ({ id: stage.id, position: stage.position })),
[
{ id: draftStageId, position: 1 },
{ id: intakeStageId, position: 2 },
],
);
assert.deepEqual(buildReorderWorkflowStagesPayload(reorderedStages), {
stage_ids: [draftStageId, intakeStageId],
});
});
}
if (createWorkflowTemplate && addWorkflowStage && reorderWorkflowStages) {
await recordAssertion("workflow API client posts create/add/reorder requests", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
const responses = [
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
{
stage: {
id: intakeStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
},
},
{
stage: {
id: draftStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [
{ id: draftStageId, stable_key: "draft-assembly", position: 1 },
{
id: intakeStageId,
stable_key: "intake-boundary-questions",
position: 2,
},
],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
];
globalThis.fetch = async (url, options) => {
requests.push({
path: new URL(String(url)).pathname,
method: options.method,
userEmail: options.headers["X-Demo-User-Email"],
body: options.body ? JSON.parse(options.body) : undefined,
});
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => JSON.stringify(responses.shift()),
};
};
try {
const created = await createWorkflowTemplate(workflowDraft);
const intakeStage = await addWorkflowStage(
created.workflow.id,
expectedIntakeStagePayload,
);
const draftStage = await addWorkflowStage(created.workflow.id, {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
});
const reordered = await reorderWorkflowStages(created.workflow.id, {
stage_ids: [draftStage.stage.id, intakeStage.stage.id],
});
assert.equal(created.workflow.status, "DRAFT");
assert.deepEqual(
reordered.workflow.stages.map((stage) => stage.id),
[draftStageId, intakeStageId],
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
path: "/api/admin/workflows",
method: "POST",
userEmail: "admin@example.com",
body: workflowDraft,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: expectedIntakeStagePayload,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
path: `/api/admin/workflows/${workflowId}/stages/reorder`,
method: "POST",
userEmail: "admin@example.com",
body: { stage_ids: [draftStageId, intakeStageId] },
},
]);
});
}
if (getRoleNavigationItems) {
await recordAssertion("Admin navigation exposes Workflows builder route", () => {
const adminItems = getRoleNavigationItems("ADMIN");
assert.deepEqual(
adminItems.find((item) => item.id === "workflows"),
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
);
});
await recordAssertion("Editor navigation does not expose Workflows", () => {
const editorItems = getRoleNavigationItems("EDITOR");
assert.equal(
editorItems.some(
(item) =>
item.id === "workflows" ||
item.href === "/admin/workflows" ||
item.label === "Workflows",
),
false,
);
});
}
if (failures.length > 0) {
assert.fail(
`Admin workflow builder frontend pre-requirements missing:\n- ${failures.join("\n- ")}`,
);
}
+127 -64
View File
@@ -15,38 +15,89 @@ const compiled = ts.transpileModule(source, {
const moduleExports = {}; const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports); new Function("exports", compiled.outputText)(moduleExports);
const { buildProductionArtifactRows } = moduleExports; const { buildArticleTimeline, buildJobDetailRows, buildDetailSummary, buildJobControlState } = moduleExports;
const rows = buildProductionArtifactRows([ const detail = {
article: {
id: "a1",
target_site_id: "site1",
status: "PARALLEL_PRODUCTION_RUNNING",
publishing_status: "PUBLISH_NOT_STARTED",
brief_description: "desc",
language: "en",
content_type: "longform_guide",
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
target_site: { name: "Demo Site" },
workflow_template_snapshot: {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
version: 2,
stage_summary: [
{ {
id: "job-section-1", id: "stage-2",
stable_key: "draft",
display_name: "Draft assembly",
position: 2,
owner_role: "EDITOR",
runner_profile_key: "draft-agent",
requires_human_approval: false,
},
{
id: "stage-1",
stable_key: "intake",
display_name: "Intake",
position: 1,
owner_role: "EDITOR",
runner_profile_key: "intake-agent",
requires_human_approval: true,
},
],
},
timeline: [
{
id: "2",
article_id: "a1", article_id: "a1",
parent_job_id: null, entry_type: "AGENT_JOB",
attempt: 1, source: "AGENT",
event_type: "SECTION_SCAFFOLD_FAILED",
from_status: null,
to_status: null,
actor_user_id: null,
job_id: "job2",
job_type: "SECTION_SCAFFOLD", job_type: "SECTION_SCAFFOLD",
agent_profile: "fake-section-scaffold", job_status: "FAILED",
status: "SUCCEEDED", retry_eligible: true,
workspace_path: null, cancel_eligible: false,
input_files: [], safe_failure_summary: "Section failed.",
output_files: [], payload: {},
payload: { created_at: "2026-05-21T00:00:02Z",
artifact_key: "section:1",
artifact_label: "Context and scope",
used_evidence_ids: ["e1", "e2"],
unsupported_claims: [],
},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 3,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
}, },
{ {
id: "job-section-2", id: "1",
article_id: "a1",
entry_type: "WORKFLOW_EVENT",
source: "USER",
event_type: "PLAN_APPROVED",
from_status: "PLAN_REVIEW_REQUIRED",
to_status: "RESEARCH_RUNNING",
actor_user_id: "u1",
job_id: null,
job_type: null,
job_status: null,
retry_eligible: false,
cancel_eligible: false,
safe_failure_summary: null,
payload: {},
created_at: "2026-05-21T00:00:01Z",
},
],
workflow_events: [],
agent_jobs: [
{
id: "job2",
article_id: "a1", article_id: "a1",
parent_job_id: null, parent_job_id: null,
attempt: 1, attempt: 1,
@@ -56,49 +107,61 @@ const rows = buildProductionArtifactRows([
workspace_path: null, workspace_path: null,
input_files: [], input_files: [],
output_files: [], output_files: [],
payload: { payload: { last_successful_step: "Outline written" },
artifact_key: "section:2",
artifact_label: "Implementation workflow",
used_evidence_ids: ["e2"],
unsupported_claims: [{ claim_text: "Unverified claim" }],
},
error_category: "UNSUPPORTED_CLAIMS_FOUND", error_category: "UNSUPPORTED_CLAIMS_FOUND",
error_message: "Unsupported claims introduced during scaffolding: 1", error_message: "Unsupported claims introduced during scaffolding: 1",
stdout: "", stdout: "token=[REDACTED]",
stderr: "", stderr: "",
exit_code: 0, exit_code: 1,
duration_ms: 3, duration_ms: 5,
queued_at: "2026-05-21T00:00:00Z", queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z", started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z", finished_at: "2026-05-21T00:00:02Z",
retry_eligible: true,
retry_block_reason: null,
cancel_eligible: false,
safe_failure_summary: "Section failed safely.",
}, },
{ ],
id: "job-non-artifact", publish_commit: null,
article_id: "a1", };
parent_job_id: null,
attempt: 1,
job_type: "TEST_CODEX",
agent_profile: "fake-codex",
status: "SUCCEEDED",
workspace_path: null,
input_files: [],
output_files: [],
payload: {},
error_category: null,
error_message: null,
stdout: "",
stderr: "",
exit_code: 0,
duration_ms: 0,
queued_at: "2026-05-21T00:00:00Z",
started_at: "2026-05-21T00:00:01Z",
finished_at: "2026-05-21T00:00:02Z",
},
]);
assert.equal(rows.length, 2); const timeline = buildArticleTimeline(detail);
assert.equal(rows[0].artifactLabel, "Context and scope"); assert.equal(timeline.length, 2);
assert.equal(rows[0].status, "SUCCEEDED"); assert.equal(timeline[0].eventType, "PLAN_APPROVED");
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]); assert.equal(timeline[1].eventType, "SECTION_SCAFFOLD_FAILED");
assert.equal(rows[1].status, "FAILED"); assert.equal(timeline[1].source, "AGENT");
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]); assert.equal(timeline[1].retryEligible, true);
const 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.workflowTemplate.name, "Demo workflow");
assert.equal(summary.workflowTemplate.version, 2);
assert.deepEqual(
summary.workflowTemplate.stages.map((stage) => stage.stableKey),
["intake", "draft"],
);
assert.equal(summary.timeline.length, 2);
assert.equal(summary.jobDetails.length, 1);
@@ -23,9 +23,29 @@ new Function("exports", compiled.outputText)(moduleExports);
const validateArticleDraft = moduleExports.validateArticleDraft; const validateArticleDraft = moduleExports.validateArticleDraft;
const initialDraft = moduleExports.initialDraft; const initialDraft = moduleExports.initialDraft;
const buildCreateArticlePayload = moduleExports.buildCreateArticlePayload;
const buildWorkflowTemplateOptions = moduleExports.buildWorkflowTemplateOptions;
const activeWorkflow = {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
description: "Demo workflow",
status: "ACTIVE",
version: 2,
created_by: "admin",
updated_by: "admin",
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
stages: [
{ id: "stage-1" },
{ id: "stage-2" },
],
};
const draft = { const draft = {
targetSiteId: "", targetSiteId: "",
workflowTemplateId: "",
briefDescription: "Keep this text intact", briefDescription: "Keep this text intact",
workingTitle: "", workingTitle: "",
contentType: "longform_guide", contentType: "longform_guide",
@@ -43,3 +63,27 @@ assert.equal(
const fallbackDraft = initialDraft([]); const fallbackDraft = initialDraft([]);
assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved"); assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved");
const workflowDraft = initialDraft(
[{ id: "site-1" }],
[activeWorkflow, { ...activeWorkflow, id: "workflow-archived", status: "ARCHIVED" }],
);
assert.equal(workflowDraft.workflowTemplateId, "workflow-1");
assert.deepEqual(buildWorkflowTemplateOptions([activeWorkflow]), [
{
id: "workflow-1",
label: "Demo workflow v2",
version: 2,
stageCount: 2,
},
]);
assert.equal(
buildCreateArticlePayload({
...workflowDraft,
briefDescription: "Brief",
primaryKeyword: "workflow",
}).workflow_template_id,
"workflow-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 -1
View File
@@ -6,7 +6,6 @@ services:
environment: environment:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
BACKEND_URL: http://backend:8000 BACKEND_URL: http://backend:8000
NEXT_PUBLIC_BACKEND_URL: http://backend:8000
ports: ports:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3000}:3000"
depends_on: depends_on:
@@ -18,6 +17,8 @@ services:
context: . context: .
dockerfile: apps/backend/Dockerfile dockerfile: apps/backend/Dockerfile
environment: 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} POSTGRES_DSN: postgresql://${POSTGRES_USER:-pipeline}:${POSTGRES_PASSWORD:-pipeline_local}@postgres:5432/${POSTGRES_DB:-pipeline}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
OBJECT_STORAGE_ENDPOINT: http://minio:9000 OBJECT_STORAGE_ENDPOINT: http://minio:9000
@@ -25,6 +26,8 @@ services:
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-minio_local_password} OBJECT_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-minio_local_password}
OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:-pipeline-local} OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:-pipeline-local}
OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-us-east-1} OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-us-east-1}
volumes:
- demo-publish-repo:/demo
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
depends_on: depends_on:
@@ -109,3 +112,4 @@ volumes:
postgres-data: postgres-data:
redis-data: redis-data:
minio-data: minio-data:
demo-publish-repo:
@@ -0,0 +1,68 @@
# Demo Readiness Protocol (2026-05-22)
## Scope
Final Task 023 protocol for proving Admin-managed workflow templates are connected to the article production flow.
Environment:
- Date: 2026-05-22
- Stack: Docker Compose demo stack
- Roles: `admin@example.com`, `editor@example.com`
- Local mapped ports from `.env`: backend `13800`, frontend `13300`, runner `13810`
## Automated Evidence
Commands run:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_generated_contract_artifacts apps.backend.tests.contracts.test_public_openapi_contract -v`
- `node apps/frontend/tests/article_form_validation.test.mjs`
- `node apps/frontend/tests/article_detail.model.test.mjs`
- `pnpm --filter @pipeline/frontend test:ui`
- `pnpm typecheck`
- `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_auth_authorization_public_api.AuthAuthorizationPublicApiTest.test_editor_can_create_articles_and_approve_plan_review -v`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest discover apps/backend/tests -v`
Results:
- Workflow-template binding public API: PASS
- OpenAPI/shared contract snapshots: PASS
- Frontend article intake/detail model checks: PASS
- Frontend UI model suite: PASS
- Shared/frontend typecheck: PASS
- Docker Compose public-health smoke: PASS
- Auth article/plan approval public flow: PASS
- Full backend discovery: PASS (`Ran 87 tests ... OK (skipped=1)`)
Additional backend discovery:
- The auth/plan-review readiness test was corrected to use the real public flow: create article, generate/answer boundary questions, submit answers, generate a plan, then approve the generated plan id. The previous hard-coded missing plan id was a stale test fixture, not the product flow.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest discover apps/backend/tests -v` now passes with 87 tests run and one Postgres-only migration test skipped because `PIPELINE_TEST_DATABASE_DSN` is not set.
## Manual Demo Checklist
Admin workflow builder:
1. Open `/admin/workflows` as `admin@example.com`.
2. Confirm the seeded `Demo article production workflow` is active and has ordered stages for intake, plan review, research/evidence, production, draft assembly, review, and publishing.
3. Create a draft workflow with a unique slug.
4. Add at least two stages with structured stage parts.
5. Edit a stage title/runner profile.
6. Reorder stages and confirm the order persists after refresh.
7. Activate the draft workflow.
8. Archive a non-demo workflow and confirm Editors no longer see it in active workflow lists.
Editor article flow:
1. Open `/articles/new` as `editor@example.com`.
2. Confirm the workflow template selector shows active workflows only.
3. Select `Demo article production workflow v2`.
4. Create an article brief.
5. Open the article detail page.
6. Confirm article detail shows the workflow template name, slug, version, and stage summary.
7. Archive the selected workflow as Admin.
8. Return to the article detail page and confirm the stored workflow template snapshot is unchanged.
9. Continue the normal publishing path through boundary questions, plan approval, research, evidence approval, draft production, reviews, final approval, dry-run, and publish commit.
10. Confirm the article status path still reaches `PUBLISH_COMMIT_CREATED`.
## Current Sign-Off
GO for Task 023 workflow-template binding and demo readiness.
Automated public API, model, typecheck, full backend discovery, in-process end-to-end demo, and Docker Compose health checks passed for the new binding behavior, including immutable article snapshots after workflow archival. The manual checklist above remains the step-by-step reviewer protocol for the live browser demo.
+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
+247
View File
@@ -26,6 +26,7 @@ export type AgentJobSummary = {
agent_profile: string; agent_profile: string;
article_id?: string | null; article_id?: string | null;
attempt?: number; attempt?: number;
cancel_eligible?: boolean;
duration_ms?: number | null; duration_ms?: number | null;
error_category?: AgentJobErrorCategory | null; error_category?: AgentJobErrorCategory | null;
error_message?: string | null; error_message?: string | null;
@@ -38,6 +39,9 @@ export type AgentJobSummary = {
parent_job_id?: string | null; parent_job_id?: string | null;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
queued_at: string; queued_at: string;
retry_block_reason?: string | null;
retry_eligible?: boolean;
safe_failure_summary?: string | null;
started_at?: string | null; started_at?: string | null;
status: AgentJobStatus; status: AgentJobStatus;
stderr?: string; stderr?: string;
@@ -58,6 +62,7 @@ export type ArticleCreateRequest = {
language?: string; language?: string;
primary_keyword?: string | null; primary_keyword?: string | null;
target_site_id: string; target_site_id: string;
workflow_template_id?: string | null;
working_title?: string | null; working_title?: string | null;
}; };
@@ -78,7 +83,9 @@ export type ArticleDetailResponse = {
research_manifests?: ResearchArtifactManifestSummary[]; research_manifests?: ResearchArtifactManifestSummary[];
reviews?: ReviewSummary[]; reviews?: ReviewSummary[];
target_site?: TargetSiteConfig | null; target_site?: TargetSiteConfig | null;
timeline?: ObservabilityTimelineEventSummary[];
workflow_events?: WorkflowEventSummary[]; workflow_events?: WorkflowEventSummary[];
workflow_template_snapshot?: WorkflowTemplateSnapshot | null;
}; };
export type ArticleListResponse = { export type ArticleListResponse = {
@@ -97,6 +104,8 @@ export type ArticleSummary = {
status: ArticleWorkflowStatus; status: ArticleWorkflowStatus;
target_site_id: string; target_site_id: string;
updated_at: string; updated_at: string;
workflow_template_id?: string | null;
workflow_template_version?: number | null;
working_title?: string | null; working_title?: string | null;
}; };
@@ -349,6 +358,40 @@ export type EvidenceUpdateRequest = {
review_status?: string | null; 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 = { export type FinalReviewIssueSummary = {
location: string; location: string;
message: string; message: string;
@@ -364,6 +407,16 @@ export type FinalReviewIssuesResponse = {
unresolved_issues?: FinalReviewIssueSummary[]; unresolved_issues?: FinalReviewIssueSummary[];
}; };
export type FinalRevisionRequest = {
draft_version?: number | null;
reason?: string | null;
};
export type FinalRevisionRequestResponse = {
article: ArticleSummary;
checklist: FinalApprovalChecklistResponse;
};
export type HTTPValidationError = { export type HTTPValidationError = {
detail?: ValidationError[]; detail?: ValidationError[];
}; };
@@ -376,6 +429,25 @@ export type LanguageReviewRunResponse = {
report: ContentReviewReportSummary; report: ContentReviewReportSummary;
}; };
export type ObservabilityTimelineEventSummary = {
actor_user_id?: string | null;
article_id: string;
cancel_eligible?: boolean;
created_at: string;
entry_type: string;
event_type: string;
from_status?: ArticleWorkflowStatus | null;
id: string;
job_id?: string | null;
job_status?: AgentJobStatus | null;
job_type?: AgentJobType | null;
payload?: Record<string, unknown>;
retry_eligible?: boolean;
safe_failure_summary?: string | null;
source: string;
to_status?: ArticleWorkflowStatus | null;
};
export type PlanListResponse = { export type PlanListResponse = {
plans: PlanSummary[]; plans: PlanSummary[];
}; };
@@ -452,6 +524,15 @@ export type PlanUpdateRequest = {
visual_needs?: string[] | null; visual_needs?: string[] | null;
}; };
export type PublishCommitCreateResponse = {
article: ArticleSummary;
publish_commit: PublishCommitSummary;
};
export type PublishCommitListResponse = {
commits?: PublishCommitSummary[];
};
export type PublishCommitSummary = { export type PublishCommitSummary = {
article_id: string; article_id: string;
branch: string; branch: string;
@@ -465,6 +546,14 @@ export type PublishCommitSummary = {
target_site_id: string; target_site_id: string;
}; };
export type PublishingDryRunResponse = {
article: ArticleSummary;
content_shape_valid: boolean;
errors?: string[];
publish_commit: PublishCommitSummary;
validation_label: string;
};
export type PublishingRules = { export type PublishingRules = {
asset_path_template: string; asset_path_template: string;
content_format?: string; content_format?: string;
@@ -478,6 +567,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 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 = { export type ResearchArtifactManifestSummary = {
agent_job_id: string; agent_job_id: string;
article_id: string; article_id: string;
@@ -542,6 +638,23 @@ export type RunnerFileRef = {
path: string; path: string;
}; };
export type ScriptConfigVersionAuditEventListResponse = {
events?: ScriptConfigVersionAuditEventSummary[];
};
export type ScriptConfigVersionAuditEventSummary = {
actor_user_id?: string | null;
created_at: string;
diff?: Record<string, unknown>;
event_type: string;
id: string;
payload?: Record<string, unknown>;
rollback_target_version_id?: string | null;
target_site_id: string;
version?: number | null;
version_id: string;
};
export type ScriptConfigVersionCreateRequest = { export type ScriptConfigVersionCreateRequest = {
activate?: boolean; activate?: boolean;
diff?: Record<string, unknown>; diff?: Record<string, unknown>;
@@ -654,3 +767,137 @@ export type WorkflowEventSummary = {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
to_status?: ArticleWorkflowStatus | null; to_status?: ArticleWorkflowStatus | null;
}; };
export type WorkflowStageCreateRequest = {
acceptance_criteria?: string[];
description: string;
display_name: string;
expected_outputs?: string[];
owner_role: Role;
parts?: WorkflowStagePart[];
position?: number | null;
required_inputs?: string[];
requires_human_approval?: boolean;
retry_policy?: Record<string, unknown>;
runner_profile_key: string;
stable_key: string;
};
export type WorkflowStagePart = {
acceptance_criteria?: string[];
key: string;
payload?: Record<string, unknown>;
title: string;
type: string;
};
export type WorkflowStageReorderRequest = {
stage_ids: string[];
};
export type WorkflowStageResponse = {
stage: WorkflowStageSummary;
};
export type WorkflowStageSummary = {
acceptance_criteria?: string[];
created_at: string;
description: string;
display_name: string;
expected_outputs?: string[];
id: string;
owner_role: Role;
parts?: WorkflowStagePart[];
position: number;
required_inputs?: string[];
requires_human_approval?: boolean;
retry_policy?: Record<string, unknown>;
runner_profile_key: string;
stable_key: string;
updated_at: string;
workflow_id: string;
};
export type WorkflowStageUpdateRequest = {
acceptance_criteria?: string[] | null;
description?: string | null;
display_name?: string | null;
expected_outputs?: string[] | null;
owner_role?: Role | null;
parts?: WorkflowStagePart[] | null;
position?: number | null;
required_inputs?: string[] | null;
requires_human_approval?: boolean | null;
retry_policy?: Record<string, unknown> | null;
runner_profile_key?: string | null;
stable_key?: string | null;
};
export type WorkflowTemplateAuditEventListResponse = {
events?: WorkflowTemplateAuditEventSummary[];
};
export type WorkflowTemplateAuditEventSummary = {
actor_user_id?: string | null;
created_at: string;
event_type: string;
id: string;
payload?: Record<string, unknown>;
workflow_id: string;
};
export type WorkflowTemplateCreateRequest = {
description: string;
name: string;
slug: string;
};
export type WorkflowTemplateListResponse = {
workflows?: WorkflowTemplateSummary[];
};
export type WorkflowTemplateResponse = {
workflow: WorkflowTemplateSummary;
};
export type WorkflowTemplateSnapshot = {
id: string;
name: string;
slug: string;
stage_summary?: WorkflowTemplateStageSnapshot[];
version: number;
};
export type WorkflowTemplateStageSnapshot = {
display_name: string;
id: string;
owner_role: Role;
position: number;
requires_human_approval?: boolean;
runner_profile_key: string;
stable_key: string;
};
export type WorkflowTemplateStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
export type WorkflowTemplateSummary = {
activated_at?: string | null;
archived_at?: string | null;
created_at: string;
created_by: string;
description: string;
id: string;
name: string;
slug: string;
stages?: WorkflowStageSummary[];
status: WorkflowTemplateStatus;
updated_at: string;
updated_by?: string | null;
version: number;
};
export type WorkflowTemplateUpdateRequest = {
description?: string | null;
name?: string | null;
slug?: string | null;
};
+37 -14
View File
@@ -34,14 +34,14 @@ Development description: Implement the final review gate that prevents publishin
## Acceptance Criteria ## 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`. - [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`.
- [ ] Final approval requires an exact draft version. - [x] Final approval requires an exact draft version.
- [ ] High-risk unsupported claims block approval. - [x] High-risk unsupported claims block approval.
- [ ] Missing required publishing settings block approval. - [x] Missing required publishing settings block approval.
- [ ] Unapproved required assets block approval. - [x] Unapproved required assets block approval.
- [ ] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings. - [x] Approval writes a workflow event with actor, timestamp, draft version, and publishing settings.
- [ ] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`. - [x] Revision request writes event and moves article to `FINAL_REVISION_REQUIRED`.
- [ ] UI shows blockers and completed checklist items. - [x] UI shows blockers and completed checklist items.
## Verification ## Verification
@@ -51,9 +51,32 @@ Development description: Implement the final review gate that prevents publishin
## Result ## Result
- Status: Pending execution. - Status: Done (TDD RED -> GREEN completed).
- TDD plan: To be filled during execution. - TDD progression:
- Red evidence: To be filled during execution. 1. Pre-requirement RED test created for high-risk unsupported claims blocker.
- Green evidence: To be filled during execution. 2. Implemented final approval gate service with checklist evaluation on assembled draft + evidence + assets + review state.
- Refactor notes: To be filled during execution. 3. Added public endpoints:
- Verification output: To be filled during execution. - `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 ## 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`. - [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`.
- [ ] Publishing cannot dry-run before final approval. - [x] Publishing cannot dry-run before final approval.
- [ ] Dry run fails if generic Markdown/MDX content-shape validation fails. - [x] Dry run fails if generic Markdown/MDX content-shape validation fails.
- [ ] Publish commit cannot run until dry run passes. - [x] Publish commit cannot run until dry run passes.
- [ ] Content bundle uses site-configured path templates and frontmatter mapping. - [x] Content bundle uses site-configured path templates and frontmatter mapping.
- [ ] Active YAML/script config version is included in publish logs/manifest. - [x] Active YAML/script config version is included in publish logs/manifest.
- [ ] Publish writes commit to configured production branch in a test repository. - [x] Publish writes commit to configured production branch in a test repository.
- [ ] Non-fast-forward push or conflict fails without automatic rebase. - [x] Non-fast-forward push or conflict fails without automatic rebase.
- [ ] Publish commit record stores repository URL, branch, commit SHA, bundle manifest, and status. - [x] 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] UI clearly labels validation as best-effort content-shape validation only.
## Verification ## Verification
@@ -53,9 +53,58 @@ Development description: Implement the Git-backed publishing path that builds a
## Result ## Result
- Status: Pending execution. - Status: Done (TDD RED -> GREEN completed).
- TDD plan: To be filled during execution. - TDD progression:
- Red evidence: To be filled during execution. 1. Restored and expanded `apps/backend/tests/integration/test_publishing_git_flow_public_api.py` from RED baseline.
- Green evidence: To be filled during execution. 2. Added incremental behavior coverage:
- Refactor notes: To be filled during execution. - dry-run blocked before final approval,
- Verification output: To be filled during execution. - 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 ## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`. - [x] TDD pre-requirement: before implementation, write one failing behavior test showing a failed job appears in article workflow history with retry eligibility; proceed one operational behavior at a time and record evidence in `Result`.
- [ ] Article detail page shows user, system, and agent events in order. - [x] Article detail page shows user, system, and agent events in order.
- [ ] Job logs are stored and displayed with sensitive values redacted. - [x] Job logs are stored and displayed with sensitive values redacted.
- [ ] Retry buttons appear only where retry is allowed. - [x] Retry buttons appear only where retry is allowed.
- [ ] Cancelling queued/running jobs prevents article state mutation. - [x] Cancelling queued/running jobs prevents article state mutation.
- [ ] Publish commit retry is blocked once a publish commit exists. - [x] Publish commit retry is blocked once a publish commit exists.
- [ ] Admin can see detailed logs; Editor sees safe failure summary. - [x] Admin can see detailed logs; Editor sees safe failure summary.
- [ ] All Admin script/config changes are audit-visible with diff and rollback target. - [x] All Admin script/config changes are audit-visible with diff and rollback target.
## Verification ## Verification
@@ -62,9 +62,42 @@ Development description: Build the operational layer for workflow history, job l
## Result ## Result
- Status: Pending execution. - Status: Implemented and verified (GREEN).
- TDD plan: To be filled during execution. - Green evidence:
- Red evidence: To be filled during execution. - Backend observability implemented:
- Green evidence: To be filled during execution. - role-aware job projection with `retry_eligible`, `retry_block_reason`, `cancel_eligible`, `safe_failure_summary`,
- Refactor notes: To be filled during execution. - redaction for payload/log/error message,
- Verification output: To be filled during execution. - mixed timeline (`USER`/`SYSTEM`/`AGENT`) sorted by event time.
- Retry policy enforced server-side:
- retry allowed only for configured job types,
- publish commit retry blocked if `PUBLISH_COMMIT_CREATED` already exists.
- Cancel behavior enforced:
- cancelled queued/running jobs stay `CANCELLED`,
- completion callback after cancellation does not mutate article workflow state.
- Admin/editor visibility:
- admin sees redacted `stdout`/`stderr`,
- editor sees safe failure summary and no raw logs.
- Script/config audit visibility:
- added `GET /api/sites/{site_id}/publishing-config/audit`,
- audit list includes `event_type`, `diff`, and `rollback_target_version_id`.
- Frontend:
- article detail timeline renders user/system/agent entries in order,
- failure table shows job type/status/error category/error message/last successful step,
- retry/cancel controls rendered only when policy allows,
- role-based diagnostics panel (admin logs vs editor summary),
- admin scripts page now shows audit trail with diff + rollback target.
- Contracts regenerated:
- `packages/shared/openapi.json`,
- `packages/shared/src/api-types.ts`.
- Refactor notes:
- Introduced focused backend module `apps/backend/src/application/observability.py` to keep redaction, retry policy, and timeline projection out of route handlers and domain-agnostic application services.
- Reused policy projection in both article detail and agent-job endpoints so UI and action gates stay consistent.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_observability_retry_cancel_audit_public_api.py`
- `Ran 7 tests in 0.704s` -> `OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_agent_job_queue_public_api.py apps/backend/tests/integration/test_parallel_production_public_api.py apps/backend/tests/integration/test_publishing_git_flow_public_api.py apps/backend/tests/integration/test_final_approval_gate_public_api.py apps/backend/tests/integration/test_script_config_audit.py`
- `Ran 23 tests in 4.656s` -> `OK`
- `node apps/frontend/tests/article_detail.model.test.mjs && node apps/frontend/tests/admin_script_versions.model.test.mjs`
- exit code `0` (pass)
- `pnpm --dir apps/frontend typecheck`
- `tsc --noEmit` -> pass
+48 -15
View File
@@ -37,15 +37,15 @@ Development description: Assemble and verify a complete demo-ready Docker Compos
## Acceptance Criteria ## 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`. - [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`.
- [ ] `docker compose up --build` starts the complete demo stack. - [x] `docker compose up --build` starts the complete demo stack.
- [ ] Demo requires no real Codex, search, WHOIS, cloud S3, or GitHub credentials. - [x] 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`. - [x] 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. - [x] Final commit contains Markdown/MDX, frontmatter, and referenced assets according to site config.
- [ ] Object storage contains research source-section artifacts and manifests. - [x] Object storage contains research source-section artifacts and manifests.
- [ ] Workflow timeline shows all major actions. - [x] Workflow timeline shows all major actions.
- [ ] Demo includes at least one visible failure/retry scenario. - [x] 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] README/demo guide is accurate and enough for a new developer to run the product.
## Verification ## Verification
@@ -56,9 +56,42 @@ Development description: Assemble and verify a complete demo-ready Docker Compos
## Result ## Result
- Status: Pending execution. - Status: Implemented and verified (GREEN).
- TDD plan: To be filled during execution. - Delivered:
- Red evidence: To be filled during execution. 1. Deterministic demo runtime (`PIPELINE_DEMO_MODE`) with local bare Git publish target bootstrap.
- Green evidence: To be filled during execution. 2. Demo seed data now points to local repository path in demo mode.
- Refactor notes: To be filled during execution. 3. Deterministic section scaffold fail-once behavior with visible retry success path.
- Verification output: To be filled during execution. 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.
+79
View File
@@ -0,0 +1,79 @@
# Task 021: Admin Workflow Template API
Development description: Build the backend domain, persistence, and public API for Admin-managed workflow templates with editable ordered stages and stage parts.
## Implementation Details
- Backend contract:
- `POST /api/admin/workflows`
- `GET /api/admin/workflows`
- `GET /api/admin/workflows/{workflow_id}`
- `PATCH /api/admin/workflows/{workflow_id}`
- `POST /api/admin/workflows/{workflow_id}/stages`
- `PATCH /api/admin/workflows/{workflow_id}/stages/{stage_id}`
- `DELETE /api/admin/workflows/{workflow_id}/stages/{stage_id}`
- `POST /api/admin/workflows/{workflow_id}/stages/reorder`
- `POST /api/admin/workflows/{workflow_id}/activate`
- `POST /api/admin/workflows/{workflow_id}/archive`
- Workflow template fields:
- Name.
- Slug.
- Description.
- Status: `DRAFT`, `ACTIVE`, `ARCHIVED`.
- Version number.
- Created/updated metadata.
- Workflow stage fields:
- Stable key.
- Display name.
- Description.
- Position.
- Owner role.
- Runner profile key.
- Required inputs.
- Expected outputs.
- Acceptance criteria.
- Human approval requirement.
- Retry policy.
- Workflow stage parts:
- Store as structured JSON under each stage for v1.
- Each part has key, type, title, prompt/config payload, and acceptance criteria.
- Repository/schema:
- Add relational tables for workflow templates and stages.
- Preserve structured stage-part JSON without ad hoc string parsing.
- Add audit events for create/update/reorder/activate/archive.
- Authorization:
- Admin can create and mutate workflow templates.
- Editor can read active workflow templates but cannot mutate them.
## Public Interface
- Admin can create a workflow template, add/edit/reorder stages, activate it, and archive it.
- Editor can list/read active workflow templates for article creation context only.
- Mutating endpoints consistently reject Editor requests.
## Acceptance Criteria
- [x] TDD pre-requirement: before implementation, write one failing Admin API test for creating a workflow template with two editable stages and one failing Editor mutation denial test; proceed one behavior at a time and record evidence in `Result`.
- [x] Workflow templates are persisted with status, version, audit metadata, and ordered stages.
- [x] Stage parts are persisted as structured JSON and returned unchanged through the public API.
- [x] Admin can update stage owner role, runner profile, inputs, outputs, acceptance criteria, human approval flag, retry policy, and stage parts.
- [x] Admin can reorder stages and the returned workflow reflects the new order.
- [x] Activation creates an immutable version increment and archives no data.
- [x] Archive hides workflow from Editor list but keeps Admin history visible.
- [x] Editor mutation attempts return the same authorization error shape used by existing Admin APIs.
- [x] OpenAPI/shared contracts include workflow template request/response schemas.
## Verification
- Run backend workflow-template API integration tests.
- Run backend authorization integration tests covering Admin/Editor access.
- Run schema initialization against the test database.
## Result
- Status: Implemented.
- TDD plan: Use the existing pre-requirement API tests in `apps/backend/tests/integration/test_admin_workflow_templates_public_api.py`, then cover the remaining acceptance criteria with a focused API flow for reorder, activate/archive, editor visibility, admin history, and audit persistence.
- Red evidence: Pre-implementation `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` failed with two public API assertions: expected `201`/`403`, actual `404 {"detail":"Not Found"}` for missing `/api/admin/workflows`.
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` -> `Ran 3 tests ... OK`.
- Refactor notes: Added workflow template status/contracts, admin/editor router, application service, SQLite/Postgres tables, repositories, ordered stage persistence, structured stage-part JSON, activation/archive, and audit events. Active workflow templates are readable by editors; draft/archived templates remain admin history only. Draft-only mutation preserves activated version immutability.
- Verification output: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_public_openapi_contract` -> `Ran 1 test ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_schema_storage_contracts` -> `Ran 3 tests ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m py_compile ...` for changed backend modules -> OK. Existing broader auth suite was also attempted and still fails in `test_editor_can_create_articles_and_approve_plan_review` with `404 {"detail":"Not Found"}` while approving nonexistent plan id `00000000-0000-0000-0000-000000000123`; left unchanged.
+69
View File
@@ -0,0 +1,69 @@
# Task 022: Admin Workflow Builder UI
Development description: Build the Admin dashboard workflow builder so Admins can create workflows and edit their ordered stages and stage parts from the frontend using the existing FSD structure.
## Implementation Details
- Frontend FSD boundaries:
- `pages/admin-workflows` owns the route-level page.
- `features/admin-workflows` owns workflow list/create/edit interactions.
- `entities/workflow-template` owns workflow template types, selectors, and formatters if shared across pages.
- `shared/pipeline-api` owns HTTP client methods only.
- Admin navigation:
- Add Workflows item to Admin navigation.
- Keep Editor navigation unchanged.
- Workflow list:
- Show name, status, version, updated timestamp, stage count, and active/archive actions.
- Allow creating a new draft workflow.
- Workflow editor:
- Edit workflow metadata.
- Add/edit/delete stages.
- Reorder stages.
- Edit stage parts as structured rows/cards with type, title, config payload, and acceptance criteria.
- Edit human approval flag, owner role, runner profile key, retry policy, required inputs, and expected outputs.
- UX constraints:
- Use dense Admin dashboard layout consistent with existing Admin screens.
- Avoid marketing/landing-page treatment.
- Show API errors inline near the relevant form/action.
- Disable destructive actions while requests are pending.
- Tests:
- Add frontend model tests for workflow builder state transitions.
- Add API client tests/mocks if the current test pattern supports them.
## Public Interface
- Admin can open `/admin/workflows`, create a draft workflow, edit stages/parts, reorder stages, activate, and archive.
- Editor does not see the Workflows nav item.
## Acceptance Criteria
- [x] TDD pre-requirement: before implementation, write one failing frontend model test for creating a workflow draft and adding/reordering stages; proceed one behavior at a time and record evidence in `Result`.
- [x] Admin navigation exposes Workflows and routes to the builder page.
- [x] Editor navigation does not expose Workflows.
- [x] Workflow list loads from the backend API and shows empty/error/loading states.
- [x] Workflow editor can create workflow drafts and persist metadata.
- [x] Workflow editor can add, edit, delete, and reorder stages.
- [x] Workflow editor can edit structured stage parts without converting them to opaque strings.
- [x] Activation/archive actions update UI state after successful API responses.
- [x] Frontend code follows the existing FSD folder boundaries.
## Verification
- Run frontend workflow builder model tests.
- Run frontend Admin navigation tests.
- Run frontend typecheck.
- Manually verify Admin workflow create/edit/reorder/activate/archive in Docker Compose.
## Result
- Status: Implemented frontend admin workflow builder.
- TDD plan: Use `apps/frontend/tests/admin_workflows.model.test.mjs` as the red pre-requirement, then add model helpers, API client methods, navigation, and the route UI.
- Red evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` failed because `features/admin-workflows/model.ts`, workflow API methods, and the Admin Workflows nav item were missing.
- Green evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` passed after adding workflow model/API/nav behavior.
- Refactor notes: Added shared workflow template contract types, dense Admin workflow list/create/editor UI, structured stage part JSON editing, stage reorder controls, activation/archive actions, audit display, and explicit frontend workspace root config for local Next runs.
- Verification output:
- `node apps/frontend/tests/admin_workflows.model.test.mjs` - passed.
- `node apps/frontend/tests/role-navigation.test.mjs` - passed: `role navigation hides Admin-only items for Editors`.
- `pnpm -C apps/frontend run test:ui` - passed.
- `pnpm -C apps/frontend run typecheck` - passed.
- Browser smoke was attempted with a local mock backend, but local Next dev startup is blocked by existing dependency drift: `Mismatching @next/swc version, detected: 15.5.4 while Next.js is on 16.2.6` and `bindings.lockfileTryAcquireSync is not a function`.
@@ -0,0 +1,62 @@
# Task 023: Workflow Template Binding And Demo Readiness
Development description: Bind Admin-managed workflow templates into the demo product flow so article/site configuration references an active workflow template and the final demo checklist proves workflow management is not an isolated Admin CRUD surface.
## Implementation Details
- Backend binding:
- Add active workflow template reference to target site configuration or article creation input, following the existing product model.
- Validate that article creation cannot reference archived or missing workflow templates.
- Preserve existing article workflow status behavior while exposing the configured workflow template snapshot in article detail.
- Store the workflow template version/snapshot used by each article so later template edits do not rewrite historical article context.
- Frontend binding:
- Article creation/site configuration shows the selected active workflow template.
- Article detail shows the configured workflow template and stage summary in the workflow/history area.
- Demo readiness:
- Update demo seed data with at least one active workflow template.
- Update demo checklist/protocol to include Admin workflow creation/editing and article creation using the active workflow.
- Keep the existing end-to-end publishing path working.
## Public Interface
- Admin can configure which active workflow template is used by a site or article.
- Editor can see which workflow template governs an article.
- Demo reviewers can prove the Admin workflow builder affects the article flow.
## Acceptance Criteria
- [x] TDD pre-requirement: before implementation, write one failing public API test showing article creation stores the active workflow template snapshot; proceed one behavior at a time and record evidence in `Result`.
- [x] Seed data includes an active workflow template with stages matching the demo pipeline.
- [x] Article creation records the workflow template id, version, and stage summary snapshot.
- [x] Article creation rejects archived/missing workflow template references.
- [x] Article detail exposes workflow template summary to the frontend.
- [x] Site configuration or article creation UI allows selecting an active workflow template.
- [x] Existing end-to-end demo publishing smoke still passes.
- [x] Final demo readiness protocol includes manual steps for Admin workflow create/edit/reorder/activate and Editor article flow using that workflow.
## Verification
- Run backend article creation workflow-template binding tests.
- Run existing end-to-end demo stack smoke test.
- Run frontend model tests for the selected workflow template display.
- Run final manual demo checklist and record the protocol.
## Result
- Status: Implemented.
- TDD plan: Use `apps/backend/tests/integration/test_workflow_template_binding_public_api.py` as the public API red test for article creation with an active workflow template, archived/missing template validation, and immutable detail snapshot after workflow archival. Then wire persistence/contracts, seed data, frontend intake/detail display, and demo protocol updates.
- Red evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` failed with all 3 tests returning `422 extra_forbidden` for `workflow_template_id`.
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
- Refactor notes: Added optional article `workflow_template_id`, stored workflow template id/version plus immutable JSON snapshot, active-template validation, seeded active demo workflow stages, generated OpenAPI/shared TypeScript contracts, article intake active-workflow selector, and article detail snapshot display. Existing article workflow statuses remain unchanged.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_generated_contract_artifacts apps.backend.tests.contracts.test_public_openapi_contract -v` -> `Ran 3 tests ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_end_to_end_demo_stack_smoke_public_api -v` -> `Ran 1 test ... OK`.
- `node apps/frontend/tests/article_form_validation.test.mjs` -> passed.
- `node apps/frontend/tests/article_detail.model.test.mjs` -> passed.
- `pnpm --filter @pipeline/frontend test:ui` -> passed.
- `pnpm typecheck` -> passed.
- `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_auth_authorization_public_api.AuthAuthorizationPublicApiTest.test_editor_can_create_articles_and_approve_plan_review -v` -> `Ran 1 test ... OK` after correcting the stale hard-coded missing plan id fixture to approve the generated public API plan.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest discover apps/backend/tests -v` -> `Ran 87 tests ... OK (skipped=1)`.
- `docs/demo-readiness-protocol-2026-05-22.md` records the final Task 023 demo readiness protocol.
@@ -0,0 +1,30 @@
# Task 024: Demo Browser API Reachability
Development description: Make the Docker Compose demo browser actions call the backend through a browser-reachable URL with CORS enabled, so Admin workflow editing and Editor article creation work during a live demo.
## Acceptance Criteria
- [x] Frontend server-side rendering still uses the internal Docker backend URL.
- [x] Browser-side frontend actions use a localhost backend URL mapped by Docker Compose.
- [x] Backend allows the demo frontend origin and `X-Demo-User-Email` header.
- [x] Admin workflow `Open` action works in the browser demo stack.
- [x] Editor article creation works in the browser demo stack.
## Result
- Status: Implemented.
- Added backend CORS middleware for demo frontend origins, configurable through `PIPELINE_CORS_ALLOW_ORIGINS`.
- Updated Docker Compose so `NEXT_PUBLIC_BACKEND_URL` points to `http://localhost:${BACKEND_PORT:-8000}` while `BACKEND_URL` remains `http://backend:8000` for server-side rendering.
- Switched browser-side API calls to same-origin `/api/*` through Next rewrites, keeping `BACKEND_URL=http://backend:8000` inside Docker.
- Fixed the Postgres article insert path by applying JSON casting to the workflow template snapshot column, not `publishing_status`.
- Updated Next 16 dynamic route adapters to await `params` and marked live-data pages dynamic.
- Added a public API integration test covering preflight for `http://localhost:13300` with `X-Demo-User-Email`.
- Verification:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 1 test ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 5 tests ... OK`.
- `pnpm typecheck` -> passed.
- `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`.
- Browser smoke on Docker demo stack:
- `/admin/workflows` -> Open seeded workflow -> stage editor loaded with 7 stages and no error.
- `/articles/new` -> create article with active workflow -> redirect to article detail.
- Article detail -> shows workflow template name, slug, version, 7-stage snapshot, and timeline.
+1
View File
@@ -23,6 +23,7 @@ This task pool turns `IDEA.md` into vertical, testable implementation slices tha
- Backend is FastAPI. - Backend is FastAPI.
- Frontend is Next.js. - Frontend is Next.js.
- Workflow orchestration uses LangGraph, while article/domain tables remain authoritative for product state. - Workflow orchestration uses LangGraph, while article/domain tables remain authoritative for product state.
- Workflow templates, stages, and stage parts are Admin-managed product configuration, not hard-coded UI-only metadata.
- Queue is Redis-backed Celery, Dramatiq, or RQ. - Queue is Redis-backed Celery, Dramatiq, or RQ.
- Postgres stores domain state and manifests, not large source snapshots. - Postgres stores domain state and manifests, not large source snapshots.
- Object storage stores research source-section artifacts, assets, and job artifacts. - Object storage stores research source-section artifacts, assets, and job artifacts.