feat(task-023): bind workflow templates into demo flow
This commit is contained in:
@@ -11,6 +11,10 @@ from src.domain.contracts import (
|
|||||||
ArticleWorkflowStatus,
|
ArticleWorkflowStatus,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
PublishingStatus,
|
PublishingStatus,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateStatus,
|
||||||
|
WorkflowTemplateSummary,
|
||||||
)
|
)
|
||||||
from src.application.observability import (
|
from src.application.observability import (
|
||||||
build_observability_timeline,
|
build_observability_timeline,
|
||||||
@@ -24,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,
|
||||||
@@ -62,6 +72,9 @@ def get_article_detail(
|
|||||||
) -> 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)
|
||||||
@@ -84,6 +97,7 @@ def get_article_detail(
|
|||||||
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,
|
||||||
@@ -100,3 +114,44 @@ def get_article_detail(
|
|||||||
|
|
||||||
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
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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,
|
||||||
@@ -22,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:
|
||||||
@@ -125,6 +127,160 @@ 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:
|
def _repository_url_for_seed() -> str:
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ from .models import (
|
|||||||
WorkflowTemplateCreateRequest,
|
WorkflowTemplateCreateRequest,
|
||||||
WorkflowTemplateListResponse,
|
WorkflowTemplateListResponse,
|
||||||
WorkflowTemplateResponse,
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
WorkflowTemplateSummary,
|
WorkflowTemplateSummary,
|
||||||
WorkflowTemplateUpdateRequest,
|
WorkflowTemplateUpdateRequest,
|
||||||
AssetGenerateSpecsResponse,
|
AssetGenerateSpecsResponse,
|
||||||
@@ -158,6 +160,8 @@ __all__ = [
|
|||||||
"WorkflowTemplateCreateRequest",
|
"WorkflowTemplateCreateRequest",
|
||||||
"WorkflowTemplateListResponse",
|
"WorkflowTemplateListResponse",
|
||||||
"WorkflowTemplateResponse",
|
"WorkflowTemplateResponse",
|
||||||
|
"WorkflowTemplateSnapshot",
|
||||||
|
"WorkflowTemplateStageSnapshot",
|
||||||
"WorkflowTemplateStatus",
|
"WorkflowTemplateStatus",
|
||||||
"WorkflowTemplateSummary",
|
"WorkflowTemplateSummary",
|
||||||
"WorkflowTemplateUpdateRequest",
|
"WorkflowTemplateUpdateRequest",
|
||||||
|
|||||||
@@ -273,8 +273,27 @@ class WorkflowTemplateAuditEventListResponse(ContractModel):
|
|||||||
events: list[WorkflowTemplateAuditEventSummary] = Field(default_factory=list)
|
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)
|
||||||
@@ -286,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
|
||||||
@@ -855,6 +876,7 @@ class AssetUploadResponse(ContractModel):
|
|||||||
class ArticleDetailResponse(ContractModel):
|
class ArticleDetailResponse(ContractModel):
|
||||||
article: ArticleSummary
|
article: ArticleSummary
|
||||||
target_site: TargetSiteConfig | None = None
|
target_site: TargetSiteConfig | None = None
|
||||||
|
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
|
||||||
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
||||||
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
||||||
plan: PlanSummary | None = None
|
plan: PlanSummary | None = None
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ from .models import (
|
|||||||
WorkflowTemplateCreateRequest,
|
WorkflowTemplateCreateRequest,
|
||||||
WorkflowTemplateListResponse,
|
WorkflowTemplateListResponse,
|
||||||
WorkflowTemplateResponse,
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
WorkflowTemplateSummary,
|
WorkflowTemplateSummary,
|
||||||
WorkflowTemplateUpdateRequest,
|
WorkflowTemplateUpdateRequest,
|
||||||
TargetSiteConfigUpdateRequest,
|
TargetSiteConfigUpdateRequest,
|
||||||
@@ -176,6 +178,8 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
|||||||
WorkflowStageResponse,
|
WorkflowStageResponse,
|
||||||
WorkflowTemplateAuditEventSummary,
|
WorkflowTemplateAuditEventSummary,
|
||||||
WorkflowTemplateAuditEventListResponse,
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateStageSnapshot,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
ArticleCreateRequest,
|
ArticleCreateRequest,
|
||||||
ArticleSummary,
|
ArticleSummary,
|
||||||
ArticleCreateResponse,
|
ArticleCreateResponse,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from src.domain.contracts import (
|
|||||||
WorkflowStagePart,
|
WorkflowStagePart,
|
||||||
WorkflowStageSummary,
|
WorkflowStageSummary,
|
||||||
WorkflowTemplateAuditEventSummary,
|
WorkflowTemplateAuditEventSummary,
|
||||||
|
WorkflowTemplateSnapshot,
|
||||||
WorkflowTemplateStatus,
|
WorkflowTemplateStatus,
|
||||||
WorkflowTemplateSummary,
|
WorkflowTemplateSummary,
|
||||||
)
|
)
|
||||||
@@ -442,6 +443,9 @@ class ArticlesRepository:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
target_site_id: UUID,
|
target_site_id: UUID,
|
||||||
|
workflow_template_id: UUID | None,
|
||||||
|
workflow_template_version: int | None,
|
||||||
|
workflow_template_snapshot: JsonObject | None,
|
||||||
status: ArticleWorkflowStatus,
|
status: ArticleWorkflowStatus,
|
||||||
publishing_status: PublishingStatus,
|
publishing_status: PublishingStatus,
|
||||||
brief_description: str,
|
brief_description: str,
|
||||||
@@ -455,10 +459,14 @@ class ArticlesRepository:
|
|||||||
) -> ArticleSummary:
|
) -> ArticleSummary:
|
||||||
article_id = uuid4()
|
article_id = uuid4()
|
||||||
placeholder = self._repository.placeholder()
|
placeholder = self._repository.placeholder()
|
||||||
|
json_cast = self._repository.json_cast()
|
||||||
sql = f"""
|
sql = f"""
|
||||||
INSERT INTO articles (
|
INSERT INTO articles (
|
||||||
id,
|
id,
|
||||||
target_site_id,
|
target_site_id,
|
||||||
|
workflow_template_id,
|
||||||
|
workflow_template_version,
|
||||||
|
workflow_template_snapshot,
|
||||||
status,
|
status,
|
||||||
publishing_status,
|
publishing_status,
|
||||||
brief_description,
|
brief_description,
|
||||||
@@ -477,6 +485,9 @@ class ArticlesRepository:
|
|||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
|
{placeholder}{json_cast},
|
||||||
|
{placeholder},
|
||||||
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
{placeholder},
|
{placeholder},
|
||||||
@@ -491,6 +502,13 @@ class ArticlesRepository:
|
|||||||
(
|
(
|
||||||
str(article_id),
|
str(article_id),
|
||||||
str(target_site_id),
|
str(target_site_id),
|
||||||
|
_uuid_value(workflow_template_id),
|
||||||
|
workflow_template_version,
|
||||||
|
(
|
||||||
|
_json_value(workflow_template_snapshot)
|
||||||
|
if workflow_template_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
status.value,
|
status.value,
|
||||||
publishing_status.value,
|
publishing_status.value,
|
||||||
brief_description,
|
brief_description,
|
||||||
@@ -513,6 +531,8 @@ class ArticlesRepository:
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
target_site_id,
|
target_site_id,
|
||||||
|
workflow_template_id,
|
||||||
|
workflow_template_version,
|
||||||
status,
|
status,
|
||||||
publishing_status,
|
publishing_status,
|
||||||
brief_description,
|
brief_description,
|
||||||
@@ -538,6 +558,8 @@ class ArticlesRepository:
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
target_site_id,
|
target_site_id,
|
||||||
|
workflow_template_id,
|
||||||
|
workflow_template_version,
|
||||||
status,
|
status,
|
||||||
publishing_status,
|
publishing_status,
|
||||||
brief_description,
|
brief_description,
|
||||||
@@ -558,6 +580,28 @@ class ArticlesRepository:
|
|||||||
raise LookupError(f"Article not found: {article_id}")
|
raise LookupError(f"Article not found: {article_id}")
|
||||||
return _article_summary_from_row(row)
|
return _article_summary_from_row(row)
|
||||||
|
|
||||||
|
def get_workflow_template_snapshot(
|
||||||
|
self,
|
||||||
|
article_id: UUID,
|
||||||
|
) -> WorkflowTemplateSnapshot | None:
|
||||||
|
placeholder = self._repository.placeholder()
|
||||||
|
with self._repository.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT workflow_template_snapshot
|
||||||
|
FROM articles
|
||||||
|
WHERE id = {placeholder}
|
||||||
|
""",
|
||||||
|
(str(article_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise LookupError(f"Article not found: {article_id}")
|
||||||
|
snapshot = _json_from_row(row, "workflow_template_snapshot")
|
||||||
|
if snapshot is None:
|
||||||
|
return None
|
||||||
|
return WorkflowTemplateSnapshot.model_validate(snapshot)
|
||||||
|
|
||||||
def update_status(
|
def update_status(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -3860,6 +3904,8 @@ def _article_summary_from_row(row: Any) -> ArticleSummary:
|
|||||||
return ArticleSummary(
|
return ArticleSummary(
|
||||||
id=_row_value(row, "id"),
|
id=_row_value(row, "id"),
|
||||||
target_site_id=_row_value(row, "target_site_id"),
|
target_site_id=_row_value(row, "target_site_id"),
|
||||||
|
workflow_template_id=_row_value(row, "workflow_template_id"),
|
||||||
|
workflow_template_version=_row_value(row, "workflow_template_version"),
|
||||||
status=_row_value(row, "status"),
|
status=_row_value(row, "status"),
|
||||||
publishing_status=_row_value(row, "publishing_status"),
|
publishing_status=_row_value(row, "publishing_status"),
|
||||||
brief_description=_row_value(row, "brief_description"),
|
brief_description=_row_value(row, "brief_description"),
|
||||||
@@ -4218,6 +4264,7 @@ def _plain_row(row: Any) -> dict[str, Any]:
|
|||||||
"retry_policy",
|
"retry_policy",
|
||||||
"parts",
|
"parts",
|
||||||
"payload",
|
"payload",
|
||||||
|
"workflow_template_snapshot",
|
||||||
):
|
):
|
||||||
if key in result:
|
if key in result:
|
||||||
result[key] = _json_decode(result[key])
|
result[key] = _json_decode(result[key])
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
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})
|
||||||
@@ -162,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(),
|
||||||
@@ -473,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)
|
||||||
""",
|
""",
|
||||||
@@ -610,6 +620,9 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
|||||||
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,
|
||||||
@@ -900,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)
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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()
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function AdminWorkflowBuilder({
|
|||||||
|
|
||||||
async function refreshWorkflowList(selectedWorkflowId?: string) {
|
async function refreshWorkflowList(selectedWorkflowId?: string) {
|
||||||
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
|
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
|
||||||
setWorkflows(listResponse.workflows);
|
setWorkflows(listResponse.workflows ?? []);
|
||||||
if (selectedWorkflowId) {
|
if (selectedWorkflowId) {
|
||||||
await loadWorkflow(selectedWorkflowId, false);
|
await loadWorkflow(selectedWorkflowId, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ export type JobDetailRow = {
|
|||||||
stderr: 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;
|
||||||
@@ -46,6 +64,7 @@ export type DetailSummary = {
|
|||||||
publishingValidationLabel: string;
|
publishingValidationLabel: string;
|
||||||
briefDescription: string;
|
briefDescription: string;
|
||||||
targetSite: string;
|
targetSite: string;
|
||||||
|
workflowTemplate: WorkflowTemplateSnapshotView | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
viewerRole: Role;
|
viewerRole: Role;
|
||||||
viewerEmail: string;
|
viewerEmail: string;
|
||||||
@@ -101,6 +120,7 @@ export function buildDetailSummary(
|
|||||||
publishingValidationLabel: resolvePublishingValidationLabel(detail),
|
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,
|
viewerRole,
|
||||||
viewerEmail,
|
viewerEmail,
|
||||||
@@ -146,6 +166,33 @@ export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobCo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
|
||||||
const manifest = detail.publish_commit?.content_bundle_manifest;
|
const manifest = detail.publish_commit?.content_bundle_manifest;
|
||||||
if (manifest && typeof manifest === "object") {
|
if (manifest && typeof manifest === "object") {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
|||||||
const [busyJobId, setBusyJobId] = useState("");
|
const [busyJobId, setBusyJobId] = useState("");
|
||||||
const timeline = summary.timeline;
|
const timeline = summary.timeline;
|
||||||
const jobDetails = summary.jobDetails;
|
const jobDetails = summary.jobDetails;
|
||||||
|
const workflowTemplate = summary.workflowTemplate;
|
||||||
const isAdmin = summary.viewerRole === "ADMIN";
|
const isAdmin = summary.viewerRole === "ADMIN";
|
||||||
|
|
||||||
async function handleRetry(jobId: string) {
|
async function handleRetry(jobId: string) {
|
||||||
@@ -104,6 +105,49 @@ 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) => (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default async function AdminWorkflowsPage({
|
|||||||
</header>
|
</header>
|
||||||
<RoleNavigation role="ADMIN" />
|
<RoleNavigation role="ADMIN" />
|
||||||
<AdminWorkflowBuilder
|
<AdminWorkflowBuilder
|
||||||
initialWorkflows={workflowListResponse.workflows}
|
initialWorkflows={workflowListResponse.workflows ?? []}
|
||||||
initialWorkflow={selectedWorkflow}
|
initialWorkflow={selectedWorkflow}
|
||||||
initialAuditEvents={auditEvents}
|
initialAuditEvents={auditEvents}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ const detail = {
|
|||||||
updated_at: "2026-05-21T00:00:00Z",
|
updated_at: "2026-05-21T00:00:00Z",
|
||||||
},
|
},
|
||||||
target_site: { name: "Demo Site" },
|
target_site: { name: "Demo Site" },
|
||||||
|
workflow_template_snapshot: {
|
||||||
|
id: "workflow-1",
|
||||||
|
name: "Demo workflow",
|
||||||
|
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: [
|
timeline: [
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
@@ -131,5 +157,11 @@ const summary = buildDetailSummary(detail, {
|
|||||||
assert.equal(summary.viewerRole, "EDITOR");
|
assert.equal(summary.viewerRole, "EDITOR");
|
||||||
assert.equal(summary.viewerEmail, "editor@example.com");
|
assert.equal(summary.viewerEmail, "editor@example.com");
|
||||||
assert.equal(summary.targetSite, "Demo Site");
|
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.timeline.length, 2);
|
||||||
assert.equal(summary.jobDetails.length, 1);
|
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,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.
|
||||||
File diff suppressed because it is too large
Load Diff
+138
-116
@@ -62,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export type ArticleDetailResponse = {
|
|||||||
target_site?: TargetSiteConfig | null;
|
target_site?: TargetSiteConfig | null;
|
||||||
timeline?: ObservabilityTimelineEventSummary[];
|
timeline?: ObservabilityTimelineEventSummary[];
|
||||||
workflow_events?: WorkflowEventSummary[];
|
workflow_events?: WorkflowEventSummary[];
|
||||||
|
workflow_template_snapshot?: WorkflowTemplateSnapshot | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArticleListResponse = {
|
export type ArticleListResponse = {
|
||||||
@@ -102,6 +104,8 @@ export type ArticleSummary = {
|
|||||||
status: ArticleWorkflowStatus;
|
status: ArticleWorkflowStatus;
|
||||||
target_site_id: string;
|
target_site_id: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
workflow_template_id?: string | null;
|
||||||
|
workflow_template_version?: number | null;
|
||||||
working_title?: string | null;
|
working_title?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -629,122 +633,6 @@ export type ReviewType = "PLAN" | "EVIDENCE" | "ASSET" | "SEO_LANGUAGE" | "FINAL
|
|||||||
|
|
||||||
export type Role = "ADMIN" | "EDITOR";
|
export type Role = "ADMIN" | "EDITOR";
|
||||||
|
|
||||||
export type WorkflowStagePart = {
|
|
||||||
acceptance_criteria?: string[];
|
|
||||||
key: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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 WorkflowTemplateCreateRequest = {
|
|
||||||
description: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowTemplateUpdateRequest = {
|
|
||||||
description?: string | null;
|
|
||||||
name?: string | null;
|
|
||||||
slug?: string | 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 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 WorkflowStageReorderRequest = {
|
|
||||||
stage_ids: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowTemplateResponse = {
|
|
||||||
workflow: WorkflowTemplateSummary;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowTemplateListResponse = {
|
|
||||||
workflows: WorkflowTemplateSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowStageResponse = {
|
|
||||||
stage: WorkflowStageSummary;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 WorkflowTemplateAuditEventListResponse = {
|
|
||||||
events?: WorkflowTemplateAuditEventSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RunnerFileRef = {
|
export type RunnerFileRef = {
|
||||||
content_hash?: string | null;
|
content_hash?: string | null;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -879,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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ Development description: Bind Admin-managed workflow templates into the demo pro
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing public API test showing article creation stores the active workflow template snapshot; proceed one behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing public API test showing article creation stores the active workflow template snapshot; proceed one behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Seed data includes an active workflow template with stages matching the demo pipeline.
|
- [x] Seed data includes an active workflow template with stages matching the demo pipeline.
|
||||||
- [ ] Article creation records the workflow template id, version, and stage summary snapshot.
|
- [x] Article creation records the workflow template id, version, and stage summary snapshot.
|
||||||
- [ ] Article creation rejects archived/missing workflow template references.
|
- [x] Article creation rejects archived/missing workflow template references.
|
||||||
- [ ] Article detail exposes workflow template summary to the frontend.
|
- [x] Article detail exposes workflow template summary to the frontend.
|
||||||
- [ ] Site configuration or article creation UI allows selecting an active workflow template.
|
- [x] Site configuration or article creation UI allows selecting an active workflow template.
|
||||||
- [ ] Existing end-to-end demo publishing smoke still passes.
|
- [x] Existing end-to-end demo publishing smoke still passes.
|
||||||
- [ ] Final demo readiness protocol includes manual steps for Admin workflow create/edit/reorder/activate and Editor article flow using that workflow.
|
- [x] Final demo readiness protocol includes manual steps for Admin workflow create/edit/reorder/activate and Editor article flow using that workflow.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -43,9 +43,20 @@ Development description: Bind Admin-managed workflow templates into the demo pro
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Not started.
|
- Status: Implemented.
|
||||||
- TDD plan:
|
- TDD plan: Use `apps/backend/tests/integration/test_workflow_template_binding_public_api.py` as the public API red test for article creation with an active workflow template, archived/missing template validation, and immutable detail snapshot after workflow archival. Then wire persistence/contracts, seed data, frontend intake/detail display, and demo protocol updates.
|
||||||
- Red evidence:
|
- Red evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` failed with all 3 tests returning `422 extra_forbidden` for `workflow_template_id`.
|
||||||
- Green evidence:
|
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
|
||||||
- Refactor notes:
|
- Refactor notes: Added optional article `workflow_template_id`, stored workflow template id/version plus immutable JSON snapshot, active-template validation, seeded active demo workflow stages, generated OpenAPI/shared TypeScript contracts, article intake active-workflow selector, and article detail snapshot display. Existing article workflow statuses remain unchanged.
|
||||||
- Verification output:
|
- Verification output:
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_generated_contract_artifacts apps.backend.tests.contracts.test_public_openapi_contract -v` -> `Ran 3 tests ... OK`.
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_end_to_end_demo_stack_smoke_public_api -v` -> `Ran 1 test ... OK`.
|
||||||
|
- `node apps/frontend/tests/article_form_validation.test.mjs` -> passed.
|
||||||
|
- `node apps/frontend/tests/article_detail.model.test.mjs` -> passed.
|
||||||
|
- `pnpm --filter @pipeline/frontend test:ui` -> passed.
|
||||||
|
- `pnpm typecheck` -> passed.
|
||||||
|
- `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`.
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_auth_authorization_public_api.AuthAuthorizationPublicApiTest.test_editor_can_create_articles_and_approve_plan_review -v` -> `Ran 1 test ... OK` after correcting the stale hard-coded missing plan id fixture to approve the generated public API plan.
|
||||||
|
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest discover apps/backend/tests -v` -> `Ran 87 tests ... OK (skipped=1)`.
|
||||||
|
- `docs/demo-readiness-protocol-2026-05-22.md` records the final Task 023 demo readiness protocol.
|
||||||
|
|||||||
Reference in New Issue
Block a user