feat(task-023): bind workflow templates into demo flow

This commit is contained in:
2026-05-22 19:38:29 +03:00
parent baff9211e8
commit 5f3e543ea2
24 changed files with 2671 additions and 148 deletions
+55
View File
@@ -11,6 +11,10 @@ from src.domain.contracts import (
ArticleWorkflowStatus,
CurrentUser,
PublishingStatus,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
)
from src.application.observability import (
build_observability_timeline,
@@ -24,10 +28,16 @@ def create_article(
current_user: CurrentUser,
) -> ArticleCreateResponse:
repository.target_sites.get_by_id(request.target_site_id)
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
now = _now()
summary = repository.articles.create(
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,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description,
@@ -62,6 +72,9 @@ def get_article_detail(
) -> ArticleDetailResponse:
article = repository.articles.get(article_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)
boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id)
@@ -84,6 +97,7 @@ def get_article_detail(
return ArticleDetailResponse(
article=article,
target_site=target_site,
workflow_template_snapshot=workflow_template_snapshot,
workflow_events=workflow_events,
boundary_questions=boundary_questions,
plan=plans[-1] if plans else None,
@@ -100,3 +114,44 @@ def get_article_detail(
def _now() -> datetime:
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
],
)
+156
View File
@@ -13,6 +13,7 @@ from src.domain.contracts import (
PublishingRules,
Role,
ScriptConfigVersionStatus,
WorkflowTemplateStatus,
)
from src.domain.schema import (
SEEDED_ADMIN_EMAIL,
@@ -22,6 +23,7 @@ from src.domain.schema import (
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
def seed_reference_data(repository: object) -> None:
@@ -125,6 +127,160 @@ assets:
transform_script=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:
@@ -45,6 +45,8 @@ from .models import (
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
AssetGenerateSpecsResponse,
@@ -158,6 +160,8 @@ __all__ = [
"WorkflowTemplateCreateRequest",
"WorkflowTemplateListResponse",
"WorkflowTemplateResponse",
"WorkflowTemplateSnapshot",
"WorkflowTemplateStageSnapshot",
"WorkflowTemplateStatus",
"WorkflowTemplateSummary",
"WorkflowTemplateUpdateRequest",
@@ -273,8 +273,27 @@ 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):
target_site_id: UUID
workflow_template_id: UUID | None = None
brief_description: str = Field(min_length=1)
working_title: str | None = None
language: str = Field(default="en", min_length=2)
@@ -286,6 +305,8 @@ class ArticleCreateRequest(ContractModel):
class ArticleSummary(ContractModel):
id: UUID
target_site_id: UUID
workflow_template_id: UUID | None = None
workflow_template_version: int | None = Field(default=None, ge=1)
status: ArticleWorkflowStatus
publishing_status: PublishingStatus
brief_description: str
@@ -855,6 +876,7 @@ class AssetUploadResponse(ContractModel):
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
plan: PlanSummary | None = None
@@ -121,6 +121,8 @@ from .models import (
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
TargetSiteConfigUpdateRequest,
@@ -176,6 +178,8 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
WorkflowStageResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSnapshot,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
@@ -47,6 +47,7 @@ from src.domain.contracts import (
WorkflowStagePart,
WorkflowStageSummary,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
)
@@ -442,6 +443,9 @@ class ArticlesRepository:
self,
*,
target_site_id: UUID,
workflow_template_id: UUID | None,
workflow_template_version: int | None,
workflow_template_snapshot: JsonObject | None,
status: ArticleWorkflowStatus,
publishing_status: PublishingStatus,
brief_description: str,
@@ -455,10 +459,14 @@ class ArticlesRepository:
) -> ArticleSummary:
article_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
sql = f"""
INSERT INTO articles (
id,
target_site_id,
workflow_template_id,
workflow_template_version,
workflow_template_snapshot,
status,
publishing_status,
brief_description,
@@ -477,6 +485,9 @@ class ArticlesRepository:
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
@@ -491,6 +502,13 @@ class ArticlesRepository:
(
str(article_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,
publishing_status.value,
brief_description,
@@ -513,6 +531,8 @@ class ArticlesRepository:
SELECT
id,
target_site_id,
workflow_template_id,
workflow_template_version,
status,
publishing_status,
brief_description,
@@ -538,6 +558,8 @@ class ArticlesRepository:
SELECT
id,
target_site_id,
workflow_template_id,
workflow_template_version,
status,
publishing_status,
brief_description,
@@ -558,6 +580,28 @@ class ArticlesRepository:
raise LookupError(f"Article not found: {article_id}")
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(
self,
*,
@@ -3860,6 +3904,8 @@ def _article_summary_from_row(row: Any) -> ArticleSummary:
return ArticleSummary(
id=_row_value(row, "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"),
publishing_status=_row_value(row, "publishing_status"),
brief_description=_row_value(row, "brief_description"),
@@ -4218,6 +4264,7 @@ def _plain_row(row: Any) -> dict[str, Any]:
"retry_policy",
"parts",
"payload",
"workflow_template_snapshot",
):
if key in result:
result[key] = _json_decode(result[key])
+17
View File
@@ -148,6 +148,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
CREATE TABLE IF NOT EXISTS articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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})),
publishing_status TEXT NOT NULL CHECK (
publishing_status IN ({PUBLISHING_STATUS_VALUES})
@@ -162,6 +165,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
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 (
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)
""",
"""
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
ON agent_jobs (status, queued_at)
""",
@@ -610,6 +620,9 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL,
workflow_template_id TEXT,
workflow_template_version INTEGER,
workflow_template_snapshot TEXT,
status TEXT NOT NULL,
publishing_status TEXT NOT NULL,
brief_description TEXT NOT NULL,
@@ -900,6 +913,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
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
ON agent_jobs (status, queued_at)
""",
@@ -45,6 +45,11 @@ def post_article(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid target_site_id",
) 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)
@@ -316,8 +316,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
)
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(
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},
)
@@ -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()
+6
View File
@@ -161,6 +161,12 @@ main {
font-size: 12px;
}
.fieldHint {
margin: 0;
color: #475467;
font-size: 12px;
}
.formSuccess {
margin: 0;
color: #027a48;
@@ -149,7 +149,7 @@ export function AdminWorkflowBuilder({
async function refreshWorkflowList(selectedWorkflowId?: string) {
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
setWorkflows(listResponse.workflows);
setWorkflows(listResponse.workflows ?? []);
if (selectedWorkflowId) {
await loadWorkflow(selectedWorkflowId, false);
}
@@ -39,6 +39,24 @@ export type JobDetailRow = {
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 = {
articleId: string;
status: string;
@@ -46,6 +64,7 @@ export type DetailSummary = {
publishingValidationLabel: string;
briefDescription: string;
targetSite: string;
workflowTemplate: WorkflowTemplateSnapshotView | null;
updatedAt: string;
viewerRole: Role;
viewerEmail: string;
@@ -101,6 +120,7 @@ export function buildDetailSummary(
publishingValidationLabel: resolvePublishingValidationLabel(detail),
briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown",
workflowTemplate: buildWorkflowTemplateSnapshotView(detail),
updatedAt: detail.article.updated_at,
viewerRole,
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 {
const manifest = detail.publish_commit?.content_bundle_manifest;
if (manifest && typeof manifest === "object") {
@@ -18,6 +18,7 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline;
const jobDetails = summary.jobDetails;
const workflowTemplate = summary.workflowTemplate;
const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) {
@@ -104,6 +105,49 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
</li>
</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>
<ul className="timeline">
{timeline.map((event) => (
@@ -1,13 +1,25 @@
import type { TargetSiteConfig } from "@pipeline/shared";
import type {
ArticleCreateRequest,
TargetSiteConfig,
WorkflowTemplateSummary,
} from "@pipeline/shared";
export type NewArticleDraft = {
targetSiteId: string;
workflowTemplateId: string;
briefDescription: string;
workingTitle: string;
contentType: string;
primaryKeyword: string;
};
export type WorkflowTemplateOption = {
id: string;
label: string;
version: number;
stageCount: number;
};
export type FieldErrors = Record<string, string>;
export type NewArticleFormState = {
@@ -16,9 +28,14 @@ export type NewArticleFormState = {
formError?: string;
};
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft {
export function initialDraft(
sites: readonly TargetSiteConfig[],
workflows: readonly WorkflowTemplateSummary[] = [],
): NewArticleDraft {
const workflowOptions = buildWorkflowTemplateOptions(workflows);
return {
targetSiteId: sites.length > 0 ? sites[0].id : "",
workflowTemplateId: workflowOptions.length > 0 ? workflowOptions[0].id : "",
briefDescription: "",
workingTitle: "",
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 {
const errors: FieldErrors = {};
@@ -3,10 +3,12 @@
import { useMemo, useState, type FormEvent } from "react";
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 {
buildCreateArticlePayload,
buildWorkflowTemplateOptions,
initialDraft,
validateArticleDraft,
type FieldErrors,
@@ -15,14 +17,24 @@ import {
type NewArticleFormProps = {
sites: readonly TargetSiteConfig[];
workflowTemplates: readonly WorkflowTemplateSummary[];
};
export function NewArticleForm({ sites }: NewArticleFormProps) {
export function NewArticleForm({ sites, workflowTemplates }: NewArticleFormProps) {
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 [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false);
const selectedWorkflow = workflowOptions.find(
(workflow) => workflow.id === values.workflowTemplateId,
);
const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0,
@@ -41,14 +53,7 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
setFormError("");
setSubmitting(true);
try {
const response = await createArticle({
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,
});
const response = await createArticle(buildCreateArticlePayload(values));
router.push(`/articles/${response.article.id}`);
} catch (error) {
if (error instanceof ApiError) {
@@ -84,6 +89,26 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
</select>
{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>
<textarea
id="briefDescription"
@@ -56,7 +56,7 @@ export default async function AdminWorkflowsPage({
</header>
<RoleNavigation role="ADMIN" />
<AdminWorkflowBuilder
initialWorkflows={workflowListResponse.workflows}
initialWorkflows={workflowListResponse.workflows ?? []}
initialWorkflow={selectedWorkflow}
initialAuditEvents={auditEvents}
/>
+14 -3
View File
@@ -3,11 +3,19 @@ import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation";
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() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
const [sites, workflowResponse] = await Promise.all([
fetchTargetSites(),
fetchWorkflowTemplates(DEMO_EDITOR_EMAIL),
]);
const targetSites = sites.map((site) => site.site);
const workflowTemplates = workflowResponse.workflows ?? [];
return (
<main>
@@ -20,7 +28,10 @@ export default async function NewArticlePage() {
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<NewArticleForm sites={targetSites} />
<NewArticleForm
sites={targetSites}
workflowTemplates={workflowTemplates}
/>
</section>
</main>
);
@@ -30,6 +30,32 @@ const detail = {
updated_at: "2026-05-21T00:00:00Z",
},
target_site: { name: "Demo Site" },
workflow_template_snapshot: {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
version: 2,
stage_summary: [
{
id: "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",
@@ -131,5 +157,11 @@ const summary = buildDetailSummary(detail, {
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 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 = {
targetSiteId: "",
workflowTemplateId: "",
briefDescription: "Keep this text intact",
workingTitle: "",
contentType: "longform_guide",
@@ -43,3 +63,27 @@ assert.equal(
const fallbackDraft = initialDraft([]);
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
View File
@@ -62,6 +62,7 @@ export type ArticleCreateRequest = {
language?: string;
primary_keyword?: string | null;
target_site_id: string;
workflow_template_id?: string | null;
working_title?: string | null;
};
@@ -84,6 +85,7 @@ export type ArticleDetailResponse = {
target_site?: TargetSiteConfig | null;
timeline?: ObservabilityTimelineEventSummary[];
workflow_events?: WorkflowEventSummary[];
workflow_template_snapshot?: WorkflowTemplateSnapshot | null;
};
export type ArticleListResponse = {
@@ -102,6 +104,8 @@ export type ArticleSummary = {
status: ArticleWorkflowStatus;
target_site_id: string;
updated_at: string;
workflow_template_id?: string | null;
workflow_template_version?: number | 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 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 = {
content_hash?: string | null;
path: string;
@@ -879,3 +767,137 @@ export type WorkflowEventSummary = {
payload?: Record<string, unknown>;
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
- [ ] 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.
- [ ] Article creation records the workflow template id, version, and stage summary snapshot.
- [ ] Article creation rejects archived/missing workflow template references.
- [ ] Article detail exposes workflow template summary to the frontend.
- [ ] Site configuration or article creation UI allows selecting an active workflow template.
- [ ] 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] TDD pre-requirement: before implementation, write one failing public API test showing article creation stores the active workflow template snapshot; proceed one behavior at a time and record evidence in `Result`.
- [x] Seed data includes an active workflow template with stages matching the demo pipeline.
- [x] Article creation records the workflow template id, version, and stage summary snapshot.
- [x] Article creation rejects archived/missing workflow template references.
- [x] Article detail exposes workflow template summary to the frontend.
- [x] Site configuration or article creation UI allows selecting an active workflow template.
- [x] Existing end-to-end demo publishing smoke still passes.
- [x] Final demo readiness protocol includes manual steps for Admin workflow create/edit/reorder/activate and Editor article flow using that workflow.
## Verification
@@ -43,9 +43,20 @@ Development description: Bind Admin-managed workflow templates into the demo pro
## Result
- Status: Not started.
- TDD plan:
- Red evidence:
- Green evidence:
- Refactor notes:
- Status: Implemented.
- TDD plan: Use `apps/backend/tests/integration/test_workflow_template_binding_public_api.py` as the public API red test for article creation with an active workflow template, archived/missing template validation, and immutable detail snapshot after workflow archival. Then wire persistence/contracts, seed data, frontend intake/detail display, and demo protocol updates.
- Red evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` failed with all 3 tests returning `422 extra_forbidden` for `workflow_template_id`.
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
- Refactor notes: Added optional article `workflow_template_id`, stored workflow template id/version plus immutable JSON snapshot, active-template validation, seeded active demo workflow stages, generated OpenAPI/shared TypeScript contracts, article intake active-workflow selector, and article detail snapshot display. Existing article workflow statuses remain unchanged.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api -v` -> `Ran 4 tests ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_generated_contract_artifacts apps.backend.tests.contracts.test_public_openapi_contract -v` -> `Ran 3 tests ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_end_to_end_demo_stack_smoke_public_api -v` -> `Ran 1 test ... OK`.
- `node apps/frontend/tests/article_form_validation.test.mjs` -> passed.
- `node apps/frontend/tests/article_detail.model.test.mjs` -> passed.
- `pnpm --filter @pipeline/frontend test:ui` -> passed.
- `pnpm typecheck` -> passed.
- `BACKEND_URL=http://localhost:13800 FRONTEND_URL=http://localhost:13300 RUNNER_URL=http://localhost:13810 WAIT_SECONDS=120 bash tests/smoke/public-health.sh` -> `public health smoke ok`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_auth_authorization_public_api.AuthAuthorizationPublicApiTest.test_editor_can_create_articles_and_approve_plan_review -v` -> `Ran 1 test ... OK` after correcting the stale hard-coded missing plan id fixture to approve the generated public API plan.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest discover apps/backend/tests -v` -> `Ran 87 tests ... OK (skipped=1)`.
- `docs/demo-readiness-protocol-2026-05-22.md` records the final Task 023 demo readiness protocol.