Compare commits
5
Commits
5ea0d5978a
...
b8937d2864
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8937d2864 | ||
|
|
5f3e543ea2 | ||
|
|
baff9211e8 | ||
|
|
cbd24d4a1c | ||
|
|
d1ae5c4437 |
@@ -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.
|
||||
|
||||
### 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:
|
||||
|
||||
```text
|
||||
@@ -50,6 +54,7 @@ Claude Code can be supported as an optional internal runner, but not as the main
|
||||
14. Multi-site configuration
|
||||
15. Job history and audit trail
|
||||
16. Codex CLI-based agent execution
|
||||
17. Admin-managed workflow templates and editable workflow stages
|
||||
```
|
||||
|
||||
## Out of scope for v1
|
||||
@@ -76,13 +81,13 @@ The publishing step in v1 should create a **direct commit to the configured prod
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ from src.domain.contracts import (
|
||||
ArticleWorkflowStatus,
|
||||
CurrentUser,
|
||||
PublishingStatus,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateStatus,
|
||||
WorkflowTemplateSummary,
|
||||
)
|
||||
from src.application.observability import (
|
||||
build_observability_timeline,
|
||||
@@ -24,10 +28,16 @@ def create_article(
|
||||
current_user: CurrentUser,
|
||||
) -> ArticleCreateResponse:
|
||||
repository.target_sites.get_by_id(request.target_site_id)
|
||||
workflow_snapshot = _resolve_workflow_template_snapshot(repository, request)
|
||||
|
||||
now = _now()
|
||||
summary = repository.articles.create(
|
||||
target_site_id=request.target_site_id,
|
||||
workflow_template_id=workflow_snapshot.id if workflow_snapshot else None,
|
||||
workflow_template_version=workflow_snapshot.version if workflow_snapshot else None,
|
||||
workflow_template_snapshot=(
|
||||
workflow_snapshot.model_dump(mode="json") if workflow_snapshot else None
|
||||
),
|
||||
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||
brief_description=request.brief_description,
|
||||
@@ -62,6 +72,9 @@ def get_article_detail(
|
||||
) -> ArticleDetailResponse:
|
||||
article = repository.articles.get(article_id)
|
||||
target_site = repository.target_sites.get_by_id(article.target_site_id)
|
||||
workflow_template_snapshot = repository.articles.get_workflow_template_snapshot(
|
||||
article_id
|
||||
)
|
||||
workflow_events = repository.articles.list_workflow_events(article_id)
|
||||
boundary_questions = repository.boundary_questions.list_for_article(article_id)
|
||||
plans = repository.article_plans.list_for_article(article_id)
|
||||
@@ -84,6 +97,7 @@ def get_article_detail(
|
||||
return ArticleDetailResponse(
|
||||
article=article,
|
||||
target_site=target_site,
|
||||
workflow_template_snapshot=workflow_template_snapshot,
|
||||
workflow_events=workflow_events,
|
||||
boundary_questions=boundary_questions,
|
||||
plan=plans[-1] if plans else None,
|
||||
@@ -100,3 +114,44 @@ def get_article_detail(
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _resolve_workflow_template_snapshot(
|
||||
repository: object,
|
||||
request: ArticleCreateRequest,
|
||||
) -> WorkflowTemplateSnapshot | None:
|
||||
if request.workflow_template_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
workflow = repository.workflow_templates.get(request.workflow_template_id)
|
||||
except LookupError as error:
|
||||
raise ValueError("Invalid workflow_template_id") from error
|
||||
|
||||
if workflow.status != WorkflowTemplateStatus.ACTIVE:
|
||||
raise ValueError("Workflow template must be active")
|
||||
|
||||
return _workflow_template_snapshot(workflow)
|
||||
|
||||
|
||||
def _workflow_template_snapshot(
|
||||
workflow: WorkflowTemplateSummary,
|
||||
) -> WorkflowTemplateSnapshot:
|
||||
return WorkflowTemplateSnapshot(
|
||||
id=workflow.id,
|
||||
name=workflow.name,
|
||||
slug=workflow.slug,
|
||||
version=workflow.version,
|
||||
stage_summary=[
|
||||
WorkflowTemplateStageSnapshot(
|
||||
id=stage.id,
|
||||
stable_key=stage.stable_key,
|
||||
display_name=stage.display_name,
|
||||
position=stage.position,
|
||||
owner_role=stage.owner_role,
|
||||
runner_profile_key=stage.runner_profile_key,
|
||||
requires_human_approval=stage.requires_human_approval,
|
||||
)
|
||||
for stage in workflow.stages
|
||||
],
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
from src.domain.schema import (
|
||||
SEEDED_ADMIN_EMAIL,
|
||||
@@ -22,6 +23,7 @@ from src.domain.schema import (
|
||||
|
||||
|
||||
SEED_TIMESTAMP = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
SEEDED_WORKFLOW_TEMPLATE_SLUG = "demo-article-production"
|
||||
|
||||
|
||||
def seed_reference_data(repository: object) -> None:
|
||||
@@ -125,6 +127,160 @@ assets:
|
||||
transform_script=transform_script,
|
||||
transform_script_hash=_sha256(transform_script),
|
||||
)
|
||||
_seed_demo_workflow_template(repository, admin_id=admin_id)
|
||||
|
||||
|
||||
def _seed_demo_workflow_template(repository: object, *, admin_id: UUID) -> None:
|
||||
existing = next(
|
||||
(
|
||||
workflow
|
||||
for workflow in repository.workflow_templates.list()
|
||||
if workflow.slug == SEEDED_WORKFLOW_TEMPLATE_SLUG
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
workflow = repository.workflow_templates.create(
|
||||
name="Demo article production workflow",
|
||||
slug=SEEDED_WORKFLOW_TEMPLATE_SLUG,
|
||||
description="Active demo workflow for article intake, drafting, review, and publishing.",
|
||||
status=WorkflowTemplateStatus.DRAFT,
|
||||
version=1,
|
||||
created_by=admin_id,
|
||||
updated_by=admin_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
for stage in _demo_workflow_stages():
|
||||
repository.workflow_template_stages.create(
|
||||
workflow_id=workflow.id,
|
||||
stable_key=stage["stable_key"],
|
||||
display_name=stage["display_name"],
|
||||
description=stage["description"],
|
||||
position=stage["position"],
|
||||
owner_role=stage["owner_role"],
|
||||
runner_profile_key=stage["runner_profile_key"],
|
||||
required_inputs=stage["required_inputs"],
|
||||
expected_outputs=stage["expected_outputs"],
|
||||
acceptance_criteria=stage["acceptance_criteria"],
|
||||
requires_human_approval=stage["requires_human_approval"],
|
||||
retry_policy=stage["retry_policy"],
|
||||
parts=stage["parts"],
|
||||
updated_by=admin_id,
|
||||
created_at=SEED_TIMESTAMP,
|
||||
updated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
repository.workflow_templates.activate(
|
||||
workflow_id=workflow.id,
|
||||
updated_by=admin_id,
|
||||
activated_at=SEED_TIMESTAMP,
|
||||
)
|
||||
|
||||
|
||||
def _demo_workflow_stages() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"stable_key": "intake-boundary-questions",
|
||||
"display_name": "Boundary question intake",
|
||||
"description": "Collect required editorial context before plan generation.",
|
||||
"position": 1,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "boundary-question-agent-v1",
|
||||
"required_inputs": ["article_brief", "target_site"],
|
||||
"expected_outputs": ["answered_boundary_questions"],
|
||||
"acceptance_criteria": ["All required boundary questions are answered."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "plan-review",
|
||||
"display_name": "Plan review",
|
||||
"description": "Generate and approve the article plan before research.",
|
||||
"position": 2,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "plan-generation-agent-v1",
|
||||
"required_inputs": ["answered_boundary_questions"],
|
||||
"expected_outputs": ["approved_plan"],
|
||||
"acceptance_criteria": ["Plan is approved by the editor."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 30},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "research-evidence",
|
||||
"display_name": "Research and evidence",
|
||||
"description": "Run research and approve evidence before production starts.",
|
||||
"position": 3,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "research-agent-v1",
|
||||
"required_inputs": ["approved_plan"],
|
||||
"expected_outputs": ["approved_evidence_matrix"],
|
||||
"acceptance_criteria": ["Evidence matrix is ready and approved."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "parallel-production",
|
||||
"display_name": "Parallel production",
|
||||
"description": "Create section scaffolds and draft-ready assets in parallel.",
|
||||
"position": 4,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "section-scaffold-agent-v1",
|
||||
"required_inputs": ["approved_evidence_matrix"],
|
||||
"expected_outputs": ["section_scaffolds", "asset_specs"],
|
||||
"acceptance_criteria": ["All production jobs have succeeded or been retried."],
|
||||
"requires_human_approval": False,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "draft-assembly",
|
||||
"display_name": "Draft assembly",
|
||||
"description": "Assemble a draft from the approved plan and production artifacts.",
|
||||
"position": 5,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "draft-assembly-agent-v1",
|
||||
"required_inputs": ["section_scaffolds", "approved_assets"],
|
||||
"expected_outputs": ["article_draft"],
|
||||
"acceptance_criteria": ["Draft is assembled for review."],
|
||||
"requires_human_approval": False,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "seo-language-review",
|
||||
"display_name": "SEO and language review",
|
||||
"description": "Run automated quality reviews and resolve required suggestions.",
|
||||
"position": 6,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "review-agent-v1",
|
||||
"required_inputs": ["article_draft"],
|
||||
"expected_outputs": ["resolved_review_suggestions"],
|
||||
"acceptance_criteria": ["Required SEO and language suggestions are resolved."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 2, "backoff_seconds": 60},
|
||||
"parts": [],
|
||||
},
|
||||
{
|
||||
"stable_key": "final-publishing",
|
||||
"display_name": "Final approval and publishing",
|
||||
"description": "Approve final draft, dry-run the bundle, and create the publish commit.",
|
||||
"position": 7,
|
||||
"owner_role": Role.EDITOR,
|
||||
"runner_profile_key": "publishing-agent-v1",
|
||||
"required_inputs": ["resolved_review_suggestions", "approved_assets"],
|
||||
"expected_outputs": ["publish_commit"],
|
||||
"acceptance_criteria": ["Publish dry-run passes and commit is created."],
|
||||
"requires_human_approval": True,
|
||||
"retry_policy": {"max_attempts": 1, "backoff_seconds": 0},
|
||||
"parts": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _repository_url_for_seed() -> str:
|
||||
|
||||
@@ -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,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
@@ -33,6 +34,21 @@ from .models import (
|
||||
BoundaryQuestionUpdateRequest,
|
||||
WorkflowEventSummary,
|
||||
ObservabilityTimelineEventSummary,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStagePart,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowStageResponse,
|
||||
WorkflowStageSummary,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
AssetGenerateSpecsResponse,
|
||||
AssetListResponse,
|
||||
AssetResponse,
|
||||
@@ -133,6 +149,22 @@ __all__ = [
|
||||
"BoundaryQuestionUpdateRequest",
|
||||
"WorkflowEventSummary",
|
||||
"ObservabilityTimelineEventSummary",
|
||||
"WorkflowStageCreateRequest",
|
||||
"WorkflowStagePart",
|
||||
"WorkflowStageReorderRequest",
|
||||
"WorkflowStageResponse",
|
||||
"WorkflowStageSummary",
|
||||
"WorkflowStageUpdateRequest",
|
||||
"WorkflowTemplateAuditEventListResponse",
|
||||
"WorkflowTemplateAuditEventSummary",
|
||||
"WorkflowTemplateCreateRequest",
|
||||
"WorkflowTemplateListResponse",
|
||||
"WorkflowTemplateResponse",
|
||||
"WorkflowTemplateSnapshot",
|
||||
"WorkflowTemplateStageSnapshot",
|
||||
"WorkflowTemplateStatus",
|
||||
"WorkflowTemplateSummary",
|
||||
"WorkflowTemplateUpdateRequest",
|
||||
"AssetGenerateSpecsResponse",
|
||||
"AssetListResponse",
|
||||
"AssetResponse",
|
||||
|
||||
@@ -139,3 +139,9 @@ class ScriptConfigVersionStatus(str, Enum):
|
||||
DRAFT = "DRAFT"
|
||||
ACTIVE = "ACTIVE"
|
||||
DEPRECATED = "DEPRECATED"
|
||||
|
||||
|
||||
class WorkflowTemplateStatus(str, Enum):
|
||||
DRAFT = "DRAFT"
|
||||
ACTIVE = "ACTIVE"
|
||||
ARCHIVED = "ARCHIVED"
|
||||
|
||||
@@ -23,6 +23,7 @@ from .enums import (
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,8 +159,141 @@ class ScriptConfigVersionAuditEventListResponse(ContractModel):
|
||||
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):
|
||||
target_site_id: UUID
|
||||
workflow_template_id: UUID | None = None
|
||||
brief_description: str = Field(min_length=1)
|
||||
working_title: str | None = None
|
||||
language: str = Field(default="en", min_length=2)
|
||||
@@ -171,6 +305,8 @@ class ArticleCreateRequest(ContractModel):
|
||||
class ArticleSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
workflow_template_id: UUID | None = None
|
||||
workflow_template_version: int | None = Field(default=None, ge=1)
|
||||
status: ArticleWorkflowStatus
|
||||
publishing_status: PublishingStatus
|
||||
brief_description: str
|
||||
@@ -740,6 +876,7 @@ class AssetUploadResponse(ContractModel):
|
||||
class ArticleDetailResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
target_site: TargetSiteConfig | None = None
|
||||
workflow_template_snapshot: WorkflowTemplateSnapshot | None = None
|
||||
workflow_events: list[WorkflowEventSummary] = Field(default_factory=list)
|
||||
boundary_questions: list[BoundaryQuestionSummary] = Field(default_factory=list)
|
||||
plan: PlanSummary | None = None
|
||||
|
||||
@@ -22,6 +22,7 @@ from .enums import (
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
@@ -109,6 +110,21 @@ from .models import (
|
||||
TargetSiteConfigCreateRequest,
|
||||
TargetSiteConfigResponse,
|
||||
WorkflowEventSummary,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStagePart,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowStageResponse,
|
||||
WorkflowStageSummary,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
TargetSiteConfigUpdateRequest,
|
||||
UserSummary,
|
||||
)
|
||||
@@ -131,6 +147,7 @@ CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||
ReviewType,
|
||||
ContentReviewKind,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
|
||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
@@ -148,6 +165,21 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
WorkflowStagePart,
|
||||
WorkflowStageSummary,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowStageResponse,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateStageSnapshot,
|
||||
WorkflowTemplateSnapshot,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
|
||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
USERS_TABLE = "users"
|
||||
TARGET_SITES_TABLE = "target_sites"
|
||||
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"
|
||||
BOUNDARY_QUESTIONS_TABLE = "boundary_questions"
|
||||
ARTICLE_PLANS_TABLE = "article_plans"
|
||||
@@ -26,6 +29,9 @@ CORE_TABLES: tuple[str, ...] = (
|
||||
USERS_TABLE,
|
||||
TARGET_SITES_TABLE,
|
||||
SCRIPT_CONFIG_VERSIONS_TABLE,
|
||||
WORKFLOW_TEMPLATES_TABLE,
|
||||
WORKFLOW_TEMPLATE_STAGES_TABLE,
|
||||
WORKFLOW_TEMPLATE_EVENTS_TABLE,
|
||||
ARTICLES_TABLE,
|
||||
BOUNDARY_QUESTIONS_TABLE,
|
||||
ARTICLE_PLANS_TABLE,
|
||||
|
||||
@@ -44,6 +44,12 @@ from src.domain.contracts import (
|
||||
ScriptConfigVersionStatus,
|
||||
TargetSiteConfig,
|
||||
UserSummary,
|
||||
WorkflowStagePart,
|
||||
WorkflowStageSummary,
|
||||
WorkflowTemplateAuditEventSummary,
|
||||
WorkflowTemplateSnapshot,
|
||||
WorkflowTemplateStatus,
|
||||
WorkflowTemplateSummary,
|
||||
)
|
||||
from src.infrastructure.schema import setup_database
|
||||
|
||||
@@ -74,6 +80,9 @@ class BackendRepository:
|
||||
self.agent_jobs = AgentJobsRepository(self)
|
||||
self.script_config_versions = ScriptConfigVersionsRepository(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)
|
||||
|
||||
def setup(self) -> None:
|
||||
@@ -434,6 +443,9 @@ class ArticlesRepository:
|
||||
self,
|
||||
*,
|
||||
target_site_id: UUID,
|
||||
workflow_template_id: UUID | None,
|
||||
workflow_template_version: int | None,
|
||||
workflow_template_snapshot: JsonObject | None,
|
||||
status: ArticleWorkflowStatus,
|
||||
publishing_status: PublishingStatus,
|
||||
brief_description: str,
|
||||
@@ -447,10 +459,14 @@ class ArticlesRepository:
|
||||
) -> ArticleSummary:
|
||||
article_id = uuid4()
|
||||
placeholder = self._repository.placeholder()
|
||||
json_cast = self._repository.json_cast()
|
||||
sql = f"""
|
||||
INSERT INTO articles (
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
workflow_template_snapshot,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -467,6 +483,9 @@ class ArticlesRepository:
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder}{json_cast},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
{placeholder},
|
||||
@@ -483,6 +502,13 @@ class ArticlesRepository:
|
||||
(
|
||||
str(article_id),
|
||||
str(target_site_id),
|
||||
_uuid_value(workflow_template_id),
|
||||
workflow_template_version,
|
||||
(
|
||||
_json_value(workflow_template_snapshot)
|
||||
if workflow_template_snapshot is not None
|
||||
else None
|
||||
),
|
||||
status.value,
|
||||
publishing_status.value,
|
||||
brief_description,
|
||||
@@ -505,6 +531,8 @@ class ArticlesRepository:
|
||||
SELECT
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -530,6 +558,8 @@ class ArticlesRepository:
|
||||
SELECT
|
||||
id,
|
||||
target_site_id,
|
||||
workflow_template_id,
|
||||
workflow_template_version,
|
||||
status,
|
||||
publishing_status,
|
||||
brief_description,
|
||||
@@ -550,6 +580,28 @@ class ArticlesRepository:
|
||||
raise LookupError(f"Article not found: {article_id}")
|
||||
return _article_summary_from_row(row)
|
||||
|
||||
def get_workflow_template_snapshot(
|
||||
self,
|
||||
article_id: UUID,
|
||||
) -> WorkflowTemplateSnapshot | None:
|
||||
placeholder = self._repository.placeholder()
|
||||
with self._repository.connection() as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT workflow_template_snapshot
|
||||
FROM articles
|
||||
WHERE id = {placeholder}
|
||||
""",
|
||||
(str(article_id),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
raise LookupError(f"Article not found: {article_id}")
|
||||
snapshot = _json_from_row(row, "workflow_template_snapshot")
|
||||
if snapshot is None:
|
||||
return None
|
||||
return WorkflowTemplateSnapshot.model_validate(snapshot)
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
*,
|
||||
@@ -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:
|
||||
def __init__(self, repository: BackendRepository) -> None:
|
||||
self._repository = repository
|
||||
@@ -3136,6 +3904,8 @@ def _article_summary_from_row(row: Any) -> ArticleSummary:
|
||||
return ArticleSummary(
|
||||
id=_row_value(row, "id"),
|
||||
target_site_id=_row_value(row, "target_site_id"),
|
||||
workflow_template_id=_row_value(row, "workflow_template_id"),
|
||||
workflow_template_version=_row_value(row, "workflow_template_version"),
|
||||
status=_row_value(row, "status"),
|
||||
publishing_status=_row_value(row, "publishing_status"),
|
||||
brief_description=_row_value(row, "brief_description"),
|
||||
@@ -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]:
|
||||
return {
|
||||
"id": _row_value(row, "id"),
|
||||
@@ -3431,6 +4258,13 @@ def _plain_row(row: Any) -> dict[str, Any]:
|
||||
"visual_rules",
|
||||
"source_rules",
|
||||
"publishing_rules",
|
||||
"required_inputs",
|
||||
"expected_outputs",
|
||||
"acceptance_criteria",
|
||||
"retry_policy",
|
||||
"parts",
|
||||
"payload",
|
||||
"workflow_template_snapshot",
|
||||
):
|
||||
if key in result:
|
||||
result[key] = _json_decode(result[key])
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.domain.contracts import (
|
||||
PublishingStatus,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
WorkflowTemplateStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +31,9 @@ PUBLISHING_STATUS_VALUES = _quoted_values(status.value for status in PublishingS
|
||||
SCRIPT_CONFIG_STATUS_VALUES = _quoted_values(
|
||||
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)
|
||||
CLAIM_SUPPORT_STATUS_VALUES = _quoted_values(
|
||||
status.value for status in ClaimSupportStatus
|
||||
@@ -94,9 +98,59 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
)
|
||||
""",
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_site_id UUID NOT NULL REFERENCES target_sites(id),
|
||||
workflow_template_id UUID REFERENCES workflow_templates(id),
|
||||
workflow_template_version INTEGER,
|
||||
workflow_template_snapshot JSONB,
|
||||
status TEXT NOT NULL CHECK (status IN ({ARTICLE_STATUS_VALUES})),
|
||||
publishing_status TEXT NOT NULL CHECK (
|
||||
publishing_status IN ({PUBLISHING_STATUS_VALUES})
|
||||
@@ -111,6 +165,9 @@ POSTGRES_SCHEMA_STATEMENTS: tuple[str, ...] = (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_id UUID REFERENCES workflow_templates(id)",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_version INTEGER",
|
||||
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS workflow_template_snapshot JSONB",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS boundary_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -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_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
|
||||
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)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||
ON articles (workflow_template_id, workflow_template_version)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
@@ -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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_site_id TEXT NOT NULL,
|
||||
workflow_template_id TEXT,
|
||||
workflow_template_version INTEGER,
|
||||
workflow_template_snapshot TEXT,
|
||||
status TEXT NOT NULL,
|
||||
publishing_status TEXT NOT NULL,
|
||||
brief_description TEXT NOT NULL,
|
||||
@@ -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_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
|
||||
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)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_workflow_template
|
||||
ON articles (workflow_template_id, workflow_template_version)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_queue
|
||||
ON agent_jobs (status, queued_at)
|
||||
""",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.domain.contracts.openapi import inject_contract_schemas
|
||||
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.reviews import router as reviews_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")
|
||||
_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(articles_router)
|
||||
app.include_router(assets_router)
|
||||
@@ -36,6 +54,7 @@ app.include_router(publishing_router)
|
||||
app.include_router(agent_jobs_router)
|
||||
app.include_router(internal_agent_jobs_router)
|
||||
app.include_router(sites_router)
|
||||
app.include_router(workflow_templates_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -45,6 +45,11 @@ def post_article(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid target_site_id",
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.get("/articles", response_model=ArticleListResponse)
|
||||
|
||||
@@ -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"]
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
for question in questions_response.json()["questions"]:
|
||||
if question["is_required"]:
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Answer for {question['category']}"},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
submit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||
|
||||
plan_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plan/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||
plan_id = plan_response.json()["plan"]["id"]
|
||||
|
||||
review_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/00000000-0000-0000-0000-000000000123/approve",
|
||||
f"/api/articles/{article_id}/plans/{plan_id}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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} */
|
||||
const nextConfig = {
|
||||
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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import AdminJobsPage from "@/pages/admin-jobs";
|
||||
|
||||
export default AdminJobsPage;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <AdminJobsPage />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import AdminScriptsPage from "@/pages/admin-scripts";
|
||||
|
||||
export { AdminScriptsPage as default };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <AdminScriptsPage />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DynamicArticleBoundaryQuestionsPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
articleId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DynamicArticleBoundaryQuestionsPage({
|
||||
params,
|
||||
}: DynamicArticleBoundaryQuestionsPageProps) {
|
||||
const { articleId } = params;
|
||||
const { articleId } = await params;
|
||||
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import ArticleEvidencePage from "@/pages/article-evidence";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DynamicArticleEvidencePageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
articleId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DynamicArticleEvidencePage({
|
||||
params,
|
||||
}: DynamicArticleEvidencePageProps) {
|
||||
const { articleId } = params;
|
||||
const { articleId } = await params;
|
||||
return <ArticleEvidencePage articleId={articleId} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import ArticleDetailPage from "@/pages/article-detail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DynamicArticleDetailPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
articleId: string;
|
||||
};
|
||||
searchParams?: {
|
||||
}>;
|
||||
searchParams?: Promise<{
|
||||
role?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DynamicArticleDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: DynamicArticleDetailPageProps) {
|
||||
const { articleId } = params;
|
||||
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
|
||||
const { articleId } = await params;
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const viewerRoleHint = resolvedSearchParams?.role === "admin" ? "admin" : "editor";
|
||||
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import ArticlePlansPage from "@/pages/article-plans";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DynamicArticlePlansPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
articleId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DynamicArticlePlansPage({
|
||||
params,
|
||||
}: DynamicArticlePlansPageProps) {
|
||||
const { articleId } = params;
|
||||
const { articleId } = await params;
|
||||
return <ArticlePlansPage articleId={articleId} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import ArticleResearchPage from "@/pages/article-research";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DynamicArticleResearchPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
articleId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DynamicArticleResearchPage({
|
||||
params,
|
||||
}: DynamicArticleResearchPageProps) {
|
||||
const { articleId } = params;
|
||||
const { articleId } = await params;
|
||||
return <ArticleResearchPage articleId={articleId} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import NewArticlePage from "@/pages/article-new";
|
||||
|
||||
export { NewArticlePage as default };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <NewArticlePage />;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,12 @@ main {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin: 0;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.formSuccess {
|
||||
margin: 0;
|
||||
color: #027a48;
|
||||
|
||||
@@ -6,6 +6,8 @@ export const metadata = {
|
||||
description: "Internal editorial workflow foundation",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateStageRow = {
|
||||
id: string;
|
||||
stableKey: string;
|
||||
displayName: string;
|
||||
position: number;
|
||||
ownerRole: string;
|
||||
runnerProfileKey: string;
|
||||
requiresHumanApproval: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateSnapshotView = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
version: number;
|
||||
stages: WorkflowTemplateStageRow[];
|
||||
};
|
||||
|
||||
export type DetailSummary = {
|
||||
articleId: string;
|
||||
status: string;
|
||||
@@ -46,6 +64,7 @@ export type DetailSummary = {
|
||||
publishingValidationLabel: string;
|
||||
briefDescription: string;
|
||||
targetSite: string;
|
||||
workflowTemplate: WorkflowTemplateSnapshotView | null;
|
||||
updatedAt: string;
|
||||
viewerRole: Role;
|
||||
viewerEmail: string;
|
||||
@@ -101,6 +120,7 @@ export function buildDetailSummary(
|
||||
publishingValidationLabel: resolvePublishingValidationLabel(detail),
|
||||
briefDescription: detail.article.brief_description,
|
||||
targetSite: detail.target_site?.name ?? "Unknown",
|
||||
workflowTemplate: buildWorkflowTemplateSnapshotView(detail),
|
||||
updatedAt: detail.article.updated_at,
|
||||
viewerRole,
|
||||
viewerEmail,
|
||||
@@ -146,6 +166,33 @@ export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobCo
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkflowTemplateSnapshotView(
|
||||
detail: ArticleDetailResponse,
|
||||
): WorkflowTemplateSnapshotView | null {
|
||||
const snapshot = detail.workflow_template_snapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
name: snapshot.name,
|
||||
slug: snapshot.slug,
|
||||
version: snapshot.version,
|
||||
stages: (snapshot.stage_summary ?? [])
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
stableKey: stage.stable_key,
|
||||
displayName: stage.display_name,
|
||||
position: stage.position,
|
||||
ownerRole: stage.owner_role,
|
||||
runnerProfileKey: stage.runner_profile_key,
|
||||
requiresHumanApproval: Boolean(stage.requires_human_approval),
|
||||
}))
|
||||
.sort((left, right) => left.position - right.position),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
|
||||
const manifest = detail.publish_commit?.content_bundle_manifest;
|
||||
if (manifest && typeof manifest === "object") {
|
||||
|
||||
@@ -18,6 +18,7 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
const [busyJobId, setBusyJobId] = useState("");
|
||||
const timeline = summary.timeline;
|
||||
const jobDetails = summary.jobDetails;
|
||||
const workflowTemplate = summary.workflowTemplate;
|
||||
const isAdmin = summary.viewerRole === "ADMIN";
|
||||
|
||||
async function handleRetry(jobId: string) {
|
||||
@@ -104,6 +105,49 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Workflow template</h3>
|
||||
{workflowTemplate ? (
|
||||
<>
|
||||
<dl className="detailGrid">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{workflowTemplate.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Slug</dt>
|
||||
<dd>{workflowTemplate.slug}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{workflowTemplate.version}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stages</dt>
|
||||
<dd>{workflowTemplate.stages.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ol className="timeline">
|
||||
{workflowTemplate.stages.map((stage) => (
|
||||
<li key={stage.id}>
|
||||
<div className="timelineMeta">
|
||||
<strong>{stage.position}. {stage.displayName}</strong>
|
||||
<span className="timelineSource">{stage.ownerRole}</span>
|
||||
<span>{stage.runnerProfileKey}</span>
|
||||
</div>
|
||||
<div>
|
||||
{stage.stableKey}
|
||||
{stage.requiresHumanApproval ? (
|
||||
<span> - approval required</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : (
|
||||
<p>No workflow template snapshot.</p>
|
||||
)}
|
||||
|
||||
<h3>Workflow timeline</h3>
|
||||
<ul className="timeline">
|
||||
{timeline.map((event) => (
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
import type {
|
||||
ArticleCreateRequest,
|
||||
TargetSiteConfig,
|
||||
WorkflowTemplateSummary,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type NewArticleDraft = {
|
||||
targetSiteId: string;
|
||||
workflowTemplateId: string;
|
||||
briefDescription: string;
|
||||
workingTitle: string;
|
||||
contentType: string;
|
||||
primaryKeyword: string;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
version: number;
|
||||
stageCount: number;
|
||||
};
|
||||
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
export type NewArticleFormState = {
|
||||
@@ -16,9 +28,14 @@ export type NewArticleFormState = {
|
||||
formError?: string;
|
||||
};
|
||||
|
||||
export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraft {
|
||||
export function initialDraft(
|
||||
sites: readonly TargetSiteConfig[],
|
||||
workflows: readonly WorkflowTemplateSummary[] = [],
|
||||
): NewArticleDraft {
|
||||
const workflowOptions = buildWorkflowTemplateOptions(workflows);
|
||||
return {
|
||||
targetSiteId: sites.length > 0 ? sites[0].id : "",
|
||||
workflowTemplateId: workflowOptions.length > 0 ? workflowOptions[0].id : "",
|
||||
briefDescription: "",
|
||||
workingTitle: "",
|
||||
contentType: "longform_guide",
|
||||
@@ -26,6 +43,33 @@ export function initialDraft(sites: readonly TargetSiteConfig[]): NewArticleDraf
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkflowTemplateOptions(
|
||||
workflows: readonly WorkflowTemplateSummary[],
|
||||
): WorkflowTemplateOption[] {
|
||||
return workflows
|
||||
.filter((workflow) => workflow.status === "ACTIVE")
|
||||
.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
label: `${workflow.name} v${workflow.version}`,
|
||||
version: workflow.version,
|
||||
stageCount: workflow.stages?.length ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCreateArticlePayload(
|
||||
values: NewArticleDraft,
|
||||
): ArticleCreateRequest {
|
||||
return {
|
||||
target_site_id: values.targetSiteId,
|
||||
workflow_template_id: values.workflowTemplateId || null,
|
||||
brief_description: values.briefDescription,
|
||||
working_title: values.workingTitle || null,
|
||||
language: "en",
|
||||
content_type: values.contentType,
|
||||
primary_keyword: values.primaryKeyword || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateArticleDraft(values: NewArticleDraft): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { TargetSiteConfig } from "@pipeline/shared";
|
||||
import type { TargetSiteConfig, WorkflowTemplateSummary } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, createArticle } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildCreateArticlePayload,
|
||||
buildWorkflowTemplateOptions,
|
||||
initialDraft,
|
||||
validateArticleDraft,
|
||||
type FieldErrors,
|
||||
@@ -15,14 +17,24 @@ import {
|
||||
|
||||
type NewArticleFormProps = {
|
||||
sites: readonly TargetSiteConfig[];
|
||||
workflowTemplates: readonly WorkflowTemplateSummary[];
|
||||
};
|
||||
|
||||
export function NewArticleForm({ sites }: NewArticleFormProps) {
|
||||
export function NewArticleForm({ sites, workflowTemplates }: NewArticleFormProps) {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<NewArticleDraft>(() => initialDraft(sites));
|
||||
const workflowOptions = useMemo(
|
||||
() => buildWorkflowTemplateOptions(workflowTemplates),
|
||||
[workflowTemplates],
|
||||
);
|
||||
const [values, setValues] = useState<NewArticleDraft>(() =>
|
||||
initialDraft(sites, workflowTemplates),
|
||||
);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [formError, setFormError] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const selectedWorkflow = workflowOptions.find(
|
||||
(workflow) => workflow.id === values.workflowTemplateId,
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !isSubmitting && sites.length > 0,
|
||||
@@ -41,14 +53,7 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
|
||||
setFormError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await createArticle({
|
||||
target_site_id: values.targetSiteId,
|
||||
brief_description: values.briefDescription,
|
||||
working_title: values.workingTitle || null,
|
||||
language: "en",
|
||||
content_type: values.contentType,
|
||||
primary_keyword: values.primaryKeyword || null,
|
||||
});
|
||||
const response = await createArticle(buildCreateArticlePayload(values));
|
||||
router.push(`/articles/${response.article.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -84,6 +89,26 @@ export function NewArticleForm({ sites }: NewArticleFormProps) {
|
||||
</select>
|
||||
{errors.targetSiteId ? <p className="fieldError">{errors.targetSiteId}</p> : null}
|
||||
|
||||
<label htmlFor="workflowTemplateId">Workflow template</label>
|
||||
<select
|
||||
id="workflowTemplateId"
|
||||
value={values.workflowTemplateId}
|
||||
onChange={(event) => updateField("workflowTemplateId", event.target.value)}
|
||||
disabled={workflowOptions.length === 0}
|
||||
>
|
||||
<option value="">No active workflow template</option>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.label} - {workflow.stageCount} stages
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedWorkflow ? (
|
||||
<p className="fieldHint">
|
||||
{selectedWorkflow.label} - {selectedWorkflow.stageCount} stages
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="briefDescription">Brief description</label>
|
||||
<textarea
|
||||
id="briefDescription"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,19 @@ import Link from "next/link";
|
||||
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { NewArticleForm } from "@/features/article-intake/ui";
|
||||
import { fetchTargetSites } from "@/shared/pipeline-api";
|
||||
import {
|
||||
DEMO_EDITOR_EMAIL,
|
||||
fetchTargetSites,
|
||||
fetchWorkflowTemplates,
|
||||
} from "@/shared/pipeline-api";
|
||||
|
||||
export default async function NewArticlePage() {
|
||||
const sites: TargetSiteConfigResponse[] = await fetchTargetSites();
|
||||
const [sites, workflowResponse] = await Promise.all([
|
||||
fetchTargetSites(),
|
||||
fetchWorkflowTemplates(DEMO_EDITOR_EMAIL),
|
||||
]);
|
||||
const targetSites = sites.map((site) => site.site);
|
||||
const workflowTemplates = workflowResponse.workflows ?? [];
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -20,7 +28,10 @@ export default async function NewArticlePage() {
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<section className="panel">
|
||||
<NewArticleForm sites={targetSites} />
|
||||
<NewArticleForm
|
||||
sites={targetSites}
|
||||
workflowTemplates={workflowTemplates}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,15 @@ import type {
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
TargetSiteConfigCreateRequest,
|
||||
WorkflowStageCreateRequest,
|
||||
WorkflowStageReorderRequest,
|
||||
WorkflowStageResponse,
|
||||
WorkflowStageUpdateRequest,
|
||||
WorkflowTemplateAuditEventListResponse,
|
||||
WorkflowTemplateCreateRequest,
|
||||
WorkflowTemplateListResponse,
|
||||
WorkflowTemplateResponse,
|
||||
WorkflowTemplateUpdateRequest,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
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(
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): 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(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
|
||||
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
|
||||
href: "/admin/scripts",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
label: "Workflows",
|
||||
href: "/admin/workflows",
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
{
|
||||
id: "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",
|
||||
},
|
||||
target_site: { name: "Demo Site" },
|
||||
workflow_template_snapshot: {
|
||||
id: "workflow-1",
|
||||
name: "Demo workflow",
|
||||
slug: "demo-workflow",
|
||||
version: 2,
|
||||
stage_summary: [
|
||||
{
|
||||
id: "stage-2",
|
||||
stable_key: "draft",
|
||||
display_name: "Draft assembly",
|
||||
position: 2,
|
||||
owner_role: "EDITOR",
|
||||
runner_profile_key: "draft-agent",
|
||||
requires_human_approval: false,
|
||||
},
|
||||
{
|
||||
id: "stage-1",
|
||||
stable_key: "intake",
|
||||
display_name: "Intake",
|
||||
position: 1,
|
||||
owner_role: "EDITOR",
|
||||
runner_profile_key: "intake-agent",
|
||||
requires_human_approval: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
id: "2",
|
||||
@@ -131,5 +157,11 @@ const summary = buildDetailSummary(detail, {
|
||||
assert.equal(summary.viewerRole, "EDITOR");
|
||||
assert.equal(summary.viewerEmail, "editor@example.com");
|
||||
assert.equal(summary.targetSite, "Demo Site");
|
||||
assert.equal(summary.workflowTemplate.name, "Demo workflow");
|
||||
assert.equal(summary.workflowTemplate.version, 2);
|
||||
assert.deepEqual(
|
||||
summary.workflowTemplate.stages.map((stage) => stage.stableKey),
|
||||
["intake", "draft"],
|
||||
);
|
||||
assert.equal(summary.timeline.length, 2);
|
||||
assert.equal(summary.jobDetails.length, 1);
|
||||
|
||||
@@ -23,9 +23,29 @@ new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const validateArticleDraft = moduleExports.validateArticleDraft;
|
||||
const initialDraft = moduleExports.initialDraft;
|
||||
const buildCreateArticlePayload = moduleExports.buildCreateArticlePayload;
|
||||
const buildWorkflowTemplateOptions = moduleExports.buildWorkflowTemplateOptions;
|
||||
|
||||
const activeWorkflow = {
|
||||
id: "workflow-1",
|
||||
name: "Demo workflow",
|
||||
slug: "demo-workflow",
|
||||
description: "Demo workflow",
|
||||
status: "ACTIVE",
|
||||
version: 2,
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
created_at: "2026-05-22T00:00:00Z",
|
||||
updated_at: "2026-05-22T00:00:00Z",
|
||||
stages: [
|
||||
{ id: "stage-1" },
|
||||
{ id: "stage-2" },
|
||||
],
|
||||
};
|
||||
|
||||
const draft = {
|
||||
targetSiteId: "",
|
||||
workflowTemplateId: "",
|
||||
briefDescription: "Keep this text intact",
|
||||
workingTitle: "",
|
||||
contentType: "longform_guide",
|
||||
@@ -43,3 +63,27 @@ assert.equal(
|
||||
|
||||
const fallbackDraft = initialDraft([]);
|
||||
assert.equal(fallbackDraft.contentType, "longform_guide", "default content type preserved");
|
||||
|
||||
const workflowDraft = initialDraft(
|
||||
[{ id: "site-1" }],
|
||||
[activeWorkflow, { ...activeWorkflow, id: "workflow-archived", status: "ARCHIVED" }],
|
||||
);
|
||||
assert.equal(workflowDraft.workflowTemplateId, "workflow-1");
|
||||
|
||||
assert.deepEqual(buildWorkflowTemplateOptions([activeWorkflow]), [
|
||||
{
|
||||
id: "workflow-1",
|
||||
label: "Demo workflow v2",
|
||||
version: 2,
|
||||
stageCount: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
buildCreateArticlePayload({
|
||||
...workflowDraft,
|
||||
briefDescription: "Brief",
|
||||
primaryKeyword: "workflow",
|
||||
}).workflow_template_id,
|
||||
"workflow-1",
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ services:
|
||||
environment:
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
BACKEND_URL: http://backend:8000
|
||||
NEXT_PUBLIC_BACKEND_URL: http://backend:8000
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
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
@@ -62,6 +62,7 @@ export type ArticleCreateRequest = {
|
||||
language?: string;
|
||||
primary_keyword?: string | null;
|
||||
target_site_id: string;
|
||||
workflow_template_id?: string | null;
|
||||
working_title?: string | null;
|
||||
};
|
||||
|
||||
@@ -84,6 +85,7 @@ export type ArticleDetailResponse = {
|
||||
target_site?: TargetSiteConfig | null;
|
||||
timeline?: ObservabilityTimelineEventSummary[];
|
||||
workflow_events?: WorkflowEventSummary[];
|
||||
workflow_template_snapshot?: WorkflowTemplateSnapshot | null;
|
||||
};
|
||||
|
||||
export type ArticleListResponse = {
|
||||
@@ -102,6 +104,8 @@ export type ArticleSummary = {
|
||||
status: ArticleWorkflowStatus;
|
||||
target_site_id: string;
|
||||
updated_at: string;
|
||||
workflow_template_id?: string | null;
|
||||
workflow_template_version?: number | null;
|
||||
working_title?: string | null;
|
||||
};
|
||||
|
||||
@@ -763,3 +767,137 @@ export type WorkflowEventSummary = {
|
||||
payload?: Record<string, unknown>;
|
||||
to_status?: ArticleWorkflowStatus | null;
|
||||
};
|
||||
|
||||
export type WorkflowStageCreateRequest = {
|
||||
acceptance_criteria?: string[];
|
||||
description: string;
|
||||
display_name: string;
|
||||
expected_outputs?: string[];
|
||||
owner_role: Role;
|
||||
parts?: WorkflowStagePart[];
|
||||
position?: number | null;
|
||||
required_inputs?: string[];
|
||||
requires_human_approval?: boolean;
|
||||
retry_policy?: Record<string, unknown>;
|
||||
runner_profile_key: string;
|
||||
stable_key: string;
|
||||
};
|
||||
|
||||
export type WorkflowStagePart = {
|
||||
acceptance_criteria?: string[];
|
||||
key: string;
|
||||
payload?: Record<string, unknown>;
|
||||
title: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type WorkflowStageReorderRequest = {
|
||||
stage_ids: string[];
|
||||
};
|
||||
|
||||
export type WorkflowStageResponse = {
|
||||
stage: WorkflowStageSummary;
|
||||
};
|
||||
|
||||
export type WorkflowStageSummary = {
|
||||
acceptance_criteria?: string[];
|
||||
created_at: string;
|
||||
description: string;
|
||||
display_name: string;
|
||||
expected_outputs?: string[];
|
||||
id: string;
|
||||
owner_role: Role;
|
||||
parts?: WorkflowStagePart[];
|
||||
position: number;
|
||||
required_inputs?: string[];
|
||||
requires_human_approval?: boolean;
|
||||
retry_policy?: Record<string, unknown>;
|
||||
runner_profile_key: string;
|
||||
stable_key: string;
|
||||
updated_at: string;
|
||||
workflow_id: string;
|
||||
};
|
||||
|
||||
export type WorkflowStageUpdateRequest = {
|
||||
acceptance_criteria?: string[] | null;
|
||||
description?: string | null;
|
||||
display_name?: string | null;
|
||||
expected_outputs?: string[] | null;
|
||||
owner_role?: Role | null;
|
||||
parts?: WorkflowStagePart[] | null;
|
||||
position?: number | null;
|
||||
required_inputs?: string[] | null;
|
||||
requires_human_approval?: boolean | null;
|
||||
retry_policy?: Record<string, unknown> | null;
|
||||
runner_profile_key?: string | null;
|
||||
stable_key?: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateAuditEventListResponse = {
|
||||
events?: WorkflowTemplateAuditEventSummary[];
|
||||
};
|
||||
|
||||
export type WorkflowTemplateAuditEventSummary = {
|
||||
actor_user_id?: string | null;
|
||||
created_at: string;
|
||||
event_type: string;
|
||||
id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
workflow_id: string;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateCreateRequest = {
|
||||
description: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateListResponse = {
|
||||
workflows?: WorkflowTemplateSummary[];
|
||||
};
|
||||
|
||||
export type WorkflowTemplateResponse = {
|
||||
workflow: WorkflowTemplateSummary;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateSnapshot = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
stage_summary?: WorkflowTemplateStageSnapshot[];
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateStageSnapshot = {
|
||||
display_name: string;
|
||||
id: string;
|
||||
owner_role: Role;
|
||||
position: number;
|
||||
requires_human_approval?: boolean;
|
||||
runner_profile_key: string;
|
||||
stable_key: string;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
|
||||
|
||||
export type WorkflowTemplateSummary = {
|
||||
activated_at?: string | null;
|
||||
archived_at?: string | null;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
stages?: WorkflowStageSummary[];
|
||||
status: WorkflowTemplateStatus;
|
||||
updated_at: string;
|
||||
updated_by?: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateUpdateRequest = {
|
||||
description?: string | null;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -23,6 +23,7 @@ This task pool turns `IDEA.md` into vertical, testable implementation slices tha
|
||||
- Backend is FastAPI.
|
||||
- Frontend is Next.js.
|
||||
- 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.
|
||||
- Postgres stores domain state and manifests, not large source snapshots.
|
||||
- Object storage stores research source-section artifacts, assets, and job artifacts.
|
||||
|
||||
Reference in New Issue
Block a user