feat(task-023): bind workflow templates into demo flow
This commit is contained in:
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user