Compare commits

..
5 Commits
55 changed files with 7121 additions and 44 deletions
+7 -2
View File
@@ -8,6 +8,10 @@ The system accepts a short article description, asks boundary-setting questions,
v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product. v1 is an internal or single-tenant editorial tool. It is not a customer-facing multi-tenant SaaS product.
### Main principle
The workflow is a first-class Admin-managed product object, not a hard-coded backend status chain. The pipeline is gate-driven: each workflow stage defines its purpose, ordered parts, owner role, runner profile, required inputs, outputs, and acceptance criteria. Runtime execution cannot move to the next stage until the current stage is complete and every required human approval or validation gate has passed.
Chosen architecture: Chosen architecture:
```text ```text
@@ -50,6 +54,7 @@ Claude Code can be supported as an optional internal runner, but not as the main
14. Multi-site configuration 14. Multi-site configuration
15. Job history and audit trail 15. Job history and audit trail
16. Codex CLI-based agent execution 16. Codex CLI-based agent execution
17. Admin-managed workflow templates and editable workflow stages
``` ```
## Out of scope for v1 ## Out of scope for v1
@@ -76,13 +81,13 @@ The publishing step in v1 should create a **direct commit to the configured prod
## Admin ## Admin
Can configure target websites, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts. Can configure target websites, workflow templates, workflow stages, pipeline parameters, agent runner profiles, prompt versions, publishing YAML, and site-specific transformation/upload scripts.
## Editor ## Editor
Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits. Can create briefs, answer boundary questions, run the pipeline, edit intermediate results, approve plans, edit drafts, approve final content, and create publish commits.
Only Admins can edit upload scripts and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories. Only Admins can edit workflow configuration, upload scripts, and pipeline configuration. Admins are fully trusted code operators because admin-defined transformation scripts run directly on the runner host inside checked-out site repositories.
--- ---
+55
View File
@@ -11,6 +11,10 @@ from src.domain.contracts import (
ArticleWorkflowStatus, ArticleWorkflowStatus,
CurrentUser, CurrentUser,
PublishingStatus, PublishingStatus,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
) )
from src.application.observability import ( from src.application.observability import (
build_observability_timeline, build_observability_timeline,
@@ -24,10 +28,16 @@ def create_article(
current_user: CurrentUser, current_user: CurrentUser,
) -> ArticleCreateResponse: ) -> ArticleCreateResponse:
repository.target_sites.get_by_id(request.target_site_id) repository.target_sites.get_by_id(request.target_site_id)
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
now = _now() now = _now()
summary = repository.articles.create( summary = repository.articles.create(
target_site_id=request.target_site_id, target_site_id=request.target_site_id,
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
workflow_template_snapshot=(
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
),
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED, status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED, publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description, brief_description=request.brief_description,
@@ -62,6 +72,9 @@ def get_article_detail(
) -> ArticleDetailResponse: ) -> ArticleDetailResponse:
article = repository.articles.get(article_id) article = repository.articles.get(article_id)
target_site = repository.target_sites.get_by_id(article.target_site_id) target_site = repository.target_sites.get_by_id(article.target_site_id)
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
article_id
)
workflow_events = repository.articles.list_workflow_events(article_id) workflow_events = repository.articles.list_workflow_events(article_id)
boundary_questions = repository.boundary_questions.list_for_article(article_id) boundary_questions = repository.boundary_questions.list_for_article(article_id)
plans = repository.article_plans.list_for_article(article_id) plans = repository.article_plans.list_for_article(article_id)
@@ -84,6 +97,7 @@ def get_article_detail(
return ArticleDetailResponse( return ArticleDetailResponse(
article=article, article=article,
target_site=target_site, target_site=target_site,
workflow_template_snapshot=workflow_template_snapshot,
workflow_events=workflow_events, workflow_events=workflow_events,
boundary_questions=boundary_questions, boundary_questions=boundary_questions,
plan=plans[-1] if plans else None, plan=plans[-1] if plans else None,
@@ -100,3 +114,44 @@ def get_article_detail(
def _now() -> datetime: def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
def _resolve_workflow_template_snapshot(
repository: object,
request: ArticleCreateRequest,
) -> WorkflowTemplateSnapshot | None:
if request.workflow_template_id is None:
return None
try:
workflow = repository.workflow_templates.get(request.workflow_template_id)
except LookupError as error:
raise ValueError("Invalid workflow_template_id") from error
if workflow.status != WorkflowTemplateStatus.ACTIVE:
raise ValueError("Workflow template must be active")
return _workflow_template_snapshot(workflow)
def _workflow_template_snapshot(
workflow: WorkflowTemplateSummary,
) -> WorkflowTemplateSnapshot:
return WorkflowTemplateSnapshot(
id=workflow.id,
name=workflow.name,
slug=workflow.slug,
version=workflow.version,
stage_summary=[
WorkflowTemplateStageSnapshot(
id=stage.id,
stable_key=stage.stable_key,
display_name=stage.display_name,
position=stage.position,
owner_role=stage.owner_role,
runner_profile_key=stage.runner_profile_key,
requires_human_approval=stage.requires_human_approval,
)
for stage in workflow.stages
],
)
+156
View File
@@ -13,6 +13,7 @@ from src.domain.contracts import (
PublishingRules, PublishingRules,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from src.domain.schema import ( from src.domain.schema import (
SEEDED_ADMIN_EMAIL, SEEDED_ADMIN_EMAIL,
@@ -22,6 +23,7 @@ from src.domain.schema import (
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC) SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
def seed_reference_data(repository: object) -> None: def seed_reference_data(repository: object) -> None:
@@ -125,6 +127,160 @@ assets:
transform_script=transform_script, transform_script=transform_script,
transform_script_hash=_sha256(transform_script), transform_script_hash=_sha256(transform_script),
) )
_seed_demo_workflow_template(repository, admin_id=admin_id)
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
existing = next(
(
workflow
for workflow in repository.workflow_templates.list()
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
),
None,
)
if existing is not None:
return
workflow = repository.workflow_templates.create(
name="Demo article production workflow",
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
description="Active demo workflow for article intake, drafting, review, and publishing.",
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=admin_id,
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
for stage in _demo_workflow_stages():
repository.workflow_template_stages.create(
workflow_id=workflow.id,
stable_key=stage["stable_key"],
display_name=stage["display_name"],
description=stage["description"],
position=stage["position"],
owner_role=stage["owner_role"],
runner_profile_key=stage["runner_profile_key"],
required_inputs=stage["required_inputs"],
expected_outputs=stage["expected_outputs"],
acceptance_criteria=stage["acceptance_criteria"],
requires_human_approval=stage["requires_human_approval"],
retry_policy=stage["retry_policy"],
parts=stage["parts"],
updated_by=admin_id,
created_at=SEED_TIMESTAMP,
updated_at=SEED_TIMESTAMP,
)
repository.workflow_templates.activate(
workflow_id=workflow.id,
updated_by=admin_id,
activated_at=SEED_TIMESTAMP,
)
def _demo_workflow_stages() -> list[dict[str, object]]:
return [
{
"stable_key": "intake-boundary-questions",
"display_name": "Boundary question intake",
"description": "Collect required editorial context before plan generation.",
"position": 1,
"owner_role": Role.EDITOR,
"runner_profile_key": "boundary-question-agent-v1",
"required_inputs": ["article_brief", "target_site"],
"expected_outputs": ["answered_boundary_questions"],
"acceptance_criteria": ["All required boundary questions are answered."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
{
"stable_key": "plan-review",
"display_name": "Plan review",
"description": "Generate and approve the article plan before research.",
"position": 2,
"owner_role": Role.EDITOR,
"runner_profile_key": "plan-generation-agent-v1",
"required_inputs": ["answered_boundary_questions"],
"expected_outputs": ["approved_plan"],
"acceptance_criteria": ["Plan is approved by the editor."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
"parts": [],
},
{
"stable_key": "research-evidence",
"display_name": "Research and evidence",
"description": "Run research and approve evidence before production starts.",
"position": 3,
"owner_role": Role.EDITOR,
"runner_profile_key": "research-agent-v1",
"required_inputs": ["approved_plan"],
"expected_outputs": ["approved_evidence_matrix"],
"acceptance_criteria": ["Evidence matrix is ready and approved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "parallel-production",
"display_name": "Parallel production",
"description": "Create section scaffolds and draft-ready assets in parallel.",
"position": 4,
"owner_role": Role.EDITOR,
"runner_profile_key": "section-scaffold-agent-v1",
"required_inputs": ["approved_evidence_matrix"],
"expected_outputs": ["section_scaffolds", "asset_specs"],
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "draft-assembly",
"display_name": "Draft assembly",
"description": "Assemble a draft from the approved plan and production artifacts.",
"position": 5,
"owner_role": Role.EDITOR,
"runner_profile_key": "draft-assembly-agent-v1",
"required_inputs": ["section_scaffolds", "approved_assets"],
"expected_outputs": ["article_draft"],
"acceptance_criteria": ["Draft is assembled for review."],
"requires_human_approval": False,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "seo-language-review",
"display_name": "SEO and language review",
"description": "Run automated quality reviews and resolve required suggestions.",
"position": 6,
"owner_role": Role.EDITOR,
"runner_profile_key": "review-agent-v1",
"required_inputs": ["article_draft"],
"expected_outputs": ["resolved_review_suggestions"],
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
"parts": [],
},
{
"stable_key": "final-publishing",
"display_name": "Final approval and publishing",
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
"position": 7,
"owner_role": Role.EDITOR,
"runner_profile_key": "publishing-agent-v1",
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
"expected_outputs": ["publish_commit"],
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
"requires_human_approval": True,
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
"parts": [],
},
]
def _repository_url_for_seed() -> str: def _repository_url_for_seed() -> str:
@@ -0,0 +1,372 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
from src.domain.contracts import (
CurrentUser,
Role,
WorkflowStageCreateRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
)
_TEMPLATE_CREATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_CREATED"
_TEMPLATE_UPDATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_UPDATED"
_STAGE_CREATED_EVENT_TYPE = "WORKFLOW_STAGE_CREATED"
_STAGE_UPDATED_EVENT_TYPE = "WORKFLOW_STAGE_UPDATED"
_STAGE_DELETED_EVENT_TYPE = "WORKFLOW_STAGE_DELETED"
_STAGES_REORDERED_EVENT_TYPE = "WORKFLOW_STAGES_REORDERED"
_TEMPLATE_ACTIVATED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ACTIVATED"
_TEMPLATE_ARCHIVED_EVENT_TYPE = "WORKFLOW_TEMPLATE_ARCHIVED"
def create_workflow_template(
repository: object,
*,
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
now = _now()
workflow = repository.workflow_templates.create(
name=request.name,
slug=request.slug,
description=request.description,
status=WorkflowTemplateStatus.DRAFT,
version=1,
created_by=current_user.id,
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={"slug": workflow.slug, "version": workflow.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def list_workflow_templates(
repository: object,
*,
current_user: CurrentUser,
) -> WorkflowTemplateListResponse:
if current_user.role == Role.ADMIN:
workflows = repository.workflow_templates.list()
else:
workflows = repository.workflow_templates.list_active()
return WorkflowTemplateListResponse(workflows=workflows)
def get_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
if current_user.role != Role.ADMIN and workflow.status != WorkflowTemplateStatus.ACTIVE:
raise LookupError(f"Workflow template not found: {workflow_id}")
return WorkflowTemplateResponse(workflow=workflow)
def update_workflow_template(
repository: object,
*,
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
existing = repository.workflow_templates.get(workflow_id)
_require_draft(existing)
changes = request.model_dump(exclude_unset=True)
now = _now()
workflow = repository.workflow_templates.update(
workflow_id=workflow_id,
name=changes.get("name", existing.name),
slug=changes.get("slug", existing.slug),
description=changes.get("description", existing.description),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow.id,
event_type=_TEMPLATE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowTemplateResponse(workflow=workflow)
def add_workflow_stage(
repository: object,
*,
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
stage = repository.workflow_template_stages.create(
workflow_id=workflow_id,
stable_key=request.stable_key,
display_name=request.display_name,
description=request.description,
position=request.position,
owner_role=request.owner_role,
runner_profile_key=request.runner_profile_key,
required_inputs=request.required_inputs,
expected_outputs=request.expected_outputs,
acceptance_criteria=request.acceptance_criteria,
requires_human_approval=request.requires_human_approval,
retry_policy=request.retry_policy,
parts=_parts_json(request.parts),
updated_by=current_user.id,
created_at=now,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_CREATED_EVENT_TYPE,
current_user=current_user,
payload={
"stage_id": str(stage.id),
"stable_key": stage.stable_key,
"position": stage.position,
},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def update_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser,
) -> WorkflowStageResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
changes = request.model_dump(exclude_unset=True)
now = _now()
stage = repository.workflow_template_stages.update(
workflow_id=workflow_id,
stage_id=stage_id,
stable_key=changes.get("stable_key", existing.stable_key),
display_name=changes.get("display_name", existing.display_name),
description=changes.get("description", existing.description),
position=changes.get("position", existing.position),
owner_role=request.owner_role or existing.owner_role,
runner_profile_key=changes.get(
"runner_profile_key",
existing.runner_profile_key,
),
required_inputs=changes.get("required_inputs", existing.required_inputs),
expected_outputs=changes.get("expected_outputs", existing.expected_outputs),
acceptance_criteria=changes.get(
"acceptance_criteria",
existing.acceptance_criteria,
),
requires_human_approval=changes.get(
"requires_human_approval",
existing.requires_human_approval,
),
retry_policy=changes.get("retry_policy", existing.retry_policy),
parts=(
_parts_json(request.parts)
if request.parts is not None
else _parts_json(existing.parts)
),
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_UPDATED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "changed_fields": sorted(changes.keys())},
created_at=now,
)
return WorkflowStageResponse(stage=stage)
def delete_workflow_stage(
repository: object,
*,
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
existing = repository.workflow_template_stages.get(
workflow_id=workflow_id,
stage_id=stage_id,
)
now = _now()
repository.workflow_template_stages.delete(
workflow_id=workflow_id,
stage_id=stage_id,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGE_DELETED_EVENT_TYPE,
current_user=current_user,
payload={"stage_id": str(stage_id), "stable_key": existing.stable_key},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def reorder_workflow_stages(
repository: object,
*,
workflow_id: UUID,
stage_ids: list[UUID],
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
repository.workflow_template_stages.reorder(
workflow_id=workflow_id,
stage_ids=stage_ids,
updated_by=current_user.id,
updated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_STAGES_REORDERED_EVENT_TYPE,
current_user=current_user,
payload={"stage_ids": [str(stage_id) for stage_id in stage_ids]},
created_at=now,
)
return WorkflowTemplateResponse(workflow=repository.workflow_templates.get(workflow_id))
def activate_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
workflow = repository.workflow_templates.get(workflow_id)
_require_draft(workflow)
now = _now()
activated = repository.workflow_templates.activate(
workflow_id=workflow_id,
updated_by=current_user.id,
activated_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ACTIVATED_EVENT_TYPE,
current_user=current_user,
payload={"version": activated.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=activated)
def archive_workflow_template(
repository: object,
*,
workflow_id: UUID,
current_user: CurrentUser,
) -> WorkflowTemplateResponse:
repository.workflow_templates.get(workflow_id)
now = _now()
archived = repository.workflow_templates.archive(
workflow_id=workflow_id,
updated_by=current_user.id,
archived_at=now,
)
_create_event(
repository,
workflow_id=workflow_id,
event_type=_TEMPLATE_ARCHIVED_EVENT_TYPE,
current_user=current_user,
payload={"version": archived.version},
created_at=now,
)
return WorkflowTemplateResponse(workflow=archived)
def list_workflow_template_audit_events(
repository: object,
*,
workflow_id: UUID,
) -> WorkflowTemplateAuditEventListResponse:
repository.workflow_templates.get(workflow_id)
return WorkflowTemplateAuditEventListResponse(
events=repository.workflow_template_events.list_for_workflow(workflow_id)
)
def _create_event(
repository: object,
*,
workflow_id: UUID,
event_type: str,
current_user: CurrentUser,
payload: dict[str, object],
created_at: datetime,
) -> None:
repository.workflow_template_events.create(
workflow_id=workflow_id,
event_type=event_type,
actor_user_id=current_user.id,
payload=payload,
created_at=created_at,
)
def _require_draft(workflow: WorkflowTemplateSummary) -> None:
if workflow.status != WorkflowTemplateStatus.DRAFT:
raise ValueError("Only draft workflow templates can be mutated.")
def _parts_json(parts: list[object]) -> list[dict[str, object]]:
return [
part.model_dump(mode="json") if hasattr(part, "model_dump") else dict(part)
for part in parts
]
def _now() -> datetime:
return datetime.now(UTC)
@@ -15,6 +15,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from .models import ( from .models import (
AgentJobOutput, AgentJobOutput,
@@ -33,6 +34,21 @@ from .models import (
BoundaryQuestionUpdateRequest, BoundaryQuestionUpdateRequest,
WorkflowEventSummary, WorkflowEventSummary,
ObservabilityTimelineEventSummary, ObservabilityTimelineEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
AssetGenerateSpecsResponse, AssetGenerateSpecsResponse,
AssetListResponse, AssetListResponse,
AssetResponse, AssetResponse,
@@ -133,6 +149,22 @@ __all__ = [
"BoundaryQuestionUpdateRequest", "BoundaryQuestionUpdateRequest",
"WorkflowEventSummary", "WorkflowEventSummary",
"ObservabilityTimelineEventSummary", "ObservabilityTimelineEventSummary",
"WorkflowStageCreateRequest",
"WorkflowStagePart",
"WorkflowStageReorderRequest",
"WorkflowStageResponse",
"WorkflowStageSummary",
"WorkflowStageUpdateRequest",
"WorkflowTemplateAuditEventListResponse",
"WorkflowTemplateAuditEventSummary",
"WorkflowTemplateCreateRequest",
"WorkflowTemplateListResponse",
"WorkflowTemplateResponse",
"WorkflowTemplateSnapshot",
"WorkflowTemplateStageSnapshot",
"WorkflowTemplateStatus",
"WorkflowTemplateSummary",
"WorkflowTemplateUpdateRequest",
"AssetGenerateSpecsResponse", "AssetGenerateSpecsResponse",
"AssetListResponse", "AssetListResponse",
"AssetResponse", "AssetResponse",
@@ -139,3 +139,9 @@ class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT" DRAFT = "DRAFT"
ACTIVE = "ACTIVE" ACTIVE = "ACTIVE"
DEPRECATED = "DEPRECATED" DEPRECATED = "DEPRECATED"
class WorkflowTemplateStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"
+137
View File
@@ -23,6 +23,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
@@ -158,8 +159,141 @@ class ScriptConfigVersionAuditEventListResponse(ContractModel):
events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list) events: list[ScriptConfigVersionAuditEventSummary] = Field(default_factory=list)
class WorkflowStagePart(ContractModel):
key: str = Field(min_length=1)
type: str = Field(min_length=1)
title: str = Field(min_length=1)
payload: JsonObject = Field(default_factory=dict)
acceptance_criteria: list[str] = Field(default_factory=list)
class WorkflowStageSummary(ContractModel):
id: UUID
workflow_id: UUID
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int = Field(ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class WorkflowTemplateSummary(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
status: WorkflowTemplateStatus
version: int = Field(ge=1)
created_by: UUID
updated_by: UUID | None = None
created_at: datetime
updated_at: datetime
activated_at: datetime | None = None
archived_at: datetime | None = None
stages: list[WorkflowStageSummary] = Field(default_factory=list)
class WorkflowTemplateCreateRequest(ContractModel):
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
description: str = Field(min_length=1)
class WorkflowTemplateUpdateRequest(ContractModel):
name: str | None = Field(default=None, min_length=1)
slug: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
class WorkflowStageCreateRequest(ContractModel):
stable_key: str = Field(min_length=1)
display_name: str = Field(min_length=1)
description: str = Field(min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role
runner_profile_key: str = Field(min_length=1)
required_inputs: list[str] = Field(default_factory=list)
expected_outputs: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
requires_human_approval: bool = False
retry_policy: JsonObject = Field(default_factory=dict)
parts: list[WorkflowStagePart] = Field(default_factory=list)
class WorkflowStageUpdateRequest(ContractModel):
stable_key: str | None = Field(default=None, min_length=1)
display_name: str | None = Field(default=None, min_length=1)
description: str | None = Field(default=None, min_length=1)
position: int | None = Field(default=None, ge=1)
owner_role: Role | None = None
runner_profile_key: str | None = Field(default=None, min_length=1)
required_inputs: list[str] | None = None
expected_outputs: list[str] | None = None
acceptance_criteria: list[str] | None = None
requires_human_approval: bool | None = None
retry_policy: JsonObject | None = None
parts: list[WorkflowStagePart] | None = None
class WorkflowStageReorderRequest(ContractModel):
stage_ids: list[UUID] = Field(min_length=1)
class WorkflowTemplateResponse(ContractModel):
workflow: WorkflowTemplateSummary
class WorkflowTemplateListResponse(ContractModel):
workflows: list[WorkflowTemplateSummary] = Field(default_factory=list)
class WorkflowStageResponse(ContractModel):
stage: WorkflowStageSummary
class WorkflowTemplateAuditEventSummary(ContractModel):
id: UUID
workflow_id: UUID
event_type: str = Field(min_length=1)
actor_user_id: UUID | None = None
payload: JsonObject = Field(default_factory=dict)
created_at: datetime
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): class ArticleCreateRequest(ContractModel):
target_site_id: UUID target_site_id: UUID
workflow_template_id: UUID | None = None
brief_description: str = Field(min_length=1) brief_description: str = Field(min_length=1)
working_title: str | None = None working_title: str | None = None
language: str = Field(default="en", min_length=2) language: str = Field(default="en", min_length=2)
@@ -171,6 +305,8 @@ class ArticleCreateRequest(ContractModel):
class ArticleSummary(ContractModel): class ArticleSummary(ContractModel):
id: UUID id: UUID
target_site_id: UUID target_site_id: UUID
workflow_template_id: UUID | None = None
workflow_template_version: int | None = Field(default=None, ge=1)
status: ArticleWorkflowStatus status: ArticleWorkflowStatus
publishing_status: PublishingStatus publishing_status: PublishingStatus
brief_description: str brief_description: str
@@ -740,6 +876,7 @@ class AssetUploadResponse(ContractModel):
class ArticleDetailResponse(ContractModel): class ArticleDetailResponse(ContractModel):
article: ArticleSummary article: ArticleSummary
target_site: TargetSiteConfig | None = None target_site: TargetSiteConfig | None = None
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list) workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list) boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
plan: PlanSummary | None = None plan: PlanSummary | None = None
@@ -22,6 +22,7 @@ from .enums import (
ReviewType, ReviewType,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
from .models import ( from .models import (
AgentJobOutput, AgentJobOutput,
@@ -109,6 +110,21 @@ from .models import (
TargetSiteConfigCreateRequest, TargetSiteConfigCreateRequest,
TargetSiteConfigResponse, TargetSiteConfigResponse,
WorkflowEventSummary, WorkflowEventSummary,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateSnapshot,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
TargetSiteConfigUpdateRequest, TargetSiteConfigUpdateRequest,
UserSummary, UserSummary,
) )
@@ -131,6 +147,7 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
ReviewType, ReviewType,
ContentReviewKind, ContentReviewKind,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = ( CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
@@ -148,6 +165,21 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
ScriptConfigVersionListResponse, ScriptConfigVersionListResponse,
ScriptConfigVersionAuditEventSummary, ScriptConfigVersionAuditEventSummary,
ScriptConfigVersionAuditEventListResponse, ScriptConfigVersionAuditEventListResponse,
WorkflowStagePart,
WorkflowStageSummary,
WorkflowTemplateSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateUpdateRequest,
WorkflowStageCreateRequest,
WorkflowStageUpdateRequest,
WorkflowStageReorderRequest,
WorkflowTemplateResponse,
WorkflowTemplateListResponse,
WorkflowStageResponse,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateStageSnapshot,
WorkflowTemplateSnapshot,
ArticleCreateRequest, ArticleCreateRequest,
ArticleSummary, ArticleSummary,
ArticleCreateResponse, ArticleCreateResponse,
+6
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
USERS_TABLE = "users" USERS_TABLE = "users"
TARGET_SITES_TABLE = "target_sites" TARGET_SITES_TABLE = "target_sites"
SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions" SCRIPT_CONFIG_VERSIONS_TABLE = "script_config_versions"
WORKFLOW_TEMPLATES_TABLE = "workflow_templates"
WORKFLOW_TEMPLATE_STAGES_TABLE = "workflow_template_stages"
WORKFLOW_TEMPLATE_EVENTS_TABLE = "workflow_template_events"
ARTICLES_TABLE = "articles" ARTICLES_TABLE = "articles"
BOUNDARY_QUESTIONS_TABLE = "boundary_questions" BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
ARTICLE_PLANS_TABLE = "article_plans" ARTICLE_PLANS_TABLE = "article_plans"
@@ -26,6 +29,9 @@ CORE_TABLES: tuple[str, ...] = (
USERS_TABLE, USERS_TABLE,
TARGET_SITES_TABLE, TARGET_SITES_TABLE,
SCRIPT_CONFIG_VERSIONS_TABLE, SCRIPT_CONFIG_VERSIONS_TABLE,
WORKFLOW_TEMPLATES_TABLE,
WORKFLOW_TEMPLATE_STAGES_TABLE,
WORKFLOW_TEMPLATE_EVENTS_TABLE,
ARTICLES_TABLE, ARTICLES_TABLE,
BOUNDARY_QUESTIONS_TABLE, BOUNDARY_QUESTIONS_TABLE,
ARTICLE_PLANS_TABLE, ARTICLE_PLANS_TABLE,
@@ -44,6 +44,12 @@ from src.domain.contracts import (
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
TargetSiteConfig, TargetSiteConfig,
UserSummary, UserSummary,
WorkflowStagePart,
WorkflowStageSummary,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSnapshot,
WorkflowTemplateStatus,
WorkflowTemplateSummary,
) )
from src.infrastructure.schema import setup_database from src.infrastructure.schema import setup_database
@@ -74,6 +80,9 @@ class BackendRepository:
self.agent_jobs = AgentJobsRepository(self) self.agent_jobs = AgentJobsRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self) self.script_config_versions = ScriptConfigVersionsRepository(self)
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self) self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
self.workflow_templates = WorkflowTemplatesRepository(self)
self.workflow_template_stages = WorkflowTemplateStagesRepository(self)
self.workflow_template_events = WorkflowTemplateAuditEventsRepository(self)
self.schema = SchemaRepository(self) self.schema = SchemaRepository(self)
def setup(self) -> None: def setup(self) -> None:
@@ -434,6 +443,9 @@ class ArticlesRepository:
self, self,
*, *,
target_site_id: UUID, target_site_id: UUID,
workflow_template_id: UUID | None,
workflow_template_version: int | None,
workflow_template_snapshot: JsonObject | None,
status: ArticleWorkflowStatus, status: ArticleWorkflowStatus,
publishing_status: PublishingStatus, publishing_status: PublishingStatus,
brief_description: str, brief_description: str,
@@ -447,10 +459,14 @@ class ArticlesRepository:
) -> ArticleSummary: ) -> ArticleSummary:
article_id = uuid4() article_id = uuid4()
placeholder = self._repository.placeholder() placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
sql = f""" sql = f"""
INSERT INTO articles ( INSERT INTO articles (
id, id,
target_site_id, target_site_id,
workflow_template_id,
workflow_template_version,
workflow_template_snapshot,
status, status,
publishing_status, publishing_status,
brief_description, brief_description,
@@ -467,6 +483,9 @@ class ArticlesRepository:
{placeholder}, {placeholder},
{placeholder}, {placeholder},
{placeholder}, {placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder}, {placeholder},
{placeholder}, {placeholder},
{placeholder}, {placeholder},
@@ -483,6 +502,13 @@ class ArticlesRepository:
( (
str(article_id), str(article_id),
str(target_site_id), str(target_site_id),
_uuid_value(workflow_template_id),
workflow_template_version,
(
_json_value(workflow_template_snapshot)
if workflow_template_snapshot is not None
else None
),
status.value, status.value,
publishing_status.value, publishing_status.value,
brief_description, brief_description,
@@ -505,6 +531,8 @@ class ArticlesRepository:
SELECT SELECT
id, id,
target_site_id, target_site_id,
workflow_template_id,
workflow_template_version,
status, status,
publishing_status, publishing_status,
brief_description, brief_description,
@@ -530,6 +558,8 @@ class ArticlesRepository:
SELECT SELECT
id, id,
target_site_id, target_site_id,
workflow_template_id,
workflow_template_version,
status, status,
publishing_status, publishing_status,
brief_description, brief_description,
@@ -550,6 +580,28 @@ class ArticlesRepository:
raise LookupError(f"Article not found: {article_id}") raise LookupError(f"Article not found: {article_id}")
return _article_summary_from_row(row) return _article_summary_from_row(row)
def get_workflow_template_snapshot(
self,
article_id: UUID,
) -> WorkflowTemplateSnapshot | None:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT workflow_template_snapshot
FROM articles
WHERE id = {placeholder}
""",
(str(article_id),),
).fetchone()
if row is None:
raise LookupError(f"Article not found: {article_id}")
snapshot = _json_from_row(row, "workflow_template_snapshot")
if snapshot is None:
return None
return WorkflowTemplateSnapshot.model_validate(snapshot)
def update_status( def update_status(
self, self,
*, *,
@@ -2735,6 +2787,722 @@ class AgentJobsRepository:
""" """
class WorkflowTemplatesRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create(
self,
*,
name: str,
slug: str,
description: str,
status: WorkflowTemplateStatus,
version: int,
created_by: UUID,
updated_by: UUID,
created_at: datetime,
updated_at: datetime,
) -> WorkflowTemplateSummary:
workflow_id = uuid4()
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO workflow_templates (
id,
name,
slug,
description,
status,
version,
created_by,
updated_by,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}
)
""",
(
str(workflow_id),
name,
slug,
description,
status.value,
version,
str(created_by),
str(updated_by),
_datetime_value(created_at),
_datetime_value(updated_at),
),
)
return self.get(workflow_id)
def list(self) -> list[WorkflowTemplateSummary]:
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM workflow_templates
ORDER BY updated_at DESC, slug
"""
).fetchall()
return [self._with_stages(row) for row in rows]
def list_active(self) -> list[WorkflowTemplateSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM workflow_templates
WHERE status = {placeholder}
ORDER BY updated_at DESC, slug
""",
(WorkflowTemplateStatus.ACTIVE.value,),
).fetchall()
return [self._with_stages(row) for row in rows]
def get(self, workflow_id: UUID) -> WorkflowTemplateSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM workflow_templates
WHERE id = {placeholder}
""",
(str(workflow_id),),
).fetchone()
if row is None:
raise LookupError(f"Workflow template not found: {workflow_id}")
return self._with_stages(row)
def update(
self,
*,
workflow_id: UUID,
name: str,
slug: str,
description: str,
updated_by: UUID,
updated_at: datetime,
) -> WorkflowTemplateSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE workflow_templates
SET
name = {placeholder},
slug = {placeholder},
description = {placeholder},
updated_by = {placeholder},
updated_at = {placeholder}
WHERE id = {placeholder}
""",
(
name,
slug,
description,
str(updated_by),
_datetime_value(updated_at),
str(workflow_id),
),
)
return self.get(workflow_id)
def activate(
self,
*,
workflow_id: UUID,
updated_by: UUID,
activated_at: datetime,
) -> WorkflowTemplateSummary:
existing = self.get(workflow_id)
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE workflow_templates
SET
status = {placeholder},
version = {placeholder},
updated_by = {placeholder},
updated_at = {placeholder},
activated_at = {placeholder}
WHERE id = {placeholder}
""",
(
WorkflowTemplateStatus.ACTIVE.value,
existing.version + 1,
str(updated_by),
_datetime_value(activated_at),
_datetime_value(activated_at),
str(workflow_id),
),
)
return self.get(workflow_id)
def archive(
self,
*,
workflow_id: UUID,
updated_by: UUID,
archived_at: datetime,
) -> WorkflowTemplateSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE workflow_templates
SET
status = {placeholder},
updated_by = {placeholder},
updated_at = {placeholder},
archived_at = {placeholder}
WHERE id = {placeholder}
""",
(
WorkflowTemplateStatus.ARCHIVED.value,
str(updated_by),
_datetime_value(archived_at),
_datetime_value(archived_at),
str(workflow_id),
),
)
return self.get(workflow_id)
def touch(
self,
*,
workflow_id: UUID,
updated_by: UUID,
updated_at: datetime,
) -> None:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE workflow_templates
SET updated_by = {placeholder}, updated_at = {placeholder}
WHERE id = {placeholder}
""",
(str(updated_by), _datetime_value(updated_at), str(workflow_id)),
)
def _with_stages(self, row: Any) -> WorkflowTemplateSummary:
workflow_id = _row_value(row, "id")
return _workflow_template_from_row(
row,
stages=self._stages_for_workflow(UUID(str(workflow_id))),
)
def _stages_for_workflow(self, workflow_id: UUID) -> list[WorkflowStageSummary]:
return self._repository.workflow_template_stages.list_for_workflow(workflow_id)
def _select_columns(self) -> str:
return """
id,
name,
slug,
description,
status,
version,
created_by,
updated_by,
created_at,
updated_at,
activated_at,
archived_at
"""
class WorkflowTemplateStagesRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create(
self,
*,
workflow_id: UUID,
stable_key: str,
display_name: str,
description: str,
position: int | None,
owner_role: Role,
runner_profile_key: str,
required_inputs: list[str],
expected_outputs: list[str],
acceptance_criteria: list[str],
requires_human_approval: bool,
retry_policy: JsonObject,
parts: list[dict[str, Any]],
updated_by: UUID,
created_at: datetime,
updated_at: datetime,
) -> WorkflowStageSummary:
stage_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
insert_position = self._clamped_insert_position(
workflow_id=workflow_id,
requested_position=position,
)
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE workflow_template_stages
SET position = position + 1
WHERE workflow_template_id = {placeholder}
AND position >= {placeholder}
""",
(str(workflow_id), insert_position),
)
connection.execute(
f"""
INSERT INTO workflow_template_stages (
id,
workflow_template_id,
stable_key,
display_name,
description,
position,
owner_role,
runner_profile_key,
required_inputs,
expected_outputs,
acceptance_criteria,
requires_human_approval,
retry_policy,
parts,
created_at,
updated_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder},
{placeholder}{json_cast},
{placeholder}{json_cast},
{placeholder},
{placeholder}
)
""",
(
str(stage_id),
str(workflow_id),
stable_key,
display_name,
description,
insert_position,
owner_role.value,
runner_profile_key,
_json_value(required_inputs),
_json_value(expected_outputs),
_json_value(acceptance_criteria),
_bool_value(requires_human_approval),
_json_value(retry_policy),
_json_value(parts),
_datetime_value(created_at),
_datetime_value(updated_at),
),
)
self._touch_parent(
connection,
workflow_id=workflow_id,
updated_by=updated_by,
updated_at=updated_at,
)
return self.get(workflow_id=workflow_id, stage_id=stage_id)
def list_for_workflow(self, workflow_id: UUID) -> list[WorkflowStageSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM workflow_template_stages
WHERE workflow_template_id = {placeholder}
ORDER BY position, created_at, id
""",
(str(workflow_id),),
).fetchall()
return [_workflow_stage_from_row(row) for row in rows]
def get(self, *, workflow_id: UUID, stage_id: UUID) -> WorkflowStageSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM workflow_template_stages
WHERE workflow_template_id = {placeholder}
AND id = {placeholder}
""",
(str(workflow_id), str(stage_id)),
).fetchone()
if row is None:
raise LookupError(f"Workflow stage not found: {stage_id}")
return _workflow_stage_from_row(row)
def update(
self,
*,
workflow_id: UUID,
stage_id: UUID,
stable_key: str,
display_name: str,
description: str,
position: int,
owner_role: Role,
runner_profile_key: str,
required_inputs: list[str],
expected_outputs: list[str],
acceptance_criteria: list[str],
requires_human_approval: bool,
retry_policy: JsonObject,
parts: list[dict[str, Any]],
updated_by: UUID,
updated_at: datetime,
) -> WorkflowStageSummary:
existing = self.get(workflow_id=workflow_id, stage_id=stage_id)
update_position = self._clamped_update_position(
workflow_id=workflow_id,
requested_position=position,
)
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
if update_position < existing.position:
connection.execute(
f"""
UPDATE workflow_template_stages
SET position = position + 1
WHERE workflow_template_id = {placeholder}
AND position >= {placeholder}
AND position < {placeholder}
""",
(str(workflow_id), update_position, existing.position),
)
elif update_position > existing.position:
connection.execute(
f"""
UPDATE workflow_template_stages
SET position = position - 1
WHERE workflow_template_id = {placeholder}
AND position <= {placeholder}
AND position > {placeholder}
""",
(str(workflow_id), update_position, existing.position),
)
connection.execute(
f"""
UPDATE workflow_template_stages
SET
stable_key = {placeholder},
display_name = {placeholder},
description = {placeholder},
position = {placeholder},
owner_role = {placeholder},
runner_profile_key = {placeholder},
required_inputs = {placeholder}{json_cast},
expected_outputs = {placeholder}{json_cast},
acceptance_criteria = {placeholder}{json_cast},
requires_human_approval = {placeholder},
retry_policy = {placeholder}{json_cast},
parts = {placeholder}{json_cast},
updated_at = {placeholder}
WHERE workflow_template_id = {placeholder}
AND id = {placeholder}
""",
(
stable_key,
display_name,
description,
update_position,
owner_role.value,
runner_profile_key,
_json_value(required_inputs),
_json_value(expected_outputs),
_json_value(acceptance_criteria),
_bool_value(requires_human_approval),
_json_value(retry_policy),
_json_value(parts),
_datetime_value(updated_at),
str(workflow_id),
str(stage_id),
),
)
self._touch_parent(
connection,
workflow_id=workflow_id,
updated_by=updated_by,
updated_at=updated_at,
)
return self.get(workflow_id=workflow_id, stage_id=stage_id)
def delete(
self,
*,
workflow_id: UUID,
stage_id: UUID,
updated_by: UUID,
updated_at: datetime,
) -> None:
existing = self.get(workflow_id=workflow_id, stage_id=stage_id)
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
DELETE FROM workflow_template_stages
WHERE workflow_template_id = {placeholder}
AND id = {placeholder}
""",
(str(workflow_id), str(stage_id)),
)
connection.execute(
f"""
UPDATE workflow_template_stages
SET position = position - 1
WHERE workflow_template_id = {placeholder}
AND position > {placeholder}
""",
(str(workflow_id), existing.position),
)
self._touch_parent(
connection,
workflow_id=workflow_id,
updated_by=updated_by,
updated_at=updated_at,
)
def reorder(
self,
*,
workflow_id: UUID,
stage_ids: list[UUID],
updated_by: UUID,
updated_at: datetime,
) -> list[WorkflowStageSummary]:
existing = self.list_for_workflow(workflow_id)
existing_ids = {stage.id for stage in existing}
requested_ids = set(stage_ids)
if requested_ids != existing_ids or len(stage_ids) != len(existing):
raise ValueError("Stage reorder must include each workflow stage exactly once.")
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
for position, stage_id in enumerate(stage_ids, start=1):
connection.execute(
f"""
UPDATE workflow_template_stages
SET position = {placeholder}, updated_at = {placeholder}
WHERE workflow_template_id = {placeholder}
AND id = {placeholder}
""",
(
position,
_datetime_value(updated_at),
str(workflow_id),
str(stage_id),
),
)
self._touch_parent(
connection,
workflow_id=workflow_id,
updated_by=updated_by,
updated_at=updated_at,
)
return self.list_for_workflow(workflow_id)
def _clamped_insert_position(
self,
*,
workflow_id: UUID,
requested_position: int | None,
) -> int:
count = len(self.list_for_workflow(workflow_id))
if requested_position is None:
return count + 1
return max(1, min(requested_position, count + 1))
def _clamped_update_position(
self,
*,
workflow_id: UUID,
requested_position: int,
) -> int:
count = len(self.list_for_workflow(workflow_id))
return max(1, min(requested_position, count))
def _touch_parent(
self,
connection: Any,
*,
workflow_id: UUID,
updated_by: UUID,
updated_at: datetime,
) -> None:
placeholder = self._repository.placeholder()
connection.execute(
f"""
UPDATE workflow_templates
SET updated_by = {placeholder}, updated_at = {placeholder}
WHERE id = {placeholder}
""",
(str(updated_by), _datetime_value(updated_at), str(workflow_id)),
)
def _select_columns(self) -> str:
return """
id,
workflow_template_id,
stable_key,
display_name,
description,
position,
owner_role,
runner_profile_key,
required_inputs,
expected_outputs,
acceptance_criteria,
requires_human_approval,
retry_policy,
parts,
created_at,
updated_at
"""
class WorkflowTemplateAuditEventsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create(
self,
*,
workflow_id: UUID,
event_type: str,
actor_user_id: UUID | None,
payload: JsonObject,
created_at: datetime,
) -> WorkflowTemplateAuditEventSummary:
event_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO workflow_template_events (
id,
workflow_template_id,
event_type,
actor_user_id,
payload,
created_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}
)
""",
(
str(event_id),
str(workflow_id),
event_type,
_uuid_value(actor_user_id),
_json_value(payload),
_datetime_value(created_at),
),
)
return self.get(event_id)
def get(self, event_id: UUID) -> WorkflowTemplateAuditEventSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT
id,
workflow_template_id,
event_type,
actor_user_id,
payload,
created_at
FROM workflow_template_events
WHERE id = {placeholder}
""",
(str(event_id),),
).fetchone()
if row is None:
raise LookupError(f"Workflow template event not found: {event_id}")
return _workflow_template_event_from_row(row)
def list_for_workflow(
self,
workflow_id: UUID,
) -> list[WorkflowTemplateAuditEventSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT
id,
workflow_template_id,
event_type,
actor_user_id,
payload,
created_at
FROM workflow_template_events
WHERE workflow_template_id = {placeholder}
ORDER BY created_at, id
""",
(str(workflow_id),),
).fetchall()
return [_workflow_template_event_from_row(row) for row in rows]
class ScriptConfigVersionsRepository: class ScriptConfigVersionsRepository:
def __init__(self, repository: BackendRepository) -> None: def __init__(self, repository: BackendRepository) -> None:
self._repository = repository self._repository = repository
@@ -3136,6 +3904,8 @@ def _article_summary_from_row(row: Any) -> ArticleSummary:
return ArticleSummary( return ArticleSummary(
id=_row_value(row, "id"), id=_row_value(row, "id"),
target_site_id=_row_value(row, "target_site_id"), target_site_id=_row_value(row, "target_site_id"),
workflow_template_id=_row_value(row, "workflow_template_id"),
workflow_template_version=_row_value(row, "workflow_template_version"),
status=_row_value(row, "status"), status=_row_value(row, "status"),
publishing_status=_row_value(row, "publishing_status"), publishing_status=_row_value(row, "publishing_status"),
brief_description=_row_value(row, "brief_description"), brief_description=_row_value(row, "brief_description"),
@@ -3407,6 +4177,63 @@ def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
) )
def _workflow_template_from_row(
row: Any,
*,
stages: list[WorkflowStageSummary],
) -> WorkflowTemplateSummary:
return WorkflowTemplateSummary(
id=_row_value(row, "id"),
name=_row_value(row, "name"),
slug=_row_value(row, "slug"),
description=_row_value(row, "description"),
status=_row_value(row, "status"),
version=_row_value(row, "version"),
created_by=_row_value(row, "created_by"),
updated_by=_row_value(row, "updated_by"),
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
activated_at=_row_value(row, "activated_at"),
archived_at=_row_value(row, "archived_at"),
stages=stages,
)
def _workflow_stage_from_row(row: Any) -> WorkflowStageSummary:
return WorkflowStageSummary(
id=_row_value(row, "id"),
workflow_id=_row_value(row, "workflow_template_id"),
stable_key=_row_value(row, "stable_key"),
display_name=_row_value(row, "display_name"),
description=_row_value(row, "description"),
position=_row_value(row, "position"),
owner_role=_row_value(row, "owner_role"),
runner_profile_key=_row_value(row, "runner_profile_key"),
required_inputs=_json_from_row(row, "required_inputs"),
expected_outputs=_json_from_row(row, "expected_outputs"),
acceptance_criteria=_json_from_row(row, "acceptance_criteria"),
requires_human_approval=bool(_row_value(row, "requires_human_approval")),
retry_policy=_json_from_row(row, "retry_policy"),
parts=[
WorkflowStagePart.model_validate(item)
for item in _json_from_row(row, "parts")
],
created_at=_row_value(row, "created_at"),
updated_at=_row_value(row, "updated_at"),
)
def _workflow_template_event_from_row(row: Any) -> WorkflowTemplateAuditEventSummary:
return WorkflowTemplateAuditEventSummary(
id=_row_value(row, "id"),
workflow_id=_row_value(row, "workflow_template_id"),
event_type=_row_value(row, "event_type"),
actor_user_id=_row_value(row, "actor_user_id"),
payload=_json_from_row(row, "payload"),
created_at=_row_value(row, "created_at"),
)
def _script_config_version_event_from_row(row: Any) -> dict[str, Any]: def _script_config_version_event_from_row(row: Any) -> dict[str, Any]:
return { return {
"id": _row_value(row, "id"), "id": _row_value(row, "id"),
@@ -3431,6 +4258,13 @@ def _plain_row(row: Any) -> dict[str, Any]:
"visual_rules", "visual_rules",
"source_rules", "source_rules",
"publishing_rules", "publishing_rules",
"required_inputs",
"expected_outputs",
"acceptance_criteria",
"retry_policy",
"parts",
"payload",
"workflow_template_snapshot",
): ):
if key in result: if key in result:
result[key] = _json_decode(result[key]) result[key] = _json_decode(result[key])
+141
View File
@@ -17,6 +17,7 @@ from src.domain.contracts import (
PublishingStatus, PublishingStatus,
Role, Role,
ScriptConfigVersionStatus, ScriptConfigVersionStatus,
WorkflowTemplateStatus,
) )
@@ -30,6 +31,9 @@ PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingS
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values( SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
status.value for status in ScriptConfigVersionStatus status.value for status in ScriptConfigVersionStatus
) )
WORKFLOW_TEMPLATE_STATUS_VALUES = _quoted_values(
status.value for status in WorkflowTemplateStatus
)
PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus) PLAN_REVIEW_STATUS_VALUES = _quoted_values(status.value for status in PlanReviewStatus)
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values( CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
status.value for status in ClaimSupportStatus status.value for status in ClaimSupportStatus
@@ -94,9 +98,59 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
f""" f"""
CREATE TABLE IF NOT EXISTS workflow_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ({WORKFLOW_TEMPLATE_STATUS_VALUES})),
version INTEGER NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL CHECK (owner_role IN ('ADMIN', 'EDITOR')),
runner_profile_key TEXT NOT NULL,
required_inputs JSONB NOT NULL DEFAULT '[]'::jsonb,
expected_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
acceptance_criteria JSONB NOT NULL DEFAULT '[]'::jsonb,
requires_human_approval BOOLEAN NOT NULL DEFAULT false,
retry_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
parts JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workflow_template_id, stable_key)
)
""",
f"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_template_id UUID NOT NULL REFERENCES workflow_templates(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id),
payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
f"""
CREATE TABLE IF NOT EXISTS articles ( CREATE TABLE IF NOT EXISTS articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_site_id UUID NOT NULL REFERENCES target_sites(id), target_site_id UUID NOT NULL REFERENCES target_sites(id),
workflow_template_id UUID REFERENCES workflow_templates(id),
workflow_template_version INTEGER,
workflow_template_snapshot JSONB,
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})), status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
publishing_status TEXT NOT NULL CHECK ( publishing_status TEXT NOT NULL CHECK (
publishing_status IN ({PUBLISHING_STATUS_VALUES}) publishing_status IN ({PUBLISHING_STATUS_VALUES})
@@ -111,6 +165,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) )
""", """,
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
""" """
CREATE TABLE IF NOT EXISTS boundary_questions ( CREATE TABLE IF NOT EXISTS boundary_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -400,6 +457,19 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at DESC)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
""" """
CREATE INDEX IF NOT EXISTS idx_articles_dashboard CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at DESC) ON articles (target_site_id, status, publishing_status, updated_at DESC)
@@ -409,6 +479,10 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON articles (assigned_editor_id, updated_at DESC) ON articles (assigned_editor_id, updated_at DESC)
""", """,
""" """
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at) ON agent_jobs (status, queued_at)
""", """,
@@ -496,9 +570,59 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
""" """
CREATE TABLE IF NOT EXISTS workflow_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
status TEXT NOT NULL,
version INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TEXT,
archived_at TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_stages (
id TEXT PRIMARY KEY,
workflow_template_id TEXT NOT NULL,
stable_key TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT NOT NULL,
position INTEGER NOT NULL,
owner_role TEXT NOT NULL,
runner_profile_key TEXT NOT NULL,
required_inputs TEXT NOT NULL DEFAULT '[]',
expected_outputs TEXT NOT NULL DEFAULT '[]',
acceptance_criteria TEXT NOT NULL DEFAULT '[]',
requires_human_approval INTEGER NOT NULL DEFAULT 0,
retry_policy TEXT NOT NULL DEFAULT '{}',
parts TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (workflow_template_id, stable_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS workflow_template_events (
id TEXT PRIMARY KEY,
workflow_template_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_user_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS articles ( CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
target_site_id TEXT NOT NULL, target_site_id TEXT NOT NULL,
workflow_template_id TEXT,
workflow_template_version INTEGER,
workflow_template_snapshot TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
publishing_status TEXT NOT NULL, publishing_status TEXT NOT NULL,
brief_description TEXT NOT NULL, brief_description TEXT NOT NULL,
@@ -767,6 +891,19 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
) )
""", """,
"CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)", "CREATE INDEX IF NOT EXISTS idx_target_sites_slug ON target_sites (slug)",
"CREATE INDEX IF NOT EXISTS idx_workflow_templates_slug ON workflow_templates (slug)",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_templates_status_updated
ON workflow_templates (status, updated_at)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_stages_order
ON workflow_template_stages (workflow_template_id, position)
""",
"""
CREATE INDEX IF NOT EXISTS idx_workflow_template_events_history
ON workflow_template_events (workflow_template_id, created_at)
""",
""" """
CREATE INDEX IF NOT EXISTS idx_articles_dashboard CREATE INDEX IF NOT EXISTS idx_articles_dashboard
ON articles (target_site_id, status, publishing_status, updated_at) ON articles (target_site_id, status, publishing_status, updated_at)
@@ -776,6 +913,10 @@ SQLITE_SCHEMA_STATEMENTS: tuple[str, ...] = (
ON articles (assigned_editor_id, updated_at) ON articles (assigned_editor_id, updated_at)
""", """,
""" """
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
ON articles (workflow_template_id, workflow_template_version)
""",
"""
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
ON agent_jobs (status, queued_at) ON agent_jobs (status, queued_at)
""", """,
+19
View File
@@ -1,8 +1,10 @@
import os
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from src.domain.contracts.openapi import inject_contract_schemas from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.agent_jobs import ( from src.presentation.routes.agent_jobs import (
@@ -20,9 +22,25 @@ from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.publishing import router as publishing_router from src.presentation.routes.publishing import router as publishing_router
from src.presentation.routes.reviews import router as reviews_router from src.presentation.routes.reviews import router as reviews_router
from src.presentation.routes.sites import router as sites_router from src.presentation.routes.sites import router as sites_router
from src.presentation.routes.workflow_templates import router as workflow_templates_router
app = FastAPI(title="AI Content Pipeline Backend") app = FastAPI(title="AI Content Pipeline Backend")
_allowed_origins = [
origin.strip()
for origin in os.getenv(
"PIPELINE_CORS_ALLOW_ORIGINS",
"http://localhost:3000,http://localhost:13300",
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(articles_router) app.include_router(articles_router)
app.include_router(assets_router) app.include_router(assets_router)
@@ -36,6 +54,7 @@ app.include_router(publishing_router)
app.include_router(agent_jobs_router) app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router) app.include_router(internal_agent_jobs_router)
app.include_router(sites_router) app.include_router(sites_router)
app.include_router(workflow_templates_router)
@app.get("/health") @app.get("/health")
@@ -45,6 +45,11 @@ def post_article(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid target_site_id", detail="Invalid target_site_id",
) from error ) from error
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error
@router.get("/articles", response_model=ArticleListResponse) @router.get("/articles", response_model=ArticleListResponse)
@@ -0,0 +1,257 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.workflow_templates import (
activate_workflow_template,
add_workflow_stage,
archive_workflow_template,
create_workflow_template,
delete_workflow_stage,
get_workflow_template,
list_workflow_template_audit_events,
list_workflow_templates,
reorder_workflow_stages,
update_workflow_stage,
update_workflow_template,
)
from src.domain.auth import ADMIN_ROLES, EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["workflow_templates"])
@router.post(
"/admin/workflows",
response_model=WorkflowTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_template(
request: WorkflowTemplateCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
return create_workflow_template(
repository,
request=request,
current_user=current_user,
)
@router.get("/admin/workflows", response_model=WorkflowTemplateListResponse)
def get_workflow_templates(
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateListResponse:
return list_workflow_templates(repository, current_user=current_user)
@router.get(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def get_workflow_template_detail(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return get_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.patch(
"/admin/workflows/{workflow_id}",
response_model=WorkflowTemplateResponse,
)
def patch_workflow_template(
workflow_id: UUID,
request: WorkflowTemplateUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return update_workflow_template(
repository,
workflow_id=workflow_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/stages",
response_model=WorkflowStageResponse,
status_code=status.HTTP_201_CREATED,
)
def post_workflow_stage(
workflow_id: UUID,
request: WorkflowStageCreateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return add_workflow_stage(
repository,
workflow_id=workflow_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.patch(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowStageResponse,
)
def patch_workflow_stage(
workflow_id: UUID,
stage_id: UUID,
request: WorkflowStageUpdateRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowStageResponse:
try:
return update_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_id,
request=request,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.delete(
"/admin/workflows/{workflow_id}/stages/{stage_id}",
response_model=WorkflowTemplateResponse,
)
def delete_workflow_template_stage(
workflow_id: UUID,
stage_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return delete_workflow_stage(
repository,
workflow_id=workflow_id,
stage_id=stage_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/stages/reorder",
response_model=WorkflowTemplateResponse,
)
def post_reorder_workflow_stages(
workflow_id: UUID,
request: WorkflowStageReorderRequest,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return reorder_workflow_stages(
repository,
workflow_id=workflow_id,
stage_ids=request.stage_ids,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/activate",
response_model=WorkflowTemplateResponse,
)
def post_activate_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return activate_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/admin/workflows/{workflow_id}/archive",
response_model=WorkflowTemplateResponse,
)
def post_archive_workflow_template(
workflow_id: UUID,
current_user: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateResponse:
try:
return archive_workflow_template(
repository,
workflow_id=workflow_id,
current_user=current_user,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/admin/workflows/{workflow_id}/audit",
response_model=WorkflowTemplateAuditEventListResponse,
)
def get_workflow_template_audit_events(
workflow_id: UUID,
_: CurrentUser = Depends(require_roles(ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> WorkflowTemplateAuditEventListResponse:
try:
return list_workflow_template_audit_events(
repository,
workflow_id=workflow_id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -0,0 +1,391 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
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 AdminWorkflowTemplatesPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'workflow-templates.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_admin_creates_workflow_template_with_two_editable_ordered_stages_and_structured_parts(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
create_response = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-v1"),
)
self.assertEqual(201, create_response.status_code, create_response.text)
workflow = create_response.json()["workflow"]
self.assertEqual("article-production-v1", workflow["slug"])
self.assertEqual("DRAFT", workflow["status"])
self.assertEqual(1, workflow["version"])
self.assertEqual([], workflow["stages"])
self.assertTrue(workflow["created_at"])
self.assertTrue(workflow["updated_at"])
intake_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
)
self.assertEqual(201, intake_response.status_code, intake_response.text)
intake_stage = intake_response.json()["stage"]
draft_response = self.client.post(
f"/api/admin/workflows/{workflow['id']}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
)
self.assertEqual(201, draft_response.status_code, draft_response.text)
draft_stage = draft_response.json()["stage"]
edited_draft_parts = [
{
"key": "draft-outline",
"type": "outline",
"title": "Edited draft outline",
"payload": {
"prompt": "Build a sourced outline before drafting.",
"config": {
"minimum_sections": 5,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
"Claims that need evidence are marked.",
],
},
{
"key": "draft-body",
"type": "generation",
"title": "Draft body",
"payload": {
"prompt": "Generate a complete longform draft.",
"config": {
"tone": "practical",
"include_evidence_markers": True,
},
},
"acceptance_criteria": [
"Draft includes all approved outline sections.",
"Unsupported claims remain marked for review.",
],
},
]
patch_response = self.client.patch(
f"/api/admin/workflows/{workflow['id']}/stages/{draft_stage['id']}",
headers=admin_headers,
json={
"display_name": "Draft and evidence assembly",
"description": "Create an evidence-aware article draft.",
"owner_role": "EDITOR",
"runner_profile_key": "draft-writer-v2",
"required_inputs": [
"approved_plan",
"evidence_matrix",
],
"expected_outputs": [
"article_draft",
"claim_evidence_map",
],
"acceptance_criteria": [
"Draft follows the approved plan.",
"Evidence markers are preserved.",
],
"requires_human_approval": True,
"retry_policy": {
"max_attempts": 2,
"backoff_seconds": 120,
},
"parts": edited_draft_parts,
},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
detail_response = self.client.get(
f"/api/admin/workflows/{workflow['id']}",
headers=admin_headers,
)
self.assertEqual(200, detail_response.status_code, detail_response.text)
detail = detail_response.json()["workflow"]
stages = detail["stages"]
self.assertEqual([1, 2], [stage["position"] for stage in stages])
self.assertEqual(
["intake-boundary-questions", "draft-assembly"],
[stage["stable_key"] for stage in stages],
)
self.assertEqual(intake_stage["id"], stages[0]["id"])
self.assertEqual(draft_stage["id"], stages[1]["id"])
self.assertEqual(self._intake_stage_payload()["parts"], stages[0]["parts"])
self.assertEqual("Draft and evidence assembly", stages[1]["display_name"])
self.assertEqual("draft-writer-v2", stages[1]["runner_profile_key"])
self.assertTrue(stages[1]["requires_human_approval"])
self.assertEqual(
{
"max_attempts": 2,
"backoff_seconds": 120,
},
stages[1]["retry_policy"],
)
self.assertEqual(edited_draft_parts, stages[1]["parts"])
def test_editor_mutation_is_denied_with_existing_authorization_error_shape(
self,
) -> None:
response = self.client.post(
"/api/admin/workflows",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json=self._workflow_payload("editor-denied-workflow"),
)
self.assertEqual(403, response.status_code, response.text)
self.assertEqual({"detail": "Forbidden"}, response.json())
def test_admin_reorders_activates_archives_and_audits_editor_visible_workflow(
self,
) -> None:
admin_headers = {DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL}
editor_headers = {DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}
workflow = self.client.post(
"/api/admin/workflows",
headers=admin_headers,
json=self._workflow_payload("article-production-ops"),
).json()["workflow"]
workflow_id = workflow["id"]
intake_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._intake_stage_payload(),
).json()["stage"]
draft_stage = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages",
headers=admin_headers,
json=self._draft_stage_payload(),
).json()["stage"]
reorder_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/stages/reorder",
headers=admin_headers,
json={"stage_ids": [draft_stage["id"], intake_stage["id"]]},
)
self.assertEqual(200, reorder_response.status_code, reorder_response.text)
self.assertEqual(
["draft-assembly", "intake-boundary-questions"],
[
stage["stable_key"]
for stage in reorder_response.json()["workflow"]["stages"]
],
)
activate_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/activate",
headers=admin_headers,
)
self.assertEqual(200, activate_response.status_code, activate_response.text)
activated = activate_response.json()["workflow"]
self.assertEqual("ACTIVE", activated["status"])
self.assertEqual(2, activated["version"])
editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(200, editor_list_response.status_code, editor_list_response.text)
self.assertIn(
workflow_id,
[item["id"] for item in editor_list_response.json()["workflows"]],
)
editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(200, editor_detail_response.status_code, editor_detail_response.text)
immutable_patch_response = self.client.patch(
f"/api/admin/workflows/{workflow_id}/stages/{draft_stage['id']}",
headers=admin_headers,
json={"display_name": "Should not mutate active template"},
)
self.assertEqual(409, immutable_patch_response.status_code, immutable_patch_response.text)
archive_response = self.client.post(
f"/api/admin/workflows/{workflow_id}/archive",
headers=admin_headers,
)
self.assertEqual(200, archive_response.status_code, archive_response.text)
self.assertEqual("ARCHIVED", archive_response.json()["workflow"]["status"])
archived_editor_list_response = self.client.get(
"/api/admin/workflows",
headers=editor_headers,
)
self.assertEqual(
200,
archived_editor_list_response.status_code,
archived_editor_list_response.text,
)
self.assertNotIn(
workflow_id,
[item["id"] for item in archived_editor_list_response.json()["workflows"]],
)
archived_editor_detail_response = self.client.get(
f"/api/admin/workflows/{workflow_id}",
headers=editor_headers,
)
self.assertEqual(
404,
archived_editor_detail_response.status_code,
archived_editor_detail_response.text,
)
audit_response = self.client.get(
f"/api/admin/workflows/{workflow_id}/audit",
headers=admin_headers,
)
self.assertEqual(200, audit_response.status_code, audit_response.text)
event_types = {
event["event_type"] for event in audit_response.json()["events"]
}
self.assertTrue(
{
"WORKFLOW_TEMPLATE_CREATED",
"WORKFLOW_STAGE_CREATED",
"WORKFLOW_STAGES_REORDERED",
"WORKFLOW_TEMPLATE_ACTIVATED",
"WORKFLOW_TEMPLATE_ARCHIVED",
}.issubset(event_types)
)
def _workflow_payload(self, slug: str) -> dict[str, str]:
return {
"name": "Article production workflow",
"slug": slug,
"description": "Reusable editorial workflow for longform article production.",
}
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.",
"Answers are tied to the source brief.",
],
"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, keyword, and source constraints.",
"config": {
"required_fields": [
"audience",
"primary_keyword",
"source_rules",
],
"block_on_missing": True,
},
},
"acceptance_criteria": [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
}
],
}
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.",
"config": {
"minimum_sections": 4,
"require_source_placeholders": True,
},
},
"acceptance_criteria": [
"Every section has a purpose.",
],
}
],
}
if __name__ == "__main__":
unittest.main()
@@ -316,8 +316,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
) )
article_id = article_response.json()["article"]["id"] article_id = article_response.json()["article"]["id"]
questions_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, questions_response.status_code, questions_response.text)
for question in questions_response.json()["questions"]:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan_id = plan_response.json()["plan"]["id"]
review_response = self.client.post( review_response = self.client.post(
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve", f"/api/articles/{article_id}/plans/{plan_id}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL}, headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
) )
@@ -0,0 +1,56 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
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
class DemoBrowserCorsPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'demo-browser-cors.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_demo_frontend_origin_can_call_backend_with_demo_user_header(self) -> None:
response = self.client.options(
"/api/admin/workflows",
headers={
"Origin": "http://localhost:13300",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-Demo-User-Email",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(
"http://localhost:13300",
response.headers.get("access-control-allow-origin"),
)
self.assertIn(
"X-Demo-User-Email",
response.headers.get("access-control-allow-headers", ""),
)
if __name__ == "__main__":
unittest.main()
@@ -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()
+18
View File
@@ -1,6 +1,24 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
outputFileTracingRoot: repoRoot,
async rewrites() {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
turbopack: {
root: repoRoot,
},
}; };
export default nextConfig; export default nextConfig;
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"test:roles": "node tests/role-navigation.test.mjs", "test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs", "test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/admin_workflows.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminJobsPage from "@/pages/admin-jobs"; import AdminJobsPage from "@/pages/admin-jobs";
export default AdminJobsPage; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminJobsPage />;
}
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminScriptsPage from "@/pages/admin-scripts"; import AdminScriptsPage from "@/pages/admin-scripts";
export { AdminScriptsPage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminScriptsPage />;
}
+5 -1
View File
@@ -1,3 +1,7 @@
import AdminSitesPage from "@/pages/admin-sites"; import AdminSitesPage from "@/pages/admin-sites";
export default AdminSitesPage; export const dynamic = "force-dynamic";
export default function Page() {
return <AdminSitesPage />;
}
@@ -0,0 +1,7 @@
import AdminWorkflowsPage from "@/pages/admin-workflows";
export const dynamic = "force-dynamic";
export default function Page() {
return <AdminWorkflowsPage />;
}
@@ -1,14 +1,16 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions"; import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
export const dynamic = "force-dynamic";
type DynamicArticleBoundaryQuestionsPageProps = { type DynamicArticleBoundaryQuestionsPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleBoundaryQuestionsPage({ export default async function DynamicArticleBoundaryQuestionsPage({
params, params,
}: DynamicArticleBoundaryQuestionsPageProps) { }: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />; return <ArticleBoundaryQuestionsPage articleId={articleId} />;
} }
@@ -1,14 +1,16 @@
import ArticleEvidencePage from "@/pages/article-evidence"; import ArticleEvidencePage from "@/pages/article-evidence";
export const dynamic = "force-dynamic";
type DynamicArticleEvidencePageProps = { type DynamicArticleEvidencePageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleEvidencePage({ export default async function DynamicArticleEvidencePage({
params, params,
}: DynamicArticleEvidencePageProps) { }: DynamicArticleEvidencePageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleEvidencePage articleId={articleId} />; return <ArticleEvidencePage articleId={articleId} />;
} }
@@ -1,19 +1,22 @@
import ArticleDetailPage from "@/pages/article-detail"; import ArticleDetailPage from "@/pages/article-detail";
export const dynamic = "force-dynamic";
type DynamicArticleDetailPageProps = { type DynamicArticleDetailPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
searchParams?: { searchParams?: Promise<{
role?: string; role?: string;
}; }>;
}; };
export default async function DynamicArticleDetailPage({ export default async function DynamicArticleDetailPage({
params, params,
searchParams, searchParams,
}: DynamicArticleDetailPageProps) { }: DynamicArticleDetailPageProps) {
const { articleId } = params; const { articleId } = await params;
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor"; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />; return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
} }
@@ -1,14 +1,16 @@
import ArticlePlansPage from "@/pages/article-plans"; import ArticlePlansPage from "@/pages/article-plans";
export const dynamic = "force-dynamic";
type DynamicArticlePlansPageProps = { type DynamicArticlePlansPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticlePlansPage({ export default async function DynamicArticlePlansPage({
params, params,
}: DynamicArticlePlansPageProps) { }: DynamicArticlePlansPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticlePlansPage articleId={articleId} />; return <ArticlePlansPage articleId={articleId} />;
} }
@@ -1,14 +1,16 @@
import ArticleResearchPage from "@/pages/article-research"; import ArticleResearchPage from "@/pages/article-research";
export const dynamic = "force-dynamic";
type DynamicArticleResearchPageProps = { type DynamicArticleResearchPageProps = {
params: { params: Promise<{
articleId: string; articleId: string;
}; }>;
}; };
export default async function DynamicArticleResearchPage({ export default async function DynamicArticleResearchPage({
params, params,
}: DynamicArticleResearchPageProps) { }: DynamicArticleResearchPageProps) {
const { articleId } = params; const { articleId } = await params;
return <ArticleResearchPage articleId={articleId} />; return <ArticleResearchPage articleId={articleId} />;
} }
+5 -1
View File
@@ -1,3 +1,7 @@
import NewArticlePage from "@/pages/article-new"; import NewArticlePage from "@/pages/article-new";
export { NewArticlePage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <NewArticlePage />;
}
+6
View File
@@ -161,6 +161,12 @@ main {
font-size: 12px; font-size: 12px;
} }
.fieldHint {
margin: 0;
color: #475467;
font-size: 12px;
}
.formSuccess { .formSuccess {
margin: 0; margin: 0;
color: #027a48; color: #027a48;
+2
View File
@@ -6,6 +6,8 @@ export const metadata = {
description: "Internal editorial workflow foundation", description: "Internal editorial workflow foundation",
}; };
export const dynamic = "force-dynamic";
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
+5 -1
View File
@@ -1,3 +1,7 @@
import DashboardPage from "@/pages/dashboard"; import DashboardPage from "@/pages/dashboard";
export { DashboardPage as default }; export const dynamic = "force-dynamic";
export default function Page() {
return <DashboardPage />;
}
@@ -0,0 +1,363 @@
import type {
Role,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared";
export type WorkflowDraft = {
name: string;
slug: string;
description: string;
};
export type WorkflowStageDraft = {
stableKey: string;
displayName: string;
description: string;
position: number;
ownerRole: Role;
runnerProfileKey: string;
requiredInputs: string[];
expectedOutputs: string[];
acceptanceCriteria: string[];
requiresHumanApproval: boolean;
retryPolicy: Record<string, unknown>;
parts: WorkflowStagePart[];
};
export type WorkflowStageFormPart = {
key: string;
type: string;
title: string;
payloadJson: string;
acceptanceCriteriaText: string;
};
export type WorkflowStageFormDraft = Omit<WorkflowStageDraft, "parts"> & {
requiredInputsText: string;
expectedOutputsText: string;
acceptanceCriteriaText: string;
retryPolicyJson: string;
parts: WorkflowStageFormPart[];
};
export type WorkflowStagePositionItem = {
id: string;
position: number;
};
export type AdminWorkflowRow = {
id: string;
name: string;
slug: string;
status: string;
version: number;
updatedAt: string;
stageCount: number;
isDraft: boolean;
isActive: boolean;
isArchived: boolean;
};
export type AdminWorkflowAuditRow = {
id: string;
eventType: string;
createdAt: string;
actorUserId: string | null;
payload: string;
};
export type StageDraftValidationErrors = Partial<
Record<
| "stableKey"
| "displayName"
| "description"
| "runnerProfileKey"
| "retryPolicyJson"
| "parts",
string
>
>;
export function defaultWorkflowDraft(): WorkflowDraft {
return {
name: "",
slug: "",
description: "",
};
}
export function buildCreateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateCreateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function buildUpdateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateUpdateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function defaultWorkflowStagePart(): WorkflowStagePart {
return {
key: "stage-part",
type: "instruction",
title: "Stage instruction",
payload: {
prompt: "",
config: {},
},
acceptance_criteria: [],
};
}
export function defaultWorkflowStageDraft(position = 1): WorkflowStageDraft {
return {
stableKey: "",
displayName: "",
description: "",
position,
ownerRole: "EDITOR",
runnerProfileKey: "",
requiredInputs: [],
expectedOutputs: [],
acceptanceCriteria: [],
requiresHumanApproval: false,
retryPolicy: {
max_attempts: 1,
backoff_seconds: 0,
},
parts: [],
};
}
export function buildCreateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageCreateRequest {
return {
stable_key: draft.stableKey,
display_name: draft.displayName,
description: draft.description,
position: draft.position,
owner_role: draft.ownerRole,
runner_profile_key: draft.runnerProfileKey,
required_inputs: draft.requiredInputs,
expected_outputs: draft.expectedOutputs,
acceptance_criteria: draft.acceptanceCriteria,
requires_human_approval: draft.requiresHumanApproval,
retry_policy: draft.retryPolicy,
parts: draft.parts,
};
}
export function buildUpdateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageUpdateRequest {
return buildCreateWorkflowStagePayload(draft);
}
export function moveWorkflowStage<TStage extends WorkflowStagePositionItem>(
stages: readonly TStage[],
stageId: string,
targetIndex: number,
): TStage[] {
const currentIndex = stages.findIndex((stage) => stage.id === stageId);
if (currentIndex === -1) {
return stages.map((stage, index) => ({
...stage,
position: index + 1,
}));
}
const nextStages = [...stages];
const [stage] = nextStages.splice(currentIndex, 1);
const clampedIndex = Math.max(0, Math.min(targetIndex, nextStages.length));
nextStages.splice(clampedIndex, 0, stage);
return nextStages.map((nextStage, index) => ({
...nextStage,
position: index + 1,
}));
}
export function buildReorderWorkflowStagesPayload(
stages: readonly WorkflowStagePositionItem[],
): WorkflowStageReorderRequest {
return {
stage_ids: stages.map((stage) => stage.id),
};
}
export function buildAdminWorkflowRows(
workflows: readonly WorkflowTemplateSummary[],
): AdminWorkflowRow[] {
return workflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
slug: workflow.slug,
status: workflow.status,
version: workflow.version,
updatedAt: workflow.updated_at,
stageCount: workflow.stages?.length ?? 0,
isDraft: workflow.status === "DRAFT",
isActive: workflow.status === "ACTIVE",
isArchived: workflow.status === "ARCHIVED",
}));
}
export function buildAdminWorkflowAuditRows(
events: readonly WorkflowTemplateAuditEventSummary[],
): AdminWorkflowAuditRow[] {
return events.map((event) => ({
id: event.id,
eventType: event.event_type,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
payload: JSON.stringify(event.payload ?? {}),
}));
}
export function workflowDraftFromSummary(
workflow: WorkflowTemplateSummary,
): WorkflowDraft {
return {
name: workflow.name,
slug: workflow.slug,
description: workflow.description,
};
}
export function workflowStageDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageDraft {
return {
stableKey: stage.stable_key,
displayName: stage.display_name,
description: stage.description,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiredInputs: stage.required_inputs ?? [],
expectedOutputs: stage.expected_outputs ?? [],
acceptanceCriteria: stage.acceptance_criteria ?? [],
requiresHumanApproval: stage.requires_human_approval ?? false,
retryPolicy: stage.retry_policy ?? {},
parts: stage.parts ?? [],
};
}
export function workflowStageFormDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageFormDraft {
const draft = workflowStageDraftFromSummary(stage);
return workflowStageDraftToFormDraft(draft);
}
export function workflowStageDraftToFormDraft(
draft: WorkflowStageDraft,
): WorkflowStageFormDraft {
return {
...draft,
requiredInputsText: draft.requiredInputs.join("\n"),
expectedOutputsText: draft.expectedOutputs.join("\n"),
acceptanceCriteriaText: draft.acceptanceCriteria.join("\n"),
retryPolicyJson: JSON.stringify(draft.retryPolicy, null, 2),
parts: draft.parts.map((part) => ({
key: part.key,
type: part.type,
title: part.title,
payloadJson: JSON.stringify(part.payload ?? {}, null, 2),
acceptanceCriteriaText: (part.acceptance_criteria ?? []).join("\n"),
})),
};
}
export function defaultWorkflowStageFormDraft(
position = 1,
): WorkflowStageFormDraft {
return workflowStageDraftToFormDraft(defaultWorkflowStageDraft(position));
}
export function workflowStageFormDraftToStageDraft(
draft: WorkflowStageFormDraft,
): WorkflowStageDraft | null {
const retryPolicy = parseJsonObject(draft.retryPolicyJson);
if (retryPolicy === null) {
return null;
}
const parts: WorkflowStagePart[] = [];
for (const part of draft.parts) {
const payload = parseJsonObject(part.payloadJson);
if (payload === null) {
return null;
}
parts.push({
key: part.key,
type: part.type,
title: part.title,
payload,
acceptance_criteria: splitLines(part.acceptanceCriteriaText),
});
}
return {
stableKey: draft.stableKey,
displayName: draft.displayName,
description: draft.description,
position: draft.position,
ownerRole: draft.ownerRole,
runnerProfileKey: draft.runnerProfileKey,
requiredInputs: splitLines(draft.requiredInputsText),
expectedOutputs: splitLines(draft.expectedOutputsText),
acceptanceCriteria: splitLines(draft.acceptanceCriteriaText),
requiresHumanApproval: draft.requiresHumanApproval,
retryPolicy,
parts,
};
}
export function splitLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function parseJsonObject(value: string): Record<string, unknown> | null {
if (!value.trim()) {
return {};
}
try {
const parsed = JSON.parse(value);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
parsed !== null
) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
@@ -0,0 +1,976 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import type {
Role,
WorkflowStageSummary,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import {
ApiError,
DEMO_ADMIN_EMAIL,
activateWorkflowTemplate,
addWorkflowStage,
archiveWorkflowTemplate,
createWorkflowTemplate,
deleteWorkflowStage,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
reorderWorkflowStages,
updateWorkflowStage,
updateWorkflowTemplate,
} from "@/shared/pipeline-api";
import {
buildAdminWorkflowAuditRows,
buildAdminWorkflowRows,
buildCreateWorkflowPayload,
buildCreateWorkflowStagePayload,
buildReorderWorkflowStagesPayload,
buildUpdateWorkflowPayload,
buildUpdateWorkflowStagePayload,
defaultWorkflowDraft,
defaultWorkflowStageFormDraft,
moveWorkflowStage,
parseJsonObject,
workflowDraftFromSummary,
workflowStageFormDraftFromSummary,
workflowStageFormDraftToStageDraft,
type StageDraftValidationErrors,
type WorkflowDraft,
type WorkflowStageFormDraft,
type WorkflowStageFormPart,
} from "./model";
type AdminWorkflowBuilderProps = {
initialWorkflows: readonly WorkflowTemplateSummary[];
initialWorkflow?: WorkflowTemplateSummary | null;
initialAuditEvents?: readonly WorkflowTemplateAuditEventSummary[];
};
const EMPTY_AUDIT_EVENTS: readonly WorkflowTemplateAuditEventSummary[] = [];
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
function sortedStages(
stages: readonly WorkflowStageSummary[] | undefined,
): WorkflowStageSummary[] {
return [...(stages ?? [])].sort((left, right) => left.position - right.position);
}
function createDefaultPart(index: number): WorkflowStageFormPart {
return {
key: `stage-part-${index}`,
type: "instruction",
title: "Stage instruction",
payloadJson: JSON.stringify({ prompt: "", config: {} }, null, 2),
acceptanceCriteriaText: "",
};
}
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.message || fallback;
}
return fallback;
}
export function AdminWorkflowBuilder({
initialWorkflows,
initialWorkflow = null,
initialAuditEvents = EMPTY_AUDIT_EVENTS,
}: AdminWorkflowBuilderProps) {
const [workflows, setWorkflows] = useState<readonly WorkflowTemplateSummary[]>(
initialWorkflows,
);
const [selectedWorkflow, setSelectedWorkflow] =
useState<WorkflowTemplateSummary | null>(initialWorkflow);
const [auditEvents, setAuditEvents] =
useState<readonly WorkflowTemplateAuditEventSummary[]>(initialAuditEvents);
const [createDraft, setCreateDraft] = useState<WorkflowDraft>(
defaultWorkflowDraft(),
);
const [metadataDraft, setMetadataDraft] = useState<WorkflowDraft>(
initialWorkflow ? workflowDraftFromSummary(initialWorkflow) : defaultWorkflowDraft(),
);
const [editingStageId, setEditingStageId] = useState<string | null>(null);
const [stageDraft, setStageDraft] = useState<WorkflowStageFormDraft>(
defaultWorkflowStageFormDraft(sortedStages(initialWorkflow?.stages).length + 1),
);
const [stageErrors, setStageErrors] = useState<StageDraftValidationErrors>({});
const [actionMessage, setActionMessage] = useState("");
const [formError, setFormError] = useState("");
const [pendingAction, setPendingAction] = useState("");
const rows = useMemo(() => buildAdminWorkflowRows(workflows), [workflows]);
const auditRows = useMemo(
() => buildAdminWorkflowAuditRows(auditEvents),
[auditEvents],
);
const stages = useMemo(
() => sortedStages(selectedWorkflow?.stages),
[selectedWorkflow],
);
const canEditSelected = selectedWorkflow?.status === "DRAFT";
const isBusy = pendingAction !== "";
function clearMessages() {
setActionMessage("");
setFormError("");
}
function setActiveWorkflow(
workflow: WorkflowTemplateSummary,
nextAuditEvents: readonly WorkflowTemplateAuditEventSummary[] = auditEvents,
) {
const nextStages = sortedStages(workflow.stages);
setSelectedWorkflow(workflow);
setMetadataDraft(workflowDraftFromSummary(workflow));
setAuditEvents(nextAuditEvents);
setEditingStageId(null);
setStageDraft(defaultWorkflowStageFormDraft(nextStages.length + 1));
setStageErrors({});
}
function replaceWorkflowInList(workflow: WorkflowTemplateSummary) {
setWorkflows((current) => {
const exists = current.some((item) => item.id === workflow.id);
if (!exists) {
return [workflow, ...current];
}
return current.map((item) => (item.id === workflow.id ? workflow : item));
});
}
async function refreshWorkflowList(selectedWorkflowId?: string) {
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
setWorkflows(listResponse.workflows ?? []);
if (selectedWorkflowId) {
await loadWorkflow(selectedWorkflowId, false);
}
}
async function loadWorkflow(workflowId: string, markPending = true) {
if (markPending) {
clearMessages();
setPendingAction(`load-${workflowId}`);
}
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(workflowId, DEMO_ADMIN_EMAIL),
]);
setActiveWorkflow(workflowResponse.workflow, auditResponse.events ?? []);
} catch (error) {
setFormError(errorMessage(error, "Unable to load workflow"));
} finally {
if (markPending) {
setPendingAction("");
}
}
}
async function handleRefresh() {
clearMessages();
setPendingAction("refresh-workflows");
try {
await refreshWorkflowList(selectedWorkflow?.id);
setActionMessage("Workflow list refreshed");
} catch (error) {
setFormError(errorMessage(error, "Unable to refresh workflow list"));
} finally {
setPendingAction("");
}
}
function updateCreateDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setCreateDraft((current) => ({ ...current, [field]: value }));
}
function updateMetadataDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setMetadataDraft((current) => ({ ...current, [field]: value }));
}
function updateStageDraft<K extends keyof WorkflowStageFormDraft>(
field: K,
value: WorkflowStageFormDraft[K],
) {
setStageDraft((current) => ({ ...current, [field]: value }));
}
function updateStagePart<K extends keyof WorkflowStageFormPart>(
index: number,
field: K,
value: WorkflowStageFormPart[K],
) {
setStageDraft((current) => ({
...current,
parts: current.parts.map((part, partIndex) =>
partIndex === index ? { ...part, [field]: value } : part,
),
}));
}
function addStagePart() {
setStageDraft((current) => ({
...current,
parts: [...current.parts, createDefaultPart(current.parts.length + 1)],
}));
}
function removeStagePart(index: number) {
setStageDraft((current) => ({
...current,
parts: current.parts.filter((_, partIndex) => partIndex !== index),
}));
}
function startNewStage() {
setEditingStageId(null);
setStageErrors({});
setStageDraft(defaultWorkflowStageFormDraft(stages.length + 1));
}
function startEditStage(stage: WorkflowStageSummary) {
setEditingStageId(stage.id);
setStageErrors({});
setStageDraft(workflowStageFormDraftFromSummary(stage));
}
function validateStageDraft(): ReturnType<typeof workflowStageFormDraftToStageDraft> {
const nextErrors: StageDraftValidationErrors = {};
if (!stageDraft.stableKey.trim()) {
nextErrors.stableKey = "Stable key is required";
}
if (!stageDraft.displayName.trim()) {
nextErrors.displayName = "Display name is required";
}
if (!stageDraft.description.trim()) {
nextErrors.description = "Description is required";
}
if (!stageDraft.runnerProfileKey.trim()) {
nextErrors.runnerProfileKey = "Runner profile key is required";
}
if (parseJsonObject(stageDraft.retryPolicyJson) === null) {
nextErrors.retryPolicyJson = "Invalid JSON object";
}
const hasInvalidPartPayload = stageDraft.parts.some(
(part) => parseJsonObject(part.payloadJson) === null,
);
const hasInvalidPartFields = stageDraft.parts.some(
(part) => !part.key.trim() || !part.type.trim() || !part.title.trim(),
);
if (hasInvalidPartPayload || hasInvalidPartFields) {
nextErrors.parts = hasInvalidPartPayload
? "Each part payload must be a JSON object"
: "Each part needs key, type, and title";
}
if (Object.keys(nextErrors).length > 0) {
setStageErrors(nextErrors);
return null;
}
const nextDraft = workflowStageFormDraftToStageDraft(stageDraft);
if (!nextDraft) {
setStageErrors({ parts: "Stage draft contains invalid structured data" });
return null;
}
setStageErrors({});
return nextDraft;
}
async function handleCreateWorkflow(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
clearMessages();
setPendingAction("create-workflow");
try {
const response = await createWorkflowTemplate(
buildCreateWorkflowPayload(createDraft),
DEMO_ADMIN_EMAIL,
);
setCreateDraft(defaultWorkflowDraft());
replaceWorkflowInList(response.workflow);
setActiveWorkflow(response.workflow, []);
setActionMessage("Workflow draft created");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to create workflow draft"));
} finally {
setPendingAction("");
}
}
async function handleSaveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction("save-metadata");
try {
const response = await updateWorkflowTemplate(
selectedWorkflow.id,
buildUpdateWorkflowPayload(metadataDraft),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow metadata saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow metadata"));
} finally {
setPendingAction("");
}
}
async function handleSaveStage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
const validDraft = validateStageDraft();
if (!validDraft) {
return;
}
clearMessages();
setPendingAction("save-stage");
try {
if (editingStageId) {
await updateWorkflowStage(
selectedWorkflow.id,
editingStageId,
buildUpdateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage updated");
} else {
await addWorkflowStage(
selectedWorkflow.id,
buildCreateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage added");
}
await refreshWorkflowList(selectedWorkflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleDeleteStage(stageId: string) {
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction(`delete-stage-${stageId}`);
try {
const response = await deleteWorkflowStage(
selectedWorkflow.id,
stageId,
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage deleted");
} catch (error) {
setFormError(errorMessage(error, "Unable to delete workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleReorderStage(stageId: string, targetIndex: number) {
if (!selectedWorkflow) {
return;
}
const movedStages = moveWorkflowStage(stages, stageId, targetIndex);
clearMessages();
setPendingAction(`reorder-${stageId}`);
try {
const response = await reorderWorkflowStages(
selectedWorkflow.id,
buildReorderWorkflowStagesPayload(movedStages),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage order saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to reorder workflow stages"));
} finally {
setPendingAction("");
}
}
async function handleActivateWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`activate-${workflowId}`);
try {
const response = await activateWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow activated");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to activate workflow"));
} finally {
setPendingAction("");
}
}
async function handleArchiveWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`archive-${workflowId}`);
try {
const response = await archiveWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow archived");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to archive workflow"));
} finally {
setPendingAction("");
}
}
return (
<>
{formError ? <p className="formError">Error: {formError}</p> : null}
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow templates</h2>
<p>{rows.length} templates</p>
</div>
<button type="button" onClick={handleRefresh} disabled={isBusy}>
{pendingAction === "refresh-workflows" ? "Refreshing..." : "Refresh"}
</button>
</div>
<form className="articleForm" onSubmit={handleCreateWorkflow}>
<h3>Create draft</h3>
<label htmlFor="workflowName">Name</label>
<input
id="workflowName"
value={createDraft.name}
onChange={(event) => updateCreateDraft("name", event.target.value)}
placeholder="Article production workflow"
required
/>
<label htmlFor="workflowSlug">Slug</label>
<input
id="workflowSlug"
value={createDraft.slug}
onChange={(event) => updateCreateDraft("slug", event.target.value)}
placeholder="article-production-v1"
required
/>
<label htmlFor="workflowDescription">Description</label>
<textarea
id="workflowDescription"
value={createDraft.description}
onChange={(event) => updateCreateDraft("description", event.target.value)}
required
/>
<div className="formActions">
<button type="submit" disabled={isBusy}>
{pendingAction === "create-workflow" ? "Creating..." : "Create draft"}
</button>
</div>
</form>
{rows.length === 0 ? (
<p className="emptyState">No workflow templates yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Version</th>
<th>Stages</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<strong>{row.name}</strong>
<div>{row.slug}</div>
</td>
<td>{row.status}</td>
<td>{row.version}</td>
<td>{row.stageCount}</td>
<td>{formatDate(row.updatedAt)}</td>
<td>
<button
type="button"
onClick={() => loadWorkflow(row.id)}
disabled={isBusy}
>
Open
</button>
{row.isDraft ? (
<button
type="button"
onClick={() => handleActivateWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Activate
</button>
) : null}
{!row.isArchived ? (
<button
type="button"
onClick={() => handleArchiveWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Archive
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow editor</h2>
<p>
{selectedWorkflow
? `${selectedWorkflow.name} - ${selectedWorkflow.status}`
: "Select a workflow"}
</p>
</div>
</div>
{!selectedWorkflow ? (
<p className="emptyState">Open a workflow template to edit stages.</p>
) : (
<>
<form className="articleForm" onSubmit={handleSaveMetadata}>
<h3>Metadata</h3>
{!canEditSelected ? (
<p className="emptyState">Only draft templates can be edited.</p>
) : null}
<label htmlFor="metadataName">Name</label>
<input
id="metadataName"
value={metadataDraft.name}
onChange={(event) => updateMetadataDraft("name", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataSlug">Slug</label>
<input
id="metadataSlug"
value={metadataDraft.slug}
onChange={(event) => updateMetadataDraft("slug", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataDescription">Description</label>
<textarea
id="metadataDescription"
value={metadataDraft.description}
onChange={(event) =>
updateMetadataDraft("description", event.target.value)
}
disabled={!canEditSelected || isBusy}
required
/>
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-metadata" ? "Saving..." : "Save metadata"}
</button>
</div>
</form>
<h3>Stages</h3>
{stages.length === 0 ? (
<p className="emptyState">No stages yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Order</th>
<th>Stage</th>
<th>Owner</th>
<th>Runner</th>
<th>Approval</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stages.map((stage, index) => (
<tr key={stage.id}>
<td>{stage.position}</td>
<td>
<strong>{stage.display_name}</strong>
<div>{stage.stable_key}</div>
<div>{stage.description}</div>
</td>
<td>{stage.owner_role}</td>
<td>{stage.runner_profile_key}</td>
<td>{stage.requires_human_approval ? "Required" : "None"}</td>
<td>
<button
type="button"
onClick={() => startEditStage(stage)}
disabled={isBusy}
>
Edit
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index - 1)}
disabled={!canEditSelected || isBusy || index === 0}
className="tableAction"
>
Up
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index + 1)}
disabled={!canEditSelected || isBusy || index === stages.length - 1}
className="tableAction"
>
Down
</button>
<button
type="button"
onClick={() => handleDeleteStage(stage.id)}
disabled={!canEditSelected || isBusy}
className="tableAction"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<form className="articleForm" onSubmit={handleSaveStage}>
<div className="sectionHeader">
<div>
<h3>{editingStageId ? "Edit stage" : "Add stage"}</h3>
<p>Structured inputs, outputs, retry policy, and parts.</p>
</div>
<button type="button" onClick={startNewStage} disabled={isBusy}>
New stage
</button>
</div>
<label htmlFor="stageStableKey">Stable key</label>
<input
id="stageStableKey"
value={stageDraft.stableKey}
onChange={(event) => updateStageDraft("stableKey", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.stableKey ? (
<p className="fieldError">{stageErrors.stableKey}</p>
) : null}
<label htmlFor="stageDisplayName">Display name</label>
<input
id="stageDisplayName"
value={stageDraft.displayName}
onChange={(event) => updateStageDraft("displayName", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.displayName ? (
<p className="fieldError">{stageErrors.displayName}</p>
) : null}
<label htmlFor="stageDescription">Description</label>
<textarea
id="stageDescription"
value={stageDraft.description}
onChange={(event) => updateStageDraft("description", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.description ? (
<p className="fieldError">{stageErrors.description}</p>
) : null}
<label htmlFor="stagePosition">Position</label>
<input
id="stagePosition"
type="number"
min={1}
value={stageDraft.position}
onChange={(event) =>
updateStageDraft("position", Number(event.target.value) || 1)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageOwnerRole">Owner role</label>
<select
id="stageOwnerRole"
value={stageDraft.ownerRole}
onChange={(event) =>
updateStageDraft("ownerRole", event.target.value as Role)
}
disabled={!canEditSelected || isBusy}
>
<option value="EDITOR">EDITOR</option>
<option value="ADMIN">ADMIN</option>
</select>
<label htmlFor="stageRunnerProfile">Runner profile key</label>
<input
id="stageRunnerProfile"
value={stageDraft.runnerProfileKey}
onChange={(event) =>
updateStageDraft("runnerProfileKey", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.runnerProfileKey ? (
<p className="fieldError">{stageErrors.runnerProfileKey}</p>
) : null}
<label htmlFor="stageRequiredInputs">Required inputs</label>
<textarea
id="stageRequiredInputs"
value={stageDraft.requiredInputsText}
onChange={(event) =>
updateStageDraft("requiredInputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageExpectedOutputs">Expected outputs</label>
<textarea
id="stageExpectedOutputs"
value={stageDraft.expectedOutputsText}
onChange={(event) =>
updateStageDraft("expectedOutputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageAcceptanceCriteria">Acceptance criteria</label>
<textarea
id="stageAcceptanceCriteria"
value={stageDraft.acceptanceCriteriaText}
onChange={(event) =>
updateStageDraft("acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageRequiresApproval">
<input
id="stageRequiresApproval"
type="checkbox"
checked={stageDraft.requiresHumanApproval}
onChange={(event) =>
updateStageDraft("requiresHumanApproval", event.target.checked)
}
disabled={!canEditSelected || isBusy}
/>
Requires human approval
</label>
<label htmlFor="stageRetryPolicy">Retry policy JSON</label>
<textarea
id="stageRetryPolicy"
className="codeArea"
value={stageDraft.retryPolicyJson}
onChange={(event) =>
updateStageDraft("retryPolicyJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.retryPolicyJson ? (
<p className="fieldError">{stageErrors.retryPolicyJson}</p>
) : null}
<div className="sectionHeader">
<div>
<h3>Stage parts</h3>
<p>{stageDraft.parts.length} structured parts</p>
</div>
<button
type="button"
onClick={addStagePart}
disabled={!canEditSelected || isBusy}
>
Add part
</button>
</div>
{stageErrors.parts ? <p className="fieldError">{stageErrors.parts}</p> : null}
{stageDraft.parts.length === 0 ? (
<p className="emptyState">No structured parts yet.</p>
) : null}
{stageDraft.parts.map((part, index) => (
<div
key={`${part.key}-${index}`}
style={{
display: "grid",
gap: 8,
borderTop: "1px solid #d0d5dd",
paddingTop: 12,
}}
>
<label htmlFor={`stagePartKey-${index}`}>Part key</label>
<input
id={`stagePartKey-${index}`}
value={part.key}
onChange={(event) => updateStagePart(index, "key", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartType-${index}`}>Part type</label>
<input
id={`stagePartType-${index}`}
value={part.type}
onChange={(event) => updateStagePart(index, "type", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartTitle-${index}`}>Part title</label>
<input
id={`stagePartTitle-${index}`}
value={part.title}
onChange={(event) => updateStagePart(index, "title", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartPayload-${index}`}>Payload JSON</label>
<textarea
id={`stagePartPayload-${index}`}
className="codeArea"
value={part.payloadJson}
onChange={(event) =>
updateStagePart(index, "payloadJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartCriteria-${index}`}>
Part acceptance criteria
</label>
<textarea
id={`stagePartCriteria-${index}`}
value={part.acceptanceCriteriaText}
onChange={(event) =>
updateStagePart(index, "acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<div className="formActions">
<button
type="button"
onClick={() => removeStagePart(index)}
disabled={!canEditSelected || isBusy}
>
Remove part
</button>
</div>
</div>
))}
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-stage" ? "Saving..." : "Save stage"}
</button>
</div>
</form>
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Created</th>
<th>Actor</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{formatDate(event.createdAt)}</td>
<td>{event.actorUserId ?? "none"}</td>
<td>{event.payload}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
</>
);
}
@@ -39,6 +39,24 @@ export type JobDetailRow = {
stderr: string; stderr: string;
}; };
export type WorkflowTemplateStageRow = {
id: string;
stableKey: string;
displayName: string;
position: number;
ownerRole: string;
runnerProfileKey: string;
requiresHumanApproval: boolean;
};
export type WorkflowTemplateSnapshotView = {
id: string;
name: string;
slug: string;
version: number;
stages: WorkflowTemplateStageRow[];
};
export type DetailSummary = { export type DetailSummary = {
articleId: string; articleId: string;
status: string; status: string;
@@ -46,6 +64,7 @@ export type DetailSummary = {
publishingValidationLabel: string; publishingValidationLabel: string;
briefDescription: string; briefDescription: string;
targetSite: string; targetSite: string;
workflowTemplate: WorkflowTemplateSnapshotView | null;
updatedAt: string; updatedAt: string;
viewerRole: Role; viewerRole: Role;
viewerEmail: string; viewerEmail: string;
@@ -101,6 +120,7 @@ export function buildDetailSummary(
publishingValidationLabel: resolvePublishingValidationLabel(detail), publishingValidationLabel: resolvePublishingValidationLabel(detail),
briefDescription: detail.article.brief_description, briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown", targetSite: detail.target_site?.name ?? "Unknown",
workflowTemplate: buildWorkflowTemplateSnapshotView(detail),
updatedAt: detail.article.updated_at, updatedAt: detail.article.updated_at,
viewerRole, viewerRole,
viewerEmail, viewerEmail,
@@ -146,6 +166,33 @@ export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobCo
}; };
} }
export function buildWorkflowTemplateSnapshotView(
detail: ArticleDetailResponse,
): WorkflowTemplateSnapshotView | null {
const snapshot = detail.workflow_template_snapshot;
if (!snapshot) {
return null;
}
return {
id: snapshot.id,
name: snapshot.name,
slug: snapshot.slug,
version: snapshot.version,
stages: (snapshot.stage_summary ?? [])
.map((stage) => ({
id: stage.id,
stableKey: stage.stable_key,
displayName: stage.display_name,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiresHumanApproval: Boolean(stage.requires_human_approval),
}))
.sort((left, right) => left.position - right.position),
};
}
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string { function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
const manifest = detail.publish_commit?.content_bundle_manifest; const manifest = detail.publish_commit?.content_bundle_manifest;
if (manifest && typeof manifest === "object") { if (manifest && typeof manifest === "object") {
@@ -18,6 +18,7 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
const [busyJobId, setBusyJobId] = useState(""); const [busyJobId, setBusyJobId] = useState("");
const timeline = summary.timeline; const timeline = summary.timeline;
const jobDetails = summary.jobDetails; const jobDetails = summary.jobDetails;
const workflowTemplate = summary.workflowTemplate;
const isAdmin = summary.viewerRole === "ADMIN"; const isAdmin = summary.viewerRole === "ADMIN";
async function handleRetry(jobId: string) { async function handleRetry(jobId: string) {
@@ -104,6 +105,49 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
</li> </li>
</ul> </ul>
<h3>Workflow template</h3>
{workflowTemplate ? (
<>
<dl className="detailGrid">
<div>
<dt>Name</dt>
<dd>{workflowTemplate.name}</dd>
</div>
<div>
<dt>Slug</dt>
<dd>{workflowTemplate.slug}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{workflowTemplate.version}</dd>
</div>
<div>
<dt>Stages</dt>
<dd>{workflowTemplate.stages.length}</dd>
</div>
</dl>
<ol className="timeline">
{workflowTemplate.stages.map((stage) => (
<li key={stage.id}>
<div className="timelineMeta">
<strong>{stage.position}. {stage.displayName}</strong>
<span className="timelineSource">{stage.ownerRole}</span>
<span>{stage.runnerProfileKey}</span>
</div>
<div>
{stage.stableKey}
{stage.requiresHumanApproval ? (
<span> - approval required</span>
) : null}
</div>
</li>
))}
</ol>
</>
) : (
<p>No workflow template snapshot.</p>
)}
<h3>Workflow timeline</h3> <h3>Workflow timeline</h3>
<ul className="timeline"> <ul className="timeline">
{timeline.map((event) => ( {timeline.map((event) => (
@@ -1,13 +1,25 @@
import type { TargetSiteConfig } from "@pipeline/shared"; import type {
ArticleCreateRequest,
TargetSiteConfig,
WorkflowTemplateSummary,
} from "@pipeline/shared";
export type NewArticleDraft = { export type NewArticleDraft = {
targetSiteId: string; targetSiteId: string;
workflowTemplateId: string;
briefDescription: string; briefDescription: string;
workingTitle: string; workingTitle: string;
contentType: string; contentType: string;
primaryKeyword: string; primaryKeyword: string;
}; };
export type WorkflowTemplateOption = {
id: string;
label: string;
version: number;
stageCount: number;
};
export type FieldErrors = Record<string, string>; export type FieldErrors = Record<string, string>;
export type NewArticleFormState = { export type NewArticleFormState = {
@@ -16,9 +28,14 @@ export type NewArticleFormState = {
formError?: string; formError?: string;
}; };
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft { export function initialDraft(
sites: readonly TargetSiteConfig[],
workflows: readonly WorkflowTemplateSummary[] = [],
): NewArticleDraft {
const workflowOptions = buildWorkflowTemplateOptions(workflows);
return { return {
targetSiteId: sites.length > 0 ? sites[0].id : "", targetSiteId: sites.length > 0 ? sites[0].id : "",
workflowTemplateId: workflowOptions.length > 0 ? workflowOptions[0].id : "",
briefDescription: "", briefDescription: "",
workingTitle: "", workingTitle: "",
contentType: "longform_guide", contentType: "longform_guide",
@@ -26,6 +43,33 @@ export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraf
}; };
} }
export function buildWorkflowTemplateOptions(
workflows: readonly WorkflowTemplateSummary[],
): WorkflowTemplateOption[] {
return workflows
.filter((workflow) => workflow.status === "ACTIVE")
.map((workflow) => ({
id: workflow.id,
label: `${workflow.name} v${workflow.version}`,
version: workflow.version,
stageCount: workflow.stages?.length ?? 0,
}));
}
export function buildCreateArticlePayload(
values: NewArticleDraft,
): ArticleCreateRequest {
return {
target_site_id: values.targetSiteId,
workflow_template_id: values.workflowTemplateId || null,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
};
}
export function validateArticleDraft(values: NewArticleDraft): FieldErrors { export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
const errors: FieldErrors = {}; const errors: FieldErrors = {};
@@ -3,10 +3,12 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { TargetSiteConfig } from "@pipeline/shared"; import type { TargetSiteConfig, WorkflowTemplateSummary } from "@pipeline/shared";
import { ApiError, createArticle } from "@/shared/pipeline-api"; import { ApiError, createArticle } from "@/shared/pipeline-api";
import { import {
buildCreateArticlePayload,
buildWorkflowTemplateOptions,
initialDraft, initialDraft,
validateArticleDraft, validateArticleDraft,
type FieldErrors, type FieldErrors,
@@ -15,14 +17,24 @@ import {
type NewArticleFormProps = { type NewArticleFormProps = {
sites: readonly TargetSiteConfig[]; sites: readonly TargetSiteConfig[];
workflowTemplates: readonly WorkflowTemplateSummary[];
}; };
export function NewArticleForm({ sites }: NewArticleFormProps) { export function NewArticleForm({ sites, workflowTemplates }: NewArticleFormProps) {
const router = useRouter(); const router = useRouter();
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites)); const workflowOptions = useMemo(
() => buildWorkflowTemplateOptions(workflowTemplates),
[workflowTemplates],
);
const [values, setValues] = useState<NewArticleDraft>(() =>
initialDraft(sites, workflowTemplates),
);
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const selectedWorkflow = workflowOptions.find(
(workflow) => workflow.id === values.workflowTemplateId,
);
const canSubmit = useMemo( const canSubmit = useMemo(
() => !isSubmitting && sites.length > 0, () => !isSubmitting && sites.length > 0,
@@ -41,14 +53,7 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
setFormError(""); setFormError("");
setSubmitting(true); setSubmitting(true);
try { try {
const response = await createArticle({ const response = await createArticle(buildCreateArticlePayload(values));
target_site_id: values.targetSiteId,
brief_description: values.briefDescription,
working_title: values.workingTitle || null,
language: "en",
content_type: values.contentType,
primary_keyword: values.primaryKeyword || null,
});
router.push(`/articles/${response.article.id}`); router.push(`/articles/${response.article.id}`);
} catch (error) { } catch (error) {
if (error instanceof ApiError) { if (error instanceof ApiError) {
@@ -84,6 +89,26 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
</select> </select>
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null} {errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
<label htmlFor="workflowTemplateId">Workflow template</label>
<select
id="workflowTemplateId"
value={values.workflowTemplateId}
onChange={(event) => updateField("workflowTemplateId", event.target.value)}
disabled={workflowOptions.length === 0}
>
<option value="">No active workflow template</option>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.label} - {workflow.stageCount} stages
</option>
))}
</select>
{selectedWorkflow ? (
<p className="fieldHint">
{selectedWorkflow.label} - {selectedWorkflow.stageCount} stages
</p>
) : null}
<label htmlFor="briefDescription">Brief description</label> <label htmlFor="briefDescription">Brief description</label>
<textarea <textarea
id="briefDescription" id="briefDescription"
@@ -0,0 +1,65 @@
import Link from "next/link";
import type {
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import { AdminWorkflowBuilder } from "@/features/admin-workflows/ui";
import {
DEMO_ADMIN_EMAIL,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type SearchParams = {
workflowId?: string;
};
type AdminWorkflowsPageProps = {
searchParams?: SearchParams;
};
export default async function AdminWorkflowsPage({
searchParams,
}: AdminWorkflowsPageProps) {
const workflowListResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
const selectedWorkflowId = searchParams?.workflowId;
let selectedWorkflow: WorkflowTemplateSummary | null = null;
let auditEvents: WorkflowTemplateAuditEventSummary[] = [];
if (selectedWorkflowId) {
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(selectedWorkflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(selectedWorkflowId, DEMO_ADMIN_EMAIL),
]);
selectedWorkflow = workflowResponse.workflow;
auditEvents = auditResponse.events ?? [];
} catch {
selectedWorkflow = null;
auditEvents = [];
}
}
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Workflows</h1>
<p>Create and manage reusable ordered workflow templates.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<AdminWorkflowBuilder
initialWorkflows={workflowListResponse.workflows ?? []}
initialWorkflow={selectedWorkflow}
initialAuditEvents={auditEvents}
/>
</main>
);
}
+14 -3
View File
@@ -3,11 +3,19 @@ import Link from "next/link";
import { RoleNavigation } from "@/widgets/role-navigation"; import { RoleNavigation } from "@/widgets/role-navigation";
import { NewArticleForm } from "@/features/article-intake/ui"; import { NewArticleForm } from "@/features/article-intake/ui";
import { fetchTargetSites } from "@/shared/pipeline-api"; import {
DEMO_EDITOR_EMAIL,
fetchTargetSites,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
export default async function NewArticlePage() { export default async function NewArticlePage() {
const sites: TargetSiteConfigResponse[] = await fetchTargetSites(); const [sites, workflowResponse] = await Promise.all([
fetchTargetSites(),
fetchWorkflowTemplates(DEMO_EDITOR_EMAIL),
]);
const targetSites = sites.map((site) => site.site); const targetSites = sites.map((site) => site.site);
const workflowTemplates = workflowResponse.workflows ?? [];
return ( return (
<main> <main>
@@ -20,7 +28,10 @@ export default async function NewArticlePage() {
</header> </header>
<RoleNavigation role="EDITOR" /> <RoleNavigation role="EDITOR" />
<section className="panel"> <section className="panel">
<NewArticleForm sites={targetSites} /> <NewArticleForm
sites={targetSites}
workflowTemplates={workflowTemplates}
/>
</section> </section>
</main> </main>
); );
+136
View File
@@ -48,6 +48,15 @@ import type {
ScriptConfigVersionResponse, ScriptConfigVersionResponse,
TargetSiteConfigResponse, TargetSiteConfigResponse,
TargetSiteConfigCreateRequest, TargetSiteConfigCreateRequest,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared"; } from "@pipeline/shared";
export const DEMO_EDITOR_EMAIL = "editor@example.com"; export const DEMO_EDITOR_EMAIL = "editor@example.com";
@@ -184,6 +193,13 @@ export function apiPatch<TRequest, TResponse>(
); );
} }
export function apiDelete<TResponse>(
path: string,
options?: ApiRequestConfig,
): Promise<TResponse> {
return requestJson<TResponse>(path, { method: "DELETE" }, options?.userEmail);
}
export function fetchCurrentUser( export function fetchCurrentUser(
userEmail = DEMO_EDITOR_EMAIL, userEmail = DEMO_EDITOR_EMAIL,
): Promise<CurrentUserResponse> { ): Promise<CurrentUserResponse> {
@@ -259,6 +275,126 @@ export function createTargetSite(
); );
} }
export function fetchWorkflowTemplates(
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateListResponse> {
return apiGet<WorkflowTemplateListResponse>("/api/admin/workflows", {
userEmail,
});
}
export function fetchWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiGet<WorkflowTemplateResponse>(`/api/admin/workflows/${workflowId}`, {
userEmail,
});
}
export function createWorkflowTemplate(
request: WorkflowTemplateCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowTemplateCreateRequest, WorkflowTemplateResponse>(
"/api/admin/workflows",
request,
{ userEmail },
);
}
export function updateWorkflowTemplate(
workflowId: string,
request: WorkflowTemplateUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPatch<WorkflowTemplateUpdateRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}`,
request,
{ userEmail },
);
}
export function addWorkflowStage(
workflowId: string,
request: WorkflowStageCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPost<WorkflowStageCreateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages`,
request,
{ userEmail },
);
}
export function updateWorkflowStage(
workflowId: string,
stageId: string,
request: WorkflowStageUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPatch<WorkflowStageUpdateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
request,
{ userEmail },
);
}
export function deleteWorkflowStage(
workflowId: string,
stageId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiDelete<WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
{ userEmail },
);
}
export function reorderWorkflowStages(
workflowId: string,
request: WorkflowStageReorderRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowStageReorderRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/reorder`,
request,
{ userEmail },
);
}
export function activateWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/activate`,
undefined,
{ userEmail },
);
}
export function archiveWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/archive`,
undefined,
{ userEmail },
);
}
export function fetchWorkflowTemplateAuditEvents(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateAuditEventListResponse> {
return apiGet<WorkflowTemplateAuditEventListResponse>(
`/api/admin/workflows/${workflowId}/audit`,
{ userEmail },
);
}
export function fetchScriptConfigVersions( export function fetchScriptConfigVersions(
siteId: string, siteId: string,
userEmail = DEMO_EDITOR_EMAIL, userEmail = DEMO_EDITOR_EMAIL,
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
href: "/admin/scripts", href: "/admin/scripts",
roles: ["ADMIN"], roles: ["ADMIN"],
}, },
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
{ {
id: "agent-jobs", id: "agent-jobs",
label: "Agent jobs", label: "Agent jobs",
@@ -0,0 +1,411 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const failures = [];
function loadTsModule(sourcePath, requireOverrides = {}) {
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const moduleExports = {};
const module = { exports: moduleExports };
const localRequire = (specifier) => {
if (specifier in requireOverrides) {
return requireOverrides[specifier];
}
throw new Error(`Unexpected runtime import in model test: ${specifier}`);
};
new Function("exports", "module", "require", compiled.outputText)(
moduleExports,
module,
localRequire,
);
return module.exports;
}
function loadExpectedModule(sourcePath, label, expectedExports = []) {
if (!existsSync(sourcePath)) {
const expected = expectedExports.join(", ");
failures.push(
`${label} is missing at ${path.relative(process.cwd(), sourcePath)}; expected exports: ${expected}`,
);
return null;
}
try {
return loadTsModule(sourcePath);
} catch (error) {
failures.push(`${label} failed to load: ${error.message}`);
return null;
}
}
function expectFunction(moduleExports, exportName, label) {
if (!moduleExports) {
return null;
}
const value = moduleExports?.[exportName];
if (typeof value !== "function") {
failures.push(`${label} must export ${exportName}()`);
return null;
}
return value;
}
async function recordAssertion(label, assertion) {
try {
await assertion();
} catch (error) {
failures.push(`${label}: ${error.message}`);
}
}
const workflowModelPath = path.resolve(
testDir,
"../src/features/admin-workflows/model.ts",
);
const apiPath = path.resolve(testDir, "../src/shared/pipeline-api.ts");
const navigationModelPath = path.resolve(
testDir,
"../src/widgets/role-navigation/model.ts",
);
const expectedWorkflowModelExports = [
"defaultWorkflowDraft",
"buildCreateWorkflowPayload",
"defaultWorkflowStageDraft",
"buildCreateWorkflowStagePayload",
"moveWorkflowStage",
"buildReorderWorkflowStagesPayload",
];
const workflowDraft = {
name: "Article production workflow",
slug: "article-production-v1",
description: "Reusable editorial workflow for longform article production.",
};
const workflowId = "11111111-1111-1111-1111-111111111111";
const intakeStageId = "22222222-2222-2222-2222-222222222222";
const draftStageId = "33333333-3333-3333-3333-333333333333";
const workflowPart = {
key: "required-context-checklist",
type: "checklist",
title: "Required context checklist",
payload: {
prompt: "Identify missing audience, keyword, and source constraints.",
config: {
required_fields: ["audience", "primary_keyword", "source_rules"],
block_on_missing: true,
},
},
acceptance_criteria: [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
};
const intakeStageDraft = {
stableKey: "intake-boundary-questions",
displayName: "Boundary question intake",
description: "Collect required editorial context before planning.",
position: 1,
ownerRole: "EDITOR",
runnerProfileKey: "boundary-question-agent-v1",
requiredInputs: ["article_brief", "target_site"],
expectedOutputs: ["answered_boundary_questions"],
acceptanceCriteria: ["All required boundary questions are answered."],
requiresHumanApproval: true,
retryPolicy: { max_attempts: 1, backoff_seconds: 0 },
parts: [workflowPart],
};
const expectedIntakeStagePayload = {
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: [workflowPart],
};
const workflowModelExports = loadExpectedModule(
workflowModelPath,
"features/admin-workflows/model",
expectedWorkflowModelExports,
);
const apiExports = loadExpectedModule(apiPath, "shared/pipeline-api");
const navigationExports = loadExpectedModule(
navigationModelPath,
"widgets/role-navigation/model",
);
const defaultWorkflowDraft = expectFunction(
workflowModelExports,
"defaultWorkflowDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowPayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowPayload",
"features/admin-workflows/model",
);
const defaultWorkflowStageDraft = expectFunction(
workflowModelExports,
"defaultWorkflowStageDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowStagePayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowStagePayload",
"features/admin-workflows/model",
);
const moveWorkflowStage = expectFunction(
workflowModelExports,
"moveWorkflowStage",
"features/admin-workflows/model",
);
const buildReorderWorkflowStagesPayload = expectFunction(
workflowModelExports,
"buildReorderWorkflowStagesPayload",
"features/admin-workflows/model",
);
const createWorkflowTemplate = expectFunction(
apiExports,
"createWorkflowTemplate",
"shared/pipeline-api",
);
const addWorkflowStage = expectFunction(
apiExports,
"addWorkflowStage",
"shared/pipeline-api",
);
const reorderWorkflowStages = expectFunction(
apiExports,
"reorderWorkflowStages",
"shared/pipeline-api",
);
const getRoleNavigationItems = expectFunction(
navigationExports,
"getRoleNavigationItems",
"widgets/role-navigation/model",
);
if (
defaultWorkflowDraft &&
buildCreateWorkflowPayload &&
defaultWorkflowStageDraft &&
buildCreateWorkflowStagePayload &&
moveWorkflowStage &&
buildReorderWorkflowStagesPayload
) {
await recordAssertion("workflow draft payload maps model state to API request", () => {
const createPayload = buildCreateWorkflowPayload({
...defaultWorkflowDraft(),
...workflowDraft,
});
assert.deepEqual(createPayload, workflowDraft);
});
await recordAssertion("workflow stage payload preserves structured parts", () => {
const stagePayload = buildCreateWorkflowStagePayload({
...defaultWorkflowStageDraft(),
...intakeStageDraft,
});
assert.deepEqual(stagePayload, expectedIntakeStagePayload);
});
await recordAssertion("workflow stage reorder returns ordered IDs for API", () => {
const stages = [
{ id: intakeStageId, stableKey: "intake-boundary-questions", position: 1 },
{ id: draftStageId, stableKey: "draft-assembly", position: 2 },
];
const reorderedStages = moveWorkflowStage(stages, draftStageId, 0);
assert.deepEqual(
reorderedStages.map((stage) => ({ id: stage.id, position: stage.position })),
[
{ id: draftStageId, position: 1 },
{ id: intakeStageId, position: 2 },
],
);
assert.deepEqual(buildReorderWorkflowStagesPayload(reorderedStages), {
stage_ids: [draftStageId, intakeStageId],
});
});
}
if (createWorkflowTemplate && addWorkflowStage && reorderWorkflowStages) {
await recordAssertion("workflow API client posts create/add/reorder requests", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
const responses = [
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
{
stage: {
id: intakeStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
},
},
{
stage: {
id: draftStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [
{ id: draftStageId, stable_key: "draft-assembly", position: 1 },
{
id: intakeStageId,
stable_key: "intake-boundary-questions",
position: 2,
},
],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
];
globalThis.fetch = async (url, options) => {
requests.push({
path: new URL(String(url)).pathname,
method: options.method,
userEmail: options.headers["X-Demo-User-Email"],
body: options.body ? JSON.parse(options.body) : undefined,
});
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => JSON.stringify(responses.shift()),
};
};
try {
const created = await createWorkflowTemplate(workflowDraft);
const intakeStage = await addWorkflowStage(
created.workflow.id,
expectedIntakeStagePayload,
);
const draftStage = await addWorkflowStage(created.workflow.id, {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
});
const reordered = await reorderWorkflowStages(created.workflow.id, {
stage_ids: [draftStage.stage.id, intakeStage.stage.id],
});
assert.equal(created.workflow.status, "DRAFT");
assert.deepEqual(
reordered.workflow.stages.map((stage) => stage.id),
[draftStageId, intakeStageId],
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
path: "/api/admin/workflows",
method: "POST",
userEmail: "admin@example.com",
body: workflowDraft,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: expectedIntakeStagePayload,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
path: `/api/admin/workflows/${workflowId}/stages/reorder`,
method: "POST",
userEmail: "admin@example.com",
body: { stage_ids: [draftStageId, intakeStageId] },
},
]);
});
}
if (getRoleNavigationItems) {
await recordAssertion("Admin navigation exposes Workflows builder route", () => {
const adminItems = getRoleNavigationItems("ADMIN");
assert.deepEqual(
adminItems.find((item) => item.id === "workflows"),
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
);
});
await recordAssertion("Editor navigation does not expose Workflows", () => {
const editorItems = getRoleNavigationItems("EDITOR");
assert.equal(
editorItems.some(
(item) =>
item.id === "workflows" ||
item.href === "/admin/workflows" ||
item.label === "Workflows",
),
false,
);
});
}
if (failures.length > 0) {
assert.fail(
`Admin workflow builder frontend pre-requirements missing:\n- ${failures.join("\n- ")}`,
);
}
@@ -30,6 +30,32 @@ const detail = {
updated_at: "2026-05-21T00:00:00Z", updated_at: "2026-05-21T00:00:00Z",
}, },
target_site: { name: "Demo Site" }, target_site: { name: "Demo Site" },
workflow_template_snapshot: {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
version: 2,
stage_summary: [
{
id: "stage-2",
stable_key: "draft",
display_name: "Draft assembly",
position: 2,
owner_role: "EDITOR",
runner_profile_key: "draft-agent",
requires_human_approval: false,
},
{
id: "stage-1",
stable_key: "intake",
display_name: "Intake",
position: 1,
owner_role: "EDITOR",
runner_profile_key: "intake-agent",
requires_human_approval: true,
},
],
},
timeline: [ timeline: [
{ {
id: "2", id: "2",
@@ -131,5 +157,11 @@ const summary = buildDetailSummary(detail, {
assert.equal(summary.viewerRole, "EDITOR"); assert.equal(summary.viewerRole, "EDITOR");
assert.equal(summary.viewerEmail, "editor@example.com"); assert.equal(summary.viewerEmail, "editor@example.com");
assert.equal(summary.targetSite, "Demo Site"); assert.equal(summary.targetSite, "Demo Site");
assert.equal(summary.workflowTemplate.name, "Demo workflow");
assert.equal(summary.workflowTemplate.version, 2);
assert.deepEqual(
summary.workflowTemplate.stages.map((stage) => stage.stableKey),
["intake", "draft"],
);
assert.equal(summary.timeline.length, 2); assert.equal(summary.timeline.length, 2);
assert.equal(summary.jobDetails.length, 1); assert.equal(summary.jobDetails.length, 1);
@@ -23,9 +23,29 @@ new Function("exports", compiled.outputText)(moduleExports);
const validateArticleDraft = moduleExports.validateArticleDraft; const validateArticleDraft = moduleExports.validateArticleDraft;
const initialDraft = moduleExports.initialDraft; const initialDraft = moduleExports.initialDraft;
const buildCreateArticlePayload = moduleExports.buildCreateArticlePayload;
const buildWorkflowTemplateOptions = moduleExports.buildWorkflowTemplateOptions;
const activeWorkflow = {
id: "workflow-1",
name: "Demo workflow",
slug: "demo-workflow",
description: "Demo workflow",
status: "ACTIVE",
version: 2,
created_by: "admin",
updated_by: "admin",
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
stages: [
{ id: "stage-1" },
{ id: "stage-2" },
],
};
const draft = { const draft = {
targetSiteId: "", targetSiteId: "",
workflowTemplateId: "",
briefDescription: "Keep this text intact", briefDescription: "Keep this text intact",
workingTitle: "", workingTitle: "",
contentType: "longform_guide", contentType: "longform_guide",
@@ -43,3 +63,27 @@ assert.equal(
const fallbackDraft = initialDraft([]); const fallbackDraft = initialDraft([]);
assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved"); assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved");
const workflowDraft = initialDraft(
[{ id: "site-1" }],
[activeWorkflow, { ...activeWorkflow, id: "workflow-archived", status: "ARCHIVED" }],
);
assert.equal(workflowDraft.workflowTemplateId, "workflow-1");
assert.deepEqual(buildWorkflowTemplateOptions([activeWorkflow]), [
{
id: "workflow-1",
label: "Demo workflow v2",
version: 2,
stageCount: 2,
},
]);
assert.equal(
buildCreateArticlePayload({
...workflowDraft,
briefDescription: "Brief",
primaryKeyword: "workflow",
}).workflow_template_id,
"workflow-1",
);
-1
View File
@@ -6,7 +6,6 @@ services:
environment: environment:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
BACKEND_URL: http://backend:8000 BACKEND_URL: http://backend:8000
NEXT_PUBLIC_BACKEND_URL: http://backend:8000
ports: ports:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3000}:3000"
depends_on: depends_on:
@@ -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
View File
@@ -62,6 +62,7 @@ export type ArticleCreateRequest = {
language?: string; language?: string;
primary_keyword?: string | null; primary_keyword?: string | null;
target_site_id: string; target_site_id: string;
workflow_template_id?: string | null;
working_title?: string | null; working_title?: string | null;
}; };
@@ -84,6 +85,7 @@ export type ArticleDetailResponse = {
target_site?: TargetSiteConfig | null; target_site?: TargetSiteConfig | null;
timeline?: ObservabilityTimelineEventSummary[]; timeline?: ObservabilityTimelineEventSummary[];
workflow_events?: WorkflowEventSummary[]; workflow_events?: WorkflowEventSummary[];
workflow_template_snapshot?: WorkflowTemplateSnapshot | null;
}; };
export type ArticleListResponse = { export type ArticleListResponse = {
@@ -102,6 +104,8 @@ export type ArticleSummary = {
status: ArticleWorkflowStatus; status: ArticleWorkflowStatus;
target_site_id: string; target_site_id: string;
updated_at: string; updated_at: string;
workflow_template_id?: string | null;
workflow_template_version?: number | null;
working_title?: string | null; working_title?: string | null;
}; };
@@ -763,3 +767,137 @@ export type WorkflowEventSummary = {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
to_status?: ArticleWorkflowStatus | null; to_status?: ArticleWorkflowStatus | null;
}; };
export type WorkflowStageCreateRequest = {
acceptance_criteria?: string[];
description: string;
display_name: string;
expected_outputs?: string[];
owner_role: Role;
parts?: WorkflowStagePart[];
position?: number | null;
required_inputs?: string[];
requires_human_approval?: boolean;
retry_policy?: Record<string, unknown>;
runner_profile_key: string;
stable_key: string;
};
export type WorkflowStagePart = {
acceptance_criteria?: string[];
key: string;
payload?: Record<string, unknown>;
title: string;
type: string;
};
export type WorkflowStageReorderRequest = {
stage_ids: string[];
};
export type WorkflowStageResponse = {
stage: WorkflowStageSummary;
};
export type WorkflowStageSummary = {
acceptance_criteria?: string[];
created_at: string;
description: string;
display_name: string;
expected_outputs?: string[];
id: string;
owner_role: Role;
parts?: WorkflowStagePart[];
position: number;
required_inputs?: string[];
requires_human_approval?: boolean;
retry_policy?: Record<string, unknown>;
runner_profile_key: string;
stable_key: string;
updated_at: string;
workflow_id: string;
};
export type WorkflowStageUpdateRequest = {
acceptance_criteria?: string[] | null;
description?: string | null;
display_name?: string | null;
expected_outputs?: string[] | null;
owner_role?: Role | null;
parts?: WorkflowStagePart[] | null;
position?: number | null;
required_inputs?: string[] | null;
requires_human_approval?: boolean | null;
retry_policy?: Record<string, unknown> | null;
runner_profile_key?: string | null;
stable_key?: string | null;
};
export type WorkflowTemplateAuditEventListResponse = {
events?: WorkflowTemplateAuditEventSummary[];
};
export type WorkflowTemplateAuditEventSummary = {
actor_user_id?: string | null;
created_at: string;
event_type: string;
id: string;
payload?: Record<string, unknown>;
workflow_id: string;
};
export type WorkflowTemplateCreateRequest = {
description: string;
name: string;
slug: string;
};
export type WorkflowTemplateListResponse = {
workflows?: WorkflowTemplateSummary[];
};
export type WorkflowTemplateResponse = {
workflow: WorkflowTemplateSummary;
};
export type WorkflowTemplateSnapshot = {
id: string;
name: string;
slug: string;
stage_summary?: WorkflowTemplateStageSnapshot[];
version: number;
};
export type WorkflowTemplateStageSnapshot = {
display_name: string;
id: string;
owner_role: Role;
position: number;
requires_human_approval?: boolean;
runner_profile_key: string;
stable_key: string;
};
export type WorkflowTemplateStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
export type WorkflowTemplateSummary = {
activated_at?: string | null;
archived_at?: string | null;
created_at: string;
created_by: string;
description: string;
id: string;
name: string;
slug: string;
stages?: WorkflowStageSummary[];
status: WorkflowTemplateStatus;
updated_at: string;
updated_by?: string | null;
version: number;
};
export type WorkflowTemplateUpdateRequest = {
description?: string | null;
name?: string | null;
slug?: string | null;
};
+79
View File
@@ -0,0 +1,79 @@
# Task 021: Admin Workflow Template API
Development description: Build the backend domain, persistence, and public API for Admin-managed workflow templates with editable ordered stages and stage parts.
## Implementation Details
- Backend contract:
- `POST /api/admin/workflows`
- `GET /api/admin/workflows`
- `GET /api/admin/workflows/{workflow_id}`
- `PATCH /api/admin/workflows/{workflow_id}`
- `POST /api/admin/workflows/{workflow_id}/stages`
- `PATCH /api/admin/workflows/{workflow_id}/stages/{stage_id}`
- `DELETE /api/admin/workflows/{workflow_id}/stages/{stage_id}`
- `POST /api/admin/workflows/{workflow_id}/stages/reorder`
- `POST /api/admin/workflows/{workflow_id}/activate`
- `POST /api/admin/workflows/{workflow_id}/archive`
- Workflow template fields:
- Name.
- Slug.
- Description.
- Status: `DRAFT`, `ACTIVE`, `ARCHIVED`.
- Version number.
- Created/updated metadata.
- Workflow stage fields:
- Stable key.
- Display name.
- Description.
- Position.
- Owner role.
- Runner profile key.
- Required inputs.
- Expected outputs.
- Acceptance criteria.
- Human approval requirement.
- Retry policy.
- Workflow stage parts:
- Store as structured JSON under each stage for v1.
- Each part has key, type, title, prompt/config payload, and acceptance criteria.
- Repository/schema:
- Add relational tables for workflow templates and stages.
- Preserve structured stage-part JSON without ad hoc string parsing.
- Add audit events for create/update/reorder/activate/archive.
- Authorization:
- Admin can create and mutate workflow templates.
- Editor can read active workflow templates but cannot mutate them.
## Public Interface
- Admin can create a workflow template, add/edit/reorder stages, activate it, and archive it.
- Editor can list/read active workflow templates for article creation context only.
- Mutating endpoints consistently reject Editor requests.
## Acceptance Criteria
- [x] TDD pre-requirement: before implementation, write one failing Admin API test for creating a workflow template with two editable stages and one failing Editor mutation denial test; proceed one behavior at a time and record evidence in `Result`.
- [x] Workflow templates are persisted with status, version, audit metadata, and ordered stages.
- [x] Stage parts are persisted as structured JSON and returned unchanged through the public API.
- [x] Admin can update stage owner role, runner profile, inputs, outputs, acceptance criteria, human approval flag, retry policy, and stage parts.
- [x] Admin can reorder stages and the returned workflow reflects the new order.
- [x] Activation creates an immutable version increment and archives no data.
- [x] Archive hides workflow from Editor list but keeps Admin history visible.
- [x] Editor mutation attempts return the same authorization error shape used by existing Admin APIs.
- [x] OpenAPI/shared contracts include workflow template request/response schemas.
## Verification
- Run backend workflow-template API integration tests.
- Run backend authorization integration tests covering Admin/Editor access.
- Run schema initialization against the test database.
## Result
- Status: Implemented.
- TDD plan: Use the existing pre-requirement API tests in `apps/backend/tests/integration/test_admin_workflow_templates_public_api.py`, then cover the remaining acceptance criteria with a focused API flow for reorder, activate/archive, editor visibility, admin history, and audit persistence.
- Red evidence: Pre-implementation `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` failed with two public API assertions: expected `201`/`403`, actual `404 {"detail":"Not Found"}` for missing `/api/admin/workflows`.
- Green evidence: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_admin_workflow_templates_public_api` -> `Ran 3 tests ... OK`.
- Refactor notes: Added workflow template status/contracts, admin/editor router, application service, SQLite/Postgres tables, repositories, ordered stage persistence, structured stage-part JSON, activation/archive, and audit events. Active workflow templates are readable by editors; draft/archived templates remain admin history only. Draft-only mutation preserves activated version immutability.
- Verification output: `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.contracts.test_public_openapi_contract` -> `Ran 1 test ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_schema_storage_contracts` -> `Ran 3 tests ... OK`; `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m py_compile ...` for changed backend modules -> OK. Existing broader auth suite was also attempted and still fails in `test_editor_can_create_articles_and_approve_plan_review` with `404 {"detail":"Not Found"}` while approving nonexistent plan id `00000000-0000-0000-0000-000000000123`; left unchanged.
+69
View File
@@ -0,0 +1,69 @@
# Task 022: Admin Workflow Builder UI
Development description: Build the Admin dashboard workflow builder so Admins can create workflows and edit their ordered stages and stage parts from the frontend using the existing FSD structure.
## Implementation Details
- Frontend FSD boundaries:
- `pages/admin-workflows` owns the route-level page.
- `features/admin-workflows` owns workflow list/create/edit interactions.
- `entities/workflow-template` owns workflow template types, selectors, and formatters if shared across pages.
- `shared/pipeline-api` owns HTTP client methods only.
- Admin navigation:
- Add Workflows item to Admin navigation.
- Keep Editor navigation unchanged.
- Workflow list:
- Show name, status, version, updated timestamp, stage count, and active/archive actions.
- Allow creating a new draft workflow.
- Workflow editor:
- Edit workflow metadata.
- Add/edit/delete stages.
- Reorder stages.
- Edit stage parts as structured rows/cards with type, title, config payload, and acceptance criteria.
- Edit human approval flag, owner role, runner profile key, retry policy, required inputs, and expected outputs.
- UX constraints:
- Use dense Admin dashboard layout consistent with existing Admin screens.
- Avoid marketing/landing-page treatment.
- Show API errors inline near the relevant form/action.
- Disable destructive actions while requests are pending.
- Tests:
- Add frontend model tests for workflow builder state transitions.
- Add API client tests/mocks if the current test pattern supports them.
## Public Interface
- Admin can open `/admin/workflows`, create a draft workflow, edit stages/parts, reorder stages, activate, and archive.
- Editor does not see the Workflows nav item.
## Acceptance Criteria
- [x] TDD pre-requirement: before implementation, write one failing frontend model test for creating a workflow draft and adding/reordering stages; proceed one behavior at a time and record evidence in `Result`.
- [x] Admin navigation exposes Workflows and routes to the builder page.
- [x] Editor navigation does not expose Workflows.
- [x] Workflow list loads from the backend API and shows empty/error/loading states.
- [x] Workflow editor can create workflow drafts and persist metadata.
- [x] Workflow editor can add, edit, delete, and reorder stages.
- [x] Workflow editor can edit structured stage parts without converting them to opaque strings.
- [x] Activation/archive actions update UI state after successful API responses.
- [x] Frontend code follows the existing FSD folder boundaries.
## Verification
- Run frontend workflow builder model tests.
- Run frontend Admin navigation tests.
- Run frontend typecheck.
- Manually verify Admin workflow create/edit/reorder/activate/archive in Docker Compose.
## Result
- Status: Implemented frontend admin workflow builder.
- TDD plan: Use `apps/frontend/tests/admin_workflows.model.test.mjs` as the red pre-requirement, then add model helpers, API client methods, navigation, and the route UI.
- Red evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` failed because `features/admin-workflows/model.ts`, workflow API methods, and the Admin Workflows nav item were missing.
- Green evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` passed after adding workflow model/API/nav behavior.
- Refactor notes: Added shared workflow template contract types, dense Admin workflow list/create/editor UI, structured stage part JSON editing, stage reorder controls, activation/archive actions, audit display, and explicit frontend workspace root config for local Next runs.
- Verification output:
- `node apps/frontend/tests/admin_workflows.model.test.mjs` - passed.
- `node apps/frontend/tests/role-navigation.test.mjs` - passed: `role navigation hides Admin-only items for Editors`.
- `pnpm -C apps/frontend run test:ui` - passed.
- `pnpm -C apps/frontend run typecheck` - passed.
- Browser smoke was attempted with a local mock backend, but local Next dev startup is blocked by existing dependency drift: `Mismatching @next/swc version, detected: 15.5.4 while Next.js is on 16.2.6` and `bindings.lockfileTryAcquireSync is not a function`.
@@ -0,0 +1,62 @@
# Task 023: Workflow Template Binding And Demo Readiness
Development description: Bind Admin-managed workflow templates into the demo product flow so article/site configuration references an active workflow template and the final demo checklist proves workflow management is not an isolated Admin CRUD surface.
## Implementation Details
- Backend binding:
- Add active workflow template reference to target site configuration or article creation input, following the existing product model.
- Validate that article creation cannot reference archived or missing workflow templates.
- Preserve existing article workflow status behavior while exposing the configured workflow template snapshot in article detail.
- Store the workflow template version/snapshot used by each article so later template edits do not rewrite historical article context.
- Frontend binding:
- Article creation/site configuration shows the selected active workflow template.
- Article detail shows the configured workflow template and stage summary in the workflow/history area.
- Demo readiness:
- Update demo seed data with at least one active workflow template.
- Update demo checklist/protocol to include Admin workflow creation/editing and article creation using the active workflow.
- Keep the existing end-to-end publishing path working.
## Public Interface
- Admin can configure which active workflow template is used by a site or article.
- Editor can see which workflow template governs an article.
- Demo reviewers can prove the Admin workflow builder affects the article flow.
## Acceptance Criteria
- [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
- Run backend article creation workflow-template binding tests.
- Run existing end-to-end demo stack smoke test.
- Run frontend model tests for the selected workflow template display.
- Run final manual demo checklist and record the protocol.
## Result
- 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.
@@ -0,0 +1,30 @@
# Task 024: Demo Browser API Reachability
Development description: Make the Docker Compose demo browser actions call the backend through a browser-reachable URL with CORS enabled, so Admin workflow editing and Editor article creation work during a live demo.
## Acceptance Criteria
- [x] Frontend server-side rendering still uses the internal Docker backend URL.
- [x] Browser-side frontend actions use a localhost backend URL mapped by Docker Compose.
- [x] Backend allows the demo frontend origin and `X-Demo-User-Email` header.
- [x] Admin workflow `Open` action works in the browser demo stack.
- [x] Editor article creation works in the browser demo stack.
## Result
- Status: Implemented.
- Added backend CORS middleware for demo frontend origins, configurable through `PIPELINE_CORS_ALLOW_ORIGINS`.
- Updated Docker Compose so `NEXT_PUBLIC_BACKEND_URL` points to `http://localhost:${BACKEND_PORT:-8000}` while `BACKEND_URL` remains `http://backend:8000` for server-side rendering.
- Switched browser-side API calls to same-origin `/api/*` through Next rewrites, keeping `BACKEND_URL=http://backend:8000` inside Docker.
- Fixed the Postgres article insert path by applying JSON casting to the workflow template snapshot column, not `publishing_status`.
- Updated Next 16 dynamic route adapters to await `params` and marked live-data pages dynamic.
- Added a public API integration test covering preflight for `http://localhost:13300` with `X-Demo-User-Email`.
- Verification:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 1 test ... OK`.
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps.backend.tests.integration.test_workflow_template_binding_public_api apps.backend.tests.integration.test_demo_browser_cors_public_api -v` -> `Ran 5 tests ... OK`.
- `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`.
- Browser smoke on Docker demo stack:
- `/admin/workflows` -> Open seeded workflow -> stage editor loaded with 7 stages and no error.
- `/articles/new` -> create article with active workflow -> redirect to article detail.
- Article detail -> shows workflow template name, slug, version, 7-stage snapshot, and timeline.
+1
View File
@@ -23,6 +23,7 @@ This task pool turns `IDEA.md` into vertical, testable implementation slices tha
- Backend is FastAPI. - Backend is FastAPI.
- Frontend is Next.js. - Frontend is Next.js.
- Workflow orchestration uses LangGraph, while article/domain tables remain authoritative for product state. - Workflow orchestration uses LangGraph, while article/domain tables remain authoritative for product state.
- Workflow templates, stages, and stage parts are Admin-managed product configuration, not hard-coded UI-only metadata.
- Queue is Redis-backed Celery, Dramatiq, or RQ. - Queue is Redis-backed Celery, Dramatiq, or RQ.
- Postgres stores domain state and manifests, not large source snapshots. - Postgres stores domain state and manifests, not large source snapshots.
- Object storage stores research source-section artifacts, assets, and job artifacts. - Object storage stores research source-section artifacts, assets, and job artifacts.