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",
);