Compare commits
12
Commits
c3914bf8be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8937d2864 | ||
|
|
5f3e543ea2 | ||
|
|
baff9211e8 | ||
|
|
cbd24d4a1c | ||
|
|
d1ae5c4437 | ||
|
|
5ea0d5978a | ||
|
|
7667819c46 | ||
|
|
95fec3ba26 | ||
|
|
318ae790ef | ||
|
|
d3990b4019 | ||
|
|
6dcbe0e36c | ||
|
|
8243f20b0e |
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -64,20 +82,76 @@ def get_article_detail(
|
|||||||
research_manifests = repository.research_manifests.list_for_article(article_id)
|
research_manifests = repository.research_manifests.list_for_article(article_id)
|
||||||
evidence = repository.evidence_items.list_for_article(article_id)
|
evidence = repository.evidence_items.list_for_article(article_id)
|
||||||
claims = repository.claims.list_for_article(article_id)
|
claims = repository.claims.list_for_article(article_id)
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
agent_jobs = repository.agent_jobs.list_for_article(article_id)
|
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,
|
||||||
draft=draft,
|
draft=draft,
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
claims=claims,
|
claims=claims,
|
||||||
agent_jobs=agent_jobs,
|
assets=assets,
|
||||||
|
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,361 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetStatus,
|
||||||
|
AssetSummary,
|
||||||
|
AssetType,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
CurrentUser,
|
||||||
|
PlanReviewStatus,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import ObjectStorageClient
|
||||||
|
|
||||||
|
|
||||||
|
def generate_asset_specs(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetGenerateSpecsResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
plan = _approved_plan(repository, article_id=article_id)
|
||||||
|
if plan is None or not plan.sections:
|
||||||
|
raise PermissionError("Approved plan with sections is required before asset specs")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
created_assets: list[AssetSummary] = []
|
||||||
|
for index, section in enumerate(plan.sections, start=1):
|
||||||
|
asset_type = _asset_type_for_section(index)
|
||||||
|
title = f"{section.heading} ({asset_type.value})"
|
||||||
|
asset = repository.assets.create(
|
||||||
|
article_id=article_id,
|
||||||
|
section_id=section.id,
|
||||||
|
asset_type=asset_type,
|
||||||
|
title=title,
|
||||||
|
prompt=(
|
||||||
|
"Create an editorial-ready visual for section "
|
||||||
|
f"'{section.heading}' with evidence-backed details."
|
||||||
|
),
|
||||||
|
object_key=None,
|
||||||
|
file_url=None,
|
||||||
|
alt_text=f"Visual for {section.heading}",
|
||||||
|
caption=f"Supporting visual for section: {section.heading}",
|
||||||
|
status=AssetStatus.GENERATED,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="SPEC_GENERATED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"section_id": str(section.id),
|
||||||
|
"asset_type": asset.asset_type.value,
|
||||||
|
"title": asset.title,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
created_assets.append(
|
||||||
|
repository.assets.get(article_id=article_id, asset_id=asset.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_SPECS_GENERATED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"count": len(created_assets),
|
||||||
|
"asset_ids": [str(asset.id) for asset in created_assets],
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return AssetGenerateSpecsResponse(assets=created_assets)
|
||||||
|
|
||||||
|
|
||||||
|
def list_assets(repository: object, *, article_id: UUID) -> AssetListResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
return AssetListResponse(assets=repository.assets.list_for_article(article_id))
|
||||||
|
|
||||||
|
|
||||||
|
def update_asset(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
next_section_id = request.section_id if request.section_id is not None else current.section_id
|
||||||
|
next_title = request.title if request.title is not None else current.title
|
||||||
|
next_prompt = request.prompt if request.prompt is not None else current.prompt
|
||||||
|
next_alt_text = request.alt_text if request.alt_text is not None else current.alt_text
|
||||||
|
next_caption = request.caption if request.caption is not None else current.caption
|
||||||
|
next_status = request.status if request.status is not None else current.status
|
||||||
|
changed_fields = _changed_fields(
|
||||||
|
current=current,
|
||||||
|
section_id=next_section_id,
|
||||||
|
title=next_title,
|
||||||
|
prompt=next_prompt,
|
||||||
|
alt_text=next_alt_text,
|
||||||
|
caption=next_caption,
|
||||||
|
status=next_status,
|
||||||
|
)
|
||||||
|
if not changed_fields:
|
||||||
|
return AssetResponse(asset=current)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=next_section_id,
|
||||||
|
title=next_title,
|
||||||
|
prompt=next_prompt,
|
||||||
|
object_key=current.object_key,
|
||||||
|
file_url=current.file_url,
|
||||||
|
alt_text=next_alt_text,
|
||||||
|
caption=next_caption,
|
||||||
|
status=next_status,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="ASSET_UPDATED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"changed_fields": changed_fields,
|
||||||
|
"status": next_status.value,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
if current.status != next_status:
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_STATUS_UPDATED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": next_status.value,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_asset(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> AssetResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
if current.status == AssetStatus.APPROVED:
|
||||||
|
return AssetResponse(asset=current)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=current.section_id,
|
||||||
|
title=current.title,
|
||||||
|
prompt=current.prompt,
|
||||||
|
object_key=current.object_key,
|
||||||
|
file_url=current.file_url,
|
||||||
|
alt_text=current.alt_text,
|
||||||
|
caption=current.caption,
|
||||||
|
status=AssetStatus.APPROVED,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
approved = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action="ASSET_APPROVED",
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": AssetStatus.APPROVED.value,
|
||||||
|
"object_key": approved.object_key,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="ASSET_APPROVED",
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"from_status": current.status.value,
|
||||||
|
"to_status": AssetStatus.APPROVED.value,
|
||||||
|
"object_key": approved.object_key,
|
||||||
|
"file_url": approved.file_url,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset_file(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
filename: str,
|
||||||
|
content_type: str | None,
|
||||||
|
content: bytes,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
object_storage: ObjectStorageClient,
|
||||||
|
) -> AssetUploadResponse:
|
||||||
|
current = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
now = _now()
|
||||||
|
revision_index = repository.assets.next_revision_index(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
)
|
||||||
|
safe_filename = _safe_filename(filename)
|
||||||
|
object_key = f"articles/{article_id}/assets/{asset_id}/r{revision_index}/{safe_filename}"
|
||||||
|
file_url = object_storage.put_bytes(
|
||||||
|
object_key=object_key,
|
||||||
|
content=content,
|
||||||
|
content_type=content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
had_previous_file = bool(current.object_key or current.file_url)
|
||||||
|
repository.assets.update(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
section_id=current.section_id,
|
||||||
|
title=current.title,
|
||||||
|
prompt=current.prompt,
|
||||||
|
object_key=object_key,
|
||||||
|
file_url=file_url,
|
||||||
|
alt_text=current.alt_text,
|
||||||
|
caption=current.caption,
|
||||||
|
status=AssetStatus.PENDING,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
action = "ASSET_FILE_REPLACED" if had_previous_file else "ASSET_FILE_UPLOADED"
|
||||||
|
repository.assets.create_revision(
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
revision_index=revision_index,
|
||||||
|
action=action,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"previous_object_key": current.object_key,
|
||||||
|
"previous_file_url": current.file_url,
|
||||||
|
"next_object_key": object_key,
|
||||||
|
"next_file_url": file_url,
|
||||||
|
"content_type": content_type or "application/octet-stream",
|
||||||
|
"filename": safe_filename,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type=action,
|
||||||
|
from_status=None,
|
||||||
|
to_status=None,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
payload={
|
||||||
|
"asset_id": str(asset_id),
|
||||||
|
"previous_object_key": current.object_key,
|
||||||
|
"next_object_key": object_key,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
refreshed = repository.assets.get(article_id=article_id, asset_id=asset_id)
|
||||||
|
return AssetUploadResponse(asset=refreshed)
|
||||||
|
|
||||||
|
|
||||||
|
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 _asset_type_for_section(index: int) -> AssetType:
|
||||||
|
ordered_types = [
|
||||||
|
AssetType.HERO_IMAGE,
|
||||||
|
AssetType.ARCHITECTURE_DIAGRAM,
|
||||||
|
AssetType.DIAGRAM,
|
||||||
|
AssetType.TABLE,
|
||||||
|
AssetType.FLOWCHART,
|
||||||
|
AssetType.COMPARISON_MATRIX,
|
||||||
|
AssetType.INLINE_IMAGE,
|
||||||
|
]
|
||||||
|
return ordered_types[(index - 1) % len(ordered_types)]
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_fields(
|
||||||
|
*,
|
||||||
|
current: AssetSummary,
|
||||||
|
section_id: UUID | None,
|
||||||
|
title: str,
|
||||||
|
prompt: str | None,
|
||||||
|
alt_text: str | None,
|
||||||
|
caption: str | None,
|
||||||
|
status: AssetStatus,
|
||||||
|
) -> list[str]:
|
||||||
|
changed: list[str] = []
|
||||||
|
if current.section_id != section_id:
|
||||||
|
changed.append("section_id")
|
||||||
|
if current.title != title:
|
||||||
|
changed.append("title")
|
||||||
|
if current.prompt != prompt:
|
||||||
|
changed.append("prompt")
|
||||||
|
if current.alt_text != alt_text:
|
||||||
|
changed.append("alt_text")
|
||||||
|
if current.caption != caption:
|
||||||
|
changed.append("caption")
|
||||||
|
if current.status != status:
|
||||||
|
changed.append("status")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(value: str) -> str:
|
||||||
|
filename = Path(value).name.strip()
|
||||||
|
if not filename:
|
||||||
|
return "asset.bin"
|
||||||
|
return filename.replace("/", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -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 ""
|
||||||
@@ -9,6 +9,7 @@ from src.domain.contracts import (
|
|||||||
AgentJobStatus,
|
AgentJobStatus,
|
||||||
AgentJobSummary,
|
AgentJobSummary,
|
||||||
AgentJobType,
|
AgentJobType,
|
||||||
|
AssetStatus,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
DraftAssembleResponse,
|
DraftAssembleResponse,
|
||||||
DraftFaqItem,
|
DraftFaqItem,
|
||||||
@@ -51,6 +52,10 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp
|
|||||||
visual_placeholders: list[str] = []
|
visual_placeholders: list[str] = []
|
||||||
evidence_references: list[str] = []
|
evidence_references: list[str] = []
|
||||||
unsupported_warnings = _collect_job_warnings(section_jobs)
|
unsupported_warnings = _collect_job_warnings(section_jobs)
|
||||||
|
approved_assets_by_section = _approved_assets_by_section(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
)
|
||||||
|
|
||||||
for section in approved_plan.sections:
|
for section in approved_plan.sections:
|
||||||
section_job = successful_jobs_by_section[str(section.id)]
|
section_job = successful_jobs_by_section[str(section.id)]
|
||||||
@@ -61,6 +66,7 @@ def assemble_draft(repository: object, *, article_id: UUID) -> DraftAssembleResp
|
|||||||
section_id=section.id,
|
section_id=section.id,
|
||||||
heading=section.heading,
|
heading=section.heading,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
approved_assets=approved_assets_by_section.get(str(section.id), []),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
|
evidence_references.extend(_string_list(payload.get("used_evidence_ids")))
|
||||||
@@ -279,7 +285,11 @@ def _visual_placeholders_for_section(
|
|||||||
section_id: UUID,
|
section_id: UUID,
|
||||||
heading: str,
|
heading: str,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
|
approved_assets: list[object],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
if approved_assets:
|
||||||
|
return [_approved_asset_markdown(asset) for asset in approved_assets]
|
||||||
|
|
||||||
suggested_visuals = payload.get("suggested_visuals")
|
suggested_visuals = payload.get("suggested_visuals")
|
||||||
placeholders: list[str] = []
|
placeholders: list[str] = []
|
||||||
if isinstance(suggested_visuals, list):
|
if isinstance(suggested_visuals, list):
|
||||||
@@ -293,6 +303,30 @@ def _visual_placeholders_for_section(
|
|||||||
return [f""]
|
return [f""]
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_assets_by_section(repository: object, *, article_id: UUID) -> dict[str, list[object]]:
|
||||||
|
assets = repository.assets.list_for_article(article_id)
|
||||||
|
by_section: dict[str, list[object]] = {}
|
||||||
|
for asset in assets:
|
||||||
|
if asset.status != AssetStatus.APPROVED:
|
||||||
|
continue
|
||||||
|
if not asset.section_id:
|
||||||
|
continue
|
||||||
|
if not asset.file_url:
|
||||||
|
continue
|
||||||
|
by_section.setdefault(str(asset.section_id), []).append(asset)
|
||||||
|
return by_section
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_asset_markdown(asset: object) -> str:
|
||||||
|
alt = _string_value(getattr(asset, "alt_text", None)) or _string_value(
|
||||||
|
getattr(asset, "title", None)
|
||||||
|
)
|
||||||
|
src = _string_value(getattr(asset, "file_url", None))
|
||||||
|
if not src:
|
||||||
|
return ""
|
||||||
|
return f""
|
||||||
|
|
||||||
|
|
||||||
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
|
def _visual_label(value: Any, *, heading: str, fallback_index: int) -> str:
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(value, str) and value.strip():
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||||
|
|
||||||
|
from src.application.drafts import update_draft
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
DraftSummary,
|
||||||
|
DraftUpdateRequest,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SEO_RULES = {
|
||||||
|
"title_min": 30,
|
||||||
|
"title_max": 60,
|
||||||
|
"meta_title_min": 30,
|
||||||
|
"meta_title_max": 60,
|
||||||
|
"meta_description_min": 120,
|
||||||
|
"meta_description_max": 160,
|
||||||
|
"schema_type": "Article",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_LANGUAGE_RULES = {
|
||||||
|
"max_sentence_words": 24,
|
||||||
|
"forbidden_phrases": ["best ever", "guaranteed"],
|
||||||
|
"brand_tone": "neutral",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_seo_review(repository: object, *, article_id: UUID) -> SeoReviewRunResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
draft = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
rules = _resolve_seo_rules(site.seo_rules)
|
||||||
|
issues = _build_seo_issues(
|
||||||
|
article_id=article_id,
|
||||||
|
title=draft.title,
|
||||||
|
meta_title=draft.meta_title or draft.title,
|
||||||
|
meta_description=draft.meta_description or "",
|
||||||
|
body_markdown=draft.body_markdown,
|
||||||
|
primary_keyword=article.primary_keyword or "",
|
||||||
|
rules=rules,
|
||||||
|
)
|
||||||
|
recommended_title = _recommended_title(
|
||||||
|
title=draft.title,
|
||||||
|
primary_keyword=article.primary_keyword or "",
|
||||||
|
)
|
||||||
|
recommended_slug = _slugify(recommended_title)
|
||||||
|
score = max(0, 100 - len(issues) * 12)
|
||||||
|
now = _now()
|
||||||
|
report = repository.content_reviews.create_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
draft_id=draft.id,
|
||||||
|
score=score,
|
||||||
|
recommended_slug=recommended_slug,
|
||||||
|
recommended_title=recommended_title,
|
||||||
|
schema_json={
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": str(rules.get("schema_type", "Article")),
|
||||||
|
"headline": recommended_title,
|
||||||
|
"inLanguage": article.language,
|
||||||
|
},
|
||||||
|
rules_snapshot=rules,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
_upsert_report_suggestions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
issues=issues,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
latest = repository.content_reviews.get_report(
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
)
|
||||||
|
_mark_review_ready(repository, article_id=article_id, event_type="SEO_REVIEW_COMPLETED", count=len(latest.issues))
|
||||||
|
return SeoReviewRunResponse(report=latest)
|
||||||
|
|
||||||
|
|
||||||
|
def get_seo_report(repository: object, *, article_id: UUID) -> SeoReviewReportResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
report = repository.content_reviews.latest_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
)
|
||||||
|
if report is None:
|
||||||
|
raise LookupError(f"SEO report not found for article: {article_id}")
|
||||||
|
return SeoReviewReportResponse(report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def run_language_review(repository: object, *, article_id: UUID) -> LanguageReviewRunResponse:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
draft = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
site = repository.target_sites.get_by_id(article.target_site_id)
|
||||||
|
rules = _resolve_language_rules(site.seo_rules, brand_voice=site.brand_voice)
|
||||||
|
issues = _build_language_issues(
|
||||||
|
article_id=article_id,
|
||||||
|
body_markdown=draft.body_markdown,
|
||||||
|
rules=rules,
|
||||||
|
)
|
||||||
|
score = max(0, 100 - len(issues) * 14)
|
||||||
|
now = _now()
|
||||||
|
report = repository.content_reviews.create_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
draft_id=draft.id,
|
||||||
|
score=score,
|
||||||
|
recommended_slug=draft.slug,
|
||||||
|
recommended_title=draft.title,
|
||||||
|
schema_json={
|
||||||
|
"tone": rules.get("brand_tone"),
|
||||||
|
"language": article.language,
|
||||||
|
"max_sentence_words": rules.get("max_sentence_words"),
|
||||||
|
},
|
||||||
|
rules_snapshot=rules,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
_upsert_report_suggestions(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
issues=issues,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
latest = repository.content_reviews.get_report(
|
||||||
|
article_id=article_id,
|
||||||
|
report_id=report.id,
|
||||||
|
)
|
||||||
|
_mark_review_ready(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
event_type="LANGUAGE_REVIEW_COMPLETED",
|
||||||
|
count=len(latest.issues),
|
||||||
|
)
|
||||||
|
return LanguageReviewRunResponse(report=latest)
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_report(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> LanguageReviewReportResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
report = repository.content_reviews.latest_report(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
)
|
||||||
|
if report is None:
|
||||||
|
raise LookupError(f"Language report not found for article: {article_id}")
|
||||||
|
return LanguageReviewReportResponse(report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def accept_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
suggestion = repository.content_reviews.get_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
draft = _apply_suggestion_to_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=None,
|
||||||
|
)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.ACCEPTED,
|
||||||
|
applied_text=None,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.REJECTED,
|
||||||
|
applied_text=None,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=None)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_suggestion(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
suggestion = repository.content_reviews.get_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
draft = _apply_suggestion_to_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=request.applied_text,
|
||||||
|
)
|
||||||
|
updated = repository.content_reviews.update_suggestion(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
status=ReviewSuggestionStatus.EDITED,
|
||||||
|
applied_text=request.applied_text,
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
return ReviewSuggestionActionResponse(suggestion=updated, draft=draft)
|
||||||
|
|
||||||
|
|
||||||
|
def get_final_review_issues(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> FinalReviewIssuesResponse:
|
||||||
|
repository.articles.get(article_id)
|
||||||
|
unresolved = repository.content_reviews.list_unresolved_for_article(article_id=article_id)
|
||||||
|
return FinalReviewIssuesResponse(
|
||||||
|
article_id=article_id,
|
||||||
|
unresolved_count=len(unresolved),
|
||||||
|
unresolved_issues=[
|
||||||
|
FinalReviewIssueSummary(
|
||||||
|
review_kind=item.review_kind,
|
||||||
|
suggestion_id=item.id,
|
||||||
|
severity=item.severity,
|
||||||
|
location=item.location,
|
||||||
|
message=item.message,
|
||||||
|
status=item.status,
|
||||||
|
)
|
||||||
|
for item in unresolved
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_latest_draft(repository: object, *, article_id: UUID) -> DraftSummary:
|
||||||
|
draft = repository.article_drafts.latest_for_article(article_id)
|
||||||
|
if draft is None:
|
||||||
|
raise PermissionError("Assembled draft is required before review")
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_seo_rules(seo_rules: dict[str, object]) -> dict[str, object]:
|
||||||
|
overrides = seo_rules.get("seo_review", {}) if isinstance(seo_rules, dict) else {}
|
||||||
|
result = dict(DEFAULT_SEO_RULES)
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
result.update(overrides)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_language_rules(
|
||||||
|
seo_rules: dict[str, object],
|
||||||
|
*,
|
||||||
|
brand_voice: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
overrides = seo_rules.get("language_review", {}) if isinstance(seo_rules, dict) else {}
|
||||||
|
result = dict(DEFAULT_LANGUAGE_RULES)
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
result.update(overrides)
|
||||||
|
if not result.get("brand_tone"):
|
||||||
|
result["brand_tone"] = brand_voice
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seo_issues(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
title: str,
|
||||||
|
meta_title: str,
|
||||||
|
meta_description: str,
|
||||||
|
body_markdown: str,
|
||||||
|
primary_keyword: str,
|
||||||
|
rules: dict[str, object],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
issues: list[dict[str, object]] = []
|
||||||
|
title_min = int(rules.get("meta_title_min", 30))
|
||||||
|
title_max = int(rules.get("meta_title_max", 60))
|
||||||
|
if len(meta_title) < title_min or len(meta_title) > title_max:
|
||||||
|
suggested = _trim(meta_title or title, min_len=title_min, max_len=title_max)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "meta-title-length",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "meta_title",
|
||||||
|
"message": f"Meta title length should be between {title_min} and {title_max} characters.",
|
||||||
|
"suggested_fix": f"Use a {title_min}-{title_max} character meta title.",
|
||||||
|
"suggested_rewrite": suggested,
|
||||||
|
"patch": {"field": "meta_title", "value": suggested},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
desc_min = int(rules.get("meta_description_min", 120))
|
||||||
|
desc_max = int(rules.get("meta_description_max", 160))
|
||||||
|
if len(meta_description) < desc_min or len(meta_description) > desc_max:
|
||||||
|
suggested_description = _trim(meta_description or title, min_len=desc_min, max_len=desc_max)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "meta-description-length",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "meta_description",
|
||||||
|
"message": f"Meta description length should be between {desc_min} and {desc_max} characters.",
|
||||||
|
"suggested_fix": "Adjust meta description length to fit SEO rules.",
|
||||||
|
"suggested_rewrite": suggested_description,
|
||||||
|
"patch": {"field": "meta_description", "value": suggested_description},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if primary_keyword:
|
||||||
|
normalized_keyword = primary_keyword.lower()
|
||||||
|
if normalized_keyword not in body_markdown.lower():
|
||||||
|
append_sentence = f" This guide addresses {primary_keyword} with concrete steps."
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "keyword-placement",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "Primary keyword is missing from draft body.",
|
||||||
|
"suggested_fix": "Add primary keyword to introduction paragraph.",
|
||||||
|
"suggested_rewrite": append_sentence.strip(),
|
||||||
|
"patch": {
|
||||||
|
"field": "body_append",
|
||||||
|
"value": append_sentence,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "](/" not in body_markdown and "](/" not in body_markdown:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "internal-link",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "No internal links found in the draft.",
|
||||||
|
"suggested_fix": "Add at least one internal link to a related page.",
|
||||||
|
"suggested_rewrite": "Add [related guide](/guides/related-topic) in the intro section.",
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
headings = re.findall(r"^##\s+(.+)$", body_markdown, flags=re.MULTILINE)
|
||||||
|
duplicate = _first_duplicate(headings)
|
||||||
|
if duplicate:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "duplicate-h2",
|
||||||
|
"severity": "LOW",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Duplicate H2 heading detected: {duplicate}",
|
||||||
|
"suggested_fix": "Rename duplicate headings to keep a clear document structure.",
|
||||||
|
"suggested_rewrite": None,
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
issues.sort(key=lambda item: str(item["id"]))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _build_language_issues(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
body_markdown: str,
|
||||||
|
rules: dict[str, object],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
del article_id
|
||||||
|
issues: list[dict[str, object]] = []
|
||||||
|
max_sentence_words = int(rules.get("max_sentence_words", 24))
|
||||||
|
first_long_sentence = _first_sentence_longer_than(body_markdown, max_sentence_words)
|
||||||
|
if first_long_sentence:
|
||||||
|
rewrite = _shorten_sentence(first_long_sentence, max_words=max_sentence_words)
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "long-sentence",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Sentence exceeds {max_sentence_words} words.",
|
||||||
|
"suggested_fix": "Split or shorten long sentence.",
|
||||||
|
"suggested_rewrite": rewrite,
|
||||||
|
"patch": {
|
||||||
|
"field": "body_replace",
|
||||||
|
"target": first_long_sentence,
|
||||||
|
"value": rewrite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
forbidden_phrases = rules.get("forbidden_phrases", [])
|
||||||
|
if isinstance(forbidden_phrases, list):
|
||||||
|
lower_body = body_markdown.lower()
|
||||||
|
for phrase in [str(item) for item in forbidden_phrases]:
|
||||||
|
if phrase.lower() in lower_body:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": f"forbidden-{_slugify(phrase)}",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": f"Forbidden phrase found: '{phrase}'.",
|
||||||
|
"suggested_fix": "Replace phrase with neutral wording.",
|
||||||
|
"suggested_rewrite": "Replace with measured, evidence-based language.",
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
tone = str(rules.get("brand_tone", "neutral"))
|
||||||
|
if tone and tone.lower() in {"strict", "formal"} and "!" in body_markdown:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"id": "tone-exclamation",
|
||||||
|
"severity": "LOW",
|
||||||
|
"location": "body_markdown",
|
||||||
|
"message": "Exclamation marks conflict with strict/formal tone.",
|
||||||
|
"suggested_fix": "Use neutral punctuation.",
|
||||||
|
"suggested_rewrite": None,
|
||||||
|
"patch": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
issues.sort(key=lambda item: str(item["id"]))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_report_suggestions(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
report_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
issues: list[dict[str, object]],
|
||||||
|
timestamp: datetime,
|
||||||
|
) -> None:
|
||||||
|
for issue in issues:
|
||||||
|
suggestion_id = _deterministic_suggestion_id(
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
suggestion_key=str(issue["id"]),
|
||||||
|
)
|
||||||
|
repository.content_reviews.upsert_suggestion(
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=review_kind,
|
||||||
|
report_id=report_id,
|
||||||
|
suggestion_key=str(issue["id"]),
|
||||||
|
severity=str(issue["severity"]),
|
||||||
|
location=str(issue["location"]),
|
||||||
|
message=str(issue["message"]),
|
||||||
|
suggested_fix=_optional_string(issue.get("suggested_fix")),
|
||||||
|
suggested_rewrite=_optional_string(issue.get("suggested_rewrite")),
|
||||||
|
patch=issue.get("patch", {}) if isinstance(issue.get("patch"), dict) else {},
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deterministic_suggestion_id(
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
review_kind: ContentReviewKind,
|
||||||
|
suggestion_key: str,
|
||||||
|
) -> UUID:
|
||||||
|
return uuid5(
|
||||||
|
NAMESPACE_URL,
|
||||||
|
f"{article_id}:{review_kind.value}:{suggestion_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_suggestion_to_draft(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion: ContentReviewSuggestionSummary,
|
||||||
|
override_text: str | None,
|
||||||
|
) -> DraftSummary | None:
|
||||||
|
request = _build_draft_update_request(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
suggestion=suggestion,
|
||||||
|
override_text=override_text,
|
||||||
|
)
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
base = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
return update_draft(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
draft_id=base.id,
|
||||||
|
request=request,
|
||||||
|
).draft
|
||||||
|
|
||||||
|
|
||||||
|
def _build_draft_update_request(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion: ContentReviewSuggestionSummary,
|
||||||
|
override_text: str | None,
|
||||||
|
) -> DraftUpdateRequest | None:
|
||||||
|
patch = suggestion.patch
|
||||||
|
if not isinstance(patch, dict) or not patch:
|
||||||
|
return None
|
||||||
|
base = _require_latest_draft(repository, article_id=article_id)
|
||||||
|
field = _optional_string(patch.get("field"))
|
||||||
|
if field == "meta_title":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != (base.meta_title or ""):
|
||||||
|
return DraftUpdateRequest(meta_title=value)
|
||||||
|
return None
|
||||||
|
if field == "meta_description":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != (base.meta_description or ""):
|
||||||
|
return DraftUpdateRequest(meta_description=value)
|
||||||
|
return None
|
||||||
|
if field == "title":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value and value != base.title:
|
||||||
|
return DraftUpdateRequest(title=value)
|
||||||
|
return None
|
||||||
|
if field == "body_append":
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if value:
|
||||||
|
return DraftUpdateRequest(body_markdown=f"{base.body_markdown}{value}")
|
||||||
|
return None
|
||||||
|
if field == "body_replace":
|
||||||
|
target = _optional_string(patch.get("target"))
|
||||||
|
value = override_text or _optional_string(patch.get("value"))
|
||||||
|
if target and value and target in base.body_markdown:
|
||||||
|
return DraftUpdateRequest(body_markdown=base.body_markdown.replace(target, value, 1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_review_ready(
|
||||||
|
repository: object,
|
||||||
|
*,
|
||||||
|
article_id: UUID,
|
||||||
|
event_type: str,
|
||||||
|
count: int,
|
||||||
|
) -> None:
|
||||||
|
article = repository.articles.get(article_id)
|
||||||
|
now = _now()
|
||||||
|
if article.status != ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY:
|
||||||
|
repository.articles.update_status(
|
||||||
|
article_id=article_id,
|
||||||
|
status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository.articles.create_workflow_event(
|
||||||
|
article_id=article_id,
|
||||||
|
event_type=event_type,
|
||||||
|
from_status=article.status,
|
||||||
|
to_status=ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||||
|
actor_user_id=None,
|
||||||
|
payload={"issue_count": count},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_sentence_longer_than(value: str, max_words: int) -> str | None:
|
||||||
|
plain = re.sub(r"\[[^\]]+\]\([^)]+\)", "link", value)
|
||||||
|
plain = re.sub(r"^#+\s+", "", plain, flags=re.MULTILINE)
|
||||||
|
sentences = re.split(r"[.!?]\s+", plain)
|
||||||
|
for sentence in sentences:
|
||||||
|
words = [item for item in sentence.strip().split() if item]
|
||||||
|
if len(words) > max_words:
|
||||||
|
return sentence.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_sentence(sentence: str, *, max_words: int) -> str:
|
||||||
|
words = [item for item in sentence.split() if item]
|
||||||
|
if len(words) <= max_words:
|
||||||
|
return sentence.strip()
|
||||||
|
return " ".join(words[: max_words - 2] + ["with", "evidence."])
|
||||||
|
|
||||||
|
|
||||||
|
def _trim(value: str, *, min_len: int, max_len: int) -> str:
|
||||||
|
cleaned = " ".join(value.split())
|
||||||
|
if len(cleaned) > max_len:
|
||||||
|
return cleaned[: max_len - 1].rstrip() + "…"
|
||||||
|
if len(cleaned) < min_len:
|
||||||
|
suffix = " SEO guidance"
|
||||||
|
while len(cleaned) < min_len:
|
||||||
|
cleaned = f"{cleaned}{suffix}"
|
||||||
|
return cleaned[:max_len]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _first_duplicate(values: list[str]) -> str | None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
key = value.strip().lower()
|
||||||
|
if key in seen:
|
||||||
|
return value.strip()
|
||||||
|
seen.add(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _recommended_title(*, title: str, primary_keyword: str) -> str:
|
||||||
|
if primary_keyword and primary_keyword.lower() not in title.lower():
|
||||||
|
return _trim(f"{title}: {primary_keyword} guide", min_len=30, max_len=60)
|
||||||
|
return _trim(title, min_len=30, max_len=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
|
return normalized or "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -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)
|
||||||
@@ -3,6 +3,7 @@ from .enums import (
|
|||||||
AgentJobStatus,
|
AgentJobStatus,
|
||||||
AgentJobType,
|
AgentJobType,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
AssetStatus,
|
AssetStatus,
|
||||||
AssetType,
|
AssetType,
|
||||||
ClaimRiskLevel,
|
ClaimRiskLevel,
|
||||||
@@ -10,9 +11,11 @@ from .enums import (
|
|||||||
PlanReviewStatus,
|
PlanReviewStatus,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentJobOutput,
|
AgentJobOutput,
|
||||||
@@ -30,7 +33,30 @@ 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,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
ClaimSummary,
|
ClaimSummary,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
@@ -40,11 +66,19 @@ from .models import (
|
|||||||
DraftResponse,
|
DraftResponse,
|
||||||
DraftSummary,
|
DraftSummary,
|
||||||
DraftUpdateRequest,
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
EvidenceCreateRequest,
|
EvidenceCreateRequest,
|
||||||
EvidenceMatrixResponse,
|
EvidenceMatrixResponse,
|
||||||
EvidenceResponse,
|
EvidenceResponse,
|
||||||
EvidenceUpdateRequest,
|
EvidenceUpdateRequest,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
PlanSectionSummary,
|
PlanSectionSummary,
|
||||||
PlanSectionEditRequest,
|
PlanSectionEditRequest,
|
||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
@@ -53,14 +87,30 @@ from .models import (
|
|||||||
PlanSummary,
|
PlanSummary,
|
||||||
PlanUpdateRequest,
|
PlanUpdateRequest,
|
||||||
PublishCommitSummary,
|
PublishCommitSummary,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
ResearchListResponse,
|
ResearchListResponse,
|
||||||
ResearchStartResponse,
|
ResearchStartResponse,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
@@ -98,15 +148,43 @@ __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",
|
||||||
|
"AssetListResponse",
|
||||||
|
"AssetResponse",
|
||||||
|
"AssetRevisionSummary",
|
||||||
"ArticleWorkflowStatus",
|
"ArticleWorkflowStatus",
|
||||||
"AssetStatus",
|
"AssetStatus",
|
||||||
"AssetSummary",
|
"AssetSummary",
|
||||||
"AssetType",
|
"AssetType",
|
||||||
|
"AssetUpdateRequest",
|
||||||
|
"AssetUploadRequest",
|
||||||
|
"AssetUploadResponse",
|
||||||
"ClaimRiskLevel",
|
"ClaimRiskLevel",
|
||||||
"ClaimSummary",
|
"ClaimSummary",
|
||||||
"ClaimSupportStatus",
|
"ClaimSupportStatus",
|
||||||
"CurrentUser",
|
"CurrentUser",
|
||||||
"CurrentUserResponse",
|
"CurrentUserResponse",
|
||||||
|
"ContentReviewIssueSummary",
|
||||||
|
"ContentReviewKind",
|
||||||
|
"ContentReviewReportSummary",
|
||||||
|
"ContentReviewSuggestionSummary",
|
||||||
"DraftAssembleResponse",
|
"DraftAssembleResponse",
|
||||||
"DraftFaqItem",
|
"DraftFaqItem",
|
||||||
"DraftListResponse",
|
"DraftListResponse",
|
||||||
@@ -118,6 +196,17 @@ __all__ = [
|
|||||||
"EvidenceMatrixResponse",
|
"EvidenceMatrixResponse",
|
||||||
"EvidenceResponse",
|
"EvidenceResponse",
|
||||||
"EvidenceUpdateRequest",
|
"EvidenceUpdateRequest",
|
||||||
|
"FinalApprovalChecklistItemSummary",
|
||||||
|
"FinalApprovalChecklistResponse",
|
||||||
|
"FinalApprovalPublishingSettings",
|
||||||
|
"FinalApprovalRequest",
|
||||||
|
"FinalApprovalResponse",
|
||||||
|
"FinalRevisionRequest",
|
||||||
|
"FinalRevisionRequestResponse",
|
||||||
|
"FinalReviewIssueSummary",
|
||||||
|
"FinalReviewIssuesResponse",
|
||||||
|
"LanguageReviewRunResponse",
|
||||||
|
"LanguageReviewReportResponse",
|
||||||
"PlanReviewStatus",
|
"PlanReviewStatus",
|
||||||
"PlanListResponse",
|
"PlanListResponse",
|
||||||
"PlanResponse",
|
"PlanResponse",
|
||||||
@@ -126,8 +215,12 @@ __all__ = [
|
|||||||
"PlanSectionSummary",
|
"PlanSectionSummary",
|
||||||
"PlanSummary",
|
"PlanSummary",
|
||||||
"PlanUpdateRequest",
|
"PlanUpdateRequest",
|
||||||
|
"PublishCommitCreateResponse",
|
||||||
|
"PublishCommitListResponse",
|
||||||
"PublishCommitSummary",
|
"PublishCommitSummary",
|
||||||
|
"PublishingDryRunResponse",
|
||||||
"PublishingRules",
|
"PublishingRules",
|
||||||
|
"PublishingStatusResponse",
|
||||||
"PublishingStatus",
|
"PublishingStatus",
|
||||||
"ResearchArtifactManifestSummary",
|
"ResearchArtifactManifestSummary",
|
||||||
"ResearchArtifactSummary",
|
"ResearchArtifactSummary",
|
||||||
@@ -135,10 +228,17 @@ __all__ = [
|
|||||||
"ResearchStartResponse",
|
"ResearchStartResponse",
|
||||||
"ReviewActionResponse",
|
"ReviewActionResponse",
|
||||||
"ReviewStatus",
|
"ReviewStatus",
|
||||||
|
"ReviewSuggestionActionResponse",
|
||||||
|
"ReviewSuggestionEditRequest",
|
||||||
|
"ReviewSuggestionStatus",
|
||||||
"ReviewSummary",
|
"ReviewSummary",
|
||||||
"ReviewType",
|
"ReviewType",
|
||||||
"Role",
|
"Role",
|
||||||
"RunnerFileRef",
|
"RunnerFileRef",
|
||||||
|
"ScriptConfigVersionAuditEventListResponse",
|
||||||
|
"ScriptConfigVersionAuditEventSummary",
|
||||||
|
"SeoReviewRunResponse",
|
||||||
|
"SeoReviewReportResponse",
|
||||||
"ScriptConfigVersionCreateRequest",
|
"ScriptConfigVersionCreateRequest",
|
||||||
"ScriptConfigVersionListResponse",
|
"ScriptConfigVersionListResponse",
|
||||||
"ScriptConfigVersionResponse",
|
"ScriptConfigVersionResponse",
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ class AssetType(str, Enum):
|
|||||||
HERO_IMAGE = "hero_image"
|
HERO_IMAGE = "hero_image"
|
||||||
DIAGRAM = "diagram"
|
DIAGRAM = "diagram"
|
||||||
TABLE = "table"
|
TABLE = "table"
|
||||||
|
FLOWCHART = "flowchart"
|
||||||
|
COMPARISON_MATRIX = "comparison_matrix"
|
||||||
|
ARCHITECTURE_DIAGRAM = "architecture_diagram"
|
||||||
INLINE_IMAGE = "inline_image"
|
INLINE_IMAGE = "inline_image"
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +123,25 @@ class ReviewType(str, Enum):
|
|||||||
FINAL = "FINAL"
|
FINAL = "FINAL"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewKind(str, Enum):
|
||||||
|
SEO = "SEO"
|
||||||
|
LANGUAGE = "LANGUAGE"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionStatus(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
EDITED = "EDITED"
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfigVersionStatus(str, Enum):
|
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"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .enums import (
|
|||||||
AgentJobStatus,
|
AgentJobStatus,
|
||||||
AgentJobType,
|
AgentJobType,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
AssetStatus,
|
AssetStatus,
|
||||||
AssetType,
|
AssetType,
|
||||||
ClaimRiskLevel,
|
ClaimRiskLevel,
|
||||||
@@ -18,9 +19,11 @@ from .enums import (
|
|||||||
PlanReviewStatus,
|
PlanReviewStatus,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,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)
|
||||||
@@ -152,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
|
||||||
@@ -183,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
|
||||||
@@ -386,16 +560,32 @@ class DraftSummary(ContractModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRevisionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
asset_id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
revision_index: int = Field(ge=1)
|
||||||
|
action: str = Field(min_length=1)
|
||||||
|
actor_user_id: UUID | None = None
|
||||||
|
payload: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class AssetSummary(ContractModel):
|
class AssetSummary(ContractModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
article_id: UUID
|
article_id: UUID
|
||||||
|
section_id: UUID | None = None
|
||||||
asset_type: AssetType
|
asset_type: AssetType
|
||||||
title: str = Field(min_length=1)
|
title: str = Field(min_length=1)
|
||||||
prompt: str | None = None
|
prompt: str | None = None
|
||||||
|
object_key: str | None = None
|
||||||
file_url: str | None = None
|
file_url: str | None = None
|
||||||
alt_text: str | None = None
|
alt_text: str | None = None
|
||||||
caption: str | None = None
|
caption: str | None = None
|
||||||
status: AssetStatus
|
status: AssetStatus
|
||||||
|
history: list[AssetRevisionSummary] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ReviewSummary(ContractModel):
|
class ReviewSummary(ContractModel):
|
||||||
@@ -425,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
|
||||||
@@ -451,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):
|
||||||
@@ -497,9 +715,168 @@ class DraftUpdateRequest(ContractModel):
|
|||||||
unsupported_claim_warnings: list[str] | None = None
|
unsupported_claim_warnings: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewIssueSummary(ContractModel):
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
suggestion_id: UUID
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
suggested_fix: str | None = None
|
||||||
|
suggested_rewrite: str | None = None
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewReportSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
draft_id: UUID
|
||||||
|
score: int = Field(ge=0, le=100)
|
||||||
|
issues: list[ContentReviewIssueSummary] = Field(default_factory=list)
|
||||||
|
recommended_slug: str = Field(min_length=1)
|
||||||
|
recommended_title: str = Field(min_length=1)
|
||||||
|
schema_json: JsonObject = Field(default_factory=dict)
|
||||||
|
rules_snapshot: JsonObject = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContentReviewSuggestionSummary(ContractModel):
|
||||||
|
id: UUID
|
||||||
|
article_id: UUID
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
report_id: UUID
|
||||||
|
suggestion_key: str = Field(min_length=1)
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
suggested_fix: str | None = None
|
||||||
|
suggested_rewrite: str | None = None
|
||||||
|
patch: JsonObject = Field(default_factory=dict)
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
applied_text: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SeoReviewRunResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class SeoReviewReportResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageReviewRunResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageReviewReportResponse(ContractModel):
|
||||||
|
report: ContentReviewReportSummary
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionActionResponse(ContractModel):
|
||||||
|
suggestion: ContentReviewSuggestionSummary
|
||||||
|
draft: DraftSummary | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSuggestionEditRequest(ContractModel):
|
||||||
|
applied_text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReviewIssueSummary(ContractModel):
|
||||||
|
review_kind: ContentReviewKind
|
||||||
|
suggestion_id: UUID
|
||||||
|
severity: str = Field(min_length=1)
|
||||||
|
location: str = Field(min_length=1)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
status: ReviewSuggestionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReviewIssuesResponse(ContractModel):
|
||||||
|
article_id: UUID
|
||||||
|
unresolved_count: int = Field(ge=0)
|
||||||
|
unresolved_issues: list[FinalReviewIssueSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalPublishingSettings(ContractModel):
|
||||||
|
content_path: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
publishing_mode: str | None = None
|
||||||
|
frontmatter: JsonObject | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalRequest(ContractModel):
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
publishing_settings: FinalApprovalPublishingSettings | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequest(ContractModel):
|
||||||
|
reason: str | None = None
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistItemSummary(ContractModel):
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
completed: bool
|
||||||
|
blocker: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalChecklistResponse(ContractModel):
|
||||||
|
article_id: UUID
|
||||||
|
draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
requested_draft_version: int | None = Field(default=None, ge=1)
|
||||||
|
can_approve: bool
|
||||||
|
completed_items: list[str] = Field(default_factory=list)
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
items: list[FinalApprovalChecklistItemSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalApprovalResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
|
class FinalRevisionRequestResponse(ContractModel):
|
||||||
|
article: ArticleSummary
|
||||||
|
checklist: FinalApprovalChecklistResponse
|
||||||
|
|
||||||
|
|
||||||
|
class AssetGenerateSpecsResponse(ContractModel):
|
||||||
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetListResponse(ContractModel):
|
||||||
|
assets: list[AssetSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetResponse(ContractModel):
|
||||||
|
asset: AssetSummary
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUpdateRequest(ContractModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1)
|
||||||
|
prompt: str | None = None
|
||||||
|
alt_text: str | None = None
|
||||||
|
caption: str | None = None
|
||||||
|
section_id: UUID | None = None
|
||||||
|
status: AssetStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUploadRequest(ContractModel):
|
||||||
|
filename: str = Field(min_length=1)
|
||||||
|
content_base64: str = Field(min_length=1)
|
||||||
|
content_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUploadResponse(ContractModel):
|
||||||
|
asset: AssetSummary
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -509,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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .enums import (
|
|||||||
AgentJobStatus,
|
AgentJobStatus,
|
||||||
AgentJobType,
|
AgentJobType,
|
||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
|
ContentReviewKind,
|
||||||
AssetStatus,
|
AssetStatus,
|
||||||
AssetType,
|
AssetType,
|
||||||
ClaimRiskLevel,
|
ClaimRiskLevel,
|
||||||
@@ -17,9 +18,11 @@ from .enums import (
|
|||||||
PlanReviewStatus,
|
PlanReviewStatus,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
ReviewType,
|
ReviewType,
|
||||||
Role,
|
Role,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AgentJobOutput,
|
AgentJobOutput,
|
||||||
@@ -36,7 +39,15 @@ from .models import (
|
|||||||
BoundaryQuestionResponse,
|
BoundaryQuestionResponse,
|
||||||
BoundaryQuestionSummary,
|
BoundaryQuestionSummary,
|
||||||
BoundaryQuestionUpdateRequest,
|
BoundaryQuestionUpdateRequest,
|
||||||
|
ObservabilityTimelineEventSummary,
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
ClaimSummary,
|
ClaimSummary,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
@@ -46,6 +57,14 @@ from .models import (
|
|||||||
DraftResponse,
|
DraftResponse,
|
||||||
DraftSummary,
|
DraftSummary,
|
||||||
DraftUpdateRequest,
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
EvidenceSummary,
|
EvidenceSummary,
|
||||||
EvidenceCreateRequest,
|
EvidenceCreateRequest,
|
||||||
EvidenceMatrixResponse,
|
EvidenceMatrixResponse,
|
||||||
@@ -58,15 +77,31 @@ from .models import (
|
|||||||
PlanRevisionRequest,
|
PlanRevisionRequest,
|
||||||
PlanSummary,
|
PlanSummary,
|
||||||
PlanUpdateRequest,
|
PlanUpdateRequest,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
PublishCommitSummary,
|
PublishCommitSummary,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
PublishingRules,
|
PublishingRules,
|
||||||
ResearchArtifactManifestSummary,
|
ResearchArtifactManifestSummary,
|
||||||
ResearchArtifactSummary,
|
ResearchArtifactSummary,
|
||||||
ResearchListResponse,
|
ResearchListResponse,
|
||||||
ResearchStartResponse,
|
ResearchStartResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
|
ScriptConfigVersionAuditEventListResponse,
|
||||||
|
ScriptConfigVersionAuditEventSummary,
|
||||||
ScriptConfigVersionCreateRequest,
|
ScriptConfigVersionCreateRequest,
|
||||||
ScriptConfigVersionListResponse,
|
ScriptConfigVersionListResponse,
|
||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
@@ -75,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,
|
||||||
)
|
)
|
||||||
@@ -93,8 +143,11 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
|||||||
AssetType,
|
AssetType,
|
||||||
PlanReviewStatus,
|
PlanReviewStatus,
|
||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
|
ReviewSuggestionStatus,
|
||||||
ReviewType,
|
ReviewType,
|
||||||
|
ContentReviewKind,
|
||||||
ScriptConfigVersionStatus,
|
ScriptConfigVersionStatus,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||||
@@ -110,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,
|
||||||
@@ -142,10 +213,39 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
DraftListResponse,
|
DraftListResponse,
|
||||||
DraftResponse,
|
DraftResponse,
|
||||||
DraftUpdateRequest,
|
DraftUpdateRequest,
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewSuggestionSummary,
|
||||||
|
FinalApprovalChecklistItemSummary,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalPublishingSettings,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
FinalReviewIssueSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetRevisionSummary,
|
||||||
AssetSummary,
|
AssetSummary,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
PublishCommitSummary,
|
PublishCommitSummary,
|
||||||
|
PublishCommitCreateResponse,
|
||||||
|
PublishCommitListResponse,
|
||||||
|
PublishingDryRunResponse,
|
||||||
|
PublishingStatusResponse,
|
||||||
RunnerFileRef,
|
RunnerFileRef,
|
||||||
AgentJobSummary,
|
AgentJobSummary,
|
||||||
AgentJobTestCodexRequest,
|
AgentJobTestCodexRequest,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -11,7 +14,10 @@ PLAN_SECTIONS_TABLE = "plan_sections"
|
|||||||
EVIDENCE_ITEMS_TABLE = "evidence_items"
|
EVIDENCE_ITEMS_TABLE = "evidence_items"
|
||||||
CLAIMS_TABLE = "claims"
|
CLAIMS_TABLE = "claims"
|
||||||
ARTICLE_DRAFTS_TABLE = "article_drafts"
|
ARTICLE_DRAFTS_TABLE = "article_drafts"
|
||||||
|
ARTICLE_REVIEW_REPORTS_TABLE = "article_review_reports"
|
||||||
|
ARTICLE_REVIEW_SUGGESTIONS_TABLE = "article_review_suggestions"
|
||||||
ASSETS_TABLE = "assets"
|
ASSETS_TABLE = "assets"
|
||||||
|
ASSET_REVISIONS_TABLE = "asset_revisions"
|
||||||
WORKFLOW_EVENTS_TABLE = "workflow_events"
|
WORKFLOW_EVENTS_TABLE = "workflow_events"
|
||||||
AGENT_JOBS_TABLE = "agent_jobs"
|
AGENT_JOBS_TABLE = "agent_jobs"
|
||||||
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
|
RESEARCH_RUN_MANIFESTS_TABLE = "research_run_manifests"
|
||||||
@@ -23,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,
|
||||||
@@ -30,7 +39,10 @@ CORE_TABLES: tuple[str, ...] = (
|
|||||||
EVIDENCE_ITEMS_TABLE,
|
EVIDENCE_ITEMS_TABLE,
|
||||||
CLAIMS_TABLE,
|
CLAIMS_TABLE,
|
||||||
ARTICLE_DRAFTS_TABLE,
|
ARTICLE_DRAFTS_TABLE,
|
||||||
|
ARTICLE_REVIEW_REPORTS_TABLE,
|
||||||
|
ARTICLE_REVIEW_SUGGESTIONS_TABLE,
|
||||||
ASSETS_TABLE,
|
ASSETS_TABLE,
|
||||||
|
ASSET_REVISIONS_TABLE,
|
||||||
WORKFLOW_EVENTS_TABLE,
|
WORKFLOW_EVENTS_TABLE,
|
||||||
AGENT_JOBS_TABLE,
|
AGENT_JOBS_TABLE,
|
||||||
RESEARCH_RUN_MANIFESTS_TABLE,
|
RESEARCH_RUN_MANIFESTS_TABLE,
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ 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(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class LocalObjectStorageClient(ObjectStorageClient):
|
class LocalObjectStorageClient(ObjectStorageClient):
|
||||||
def __init__(self, root: Path) -> None:
|
def __init__(self, root: Path) -> None:
|
||||||
@@ -19,6 +31,23 @@ class LocalObjectStorageClient(ObjectStorageClient):
|
|||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
return f"file://{path}"
|
return f"file://{path}"
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
|
del content_type
|
||||||
|
path = self.root / object_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(content)
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
def get_bytes(self, *, object_key: str) -> bytes:
|
||||||
|
path = self.root / object_key
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
class S3ObjectStorageClient(ObjectStorageClient):
|
class S3ObjectStorageClient(ObjectStorageClient):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -36,11 +65,34 @@ class S3ObjectStorageClient(ObjectStorageClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def put_text(self, *, object_key: str, content: str) -> str:
|
def put_text(self, *, object_key: str, content: str) -> str:
|
||||||
|
return self.put_bytes(
|
||||||
|
object_key=object_key,
|
||||||
|
content=content.encode("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(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_key: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> str:
|
||||||
self.client.put_object(
|
self.client.put_object(
|
||||||
Bucket=self.bucket,
|
Bucket=self.bucket,
|
||||||
Key=object_key,
|
Key=object_key,
|
||||||
Body=content.encode("utf-8"),
|
Body=content,
|
||||||
ContentType="application/json; charset=utf-8",
|
ContentType=content_type,
|
||||||
)
|
)
|
||||||
return f"s3://{self.bucket}/{object_key}"
|
return f"s3://{self.bucket}/{object_key}"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
@@ -233,13 +290,50 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS evidence_references JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS unsupported_claim_warnings JSONB NOT NULL DEFAULT '[]'::jsonb",
|
||||||
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
|
"ALTER TABLE article_drafts ADD COLUMN IF NOT EXISTS based_on_draft_id UUID REFERENCES article_drafts(id) ON DELETE SET NULL",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
|
||||||
|
draft_id UUID NOT NULL REFERENCES article_drafts(id) ON DELETE CASCADE,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
issues JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
recommended_slug TEXT NOT NULL,
|
||||||
|
recommended_title TEXT NOT NULL,
|
||||||
|
schema_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
rules_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_suggestions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
review_kind TEXT NOT NULL CHECK (review_kind IN ('SEO', 'LANGUAGE')),
|
||||||
|
report_id UUID NOT NULL REFERENCES article_review_reports(id) ON DELETE CASCADE,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
suggested_fix TEXT,
|
||||||
|
suggested_rewrite TEXT,
|
||||||
|
patch JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'EDITED')),
|
||||||
|
applied_text TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (article_id, review_kind, suggestion_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL,
|
||||||
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
|
asset_type TEXT NOT NULL CHECK (asset_type IN ({ASSET_TYPE_VALUES})),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
prompt TEXT,
|
prompt TEXT,
|
||||||
|
object_key TEXT,
|
||||||
file_url TEXT,
|
file_url TEXT,
|
||||||
alt_text TEXT,
|
alt_text TEXT,
|
||||||
caption TEXT,
|
caption TEXT,
|
||||||
@@ -248,6 +342,20 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
|
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES plan_sections(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS object_key TEXT",
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_revisions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
revision_index INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS workflow_events (
|
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -349,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)
|
||||||
@@ -358,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)
|
||||||
""",
|
""",
|
||||||
@@ -370,6 +495,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
ON workflow_events (article_id, created_at)
|
ON workflow_events (article_id, created_at)
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
|
||||||
|
ON asset_revisions (asset_id, revision_index, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||||
ON research_run_manifests (article_id, created_at)
|
ON research_run_manifests (article_id, created_at)
|
||||||
""",
|
""",
|
||||||
@@ -381,6 +510,14 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||||
ON script_config_version_events (target_site_id, created_at)
|
ON script_config_version_events (target_site_id, created_at)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
|
||||||
|
ON article_review_reports (article_id, review_kind, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
|
||||||
|
ON article_review_suggestions (report_id, status)
|
||||||
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -433,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,
|
||||||
@@ -550,12 +737,49 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_reports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
review_kind TEXT NOT NULL,
|
||||||
|
draft_id TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
issues TEXT NOT NULL DEFAULT '[]',
|
||||||
|
recommended_slug TEXT NOT NULL,
|
||||||
|
recommended_title TEXT NOT NULL,
|
||||||
|
schema_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
rules_snapshot TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_review_suggestions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
review_kind TEXT NOT NULL,
|
||||||
|
report_id TEXT NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
suggested_fix TEXT,
|
||||||
|
suggested_rewrite TEXT,
|
||||||
|
patch TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
applied_text TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, review_kind, suggestion_key)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
article_id TEXT NOT NULL,
|
article_id TEXT NOT NULL,
|
||||||
|
section_id TEXT,
|
||||||
asset_type TEXT NOT NULL,
|
asset_type TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
prompt TEXT,
|
prompt TEXT,
|
||||||
|
object_key TEXT,
|
||||||
file_url TEXT,
|
file_url TEXT,
|
||||||
alt_text TEXT,
|
alt_text TEXT,
|
||||||
caption TEXT,
|
caption TEXT,
|
||||||
@@ -565,6 +789,18 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
revision_index INTEGER NOT NULL,
|
||||||
|
action 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 workflow_events (
|
CREATE TABLE IF NOT EXISTS workflow_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
article_id TEXT NOT NULL,
|
article_id TEXT NOT NULL,
|
||||||
@@ -655,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)
|
||||||
@@ -664,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)
|
||||||
""",
|
""",
|
||||||
@@ -676,6 +929,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
ON workflow_events (article_id, created_at)
|
ON workflow_events (article_id, created_at)
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_revisions_asset
|
||||||
|
ON asset_revisions (asset_id, revision_index, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
CREATE INDEX IF NOT EXISTS idx_research_run_manifests_article
|
||||||
ON research_run_manifests (article_id, created_at)
|
ON research_run_manifests (article_id, created_at)
|
||||||
""",
|
""",
|
||||||
@@ -687,6 +944,14 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
CREATE INDEX IF NOT EXISTS idx_script_config_version_events_site
|
||||||
ON script_config_version_events (target_site_id, created_at)
|
ON script_config_version_events (target_site_id, created_at)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_reports_article
|
||||||
|
ON article_review_reports (article_id, review_kind, created_at)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_review_suggestions_report
|
||||||
|
ON article_review_suggestions (report_id, status)
|
||||||
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -10,24 +12,49 @@ from src.presentation.routes.agent_jobs import (
|
|||||||
)
|
)
|
||||||
from src.presentation.routes.agent_jobs import router as agent_jobs_router
|
from src.presentation.routes.agent_jobs import router as agent_jobs_router
|
||||||
from src.presentation.routes.articles import router as articles_router
|
from src.presentation.routes.articles import router as articles_router
|
||||||
|
from src.presentation.routes.assets import router as assets_router
|
||||||
from src.presentation.routes.auth import router as auth_router
|
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.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(boundary_questions_router)
|
app.include_router(boundary_questions_router)
|
||||||
app.include_router(plans_router)
|
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(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,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.assets import (
|
||||||
|
approve_asset,
|
||||||
|
generate_asset_specs,
|
||||||
|
list_assets,
|
||||||
|
update_asset,
|
||||||
|
upload_asset_file,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
|
CurrentUser,
|
||||||
|
)
|
||||||
|
from src.infrastructure.object_storage import open_object_storage_client
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["assets"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/generate-specs",
|
||||||
|
response_model=AssetGenerateSpecsResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_generate_asset_specs(
|
||||||
|
article_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetGenerateSpecsResponse:
|
||||||
|
try:
|
||||||
|
return generate_asset_specs(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
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.get(
|
||||||
|
"/articles/{article_id}/assets",
|
||||||
|
response_model=AssetListResponse,
|
||||||
|
)
|
||||||
|
def get_article_assets(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetListResponse:
|
||||||
|
try:
|
||||||
|
return list_assets(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}",
|
||||||
|
response_model=AssetResponse,
|
||||||
|
)
|
||||||
|
def patch_article_asset(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUpdateRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetResponse:
|
||||||
|
try:
|
||||||
|
return update_asset(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_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
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}/approve",
|
||||||
|
response_model=AssetResponse,
|
||||||
|
)
|
||||||
|
def post_approve_asset(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetResponse:
|
||||||
|
try:
|
||||||
|
return approve_asset(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
response_model=AssetUploadResponse,
|
||||||
|
)
|
||||||
|
def post_upload_asset_file(
|
||||||
|
article_id: UUID,
|
||||||
|
asset_id: UUID,
|
||||||
|
request: AssetUploadRequest,
|
||||||
|
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> AssetUploadResponse:
|
||||||
|
try:
|
||||||
|
payload = base64.b64decode(request.content_base64.encode("utf-8"), validate=True)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Invalid base64 payload",
|
||||||
|
) from error
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Uploaded file is empty",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return upload_asset_file(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
filename=request.filename,
|
||||||
|
content_type=request.content_type,
|
||||||
|
content=payload,
|
||||||
|
current_user=current_user,
|
||||||
|
object_storage=open_object_storage_client(),
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from src.application.reviews import (
|
||||||
|
accept_suggestion,
|
||||||
|
edit_suggestion,
|
||||||
|
get_final_review_issues,
|
||||||
|
get_language_report,
|
||||||
|
get_seo_report,
|
||||||
|
reject_suggestion,
|
||||||
|
run_language_review,
|
||||||
|
run_seo_review,
|
||||||
|
)
|
||||||
|
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
|
||||||
|
from src.domain.contracts import (
|
||||||
|
ContentReviewKind,
|
||||||
|
CurrentUser,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
SeoReviewRunResponse,
|
||||||
|
)
|
||||||
|
from src.infrastructure.repositories import BackendRepository
|
||||||
|
from src.presentation.dependencies import get_repository, require_roles
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["reviews"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/review",
|
||||||
|
response_model=SeoReviewRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_run_seo_review(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> SeoReviewRunResponse:
|
||||||
|
try:
|
||||||
|
return run_seo_review(repository, article_id=article_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.get(
|
||||||
|
"/articles/{article_id}/seo/report",
|
||||||
|
response_model=SeoReviewReportResponse,
|
||||||
|
)
|
||||||
|
def get_article_seo_report(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> SeoReviewReportResponse:
|
||||||
|
try:
|
||||||
|
return get_seo_report(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/review",
|
||||||
|
response_model=LanguageReviewRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def post_run_language_review(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> LanguageReviewRunResponse:
|
||||||
|
try:
|
||||||
|
return run_language_review(repository, article_id=article_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.get(
|
||||||
|
"/articles/{article_id}/language/report",
|
||||||
|
response_model=LanguageReviewReportResponse,
|
||||||
|
)
|
||||||
|
def get_article_language_report(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> LanguageReviewReportResponse:
|
||||||
|
try:
|
||||||
|
return get_language_report(repository, article_id=article_id)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/accept",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_accept_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return accept_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/reject",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_reject_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return reject_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/seo/suggestions/{suggestion_id}/edit",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_edit_seo_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return edit_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.SEO,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/accept",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_accept_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return accept_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/reject",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_reject_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return reject_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/articles/{article_id}/language/suggestions/{suggestion_id}/edit",
|
||||||
|
response_model=ReviewSuggestionActionResponse,
|
||||||
|
)
|
||||||
|
def post_edit_language_suggestion(
|
||||||
|
article_id: UUID,
|
||||||
|
suggestion_id: UUID,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> ReviewSuggestionActionResponse:
|
||||||
|
try:
|
||||||
|
return edit_suggestion(
|
||||||
|
repository,
|
||||||
|
article_id=article_id,
|
||||||
|
review_kind=ContentReviewKind.LANGUAGE,
|
||||||
|
suggestion_id=suggestion_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except LookupError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/articles/{article_id}/final-review/issues",
|
||||||
|
response_model=FinalReviewIssuesResponse,
|
||||||
|
)
|
||||||
|
def get_article_final_review_issues(
|
||||||
|
article_id: UUID,
|
||||||
|
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
|
||||||
|
repository: BackendRepository = Depends(get_repository),
|
||||||
|
) -> FinalReviewIssuesResponse:
|
||||||
|
try:
|
||||||
|
return get_final_review_issues(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()
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
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_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||||
|
|
||||||
|
|
||||||
|
class AssetsMediaLibraryPublicApiTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.objects_root = Path(self.tmp_dir.name) / "objects"
|
||||||
|
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
|
||||||
|
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'assets-media-library.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_generate_specs_links_assets_to_approved_plan_sections(self) -> None:
|
||||||
|
article_id, plan = self._prepare_article_with_approved_plan()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
assets = response.json()["assets"]
|
||||||
|
self.assertEqual(len(plan["sections"]), len(assets))
|
||||||
|
section_ids = {section["id"] for section in plan["sections"]}
|
||||||
|
for asset in assets:
|
||||||
|
self.assertIn(asset["section_id"], section_ids)
|
||||||
|
self.assertEqual("GENERATED", asset["status"])
|
||||||
|
self.assertTrue(asset["title"])
|
||||||
|
|
||||||
|
def test_upload_stores_object_and_persists_object_key(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
|
||||||
|
upload_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": "hero.png",
|
||||||
|
"content_base64": base64.b64encode(b"fake-binary-image-content").decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||||
|
asset = upload_response.json()["asset"]
|
||||||
|
self.assertEqual("PENDING", asset["status"])
|
||||||
|
self.assertTrue(asset["object_key"])
|
||||||
|
self.assertTrue(asset["file_url"].startswith("file://"))
|
||||||
|
self.assertTrue(
|
||||||
|
(self.objects_root / asset["object_key"]).exists(),
|
||||||
|
"Uploaded object must exist in local object storage",
|
||||||
|
)
|
||||||
|
persisted = (self.objects_root / asset["object_key"]).read_bytes()
|
||||||
|
self.assertEqual(b"fake-binary-image-content", persisted)
|
||||||
|
history = asset["history"]
|
||||||
|
self.assertGreaterEqual(len(history), 1)
|
||||||
|
self.assertEqual("ASSET_FILE_UPLOADED", history[-1]["action"])
|
||||||
|
|
||||||
|
def test_approve_asset_writes_workflow_event(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
self._upload_asset(article_id, asset_id, filename="approve.png", payload=b"approve")
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertEqual("APPROVED", approve_response.json()["asset"]["status"])
|
||||||
|
|
||||||
|
article_detail = self.client.get(
|
||||||
|
f"/api/articles/{article_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, article_detail.status_code, article_detail.text)
|
||||||
|
events = article_detail.json()["workflow_events"]
|
||||||
|
approval_events = [event for event in events if event["event_type"] == "ASSET_APPROVED"]
|
||||||
|
self.assertTrue(approval_events, "Approval must write workflow event")
|
||||||
|
self.assertEqual(asset_id, approval_events[-1]["payload"]["asset_id"])
|
||||||
|
|
||||||
|
def test_rejected_assets_are_excluded_from_draft_publish_bundle(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
generated_assets = self._generate_specs(article_id)
|
||||||
|
self.assertGreaterEqual(len(generated_assets), 2)
|
||||||
|
approved_asset = generated_assets[0]
|
||||||
|
rejected_asset = generated_assets[1]
|
||||||
|
|
||||||
|
approved_upload = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
approved_asset["id"],
|
||||||
|
filename="approved.png",
|
||||||
|
payload=b"approved-image",
|
||||||
|
)
|
||||||
|
self._approve_asset(article_id, approved_asset["id"])
|
||||||
|
rejected_upload = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
rejected_asset["id"],
|
||||||
|
filename="rejected.png",
|
||||||
|
payload=b"rejected-image",
|
||||||
|
)
|
||||||
|
self._patch_asset(
|
||||||
|
article_id,
|
||||||
|
rejected_asset["id"],
|
||||||
|
request={"status": "REJECTED"},
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = self._assemble_draft(article_id)
|
||||||
|
self.assertIn(approved_upload["file_url"], draft["body_markdown"])
|
||||||
|
self.assertNotIn(rejected_upload["file_url"], draft["body_markdown"])
|
||||||
|
self.assertIn(approved_upload["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
self.assertNotIn(rejected_upload["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
|
||||||
|
def test_replacement_preserves_audit_history(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
first = self._upload_asset(article_id, asset_id, filename="first.png", payload=b"first")
|
||||||
|
second = self._upload_asset(article_id, asset_id, filename="second.png", payload=b"second")
|
||||||
|
self.assertNotEqual(first["object_key"], second["object_key"])
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/assets",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
|
||||||
|
history = asset["history"]
|
||||||
|
actions = [item["action"] for item in history]
|
||||||
|
self.assertIn("ASSET_FILE_UPLOADED", actions)
|
||||||
|
self.assertIn("ASSET_FILE_REPLACED", actions)
|
||||||
|
replaced_event = next(item for item in history if item["action"] == "ASSET_FILE_REPLACED")
|
||||||
|
self.assertEqual(first["object_key"], replaced_event["payload"]["previous_object_key"])
|
||||||
|
self.assertEqual(second["object_key"], replaced_event["payload"]["next_object_key"])
|
||||||
|
|
||||||
|
def test_media_library_lists_assets_with_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
asset_id = self._generate_specs(article_id)[0]["id"]
|
||||||
|
self._upload_asset(article_id, asset_id, filename="library.png", payload=b"library")
|
||||||
|
self._patch_asset(
|
||||||
|
article_id,
|
||||||
|
asset_id,
|
||||||
|
request={
|
||||||
|
"title": "Library Hero",
|
||||||
|
"alt_text": "Dashboard on laptop screen",
|
||||||
|
"caption": "Screenshot used in introduction section.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/assets",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||||
|
asset = next(item for item in list_response.json()["assets"] if item["id"] == asset_id)
|
||||||
|
self.assertEqual("Library Hero", asset["title"])
|
||||||
|
self.assertTrue(asset["asset_type"])
|
||||||
|
self.assertTrue(asset["status"])
|
||||||
|
self.assertTrue(asset["file_url"])
|
||||||
|
self.assertEqual("Dashboard on laptop screen", asset["alt_text"])
|
||||||
|
self.assertEqual("Screenshot used in introduction section.", asset["caption"])
|
||||||
|
|
||||||
|
def test_draft_preview_uses_approved_asset_references(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
asset = self._generate_specs(article_id)[0]
|
||||||
|
uploaded = self._upload_asset(
|
||||||
|
article_id,
|
||||||
|
asset["id"],
|
||||||
|
filename="preview.png",
|
||||||
|
payload=b"preview",
|
||||||
|
)
|
||||||
|
self._approve_asset(article_id, asset["id"])
|
||||||
|
|
||||||
|
draft = self._assemble_draft(article_id)
|
||||||
|
self.assertIn(uploaded["file_url"], draft["body_markdown"])
|
||||||
|
self.assertIn(uploaded["file_url"], "\n".join(draft["visual_placeholders"]))
|
||||||
|
|
||||||
|
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["draft"]
|
||||||
|
|
||||||
|
def _upload_asset(self, article_id: UUID, asset_id: str, *, filename: str, payload: bytes) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/upload",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"filename": filename,
|
||||||
|
"content_base64": base64.b64encode(payload).decode("utf-8"),
|
||||||
|
"content_type": "image/png",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["asset"]
|
||||||
|
|
||||||
|
def _approve_asset(self, article_id: UUID, asset_id: str) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}/approve",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
|
||||||
|
def _patch_asset(self, article_id: UUID, asset_id: str, *, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/assets/{asset_id}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json=request,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["asset"]
|
||||||
|
|
||||||
|
def _generate_specs(self, article_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/assets/generate-specs",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["assets"]
|
||||||
|
|
||||||
|
def _prepare_article_with_assembled_draft(self) -> UUID:
|
||||||
|
article_id = self._prepare_article_with_completed_scaffolds()
|
||||||
|
self._assemble_draft(article_id)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_completed_scaffolds(self) -> UUID:
|
||||||
|
article_id, _ = self._prepare_article_with_approved_plan()
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self._complete_all_section_jobs_success(section_jobs)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_approved_plan(self) -> tuple[UUID, dict[str, Any]]:
|
||||||
|
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": "Assets library integration coverage",
|
||||||
|
"working_title": "Assets and Media Library",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "assets media library",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = UUID(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_plan_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_plan_response.status_code, approve_plan_response.text)
|
||||||
|
return article_id, plan
|
||||||
|
|
||||||
|
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
complete_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": {
|
||||||
|
"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}.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
def _start_parallel_production(self, article_id: UUID) -> 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)
|
||||||
|
jobs = response.json()["jobs"]
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
return section_jobs
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: UUID) -> 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"])
|
||||||
|
|
||||||
|
|
||||||
|
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,431 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 SeoLanguageReviewPublicApiTest(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) / 'seo-language-review.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_seo_report_includes_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
|
||||||
|
report = self._get_seo_report(article_id)
|
||||||
|
self.assertIn("score", report)
|
||||||
|
self.assertIn("issues", report)
|
||||||
|
self.assertIn("recommended_slug", report)
|
||||||
|
self.assertIn("recommended_title", report)
|
||||||
|
self.assertIn("schema_json", report)
|
||||||
|
self.assertGreaterEqual(len(report["issues"]), 1)
|
||||||
|
|
||||||
|
first_issue = report["issues"][0]
|
||||||
|
self.assertIn("severity", first_issue)
|
||||||
|
self.assertIn("location", first_issue)
|
||||||
|
self.assertIn("message", first_issue)
|
||||||
|
self.assertIn("suggested_fix", first_issue)
|
||||||
|
self.assertIn("suggestion_id", first_issue)
|
||||||
|
|
||||||
|
def test_language_report_includes_required_fields(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
report = self._get_language_report(article_id)
|
||||||
|
self.assertGreaterEqual(len(report["issues"]), 1)
|
||||||
|
self.assertTrue(any(issue.get("suggested_rewrite") for issue in report["issues"]))
|
||||||
|
first_issue = report["issues"][0]
|
||||||
|
self.assertIn("severity", first_issue)
|
||||||
|
self.assertIn("location", first_issue)
|
||||||
|
self.assertIn("message", first_issue)
|
||||||
|
self.assertIn("suggested_rewrite", first_issue)
|
||||||
|
|
||||||
|
def test_editor_actions_accept_reject_edit_and_immutable_drafts(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
language_report = self._get_language_report(article_id)
|
||||||
|
base_latest = self._latest_draft(article_id)
|
||||||
|
|
||||||
|
seo_patch_issue = next(
|
||||||
|
issue for issue in seo_report["issues"] if issue["location"] == "meta_description"
|
||||||
|
)
|
||||||
|
accept_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{seo_patch_issue['suggestion_id']}/accept",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, accept_response.status_code, accept_response.text)
|
||||||
|
accepted = accept_response.json()
|
||||||
|
self.assertEqual("ACCEPTED", accepted["suggestion"]["status"])
|
||||||
|
self.assertEqual(base_latest["version"] + 1, accepted["draft"]["version"])
|
||||||
|
|
||||||
|
seo_non_patch_issue = next(
|
||||||
|
issue for issue in seo_report["issues"] if issue["location"] == "body_markdown"
|
||||||
|
)
|
||||||
|
reject_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{seo_non_patch_issue['suggestion_id']}/reject",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, reject_response.status_code, reject_response.text)
|
||||||
|
self.assertEqual("REJECTED", reject_response.json()["suggestion"]["status"])
|
||||||
|
self.assertIsNone(reject_response.json()["draft"])
|
||||||
|
|
||||||
|
language_rewrite_issue = next(
|
||||||
|
issue for issue in language_report["issues"] if "Sentence exceeds" in issue["message"]
|
||||||
|
)
|
||||||
|
edit_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/language/suggestions/{language_rewrite_issue['suggestion_id']}/edit",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"applied_text": "This sentence now states the same idea clearly."},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, edit_response.status_code, edit_response.text)
|
||||||
|
edited = edit_response.json()
|
||||||
|
self.assertEqual("EDITED", edited["suggestion"]["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
accepted["draft"]["version"] + 1,
|
||||||
|
edited["draft"]["version"],
|
||||||
|
)
|
||||||
|
self.assertIn("states the same idea clearly", edited["draft"]["body_markdown"])
|
||||||
|
|
||||||
|
def test_target_site_overrides_are_applied(self) -> None:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/sites/{site['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||||
|
json={
|
||||||
|
"seo_rules": {
|
||||||
|
"seo_review": {
|
||||||
|
"meta_description_min": 10,
|
||||||
|
"meta_description_max": 40,
|
||||||
|
"schema_type": "HowTo",
|
||||||
|
},
|
||||||
|
"language_review": {
|
||||||
|
"max_sentence_words": 8,
|
||||||
|
"forbidden_phrases": ["precision tone"],
|
||||||
|
"brand_tone": "strict",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
article_id = self._prepare_article_with_assembled_draft(target_site_id=UUID(site["id"]))
|
||||||
|
self._seed_language_problematic_draft(
|
||||||
|
article_id,
|
||||||
|
extra_body="This precision tone phrase should be avoided!",
|
||||||
|
)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
self.assertEqual("HowTo", seo_report["schema_json"]["@type"])
|
||||||
|
|
||||||
|
language_report = self._get_language_report(article_id)
|
||||||
|
self.assertEqual(8, language_report["rules_snapshot"]["max_sentence_words"])
|
||||||
|
self.assertEqual("strict", language_report["rules_snapshot"]["brand_tone"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("8 words" in issue["message"] for issue in language_report["issues"])
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Forbidden phrase" in issue["message"] for issue in language_report["issues"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_final_review_shows_unresolved_issues(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
self._run_language_review(article_id)
|
||||||
|
|
||||||
|
seo_report = self._get_seo_report(article_id)
|
||||||
|
issue_to_accept = seo_report["issues"][0]
|
||||||
|
accept_response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/suggestions/{issue_to_accept['suggestion_id']}/accept",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, accept_response.status_code, accept_response.text)
|
||||||
|
|
||||||
|
unresolved_response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/final-review/issues",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, unresolved_response.status_code, unresolved_response.text)
|
||||||
|
unresolved = unresolved_response.json()
|
||||||
|
self.assertGreaterEqual(unresolved["unresolved_count"], 1)
|
||||||
|
self.assertTrue(unresolved["unresolved_issues"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(item["status"] == "PENDING" for item in unresolved["unresolved_issues"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fake_runner_is_deterministic(self) -> None:
|
||||||
|
article_id = self._prepare_article_with_assembled_draft()
|
||||||
|
self._seed_language_problematic_draft(article_id)
|
||||||
|
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
first = self._get_seo_report(article_id)
|
||||||
|
self._run_seo_review(article_id)
|
||||||
|
second = self._get_seo_report(article_id)
|
||||||
|
|
||||||
|
self.assertEqual(first["score"], second["score"])
|
||||||
|
self.assertEqual(first["recommended_slug"], second["recommended_slug"])
|
||||||
|
self.assertEqual(first["recommended_title"], second["recommended_title"])
|
||||||
|
|
||||||
|
first_issue_signature = [
|
||||||
|
(
|
||||||
|
issue["id"],
|
||||||
|
issue["severity"],
|
||||||
|
issue["location"],
|
||||||
|
issue["message"],
|
||||||
|
issue["suggested_fix"],
|
||||||
|
issue["suggested_rewrite"],
|
||||||
|
)
|
||||||
|
for issue in first["issues"]
|
||||||
|
]
|
||||||
|
second_issue_signature = [
|
||||||
|
(
|
||||||
|
issue["id"],
|
||||||
|
issue["severity"],
|
||||||
|
issue["location"],
|
||||||
|
issue["message"],
|
||||||
|
issue["suggested_fix"],
|
||||||
|
issue["suggested_rewrite"],
|
||||||
|
)
|
||||||
|
for issue in second["issues"]
|
||||||
|
]
|
||||||
|
self.assertEqual(first_issue_signature, second_issue_signature)
|
||||||
|
|
||||||
|
def _run_seo_review(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/seo/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _run_language_review(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/language/review",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _get_seo_report(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/seo/report",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _get_language_report(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/language/report",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["report"]
|
||||||
|
|
||||||
|
def _latest_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/articles/{article_id}/drafts",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
return response.json()["drafts"][0]
|
||||||
|
|
||||||
|
def _seed_language_problematic_draft(
|
||||||
|
self,
|
||||||
|
article_id: UUID,
|
||||||
|
*,
|
||||||
|
extra_body: str = "",
|
||||||
|
) -> None:
|
||||||
|
latest = self._latest_draft(article_id)
|
||||||
|
body = "\n".join(
|
||||||
|
[
|
||||||
|
latest["body_markdown"],
|
||||||
|
(
|
||||||
|
"This sentence is intentionally extremely long and includes many additional words "
|
||||||
|
"that make it exceed any practical editorial threshold for readability while still "
|
||||||
|
"describing the same guidance in an unnecessarily verbose and repetitive way for testing."
|
||||||
|
),
|
||||||
|
"Our approach is the best ever for everyone.",
|
||||||
|
extra_body,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
patch_response = self.client.patch(
|
||||||
|
f"/api/articles/{article_id}/drafts/{latest['id']}",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={"body_markdown": body},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||||
|
|
||||||
|
def _prepare_article_with_assembled_draft(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target_site_id: UUID | None = None,
|
||||||
|
) -> UUID:
|
||||||
|
article_id, _ = self._prepare_article_with_approved_plan(target_site_id=target_site_id)
|
||||||
|
self._ensure_evidence_ready(article_id)
|
||||||
|
section_jobs = self._start_parallel_production(article_id)
|
||||||
|
self._complete_all_section_jobs_success(section_jobs)
|
||||||
|
self._assemble_draft(article_id)
|
||||||
|
return article_id
|
||||||
|
|
||||||
|
def _prepare_article_with_approved_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target_site_id: UUID | None = None,
|
||||||
|
) -> tuple[UUID, dict[str, Any]]:
|
||||||
|
if target_site_id is None:
|
||||||
|
site = self.client.get(
|
||||||
|
"/api/sites",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
).json()[0]["site"]
|
||||||
|
target_site_id = UUID(site["id"])
|
||||||
|
article = self.client.post(
|
||||||
|
"/api/articles",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
json={
|
||||||
|
"target_site_id": str(target_site_id),
|
||||||
|
"brief_description": "SEO and language review implementation coverage.",
|
||||||
|
"working_title": "Short",
|
||||||
|
"content_type": "longform_guide",
|
||||||
|
"primary_keyword": "seo language review",
|
||||||
|
},
|
||||||
|
).json()["article"]
|
||||||
|
article_id = UUID(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_plan_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_plan_response.status_code, approve_plan_response.text)
|
||||||
|
return article_id, plan
|
||||||
|
|
||||||
|
def _ensure_evidence_ready(self, article_id: UUID) -> 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 _start_parallel_production(self, article_id: UUID) -> 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)
|
||||||
|
jobs = response.json()["jobs"]
|
||||||
|
section_jobs = [job for job in jobs if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||||
|
self.assertTrue(section_jobs)
|
||||||
|
return section_jobs
|
||||||
|
|
||||||
|
def _complete_all_section_jobs_success(self, section_jobs: list[dict[str, Any]]) -> None:
|
||||||
|
for index, job in enumerate(section_jobs, start=1):
|
||||||
|
complete_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": {
|
||||||
|
"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}.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, complete_response.status_code, complete_response.text)
|
||||||
|
self.assertEqual("SUCCEEDED", complete_response.json()["job"]["status"])
|
||||||
|
|
||||||
|
def _assemble_draft(self, article_id: UUID) -> dict[str, Any]:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/articles/{article_id}/draft/assemble",
|
||||||
|
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, response.status_code, response.text)
|
||||||
|
return response.json()["draft"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +350,32 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reviewPanel {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.draftEditor .detailGrid select {
|
.draftEditor .detailGrid select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -382,6 +410,16 @@ main {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reviewActions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(88px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewActions input {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
main {
|
main {
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { AssetSummary } from "@pipeline/shared";
|
||||||
|
|
||||||
|
export type MediaLibraryRow = {
|
||||||
|
id: string;
|
||||||
|
sectionId: string | null;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
preview: string | null;
|
||||||
|
altText: string | null;
|
||||||
|
caption: string | null;
|
||||||
|
publishReady: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildMediaLibraryRows(assets: readonly AssetSummary[]): MediaLibraryRow[] {
|
||||||
|
return [...assets]
|
||||||
|
.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
sectionId: asset.section_id ?? null,
|
||||||
|
status: asset.status,
|
||||||
|
type: asset.asset_type,
|
||||||
|
title: asset.title,
|
||||||
|
preview: asset.file_url ?? null,
|
||||||
|
altText: asset.alt_text ?? null,
|
||||||
|
caption: asset.caption ?? null,
|
||||||
|
publishReady: asset.status === "APPROVED" && Boolean(asset.file_url),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.title.localeCompare(right.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDraftPreviewMarkdown(
|
||||||
|
markdown: string,
|
||||||
|
assets: readonly AssetSummary[],
|
||||||
|
): string {
|
||||||
|
if (!markdown) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const approvedBySection = approvedAssetMapBySection(assets);
|
||||||
|
return markdown.replace(
|
||||||
|
/!\[([^\]]*)\]\(placeholder:\/\/([^/]+)\/(\d+)\)/g,
|
||||||
|
(_full, alt: string, sectionId: string, indexText: string) => {
|
||||||
|
const index = Number.parseInt(indexText, 10);
|
||||||
|
const sectionAssets = approvedBySection.get(sectionId) ?? [];
|
||||||
|
if (!Number.isFinite(index) || index < 1 || index > sectionAssets.length) {
|
||||||
|
return _full;
|
||||||
|
}
|
||||||
|
const asset = sectionAssets[index - 1];
|
||||||
|
const resolvedAlt = asset.alt_text?.trim() || asset.title || alt || "asset";
|
||||||
|
return ``;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvedAssetMapBySection(
|
||||||
|
assets: readonly AssetSummary[],
|
||||||
|
): Map<string, AssetSummary[]> {
|
||||||
|
const map = new Map<string, AssetSummary[]>();
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (!asset.section_id || asset.status !== "APPROVED" || !asset.file_url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = map.get(asset.section_id) ?? [];
|
||||||
|
list.push(asset);
|
||||||
|
map.set(asset.section_id, list);
|
||||||
|
}
|
||||||
|
for (const [sectionId, list] of map.entries()) {
|
||||||
|
list.sort((left, right) => left.created_at.localeCompare(right.created_at));
|
||||||
|
map.set(sectionId, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { AssetSummary } from "@pipeline/shared";
|
||||||
|
|
||||||
|
import { buildMediaLibraryRows } from "./model";
|
||||||
|
|
||||||
|
type MediaLibraryPanelProps = {
|
||||||
|
assets: readonly AssetSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaLibraryPanel({ assets }: MediaLibraryPanelProps) {
|
||||||
|
const rows = useMemo(() => buildMediaLibraryRows(assets), [assets]);
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>Media library</h3>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Preview</th>
|
||||||
|
<th>Alt text</th>
|
||||||
|
<th>Caption</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{row.title}</td>
|
||||||
|
<td>{row.type}</td>
|
||||||
|
<td>{row.status}</td>
|
||||||
|
<td>
|
||||||
|
{row.preview ? (
|
||||||
|
<a href={row.preview} target="_blank" rel="noreferrer">
|
||||||
|
file
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{row.altText ?? "—"}</td>
|
||||||
|
<td>{row.caption ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>No assets yet.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</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 ?? [];
|
||||||
id: event.id,
|
if (timeline.length > 0) {
|
||||||
event_type: event.event_type,
|
return timeline
|
||||||
to_status: event.to_status,
|
.map((entry) => normalizeTimelineEntry(entry))
|
||||||
from_status: event.from_status,
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||||
actor_user_id: event.actor_user_id,
|
}
|
||||||
created_at: event.created_at,
|
|
||||||
articleId: event.article_id,
|
return (detail.workflow_events ?? [])
|
||||||
}));
|
.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
articleId: event.article_id,
|
||||||
|
entryType: "WORKFLOW_EVENT",
|
||||||
|
source: event.actor_user_id ? "USER" : "SYSTEM",
|
||||||
|
eventType: event.event_type,
|
||||||
|
fromStatus: event.from_status ?? null,
|
||||||
|
toStatus: event.to_status ?? null,
|
||||||
|
actorUserId: event.actor_user_id ?? null,
|
||||||
|
jobId: null,
|
||||||
|
jobType: null,
|
||||||
|
jobStatus: null,
|
||||||
|
retryEligible: false,
|
||||||
|
cancelEligible: false,
|
||||||
|
safeFailureSummary: null,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
|
export function buildDetailSummary(
|
||||||
|
detail: ArticleDetailResponse,
|
||||||
|
options: { viewerRole: Role; viewerEmail: string },
|
||||||
|
): DetailSummary {
|
||||||
|
const { viewerRole, viewerEmail } = options;
|
||||||
return {
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
<td>
|
const controls = buildJobControlState(job, summary.viewerRole);
|
||||||
<div>{artifact.artifactLabel}</div>
|
return (
|
||||||
<div>{artifact.artifactKey}</div>
|
<tr key={job.jobId}>
|
||||||
</td>
|
<td>
|
||||||
<td>{artifact.status}</td>
|
<div>{job.jobType}</div>
|
||||||
<td>{artifact.attempt}</td>
|
<div>attempt {job.attempt}</div>
|
||||||
<td>
|
</td>
|
||||||
{artifact.usedEvidenceIds.length
|
<td>{job.status}</td>
|
||||||
? artifact.usedEvidenceIds.join(", ")
|
<td>{job.errorCategory ?? "—"}</td>
|
||||||
: "—"}
|
<td>
|
||||||
</td>
|
{job.errorMessage ?? "—"}
|
||||||
<td>
|
{!job.errorMessage && job.retryBlockReason ? (
|
||||||
{artifact.unsupportedClaims.length
|
<div>{job.retryBlockReason}</div>
|
||||||
? artifact.unsupportedClaims.join("; ")
|
) : null}
|
||||||
: artifact.errorMessage ?? "—"}
|
</td>
|
||||||
</td>
|
<td>{job.lastSuccessfulStep ?? "—"}</td>
|
||||||
</tr>
|
<td>
|
||||||
))}
|
{controls.showRetry ? (
|
||||||
{productionArtifacts.length === 0 ? (
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRetry(job.jobId)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{controls.showCancel ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCancel(job.jobId)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="tableAction"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{!controls.showRetry && !controls.showCancel ? "—" : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{controls.showAdminLogs ? (
|
||||||
|
<>
|
||||||
|
<div>{job.stdout || "stdout: —"}</div>
|
||||||
|
<div>{job.stderr || "stderr: —"}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
job.safeFailureSummary ?? "—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{jobDetails.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>No production artifacts yet.</td>
|
<td colSpan={7}>No agent jobs yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { DraftSummary } from "@pipeline/shared";
|
import type { DraftSummary } from "@pipeline/shared";
|
||||||
|
import type { AssetSummary } from "@pipeline/shared";
|
||||||
|
|
||||||
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
|
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
|
||||||
|
import { resolveDraftPreviewMarkdown } from "@/features/article-assets/model";
|
||||||
import {
|
import {
|
||||||
compareDraftVersions,
|
compareDraftVersions,
|
||||||
parseDraftPreview,
|
parseDraftPreview,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
type DraftEditorPanelProps = {
|
type DraftEditorPanelProps = {
|
||||||
articleId: string;
|
articleId: string;
|
||||||
initialDrafts: readonly DraftSummary[];
|
initialDrafts: readonly DraftSummary[];
|
||||||
|
assets: readonly AssetSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type DraftEditorState = {
|
type DraftEditorState = {
|
||||||
@@ -23,7 +26,7 @@ type DraftEditorState = {
|
|||||||
bodyMarkdown: string;
|
bodyMarkdown: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
|
export function DraftEditorPanel({ articleId, initialDrafts, assets }: DraftEditorPanelProps) {
|
||||||
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
|
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
|
||||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
|
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
|
||||||
initialDrafts[0]?.id ?? null,
|
initialDrafts[0]?.id ?? null,
|
||||||
@@ -44,8 +47,12 @@ export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelP
|
|||||||
[drafts, compareDraftId],
|
[drafts, compareDraftId],
|
||||||
);
|
);
|
||||||
const previewBlocks = useMemo(
|
const previewBlocks = useMemo(
|
||||||
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
|
() =>
|
||||||
[editor.bodyMarkdown, selectedDraft],
|
parseDraftPreview(
|
||||||
|
resolveDraftPreviewMarkdown(editor.bodyMarkdown, assets),
|
||||||
|
selectedDraft?.faq_items ?? [],
|
||||||
|
),
|
||||||
|
[assets, editor.bodyMarkdown, selectedDraft],
|
||||||
);
|
);
|
||||||
const comparison = useMemo(() => {
|
const comparison = useMemo(() => {
|
||||||
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
|
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
import type {
|
||||||
|
ContentReviewIssueSummary,
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
ContentReviewKind,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
} from "@pipeline/shared";
|
||||||
|
|
||||||
|
export type ReviewIssueRow = {
|
||||||
|
reviewKind: ContentReviewKind;
|
||||||
|
issueId: string;
|
||||||
|
suggestionId: string;
|
||||||
|
severity: string;
|
||||||
|
location: string;
|
||||||
|
message: string;
|
||||||
|
suggestedFix: string;
|
||||||
|
suggestedRewrite: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewSummary = {
|
||||||
|
unresolvedCount: number;
|
||||||
|
unresolvedTitles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildReviewIssueRows(
|
||||||
|
seoReport: ContentReviewReportSummary | null,
|
||||||
|
languageReport: ContentReviewReportSummary | null,
|
||||||
|
): ReviewIssueRow[] {
|
||||||
|
return [
|
||||||
|
...mapIssues("SEO", seoReport?.issues ?? []),
|
||||||
|
...mapIssues("LANGUAGE", languageReport?.issues ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFinalReviewSummary(
|
||||||
|
response: FinalReviewIssuesResponse | null,
|
||||||
|
): ReviewSummary {
|
||||||
|
if (!response) {
|
||||||
|
return { unresolvedCount: 0, unresolvedTitles: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unresolvedCount: response.unresolved_count,
|
||||||
|
unresolvedTitles: (response.unresolved_issues ?? []).map(
|
||||||
|
(issue) => `${issue.review_kind}: ${issue.message}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIssues(
|
||||||
|
reviewKind: ContentReviewKind,
|
||||||
|
issues: readonly ContentReviewIssueSummary[],
|
||||||
|
): ReviewIssueRow[] {
|
||||||
|
return issues.map((issue) => ({
|
||||||
|
reviewKind,
|
||||||
|
issueId: issue.id,
|
||||||
|
suggestionId: issue.suggestion_id,
|
||||||
|
severity: issue.severity,
|
||||||
|
location: issue.location,
|
||||||
|
message: issue.message,
|
||||||
|
suggestedFix: issue.suggested_fix ?? "",
|
||||||
|
suggestedRewrite: issue.suggested_rewrite ?? "",
|
||||||
|
status: issue.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ContentReviewReportSummary,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
} from "@pipeline/shared";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
acceptLanguageSuggestion,
|
||||||
|
acceptSeoSuggestion,
|
||||||
|
editLanguageSuggestion,
|
||||||
|
editSeoSuggestion,
|
||||||
|
fetchFinalReviewIssues,
|
||||||
|
fetchLanguageReport,
|
||||||
|
fetchSeoReport,
|
||||||
|
rejectLanguageSuggestion,
|
||||||
|
rejectSeoSuggestion,
|
||||||
|
runLanguageReview,
|
||||||
|
runSeoReview,
|
||||||
|
} from "@/shared/pipeline-api";
|
||||||
|
import { buildFinalReviewSummary, buildReviewIssueRows } from "./model";
|
||||||
|
|
||||||
|
type ArticleReviewPanelProps = {
|
||||||
|
articleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArticleReviewPanel({ articleId }: ArticleReviewPanelProps) {
|
||||||
|
const [seoReport, setSeoReport] = useState<ContentReviewReportSummary | null>(null);
|
||||||
|
const [languageReport, setLanguageReport] = useState<ContentReviewReportSummary | null>(null);
|
||||||
|
const [finalIssues, setFinalIssues] = useState<FinalReviewIssuesResponse | null>(null);
|
||||||
|
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const issueRows = useMemo(
|
||||||
|
() => buildReviewIssueRows(seoReport, languageReport),
|
||||||
|
[seoReport, languageReport],
|
||||||
|
);
|
||||||
|
const summary = useMemo(() => buildFinalReviewSummary(finalIssues), [finalIssues]);
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
const [seo, language, unresolved] = await Promise.all([
|
||||||
|
fetchSeoReport(articleId).catch(() => null),
|
||||||
|
fetchLanguageReport(articleId).catch(() => null),
|
||||||
|
fetchFinalReviewIssues(articleId).catch(() => null),
|
||||||
|
]);
|
||||||
|
setSeoReport(seo?.report ?? null);
|
||||||
|
setLanguageReport(language?.report ?? null);
|
||||||
|
setFinalIssues(unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunSeo() {
|
||||||
|
setBusyKey("seo-run");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await runSeoReview(articleId);
|
||||||
|
setSeoReport(response.report);
|
||||||
|
await refreshAll();
|
||||||
|
setMessage(`SEO review completed with score ${response.report.score}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "SEO review failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunLanguage() {
|
||||||
|
setBusyKey("language-run");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await runLanguageReview(articleId);
|
||||||
|
setLanguageReport(response.report);
|
||||||
|
await refreshAll();
|
||||||
|
setMessage(`Language review completed with score ${response.report.score}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Language review failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAccept(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||||
|
setBusyKey(`accept-${suggestionId}`);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
kind === "SEO"
|
||||||
|
? await acceptSeoSuggestion(articleId, suggestionId)
|
||||||
|
: await acceptLanguageSuggestion(articleId, suggestionId);
|
||||||
|
await refreshAll();
|
||||||
|
setMessage(
|
||||||
|
response.draft
|
||||||
|
? `Suggestion accepted. Draft v${response.draft.version} created.`
|
||||||
|
: "Suggestion accepted.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Accept failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReject(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||||
|
setBusyKey(`reject-${suggestionId}`);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
if (kind === "SEO") {
|
||||||
|
await rejectSeoSuggestion(articleId, suggestionId);
|
||||||
|
} else {
|
||||||
|
await rejectLanguageSuggestion(articleId, suggestionId);
|
||||||
|
}
|
||||||
|
await refreshAll();
|
||||||
|
setMessage("Suggestion rejected.");
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Reject failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEdit(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||||
|
const appliedText = (edits[suggestionId] ?? "").trim();
|
||||||
|
if (!appliedText) {
|
||||||
|
setMessage("Provide edited text before applying.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyKey(`edit-${suggestionId}`);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
kind === "SEO"
|
||||||
|
? await editSeoSuggestion(articleId, suggestionId, { applied_text: appliedText })
|
||||||
|
: await editLanguageSuggestion(articleId, suggestionId, { applied_text: appliedText });
|
||||||
|
await refreshAll();
|
||||||
|
setMessage(
|
||||||
|
response.draft
|
||||||
|
? `Suggestion edited. Draft v${response.draft.version} created.`
|
||||||
|
: "Suggestion edited.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof ApiError ? error.message : "Edit failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="reviewPanel">
|
||||||
|
<div className="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<h2>SEO and language review</h2>
|
||||||
|
<p>Unresolved issues: {summary.unresolvedCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="formActions">
|
||||||
|
<button type="button" onClick={onRunSeo} disabled={busyKey !== null}>
|
||||||
|
{busyKey === "seo-run" ? "Running..." : "Run SEO review"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onRunLanguage} disabled={busyKey !== null}>
|
||||||
|
{busyKey === "language-run" ? "Running..." : "Run language review"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void refreshAll()} disabled={busyKey !== null}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
|
||||||
|
|
||||||
|
{summary.unresolvedTitles.length > 0 ? (
|
||||||
|
<ul className="timeline">
|
||||||
|
{summary.unresolvedTitles.map((title) => (
|
||||||
|
<li key={title}>{title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Suggestion</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issueRows.map((row) => (
|
||||||
|
<tr key={`${row.reviewKind}-${row.suggestionId}`}>
|
||||||
|
<td>{row.reviewKind}</td>
|
||||||
|
<td>{row.severity}</td>
|
||||||
|
<td>{row.location}</td>
|
||||||
|
<td>{row.message}</td>
|
||||||
|
<td>{row.suggestedRewrite || row.suggestedFix || "—"}</td>
|
||||||
|
<td>{row.status}</td>
|
||||||
|
<td>
|
||||||
|
<div className="reviewActions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onAccept(row.reviewKind, row.suggestionId)}
|
||||||
|
disabled={busyKey !== null}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onReject(row.reviewKind, row.suggestionId)}
|
||||||
|
disabled={busyKey !== null}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
value={edits[row.suggestionId] ?? row.suggestedRewrite}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdits((current) => ({
|
||||||
|
...current,
|
||||||
|
[row.suggestionId]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onEdit(row.reviewKind, row.suggestionId)}
|
||||||
|
disabled={busyKey !== null}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{issueRows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7}>No review issues yet.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([
|
||||||
selectedSite.id,
|
fetchScriptConfigVersions(
|
||||||
DEMO_ADMIN_EMAIL,
|
selectedSite.id,
|
||||||
);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,22 +6,33 @@ import {
|
|||||||
} from "@/features/article-detail/model";
|
} from "@/features/article-detail/model";
|
||||||
import { ArticleDetailShell } from "@/features/article-detail/ui";
|
import { ArticleDetailShell } from "@/features/article-detail/ui";
|
||||||
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||||
|
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||||
|
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>
|
||||||
@@ -32,10 +43,17 @@ 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} />
|
||||||
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
|
<MediaLibraryPanel assets={detail.assets ?? []} />
|
||||||
|
<DraftEditorPanel
|
||||||
|
articleId={articleId}
|
||||||
|
initialDrafts={draftsResponse.drafts ?? []}
|
||||||
|
assets={detail.assets ?? []}
|
||||||
|
/>
|
||||||
|
<ArticleReviewPanel articleId={articleId} />
|
||||||
|
<FinalApprovalPanel articleId={articleId} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AssetGenerateSpecsResponse,
|
||||||
|
AssetListResponse,
|
||||||
|
AssetResponse,
|
||||||
|
AssetUpdateRequest,
|
||||||
|
AssetUploadResponse,
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
ArticleCreateResponse,
|
ArticleCreateResponse,
|
||||||
ArticleDetailResponse,
|
ArticleDetailResponse,
|
||||||
@@ -18,18 +23,40 @@ import type {
|
|||||||
EvidenceResponse,
|
EvidenceResponse,
|
||||||
EvidenceCreateRequest,
|
EvidenceCreateRequest,
|
||||||
EvidenceUpdateRequest,
|
EvidenceUpdateRequest,
|
||||||
|
FinalReviewIssuesResponse,
|
||||||
|
FinalApprovalChecklistResponse,
|
||||||
|
FinalApprovalRequest,
|
||||||
|
FinalApprovalResponse,
|
||||||
|
FinalRevisionRequest,
|
||||||
|
FinalRevisionRequestResponse,
|
||||||
|
LanguageReviewReportResponse,
|
||||||
|
LanguageReviewRunResponse,
|
||||||
PlanListResponse,
|
PlanListResponse,
|
||||||
PlanResponse,
|
PlanResponse,
|
||||||
PlanRevisionRequest,
|
PlanRevisionRequest,
|
||||||
PlanUpdateRequest,
|
PlanUpdateRequest,
|
||||||
ResearchListResponse,
|
ResearchListResponse,
|
||||||
ResearchStartResponse,
|
ResearchStartResponse,
|
||||||
|
ReviewSuggestionActionResponse,
|
||||||
|
ReviewSuggestionEditRequest,
|
||||||
ReviewActionResponse,
|
ReviewActionResponse,
|
||||||
|
SeoReviewReportResponse,
|
||||||
|
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";
|
||||||
@@ -166,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(
|
||||||
@@ -239,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,
|
||||||
@@ -249,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,
|
||||||
@@ -291,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(
|
||||||
@@ -438,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(
|
||||||
@@ -459,3 +629,182 @@ export function updateDraft(
|
|||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateAssetSpecs(articleId: string): Promise<AssetGenerateSpecsResponse> {
|
||||||
|
return apiPost<unknown, AssetGenerateSpecsResponse>(
|
||||||
|
`/api/articles/${articleId}/assets/generate-specs`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAssets(articleId: string): Promise<AssetListResponse> {
|
||||||
|
return apiGet<AssetListResponse>(`/api/articles/${articleId}/assets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAsset(
|
||||||
|
articleId: string,
|
||||||
|
assetId: string,
|
||||||
|
request: AssetUpdateRequest,
|
||||||
|
): Promise<AssetResponse> {
|
||||||
|
return apiPatch<AssetUpdateRequest, AssetResponse>(
|
||||||
|
`/api/articles/${articleId}/assets/${assetId}`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveAsset(articleId: string, assetId: string): Promise<AssetResponse> {
|
||||||
|
return apiPost<unknown, AssetResponse>(
|
||||||
|
`/api/articles/${articleId}/assets/${assetId}/approve`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadAssetFile(
|
||||||
|
articleId: string,
|
||||||
|
assetId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<AssetUploadResponse> {
|
||||||
|
return file.arrayBuffer().then((buffer) => {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
if (typeof btoa !== "function") {
|
||||||
|
throw new Error("Base64 encoding is not available in this runtime");
|
||||||
|
}
|
||||||
|
const content_base64 = btoa(binary);
|
||||||
|
return requestJson<AssetUploadResponse>(
|
||||||
|
`/api/articles/${articleId}/assets/${assetId}/upload`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: file.name || "asset.bin",
|
||||||
|
content_base64,
|
||||||
|
content_type: file.type || "application/octet-stream",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
DEMO_EDITOR_EMAIL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSeoReview(articleId: string): Promise<SeoReviewRunResponse> {
|
||||||
|
return apiPost<unknown, SeoReviewRunResponse>(
|
||||||
|
`/api/articles/${articleId}/seo/review`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSeoReport(articleId: string): Promise<SeoReviewReportResponse> {
|
||||||
|
return apiGet<SeoReviewReportResponse>(`/api/articles/${articleId}/seo/report`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runLanguageReview(articleId: string): Promise<LanguageReviewRunResponse> {
|
||||||
|
return apiPost<unknown, LanguageReviewRunResponse>(
|
||||||
|
`/api/articles/${articleId}/language/review`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLanguageReport(
|
||||||
|
articleId: string,
|
||||||
|
): Promise<LanguageReviewReportResponse> {
|
||||||
|
return apiGet<LanguageReviewReportResponse>(`/api/articles/${articleId}/language/report`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptSeoSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/accept`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectSeoSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/reject`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editSeoSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/edit`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptLanguageSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/language/suggestions/${suggestionId}/accept`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectLanguageSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/language/suggestions/${suggestionId}/reject`,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editLanguageSuggestion(
|
||||||
|
articleId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
request: ReviewSuggestionEditRequest,
|
||||||
|
): Promise<ReviewSuggestionActionResponse> {
|
||||||
|
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
|
||||||
|
`/api/articles/${articleId}/language/suggestions/${suggestionId}/edit`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchFinalReviewIssues(
|
||||||
|
articleId: string,
|
||||||
|
): Promise<FinalReviewIssuesResponse> {
|
||||||
|
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchFinalApprovalChecklist(
|
||||||
|
articleId: string,
|
||||||
|
): Promise<FinalApprovalChecklistResponse> {
|
||||||
|
return apiGet<FinalApprovalChecklistResponse>(
|
||||||
|
`/api/articles/${articleId}/final-approval/checklist`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFinalApproval(
|
||||||
|
articleId: string,
|
||||||
|
request: FinalApprovalRequest,
|
||||||
|
): Promise<FinalApprovalResponse> {
|
||||||
|
return apiPost<FinalApprovalRequest, FinalApprovalResponse>(
|
||||||
|
`/api/articles/${articleId}/final-approval`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFinalRevisionRequest(
|
||||||
|
articleId: string,
|
||||||
|
request: FinalRevisionRequest,
|
||||||
|
): Promise<FinalRevisionRequestResponse> {
|
||||||
|
return apiPost<FinalRevisionRequest, FinalRevisionRequestResponse>(
|
||||||
|
`/api/articles/${articleId}/final-revision-request`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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- ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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/article-assets/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 { buildMediaLibraryRows, resolveDraftPreviewMarkdown } = moduleExports;
|
||||||
|
|
||||||
|
const assets = [
|
||||||
|
{
|
||||||
|
id: "asset-approved",
|
||||||
|
article_id: "article-1",
|
||||||
|
section_id: "section-1",
|
||||||
|
asset_type: "hero_image",
|
||||||
|
title: "Hero visual",
|
||||||
|
prompt: "hero prompt",
|
||||||
|
object_key: "articles/article-1/assets/asset-approved/r2/hero.png",
|
||||||
|
file_url: "file:///tmp/hero.png",
|
||||||
|
alt_text: "Approved hero visual",
|
||||||
|
caption: "Used for section one",
|
||||||
|
status: "APPROVED",
|
||||||
|
history: [],
|
||||||
|
created_at: "2026-05-21T00:00:00Z",
|
||||||
|
updated_at: "2026-05-21T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset-rejected",
|
||||||
|
article_id: "article-1",
|
||||||
|
section_id: "section-1",
|
||||||
|
asset_type: "diagram",
|
||||||
|
title: "Rejected visual",
|
||||||
|
prompt: "diagram prompt",
|
||||||
|
object_key: "articles/article-1/assets/asset-rejected/r1/rejected.png",
|
||||||
|
file_url: "file:///tmp/rejected.png",
|
||||||
|
alt_text: "Rejected visual",
|
||||||
|
caption: "Do not publish",
|
||||||
|
status: "REJECTED",
|
||||||
|
history: [],
|
||||||
|
created_at: "2026-05-21T00:00:01Z",
|
||||||
|
updated_at: "2026-05-21T00:00:01Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = buildMediaLibraryRows(assets);
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.equal(rows[0].title, "Hero visual");
|
||||||
|
assert.equal(rows[0].publishReady, true);
|
||||||
|
assert.equal(rows[1].publishReady, false);
|
||||||
|
|
||||||
|
const markdown = [
|
||||||
|
"## Intro",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
const resolved = resolveDraftPreviewMarkdown(markdown, assets);
|
||||||
|
assert.ok(resolved.includes("file:///tmp/hero.png"));
|
||||||
|
assert.ok(resolved.includes("placeholder://section-1/2"));
|
||||||
|
assert.ok(!resolved.includes("file:///tmp/rejected.png"));
|
||||||
@@ -15,90 +15,153 @@ 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: "job-section-1",
|
id: "a1",
|
||||||
article_id: "a1",
|
target_site_id: "site1",
|
||||||
parent_job_id: null,
|
status: "PARALLEL_PRODUCTION_RUNNING",
|
||||||
attempt: 1,
|
publishing_status: "PUBLISH_NOT_STARTED",
|
||||||
job_type: "SECTION_SCAFFOLD",
|
brief_description: "desc",
|
||||||
agent_profile: "fake-section-scaffold",
|
language: "en",
|
||||||
status: "SUCCEEDED",
|
content_type: "longform_guide",
|
||||||
workspace_path: null,
|
created_at: "2026-05-21T00:00:00Z",
|
||||||
input_files: [],
|
updated_at: "2026-05-21T00:00:00Z",
|
||||||
output_files: [],
|
},
|
||||||
payload: {
|
target_site: { name: "Demo Site" },
|
||||||
artifact_key: "section:1",
|
workflow_template_snapshot: {
|
||||||
artifact_label: "Context and scope",
|
id: "workflow-1",
|
||||||
used_evidence_ids: ["e1", "e2"],
|
name: "Demo workflow",
|
||||||
unsupported_claims: [],
|
slug: "demo-workflow",
|
||||||
|
version: 2,
|
||||||
|
stage_summary: [
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
entry_type: "AGENT_JOB",
|
||||||
|
source: "AGENT",
|
||||||
|
event_type: "SECTION_SCAFFOLD_FAILED",
|
||||||
|
from_status: null,
|
||||||
|
to_status: null,
|
||||||
|
actor_user_id: null,
|
||||||
|
job_id: "job2",
|
||||||
|
job_type: "SECTION_SCAFFOLD",
|
||||||
|
job_status: "FAILED",
|
||||||
|
retry_eligible: true,
|
||||||
|
cancel_eligible: false,
|
||||||
|
safe_failure_summary: "Section failed.",
|
||||||
|
payload: {},
|
||||||
|
created_at: "2026-05-21T00:00:02Z",
|
||||||
},
|
},
|
||||||
error_category: null,
|
{
|
||||||
error_message: null,
|
id: "1",
|
||||||
stdout: "",
|
article_id: "a1",
|
||||||
stderr: "",
|
entry_type: "WORKFLOW_EVENT",
|
||||||
exit_code: 0,
|
source: "USER",
|
||||||
duration_ms: 3,
|
event_type: "PLAN_APPROVED",
|
||||||
queued_at: "2026-05-21T00:00:00Z",
|
from_status: "PLAN_REVIEW_REQUIRED",
|
||||||
started_at: "2026-05-21T00:00:01Z",
|
to_status: "RESEARCH_RUNNING",
|
||||||
finished_at: "2026-05-21T00:00:02Z",
|
actor_user_id: "u1",
|
||||||
},
|
job_id: null,
|
||||||
{
|
job_type: null,
|
||||||
id: "job-section-2",
|
job_status: null,
|
||||||
article_id: "a1",
|
retry_eligible: false,
|
||||||
parent_job_id: null,
|
cancel_eligible: false,
|
||||||
attempt: 1,
|
safe_failure_summary: null,
|
||||||
job_type: "SECTION_SCAFFOLD",
|
payload: {},
|
||||||
agent_profile: "fake-section-scaffold",
|
created_at: "2026-05-21T00:00:01Z",
|
||||||
status: "FAILED",
|
|
||||||
workspace_path: null,
|
|
||||||
input_files: [],
|
|
||||||
output_files: [],
|
|
||||||
payload: {
|
|
||||||
artifact_key: "section:2",
|
|
||||||
artifact_label: "Implementation workflow",
|
|
||||||
used_evidence_ids: ["e2"],
|
|
||||||
unsupported_claims: [{ claim_text: "Unverified claim" }],
|
|
||||||
},
|
},
|
||||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
],
|
||||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
workflow_events: [],
|
||||||
stdout: "",
|
agent_jobs: [
|
||||||
stderr: "",
|
{
|
||||||
exit_code: 0,
|
id: "job2",
|
||||||
duration_ms: 3,
|
article_id: "a1",
|
||||||
queued_at: "2026-05-21T00:00:00Z",
|
parent_job_id: null,
|
||||||
started_at: "2026-05-21T00:00:01Z",
|
attempt: 1,
|
||||||
finished_at: "2026-05-21T00:00:02Z",
|
job_type: "SECTION_SCAFFOLD",
|
||||||
},
|
agent_profile: "fake-section-scaffold",
|
||||||
{
|
status: "FAILED",
|
||||||
id: "job-non-artifact",
|
workspace_path: null,
|
||||||
article_id: "a1",
|
input_files: [],
|
||||||
parent_job_id: null,
|
output_files: [],
|
||||||
attempt: 1,
|
payload: { last_successful_step: "Outline written" },
|
||||||
job_type: "TEST_CODEX",
|
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||||
agent_profile: "fake-codex",
|
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||||
status: "SUCCEEDED",
|
stdout: "token=[REDACTED]",
|
||||||
workspace_path: null,
|
stderr: "",
|
||||||
input_files: [],
|
exit_code: 1,
|
||||||
output_files: [],
|
duration_ms: 5,
|
||||||
payload: {},
|
queued_at: "2026-05-21T00:00:00Z",
|
||||||
error_category: null,
|
started_at: "2026-05-21T00:00:01Z",
|
||||||
error_message: null,
|
finished_at: "2026-05-21T00:00:02Z",
|
||||||
stdout: "",
|
retry_eligible: true,
|
||||||
stderr: "",
|
retry_block_reason: null,
|
||||||
exit_code: 0,
|
cancel_eligible: false,
|
||||||
duration_ms: 0,
|
safe_failure_summary: "Section failed safely.",
|
||||||
queued_at: "2026-05-21T00:00:00Z",
|
},
|
||||||
started_at: "2026-05-21T00:00:01Z",
|
],
|
||||||
finished_at: "2026-05-21T00:00:02Z",
|
publish_commit: null,
|
||||||
},
|
};
|
||||||
]);
|
|
||||||
|
|
||||||
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,94 @@
|
|||||||
|
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/article-review/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 { buildReviewIssueRows, buildFinalReviewSummary } = moduleExports;
|
||||||
|
|
||||||
|
const seoReport = {
|
||||||
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
review_kind: "SEO",
|
||||||
|
draft_id: "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||||
|
score: 72,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
id: "meta-description-length",
|
||||||
|
suggestion_id: "22222222-2222-2222-2222-222222222222",
|
||||||
|
severity: "HIGH",
|
||||||
|
location: "meta_description",
|
||||||
|
message: "Meta description too short.",
|
||||||
|
suggested_fix: "Use 120-160 chars.",
|
||||||
|
suggested_rewrite: "Expanded meta description text.",
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recommended_slug: "seo-language-review-guide",
|
||||||
|
recommended_title: "SEO Language Review Guide",
|
||||||
|
schema_json: { "@type": "Article" },
|
||||||
|
rules_snapshot: { meta_description_min: 120 },
|
||||||
|
created_at: "2026-05-22T12:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageReport = {
|
||||||
|
...seoReport,
|
||||||
|
id: "33333333-3333-3333-3333-333333333333",
|
||||||
|
review_kind: "LANGUAGE",
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
id: "long-sentence",
|
||||||
|
suggestion_id: "44444444-4444-4444-4444-444444444444",
|
||||||
|
severity: "MEDIUM",
|
||||||
|
location: "body_markdown",
|
||||||
|
message: "Sentence exceeds 24 words.",
|
||||||
|
suggested_fix: "Split sentence.",
|
||||||
|
suggested_rewrite: "This sentence is shorter.",
|
||||||
|
status: "ACCEPTED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = buildReviewIssueRows(seoReport, languageReport);
|
||||||
|
assert.equal(rows.length, 2);
|
||||||
|
assert.equal(rows[0].reviewKind, "SEO");
|
||||||
|
assert.equal(rows[0].suggestionId, "22222222-2222-2222-2222-222222222222");
|
||||||
|
assert.equal(rows[1].reviewKind, "LANGUAGE");
|
||||||
|
assert.equal(rows[1].status, "ACCEPTED");
|
||||||
|
|
||||||
|
const summary = buildFinalReviewSummary({
|
||||||
|
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
unresolved_count: 2,
|
||||||
|
unresolved_issues: [
|
||||||
|
{
|
||||||
|
review_kind: "SEO",
|
||||||
|
suggestion_id: "22222222-2222-2222-2222-222222222222",
|
||||||
|
severity: "HIGH",
|
||||||
|
location: "meta_description",
|
||||||
|
message: "Meta description too short.",
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
review_kind: "LANGUAGE",
|
||||||
|
suggestion_id: "55555555-5555-5555-5555-555555555555",
|
||||||
|
severity: "MEDIUM",
|
||||||
|
location: "body_markdown",
|
||||||
|
message: "Sentence exceeds 24 words.",
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(summary.unresolvedCount, 2);
|
||||||
|
assert.equal(summary.unresolvedTitles.length, 2);
|
||||||
|
assert.ok(summary.unresolvedTitles[0].startsWith("SEO:"));
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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).
|
||||||
+4468
-1
File diff suppressed because it is too large
Load Diff
@@ -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,11 +104,36 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED";
|
export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED";
|
||||||
|
|
||||||
|
export type AssetGenerateSpecsResponse = {
|
||||||
|
assets?: AssetSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetListResponse = {
|
||||||
|
assets?: AssetSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetResponse = {
|
||||||
|
asset: AssetSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetRevisionSummary = {
|
||||||
|
action: string;
|
||||||
|
actor_user_id?: string | null;
|
||||||
|
article_id: string;
|
||||||
|
asset_id: string;
|
||||||
|
created_at: string;
|
||||||
|
id: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
revision_index: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED";
|
export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED";
|
||||||
|
|
||||||
export type AssetSummary = {
|
export type AssetSummary = {
|
||||||
@@ -109,14 +141,38 @@ export type AssetSummary = {
|
|||||||
article_id: string;
|
article_id: string;
|
||||||
asset_type: AssetType;
|
asset_type: AssetType;
|
||||||
caption?: string | null;
|
caption?: string | null;
|
||||||
|
created_at: string;
|
||||||
file_url?: string | null;
|
file_url?: string | null;
|
||||||
|
history?: AssetRevisionSummary[];
|
||||||
id: string;
|
id: string;
|
||||||
|
object_key?: string | null;
|
||||||
prompt?: string | null;
|
prompt?: string | null;
|
||||||
|
section_id?: string | null;
|
||||||
status: AssetStatus;
|
status: AssetStatus;
|
||||||
title: string;
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetType = "hero_image" | "diagram" | "table" | "inline_image";
|
export type AssetType = "hero_image" | "diagram" | "table" | "flowchart" | "comparison_matrix" | "architecture_diagram" | "inline_image";
|
||||||
|
|
||||||
|
export type AssetUpdateRequest = {
|
||||||
|
alt_text?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
section_id?: string | null;
|
||||||
|
status?: AssetStatus | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetUploadRequest = {
|
||||||
|
content_base64: string;
|
||||||
|
content_type?: string | null;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetUploadResponse = {
|
||||||
|
asset: AssetSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type BoundaryQuestionListResponse = {
|
export type BoundaryQuestionListResponse = {
|
||||||
job?: AgentJobSummary | null;
|
job?: AgentJobSummary | null;
|
||||||
@@ -157,6 +213,51 @@ export type ClaimSummary = {
|
|||||||
|
|
||||||
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
|
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
|
||||||
|
|
||||||
|
export type ContentReviewIssueSummary = {
|
||||||
|
id: string;
|
||||||
|
location: string;
|
||||||
|
message: string;
|
||||||
|
severity: string;
|
||||||
|
status: ReviewSuggestionStatus;
|
||||||
|
suggested_fix?: string | null;
|
||||||
|
suggested_rewrite?: string | null;
|
||||||
|
suggestion_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentReviewKind = "SEO" | "LANGUAGE";
|
||||||
|
|
||||||
|
export type ContentReviewReportSummary = {
|
||||||
|
article_id: string;
|
||||||
|
created_at: string;
|
||||||
|
draft_id: string;
|
||||||
|
id: string;
|
||||||
|
issues?: ContentReviewIssueSummary[];
|
||||||
|
recommended_slug: string;
|
||||||
|
recommended_title: string;
|
||||||
|
review_kind: ContentReviewKind;
|
||||||
|
rules_snapshot?: Record<string, unknown>;
|
||||||
|
schema_json?: Record<string, unknown>;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentReviewSuggestionSummary = {
|
||||||
|
applied_text?: string | null;
|
||||||
|
article_id: string;
|
||||||
|
created_at: string;
|
||||||
|
id: string;
|
||||||
|
location: string;
|
||||||
|
message: string;
|
||||||
|
patch?: Record<string, unknown>;
|
||||||
|
report_id: string;
|
||||||
|
review_kind: ContentReviewKind;
|
||||||
|
severity: string;
|
||||||
|
status: ReviewSuggestionStatus;
|
||||||
|
suggested_fix?: string | null;
|
||||||
|
suggested_rewrite?: string | null;
|
||||||
|
suggestion_key: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CurrentUser = {
|
export type CurrentUser = {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -257,10 +358,96 @@ 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 = {
|
||||||
|
location: string;
|
||||||
|
message: string;
|
||||||
|
review_kind: ContentReviewKind;
|
||||||
|
severity: string;
|
||||||
|
status: ReviewSuggestionStatus;
|
||||||
|
suggestion_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalReviewIssuesResponse = {
|
||||||
|
article_id: string;
|
||||||
|
unresolved_count: number;
|
||||||
|
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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LanguageReviewReportResponse = {
|
||||||
|
report: ContentReviewReportSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LanguageReviewRunResponse = {
|
||||||
|
report: ContentReviewReportSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObservabilityTimelineEventSummary = {
|
||||||
|
actor_user_id?: string | null;
|
||||||
|
article_id: string;
|
||||||
|
cancel_eligible?: boolean;
|
||||||
|
created_at: string;
|
||||||
|
entry_type: string;
|
||||||
|
event_type: string;
|
||||||
|
from_status?: ArticleWorkflowStatus | null;
|
||||||
|
id: string;
|
||||||
|
job_id?: string | null;
|
||||||
|
job_status?: AgentJobStatus | null;
|
||||||
|
job_type?: AgentJobType | null;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
retry_eligible?: boolean;
|
||||||
|
safe_failure_summary?: string | null;
|
||||||
|
source: string;
|
||||||
|
to_status?: ArticleWorkflowStatus | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlanListResponse = {
|
export type PlanListResponse = {
|
||||||
plans: PlanSummary[];
|
plans: PlanSummary[];
|
||||||
};
|
};
|
||||||
@@ -337,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;
|
||||||
@@ -350,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;
|
||||||
@@ -363,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;
|
||||||
@@ -397,6 +608,17 @@ export type ReviewActionResponse = {
|
|||||||
|
|
||||||
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
|
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
|
||||||
|
|
||||||
|
export type ReviewSuggestionActionResponse = {
|
||||||
|
draft?: DraftSummary | null;
|
||||||
|
suggestion: ContentReviewSuggestionSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewSuggestionEditRequest = {
|
||||||
|
applied_text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewSuggestionStatus = "PENDING" | "ACCEPTED" | "REJECTED" | "EDITED";
|
||||||
|
|
||||||
export type ReviewSummary = {
|
export type ReviewSummary = {
|
||||||
article_id: string;
|
article_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -416,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>;
|
||||||
@@ -449,6 +688,14 @@ export type ScriptConfigVersionSummary = {
|
|||||||
version: number;
|
version: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SeoReviewReportResponse = {
|
||||||
|
report: ContentReviewReportSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeoReviewRunResponse = {
|
||||||
|
report: ContentReviewReportSummary;
|
||||||
|
};
|
||||||
|
|
||||||
export type TargetSiteConfig = {
|
export type TargetSiteConfig = {
|
||||||
active_script_config_version_id?: string | null;
|
active_script_config_version_id?: string | null;
|
||||||
audience: string;
|
audience: string;
|
||||||
@@ -520,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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ Development description: Implement asset specifications, generated or uploaded a
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing public API test for approving an asset and seeing it become available to the article; proceed one asset behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Asset specs are generated and linked to article or section.
|
- [x] Asset specs are generated and linked to article or section.
|
||||||
- [ ] File uploads store objects in object storage and persist object keys.
|
- [x] File uploads store objects in object storage and persist object keys.
|
||||||
- [ ] Asset approval writes workflow event.
|
- [x] Asset approval writes workflow event.
|
||||||
- [ ] Rejected assets are excluded from publish bundle.
|
- [x] Rejected assets are excluded from publish bundle.
|
||||||
- [ ] Replacement preserves audit history.
|
- [x] Replacement preserves audit history.
|
||||||
- [ ] Media library lists article assets with status, type, title, preview/file link, alt text, and caption.
|
- [x] Media library lists article assets with status, type, title, preview/file link, alt text, and caption.
|
||||||
- [ ] Draft preview uses approved asset references where available.
|
- [x] Draft preview uses approved asset references where available.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -59,9 +59,39 @@ Development description: Implement asset specifications, generated or uploaded a
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Implementation completed, acceptance criteria closed.
|
||||||
- TDD plan: To be filled during execution.
|
- TDD plan:
|
||||||
- Red evidence: To be filled during execution.
|
1. Keep RED pre-requirement test that calls approve API and asserts asset visibility in article context.
|
||||||
- Green evidence: To be filled during execution.
|
2. Expand integration tests to cover full assets lifecycle: specs generation, upload, approve/reject, replacement audit, and draft-facing inclusion/exclusion behavior.
|
||||||
- Refactor notes: To be filled during execution.
|
3. Implement API contracts + persistence + routes + object storage integration.
|
||||||
- Verification output: To be filled during execution.
|
4. Add frontend media library model/UI and draft preview asset reference resolution.
|
||||||
|
- Red evidence:
|
||||||
|
- Initial pre-requirement failure: `POST /api/articles/{article_id}/assets/{asset_id}/approve` returned `404 Not Found`.
|
||||||
|
- During implementation hardening: transient runtime bug `NameError: name 'approved' is not defined` in `approve_asset` (fixed), confirmed by rerun to green.
|
||||||
|
- Green evidence:
|
||||||
|
- Backend assets suite now green with 7 integration tests, including:
|
||||||
|
- specs linked to sections,
|
||||||
|
- upload persists `object_key` and writes object file,
|
||||||
|
- approval writes workflow event,
|
||||||
|
- rejected asset excluded from assembled draft publish-facing data,
|
||||||
|
- replacement keeps full audit history.
|
||||||
|
- Draft assembly and parallel production suites remain green after assets integration.
|
||||||
|
- Frontend media-library model tests and typecheck are green.
|
||||||
|
- Refactor notes:
|
||||||
|
- Added dedicated assets application module and routes.
|
||||||
|
- Extended object storage adapter with `put_bytes`.
|
||||||
|
- Added persistent `asset_revisions` history table and API-visible history payload.
|
||||||
|
- Updated shared OpenAPI contracts and generated TS types.
|
||||||
|
- Verification output:
|
||||||
|
- `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 -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_parallel_production_public_api.py`
|
||||||
|
- `Ran 6 tests ... OK`
|
||||||
|
- `node apps/frontend/tests/article_assets.model.test.mjs`
|
||||||
|
- `OK` (exit code `0`)
|
||||||
|
- `node apps/frontend/tests/draft_editor.model.test.mjs`
|
||||||
|
- `OK` (exit code `0`)
|
||||||
|
- `pnpm --dir apps/frontend typecheck`
|
||||||
|
- `tsc --noEmit` completed successfully.
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ Development description: Implement SEO and linguistic review jobs, issue reports
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing behavior test that runs an SEO review and returns a visible issue through the public API; proceed one review behavior at a time and record evidence in `Result`.
|
||||||
- [ ] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
|
- [x] SEO report includes score, issues, suggested fixes, recommended slug/title, and schema JSON.
|
||||||
- [ ] Language report includes severity, location, message, and suggested rewrite.
|
- [x] Language report includes severity, location, message, and suggested rewrite.
|
||||||
- [ ] Editor can accept, reject, or edit each suggestion.
|
- [x] Editor can accept, reject, or edit each suggestion.
|
||||||
- [ ] Accepted content changes create a new immutable draft version.
|
- [x] Accepted content changes create a new immutable draft version.
|
||||||
- [ ] Target-site SEO and tone rules override global defaults.
|
- [x] Target-site SEO and tone rules override global defaults.
|
||||||
- [ ] Final review can show unresolved SEO/language issues.
|
- [x] Final review can show unresolved SEO/language issues.
|
||||||
- [ ] Fake review runner produces deterministic demo reports.
|
- [x] Fake review runner produces deterministic demo reports.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -64,9 +64,40 @@ Development description: Implement SEO and linguistic review jobs, issue reports
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Pending execution.
|
- Status: Completed (pre-requirement RED + implementation GREEN).
|
||||||
- TDD plan: To be filled during execution.
|
- TDD plan (done):
|
||||||
- Red evidence: To be filled during execution.
|
1. Prepare article through public flow to assembled draft state.
|
||||||
- Green evidence: To be filled during execution.
|
2. Run SEO and language review via public endpoints.
|
||||||
- Refactor notes: To be filled during execution.
|
3. Fetch reports and verify issue visibility through API.
|
||||||
- Verification output: To be filled during execution.
|
4. Apply suggestion actions (accept/reject/edit) and verify immutable draft versioning.
|
||||||
|
5. Verify unresolved issues in final review context and deterministic fake-runner behavior.
|
||||||
|
- Red evidence:
|
||||||
|
- Pre-requirement test initially failed with `404 Not Found` on `POST /api/articles/{article_id}/seo/review`.
|
||||||
|
- Green evidence:
|
||||||
|
- Added review domain contracts, persistence tables, repository, application service, and routes:
|
||||||
|
- `POST /api/articles/{article_id}/seo/review`
|
||||||
|
- `GET /api/articles/{article_id}/seo/report`
|
||||||
|
- `POST /api/articles/{article_id}/language/review`
|
||||||
|
- `GET /api/articles/{article_id}/language/report`
|
||||||
|
- suggestion actions:
|
||||||
|
- `POST /api/articles/{article_id}/seo/suggestions/{suggestion_id}/accept|reject|edit`
|
||||||
|
- `POST /api/articles/{article_id}/language/suggestions/{suggestion_id}/accept|reject|edit`
|
||||||
|
- unresolved issues endpoint:
|
||||||
|
- `GET /api/articles/{article_id}/final-review/issues`
|
||||||
|
- Reports are generated deterministically from assembled draft + site rules and stored in review tables.
|
||||||
|
- Accept/edit actions that modify content/metadata create a new immutable draft version through existing draft versioning flow.
|
||||||
|
- Site-level overrides are applied from `target_sites.seo_rules` (`seo_review` / `language_review`) over defaults.
|
||||||
|
- Added frontend review feature model + UI and API bindings.
|
||||||
|
- Regenerated shared contracts:
|
||||||
|
- `packages/shared/openapi.json`
|
||||||
|
- `packages/shared/src/api-types.ts`
|
||||||
|
- Refactor notes:
|
||||||
|
- `ContentReviewReportSummary.schema_json` currently emits a pydantic shadow warning; behavior is correct and contracts remain stable.
|
||||||
|
- Verification output:
|
||||||
|
- `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_draft_assembly_public_api.py` -> `OK (5 tests)`
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> `OK (7 tests)`
|
||||||
|
- `node apps/frontend/tests/draft_editor.model.test.mjs` -> `OK`
|
||||||
|
- `node apps/frontend/tests/article_assets.model.test.mjs` -> `OK`
|
||||||
|
- `node apps/frontend/tests/article_review.model.test.mjs` -> `OK`
|
||||||
|
- `pnpm --dir apps/frontend typecheck` -> `OK`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user