diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py index d0aa722..5596b49 100644 --- a/apps/backend/src/application/articles.py +++ b/apps/backend/src/application/articles.py @@ -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 + ], + ) diff --git a/apps/backend/src/application/seed_data.py b/apps/backend/src/application/seed_data.py index 93e6c98..813b15a 100644 --- a/apps/backend/src/application/seed_data.py +++ b/apps/backend/src/application/seed_data.py @@ -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: diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py index adef0fc..feb9b2d 100644 --- a/apps/backend/src/domain/contracts/__init__.py +++ b/apps/backend/src/domain/contracts/__init__.py @@ -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", diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py index c559526..be83c9c 100644 --- a/apps/backend/src/domain/contracts/models.py +++ b/apps/backend/src/domain/contracts/models.py @@ -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 diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py index 3c7709d..5d89fff 100644 --- a/apps/backend/src/domain/contracts/openapi.py +++ b/apps/backend/src/domain/contracts/openapi.py @@ -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, diff --git a/apps/backend/src/infrastructure/repositories.py b/apps/backend/src/infrastructure/repositories.py index c31d3e2..8e8ce5e 100644 --- a/apps/backend/src/infrastructure/repositories.py +++ b/apps/backend/src/infrastructure/repositories.py @@ -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]) diff --git a/apps/backend/src/infrastructure/schema.py b/apps/backend/src/infrastructure/schema.py index 8aa5849..a85d3ec 100644 --- a/apps/backend/src/infrastructure/schema.py +++ b/apps/backend/src/infrastructure/schema.py @@ -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) """, diff --git a/apps/backend/src/presentation/routes/articles.py b/apps/backend/src/presentation/routes/articles.py index 1791c05..c4ddc0a 100644 --- a/apps/backend/src/presentation/routes/articles.py +++ b/apps/backend/src/presentation/routes/articles.py @@ -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) diff --git a/apps/backend/tests/integration/test_auth_authorization_public_api.py b/apps/backend/tests/integration/test_auth_authorization_public_api.py index 991f1a8..29a4695 100644 --- a/apps/backend/tests/integration/test_auth_authorization_public_api.py +++ b/apps/backend/tests/integration/test_auth_authorization_public_api.py @@ -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}, ) diff --git a/apps/backend/tests/integration/test_workflow_template_binding_public_api.py b/apps/backend/tests/integration/test_workflow_template_binding_public_api.py new file mode 100644 index 0000000..5a8ce2b --- /dev/null +++ b/apps/backend/tests/integration/test_workflow_template_binding_public_api.py @@ -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() diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index 1822b15..682f30d 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -161,6 +161,12 @@ main { font-size: 12px; } +.fieldHint { + margin: 0; + color: #475467; + font-size: 12px; +} + .formSuccess { margin: 0; color: #027a48; diff --git a/apps/frontend/src/features/admin-workflows/ui.tsx b/apps/frontend/src/features/admin-workflows/ui.tsx index 4f689ce..362e514 100644 --- a/apps/frontend/src/features/admin-workflows/ui.tsx +++ b/apps/frontend/src/features/admin-workflows/ui.tsx @@ -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); } diff --git a/apps/frontend/src/features/article-detail/model.ts b/apps/frontend/src/features/article-detail/model.ts index 2c3705a..558145d 100644 --- a/apps/frontend/src/features/article-detail/model.ts +++ b/apps/frontend/src/features/article-detail/model.ts @@ -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") { diff --git a/apps/frontend/src/features/article-detail/ui.tsx b/apps/frontend/src/features/article-detail/ui.tsx index cc9f546..c5040f6 100644 --- a/apps/frontend/src/features/article-detail/ui.tsx +++ b/apps/frontend/src/features/article-detail/ui.tsx @@ -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) { +

Workflow template

+ {workflowTemplate ? ( + <> +
+
+
Name
+
{workflowTemplate.name}
+
+
+
Slug
+
{workflowTemplate.slug}
+
+
+
Version
+
{workflowTemplate.version}
+
+
+
Stages
+
{workflowTemplate.stages.length}
+
+
+
    + {workflowTemplate.stages.map((stage) => ( +
  1. +
    + {stage.position}. {stage.displayName} + {stage.ownerRole} + {stage.runnerProfileKey} +
    +
    + {stage.stableKey} + {stage.requiresHumanApproval ? ( + - approval required + ) : null} +
    +
  2. + ))} +
+ + ) : ( +

No workflow template snapshot.

+ )} +

Workflow timeline