Task 002: add shared domain contracts

This commit is contained in:
2026-05-21 17:45:00 +03:00
parent d42f0c4392
commit a0ae06adfa
31 changed files with 4043 additions and 27 deletions
+1
View File
@@ -2,6 +2,7 @@
.DS_Store
node_modules/
.next/
*.tsbuildinfo
__pycache__/
*.pyc
.pytest_cache/
+7
View File
@@ -0,0 +1,7 @@
supportedArchitectures.os[]=current
supportedArchitectures.os[]=linux
supportedArchitectures.cpu[]=current
supportedArchitectures.cpu[]=x64
supportedArchitectures.cpu[]=arm64
supportedArchitectures.libc[]=current
supportedArchitectures.libc[]=glibc
+6 -1
View File
@@ -1,4 +1,9 @@
.PHONY: dev down smoke
PYTHON ?= python3
.PHONY: contracts dev down smoke
contracts:
$(PYTHON) scripts/generate_openapi_contracts.py
dev:
docker compose up --build
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import NAMESPACE_URL, uuid5
from src.domain.contracts import (
ArticleCreateRequest,
ArticleCreateResponse,
ArticleSummary,
ArticleWorkflowStatus,
PublishingStatus,
)
PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC)
def build_placeholder_article_response(
request: ArticleCreateRequest,
) -> ArticleCreateResponse:
article_id = uuid5(
NAMESPACE_URL,
f"article:{request.target_site_id}:{request.brief_description}",
)
article = ArticleSummary(
id=article_id,
target_site_id=request.target_site_id,
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
brief_description=request.brief_description,
working_title=request.working_title,
language=request.language,
content_type=request.content_type,
primary_keyword=request.primary_keyword,
assigned_editor_id=request.assigned_editor_id,
created_at=PLACEHOLDER_CREATED_AT,
updated_at=PLACEHOLDER_CREATED_AT,
)
return ArticleCreateResponse(article=article)
@@ -0,0 +1,87 @@
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
from .models import (
AgentJobOutput,
AgentJobSummary,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
AssetSummary,
ClaimSummary,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSummary,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionSummary,
TargetSiteConfig,
UserSummary,
)
from .workflow import (
ARTICLE_WORKFLOW_STATUSES,
ARTICLE_WORKFLOW_TRANSITIONS,
next_workflow_statuses,
)
__all__ = [
"ARTICLE_WORKFLOW_STATUSES",
"ARTICLE_WORKFLOW_TRANSITIONS",
"AgentJobErrorCategory",
"AgentJobOutput",
"AgentJobStatus",
"AgentJobSummary",
"AgentJobType",
"ArticleCreateRequest",
"ArticleCreateResponse",
"ArticleDetailResponse",
"ArticleListResponse",
"ArticleSummary",
"ArticleWorkflowStatus",
"AssetStatus",
"AssetSummary",
"AssetType",
"ClaimRiskLevel",
"ClaimSummary",
"ClaimSupportStatus",
"DraftSummary",
"EvidenceSummary",
"PlanReviewStatus",
"PlanSectionSummary",
"PlanSummary",
"PublishCommitSummary",
"PublishingRules",
"PublishingStatus",
"ResearchArtifactManifestSummary",
"ResearchArtifactSummary",
"ReviewStatus",
"ReviewSummary",
"ReviewType",
"Role",
"RunnerFileRef",
"ScriptConfigVersionStatus",
"ScriptConfigVersionSummary",
"TargetSiteConfig",
"UserSummary",
"next_workflow_statuses",
]
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "ADMIN"
EDITOR = "EDITOR"
class ArticleWorkflowStatus(str, Enum):
ARTICLE_BRIEF_CREATED = "ARTICLE_BRIEF_CREATED"
BOUNDARY_QUESTIONS_GENERATED = "BOUNDARY_QUESTIONS_GENERATED"
BOUNDARY_ANSWERS_SUBMITTED = "BOUNDARY_ANSWERS_SUBMITTED"
PLAN_GENERATED = "PLAN_GENERATED"
PLAN_REVIEW_REQUIRED = "PLAN_REVIEW_REQUIRED"
PLAN_REVISION_REQUIRED = "PLAN_REVISION_REQUIRED"
RESEARCH_RUNNING = "RESEARCH_RUNNING"
EVIDENCE_MATRIX_READY = "EVIDENCE_MATRIX_READY"
PARALLEL_PRODUCTION_RUNNING = "PARALLEL_PRODUCTION_RUNNING"
DRAFT_ASSEMBLED = "DRAFT_ASSEMBLED"
SEO_AND_LANGUAGE_REVIEW_READY = "SEO_AND_LANGUAGE_REVIEW_READY"
FINAL_REVIEW_REQUIRED = "FINAL_REVIEW_REQUIRED"
FINAL_REVISION_REQUIRED = "FINAL_REVISION_REQUIRED"
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
class AgentJobStatus(str, Enum):
QUEUED = "QUEUED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class AgentJobType(str, Enum):
BOUNDARY_QUESTIONS = "BOUNDARY_QUESTIONS"
PLAN_GENERATION = "PLAN_GENERATION"
RESEARCH = "RESEARCH"
EVIDENCE_MATRIX = "EVIDENCE_MATRIX"
SECTION_SCAFFOLD = "SECTION_SCAFFOLD"
DRAFT_ASSEMBLY = "DRAFT_ASSEMBLY"
SEO_REVIEW = "SEO_REVIEW"
LANGUAGE_REVIEW = "LANGUAGE_REVIEW"
PUBLISH_DRY_RUN = "PUBLISH_DRY_RUN"
PUBLISH_COMMIT = "PUBLISH_COMMIT"
TEST_CODEX = "TEST_CODEX"
class AgentJobErrorCategory(str, Enum):
FAILED_SCHEMA_VALIDATION = "FAILED_SCHEMA_VALIDATION"
CLI_EXIT_CODE_FAILURE = "CLI_EXIT_CODE_FAILURE"
TIMEOUT = "TIMEOUT"
MISSING_OUTPUT_FILE = "MISSING_OUTPUT_FILE"
UNSUPPORTED_CLAIMS_FOUND = "UNSUPPORTED_CLAIMS_FOUND"
SOURCE_RETRIEVAL_FAILED = "SOURCE_RETRIEVAL_FAILED"
RESEARCH_ARTIFACT_UPLOAD_FAILED = "RESEARCH_ARTIFACT_UPLOAD_FAILED"
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
GIT_CHECKOUT_FAILED = "GIT_CHECKOUT_FAILED"
GIT_COMMIT_FAILED = "GIT_COMMIT_FAILED"
GIT_PUSH_NON_FAST_FORWARD = "GIT_PUSH_NON_FAST_FORWARD"
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
class ClaimSupportStatus(str, Enum):
SUPPORTED = "SUPPORTED"
UNSUPPORTED = "UNSUPPORTED"
NEEDS_REVIEW = "NEEDS_REVIEW"
class ClaimRiskLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class PublishingStatus(str, Enum):
PUBLISH_NOT_STARTED = "PUBLISH_NOT_STARTED"
PUBLISH_DRY_RUN_REQUIRED = "PUBLISH_DRY_RUN_REQUIRED"
PUBLISH_DRY_RUN_RUNNING = "PUBLISH_DRY_RUN_RUNNING"
PUBLISH_DRY_RUN_FAILED = "PUBLISH_DRY_RUN_FAILED"
PUBLISH_COMMIT_READY = "PUBLISH_COMMIT_READY"
PUBLISH_COMMIT_CREATED = "PUBLISH_COMMIT_CREATED"
PUBLISH_VERIFICATION_FAILED = "PUBLISH_VERIFICATION_FAILED"
class AssetStatus(str, Enum):
PENDING = "PENDING"
GENERATED = "GENERATED"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
class AssetType(str, Enum):
HERO_IMAGE = "hero_image"
DIAGRAM = "diagram"
TABLE = "table"
INLINE_IMAGE = "inline_image"
class PlanReviewStatus(str, Enum):
PENDING_REVIEW = "PENDING_REVIEW"
APPROVED = "APPROVED"
REVISION_REQUESTED = "REVISION_REQUESTED"
class ReviewStatus(str, Enum):
PENDING = "PENDING"
APPROVED = "APPROVED"
CHANGES_REQUESTED = "CHANGES_REQUESTED"
class ReviewType(str, Enum):
PLAN = "PLAN"
EVIDENCE = "EVIDENCE"
ASSET = "ASSET"
SEO_LANGUAGE = "SEO_LANGUAGE"
FINAL = "FINAL"
class ScriptConfigVersionStatus(str, Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
DEPRECATED = "DEPRECATED"
+266
View File
@@ -0,0 +1,266 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
JsonObject = dict[str, Any]
class ContractModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class UserSummary(ContractModel):
id: UUID
display_name: str = Field(min_length=1)
role: Role
class PublishingRules(ContractModel):
repository_url: str = Field(min_length=1)
production_branch: str = Field(min_length=1)
content_format: str = Field(default="mdx", min_length=1)
content_path_template: str = Field(min_length=1)
asset_path_template: str = Field(min_length=1)
frontmatter_mapping: JsonObject = Field(default_factory=dict)
transform_script_version_id: UUID | None = None
dry_run_renderer: str | None = None
class TargetSiteConfig(ContractModel):
id: UUID
name: str = Field(min_length=1)
slug: str = Field(min_length=1)
publishing_type: str = Field(default="git_next", min_length=1)
default_language: str = Field(default="en", min_length=2)
brand_voice: str = Field(min_length=1)
audience: str = Field(min_length=1)
seo_rules: JsonObject = Field(default_factory=dict)
visual_rules: JsonObject = Field(default_factory=dict)
source_rules: JsonObject = Field(default_factory=dict)
publishing_rules: PublishingRules
active_script_config_version_id: UUID | None = None
created_at: datetime
updated_at: datetime
class ScriptConfigVersionSummary(ContractModel):
id: UUID
target_site_id: UUID
version: int = Field(ge=1)
status: ScriptConfigVersionStatus
publishing_yaml_hash: str = Field(min_length=1)
transform_script_hash: str = Field(min_length=1)
created_by: UUID
created_at: datetime
class ArticleCreateRequest(ContractModel):
target_site_id: UUID
brief_description: str = Field(min_length=1)
working_title: str | None = None
language: str = Field(default="en", min_length=2)
content_type: str = Field(default="longform_guide", min_length=1)
primary_keyword: str | None = None
assigned_editor_id: UUID | None = None
class ArticleSummary(ContractModel):
id: UUID
target_site_id: UUID
status: ArticleWorkflowStatus
publishing_status: PublishingStatus
brief_description: str
working_title: str | None = None
language: str
content_type: str
primary_keyword: str | None = None
assigned_editor_id: UUID | None = None
created_at: datetime
updated_at: datetime
class ArticleCreateResponse(ContractModel):
article: ArticleSummary
class ArticleListResponse(ContractModel):
articles: list[ArticleSummary]
class PlanSectionSummary(ContractModel):
id: UUID
article_plan_id: UUID
sort_order: int = Field(ge=1)
heading: str = Field(min_length=1)
purpose: str | None = None
claims_to_support: list[str] = Field(default_factory=list)
target_word_count: int | None = Field(default=None, ge=1)
class PlanSummary(ContractModel):
id: UUID
article_id: UUID
version: int = Field(ge=1)
status: PlanReviewStatus
recommended_title: str | None = None
search_intent: str | None = None
thesis: str | None = None
sections: list[PlanSectionSummary] = Field(default_factory=list)
claims_to_prove: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
created_at: datetime
class ResearchArtifactSummary(ContractModel):
artifact_type: str = Field(min_length=1)
source_url: str = Field(min_length=1)
object_key: str = Field(min_length=1)
content_hash: str = Field(min_length=1)
metadata: JsonObject = Field(default_factory=dict)
class ResearchArtifactManifestSummary(ContractModel):
id: UUID
article_id: UUID
agent_job_id: UUID
s3_prefix: str = Field(min_length=1)
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
created_at: datetime
class EvidenceSummary(ContractModel):
id: UUID
article_id: UUID
source_title: str = Field(min_length=1)
source_url: str = Field(min_length=1)
source_type: str = Field(min_length=1)
source_quality_score: float = Field(ge=0, le=1)
summary: str = Field(min_length=1)
supports_claims: list[UUID] = Field(default_factory=list)
artifact_manifest_id: UUID
retrieved_at: datetime
class ClaimSummary(ContractModel):
id: UUID
article_id: UUID
section_id: UUID | None = None
claim_text: str = Field(min_length=1)
support_status: ClaimSupportStatus
risk_level: ClaimRiskLevel
evidence_item_ids: list[UUID] = Field(default_factory=list)
class DraftSummary(ContractModel):
id: UUID
article_id: UUID
version: int = Field(ge=1)
title: str = Field(min_length=1)
slug: str = Field(min_length=1)
meta_title: str | None = None
meta_description: str | None = None
body_object_key: str | None = None
status: ArticleWorkflowStatus
updated_at: datetime
class AssetSummary(ContractModel):
id: UUID
article_id: UUID
asset_type: AssetType
title: str = Field(min_length=1)
prompt: str | None = None
file_url: str | None = None
alt_text: str | None = None
caption: str | None = None
status: AssetStatus
class ReviewSummary(ContractModel):
id: UUID
article_id: UUID
review_type: ReviewType
status: ReviewStatus
reviewer_id: UUID | None = None
notes: str | None = None
created_at: datetime
class PublishCommitSummary(ContractModel):
id: UUID
article_id: UUID
target_site_id: UUID
repository_url: str = Field(min_length=1)
branch: str = Field(min_length=1)
commit_sha: str | None = None
content_bundle_manifest: JsonObject = Field(default_factory=dict)
status: PublishingStatus
deployment_status: str | None = None
created_at: datetime
class RunnerFileRef(ContractModel):
path: str = Field(min_length=1)
content_hash: str | None = None
class AgentJobSummary(ContractModel):
id: UUID
article_id: UUID
job_type: AgentJobType
agent_profile: str = Field(min_length=1)
status: AgentJobStatus
workspace_path: str | None = None
input_files: list[RunnerFileRef] = Field(default_factory=list)
output_files: list[RunnerFileRef] = Field(default_factory=list)
error_category: AgentJobErrorCategory | None = None
error_message: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class AgentJobOutput(ContractModel):
status: AgentJobStatus
output_files: list[RunnerFileRef] = Field(default_factory=list)
error_category: AgentJobErrorCategory | None = None
error_message: str | None = None
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
payload: JsonObject = Field(default_factory=dict)
class ArticleDetailResponse(ContractModel):
article: ArticleSummary
target_site: TargetSiteConfig | None = None
plan: PlanSummary | None = None
draft: DraftSummary | None = None
evidence: list[EvidenceSummary] = Field(default_factory=list)
claims: list[ClaimSummary] = Field(default_factory=list)
assets: list[AssetSummary] = Field(default_factory=list)
reviews: list[ReviewSummary] = Field(default_factory=list)
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
research_manifests: list[ResearchArtifactManifestSummary] = Field(
default_factory=list
)
publish_commit: PublishCommitSummary | None = None
@@ -0,0 +1,105 @@
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, TypeAdapter
from .enums import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobType,
ArticleWorkflowStatus,
AssetStatus,
AssetType,
ClaimRiskLevel,
ClaimSupportStatus,
PlanReviewStatus,
PublishingStatus,
ReviewStatus,
ReviewType,
Role,
ScriptConfigVersionStatus,
)
from .models import (
AgentJobOutput,
AgentJobSummary,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
ArticleListResponse,
ArticleSummary,
AssetSummary,
ClaimSummary,
DraftSummary,
EvidenceSummary,
PlanSectionSummary,
PlanSummary,
PublishCommitSummary,
PublishingRules,
ResearchArtifactManifestSummary,
ResearchArtifactSummary,
ReviewSummary,
RunnerFileRef,
ScriptConfigVersionSummary,
TargetSiteConfig,
UserSummary,
)
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
Role,
ArticleWorkflowStatus,
AgentJobStatus,
AgentJobType,
AgentJobErrorCategory,
ClaimSupportStatus,
ClaimRiskLevel,
PublishingStatus,
AssetStatus,
AssetType,
PlanReviewStatus,
ReviewStatus,
ReviewType,
ScriptConfigVersionStatus,
)
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
UserSummary,
PublishingRules,
TargetSiteConfig,
ScriptConfigVersionSummary,
ArticleCreateRequest,
ArticleSummary,
ArticleCreateResponse,
ArticleListResponse,
PlanSectionSummary,
PlanSummary,
ResearchArtifactSummary,
ResearchArtifactManifestSummary,
EvidenceSummary,
ClaimSummary,
DraftSummary,
AssetSummary,
ReviewSummary,
PublishCommitSummary,
RunnerFileRef,
AgentJobSummary,
AgentJobOutput,
ArticleDetailResponse,
)
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
for enum in CONTRACT_ENUMS:
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
ref_template="#/components/schemas/{model}"
)
for model in CONTRACT_SCHEMA_MODELS:
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
for name, definition in schema.pop("$defs", {}).items():
schemas.setdefault(name, definition)
schemas[model.__name__] = schema
@@ -0,0 +1,83 @@
from __future__ import annotations
from .enums import ArticleWorkflowStatus
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
ArticleWorkflowStatus.PLAN_GENERATED,
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
ArticleWorkflowStatus.RESEARCH_RUNNING,
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
)
ARTICLE_WORKFLOW_TRANSITIONS: dict[
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
] = {
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
),
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
),
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
ArticleWorkflowStatus.PLAN_GENERATED,
),
ArticleWorkflowStatus.PLAN_GENERATED: (
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
),
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
ArticleWorkflowStatus.RESEARCH_RUNNING,
),
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
ArticleWorkflowStatus.PLAN_GENERATED,
),
ArticleWorkflowStatus.RESEARCH_RUNNING: (
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
),
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
),
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
),
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
),
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
),
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
),
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
),
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
),
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
),
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
}
def next_workflow_statuses(
status: ArticleWorkflowStatus,
) -> tuple[ArticleWorkflowStatus, ...]:
return ARTICLE_WORKFLOW_TRANSITIONS[status]
+25 -1
View File
@@ -1,10 +1,15 @@
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from src.infrastructure.dependencies import check_dependencies
from src.domain.contracts.openapi import inject_contract_schemas
from src.presentation.routes.articles import router as articles_router
app = FastAPI(title="AI Content Pipeline Backend")
app.include_router(articles_router)
@app.get("/health")
@@ -14,6 +19,8 @@ def health() -> dict[str, str]:
@app.get("/health/dependencies")
def dependency_health() -> JSONResponse:
from src.infrastructure.dependencies import check_dependencies
dependencies = check_dependencies()
is_ok = all(status == "ok" for status in dependencies.values())
@@ -25,3 +32,20 @@ def dependency_health() -> JSONResponse:
"dependencies": dependencies,
},
)
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
routes=app.routes,
)
inject_contract_schemas(openapi_schema)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
@@ -0,0 +1 @@
"""Backend HTTP route modules."""
@@ -0,0 +1,18 @@
from __future__ import annotations
from fastapi import APIRouter, status
from src.application.articles import build_placeholder_article_response
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
router = APIRouter(prefix="/api", tags=["articles"])
@router.post(
"/articles",
response_model=ArticleCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
return build_placeholder_article_response(request)
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,77 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from pydantic import ValidationError
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.domain.contracts import ( # noqa: E402
ARTICLE_WORKFLOW_STATUSES,
ARTICLE_WORKFLOW_TRANSITIONS,
ArticleCreateRequest,
ArticleWorkflowStatus,
Role,
UserSummary,
)
class DomainContractTest(unittest.TestCase):
def test_role_enum_is_canonical(self) -> None:
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
with self.assertRaises(ValidationError):
UserSummary.model_validate(
{
"id": "00000000-0000-0000-0000-000000000001",
"display_name": "Writer",
"role": "WRITER",
}
)
def test_article_create_rejects_missing_required_fields(self) -> None:
with self.assertRaises(ValidationError) as error:
ArticleCreateRequest.model_validate({})
missing_fields = {
issue["loc"][0]
for issue in error.exception.errors()
if issue["type"] == "missing"
}
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
self.assertEqual(
[
"ARTICLE_BRIEF_CREATED",
"BOUNDARY_QUESTIONS_GENERATED",
"BOUNDARY_ANSWERS_SUBMITTED",
"PLAN_GENERATED",
"PLAN_REVIEW_REQUIRED",
"PLAN_REVISION_REQUIRED",
"RESEARCH_RUNNING",
"EVIDENCE_MATRIX_READY",
"PARALLEL_PRODUCTION_RUNNING",
"DRAFT_ASSEMBLED",
"SEO_AND_LANGUAGE_REVIEW_READY",
"FINAL_REVIEW_REQUIRED",
"FINAL_REVISION_REQUIRED",
"PUBLISH_DRY_RUN_REQUIRED",
"PUBLISH_COMMIT_READY",
"PUBLISH_COMMIT_CREATED",
],
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
)
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
self.assertIsInstance(status, ArticleWorkflowStatus)
for next_status in next_statuses:
self.assertIsInstance(next_status, ArticleWorkflowStatus)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
sys.path.insert(0, str(BACKEND_ROOT))
from src.presentation.main import app # noqa: E402
class GeneratedContractArtifactsTest(unittest.TestCase):
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
self.assertEqual(app.openapi(), snapshot)
def test_typescript_types_are_marked_generated(self) -> None:
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
content = types_path.read_text(encoding="utf-8")
self.assertTrue(
content.startswith(
"// Generated from backend OpenAPI by "
"scripts/generate_openapi_contracts.py."
)
)
self.assertIn("export type ArticleCreateRequest", content)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,188 @@
from __future__ import annotations
import sys
import types
import unittest
from importlib.util import find_spec
from pathlib import Path
from typing import Any
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
def _module_available(name: str) -> bool:
try:
return find_spec(name) is not None
except (ModuleNotFoundError, ValueError):
return False
def _install_optional_dependency_stubs() -> None:
# Health routes import external clients; this contract test only reads OpenAPI.
if not _module_available("boto3"):
boto3 = types.ModuleType("boto3")
boto3.client = lambda *args, **kwargs: None
sys.modules["boto3"] = boto3
if not _module_available("psycopg"):
psycopg = types.ModuleType("psycopg")
psycopg.connect = lambda *args, **kwargs: None
sys.modules["psycopg"] = psycopg
if not _module_available("redis"):
redis = types.ModuleType("redis")
class Redis:
@staticmethod
def from_url(*args: Any, **kwargs: Any) -> None:
return None
redis.Redis = Redis
sys.modules["redis"] = redis
if not _module_available("botocore.config"):
botocore = types.ModuleType("botocore")
config = types.ModuleType("botocore.config")
class Config:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
config.Config = Config
sys.modules["botocore"] = botocore
sys.modules["botocore.config"] = config
_install_optional_dependency_stubs()
from src.presentation.main import app # noqa: E402
class BackendPublicOpenApiContractTest(unittest.TestCase):
def setUp(self) -> None:
self.openapi = app.openapi()
self.schemas = self.openapi.get("components", {}).get("schemas", {})
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
self,
) -> None:
failures: list[str] = []
article_schema = self.schemas.get("ArticleCreateRequest")
if article_schema is None:
failures.append("components.schemas.ArticleCreateRequest is missing")
else:
required = set(article_schema.get("required", []))
for field in ("brief_description", "target_site_id"):
if field not in required:
failures.append(f"ArticleCreateRequest must require {field!r}")
invalid_article_errors = self._validation_errors(
schema_name="ArticleCreateRequest",
payload={},
)
if not self._has_required_error(invalid_article_errors, "brief_description"):
failures.append(
"ArticleCreateRequest contract must reject payloads missing "
"'brief_description'"
)
if not self._has_required_error(invalid_article_errors, "target_site_id"):
failures.append(
"ArticleCreateRequest contract must reject payloads missing "
"'target_site_id'"
)
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
if request_ref != "#/components/schemas/ArticleCreateRequest":
failures.append(
"POST /api/articles must expose ArticleCreateRequest as its JSON "
f"request body; got {request_ref or 'no request body'}"
)
role_schema = self.schemas.get("Role")
if role_schema is None:
failures.append("components.schemas.Role is missing")
else:
role_values = self._enum_values(role_schema)
if role_values != ["ADMIN", "EDITOR"]:
failures.append(
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
f"got {role_values!r}"
)
if not self._has_enum_error("Role", "WRITER"):
failures.append("Role contract must reject invalid value 'WRITER'")
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
if workflow_status_schema is None:
failures.append("components.schemas.ArticleWorkflowStatus is missing")
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
failures.append(
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
)
self.assertEqual([], failures)
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
content = operation.get("requestBody", {}).get("content", {})
schema = content.get("application/json", {}).get("schema", {})
return schema.get("$ref")
def _has_required_error(self, errors: list[str], field: str) -> bool:
return any(error == f"missing required field: {field}" for error in errors)
def _has_enum_error(self, schema_name: str, value: str) -> bool:
return f"invalid enum value: {value}" in self._validation_errors(
schema_name=schema_name,
payload=value,
)
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
schema = self.schemas.get(schema_name)
if schema is None:
return [f"missing schema: {schema_name}"]
enum_values = self._enum_values(schema)
if enum_values is not None:
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
if schema.get("type") != "object" or not isinstance(payload, dict):
return []
required = schema.get("required", [])
return [
f"missing required field: {field}"
for field in required
if field not in payload
]
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
enum_values = schema.get("enum")
if isinstance(enum_values, list):
return enum_values
for composite_key in ("allOf", "anyOf", "oneOf"):
for item in schema.get(composite_key, []):
resolved = self._resolve_schema(item)
values = self._enum_values(resolved)
if values is not None:
return values
return None
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
ref = schema.get("$ref")
if not isinstance(ref, str):
return schema
prefix = "#/components/schemas/"
if not ref.startswith(prefix):
return schema
return self.schemas.get(ref.removeprefix(prefix), schema)
if __name__ == "__main__":
unittest.main()
+12 -5
View File
@@ -1,12 +1,19 @@
FROM node:22-alpine
FROM node:22-slim
WORKDIR /app
COPY apps/frontend/package.json ./package.json
RUN npm install
RUN corepack enable
COPY apps/frontend ./
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY packages/shared/package.json ./packages/shared/package.json
RUN pnpm install --frozen-lockfile
COPY apps/frontend ./apps/frontend
COPY packages/shared ./packages/shared
WORKDIR /app/apps/frontend
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
CMD ["pnpm", "exec", "next", "dev", "--hostname", "0.0.0.0"]
+7 -1
View File
@@ -4,9 +4,11 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pipeline/shared": "workspace:*",
"next": "16.2.6",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -17,6 +19,10 @@
"@types/react-dom": "19.0.2",
"typescript": "5.7.2"
},
"optionalDependencies": {
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6"
},
"overrides": {
"postcss": "8.5.14"
}
+9
View File
@@ -1,8 +1,17 @@
import type { ArticleListResponse } from "@pipeline/shared";
const initialArticles: ArticleListResponse = {
articles: [],
};
export default function HomePage() {
return (
<main>
<h1>AI Content Pipeline</h1>
<p>Dockerized monorepo foundation is running.</p>
<p>
{initialArticles.articles.length} articles in the contract-backed dashboard.
</p>
</main>
);
}
+4
View File
@@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY apps/runner/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/backend/src/domain/contracts ./backend_contracts/contracts
COPY apps/runner/src ./src
EXPOSE 8010
+1
View File
@@ -0,0 +1 @@
pydantic==2.13.4
@@ -0,0 +1,63 @@
from __future__ import annotations
import importlib.util
import os
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any, Mapping
@lru_cache(maxsize=1)
def _backend_contracts_module() -> Any:
contracts_dir = _backend_contracts_dir()
init_file = contracts_dir / "__init__.py"
module_name = "_pipeline_backend_domain_contracts"
existing = sys.modules.get(module_name)
if existing is not None and Path(str(existing.__file__)).resolve() == init_file.resolve():
return existing
sys.modules.pop(module_name, None)
spec = importlib.util.spec_from_file_location(
module_name,
init_file,
submodule_search_locations=[str(contracts_dir)],
)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load backend contract module from {init_file}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _backend_contracts_dir() -> Path:
for candidate in _backend_contract_candidates():
init_file = candidate / "__init__.py"
if init_file.is_file():
return candidate
candidates = ", ".join(str(path) for path in _backend_contract_candidates())
raise RuntimeError(f"Cannot find backend contract package. Checked: {candidates}")
def _backend_contract_candidates() -> tuple[Path, ...]:
configured = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
candidates: list[Path] = []
if configured:
candidates.append(Path(configured))
current_file = Path(__file__).resolve()
candidates.append(Path("/app/backend_contracts/contracts"))
for parent in current_file.parents:
candidates.append(parent / "apps" / "backend" / "src" / "domain" / "contracts")
return tuple(dict.fromkeys(candidates))
def validate_agent_job_output(payload: Mapping[str, Any]) -> Any:
contracts = _backend_contracts_module()
return contracts.AgentJobOutput.model_validate(payload)
@@ -0,0 +1,62 @@
from __future__ import annotations
import sys
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[3]
RUNNER_ROOT = REPO_ROOT / "apps" / "runner"
sys.path.insert(0, str(RUNNER_ROOT))
from src.application.job_output_validation import ( # noqa: E402
_backend_contracts_module,
validate_agent_job_output,
)
class RunnerJobOutputValidationTest(unittest.TestCase):
def test_valid_runner_job_output_uses_backend_contracts(self) -> None:
output = validate_agent_job_output(
{
"status": "SUCCEEDED",
"output_files": [{"path": "outputs/plan.json"}],
"payload": {"artifact": "plan"},
}
)
self.assertEqual("SUCCEEDED", output.status.value)
def test_invalid_runner_job_output_status_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
validate_agent_job_output({"status": "NOT_A_STATUS"})
def test_runner_image_layout_can_use_copied_backend_contracts(self) -> None:
source_contracts = REPO_ROOT / "apps" / "backend" / "src" / "domain" / "contracts"
with tempfile.TemporaryDirectory() as temp_dir:
copied_contracts = Path(temp_dir) / "backend_contracts" / "contracts"
shutil.copytree(source_contracts, copied_contracts)
previous = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = str(copied_contracts)
_backend_contracts_module.cache_clear()
try:
output = validate_agent_job_output({"status": "SUCCEEDED"})
finally:
_backend_contracts_module.cache_clear()
if previous is None:
os.environ.pop("PIPELINE_BACKEND_CONTRACTS_DIR", None)
else:
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = previous
self.assertEqual("SUCCEEDED", output.status.value)
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -1,9 +1,12 @@
{
"name": "ai-content-pipeline",
"private": true,
"packageManager": "pnpm@10.27.0",
"scripts": {
"dev:stack": "docker compose up --build",
"smoke": "bash tests/smoke/public-health.sh"
"generate:contracts": "python3 scripts/generate_openapi_contracts.py",
"smoke": "bash tests/smoke/public-health.sh",
"typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck"
},
"workspaces": [
"apps/frontend",
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
// Generated from backend OpenAPI by scripts/generate_openapi_contracts.py.
// Do not edit by hand.
export type AgentJobErrorCategory = "FAILED_SCHEMA_VALIDATION" | "CLI_EXIT_CODE_FAILURE" | "TIMEOUT" | "MISSING_OUTPUT_FILE" | "UNSUPPORTED_CLAIMS_FOUND" | "SOURCE_RETRIEVAL_FAILED" | "RESEARCH_ARTIFACT_UPLOAD_FAILED" | "PUBLISH_DRY_RUN_FAILED" | "GIT_CHECKOUT_FAILED" | "GIT_COMMIT_FAILED" | "GIT_PUSH_NON_FAST_FORWARD" | "PUBLISH_VERIFICATION_FAILED";
export type AgentJobOutput = {
artifacts?: ResearchArtifactSummary[];
error_category?: AgentJobErrorCategory | null;
error_message?: string | null;
output_files?: RunnerFileRef[];
payload?: Record<string, unknown>;
status: AgentJobStatus;
};
export type AgentJobStatus = "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
export type AgentJobSummary = {
agent_profile: string;
article_id: string;
error_category?: AgentJobErrorCategory | null;
error_message?: string | null;
finished_at?: string | null;
id: string;
input_files?: RunnerFileRef[];
job_type: AgentJobType;
output_files?: RunnerFileRef[];
started_at?: string | null;
status: AgentJobStatus;
workspace_path?: string | null;
};
export type AgentJobType = "BOUNDARY_QUESTIONS" | "PLAN_GENERATION" | "RESEARCH" | "EVIDENCE_MATRIX" | "SECTION_SCAFFOLD" | "DRAFT_ASSEMBLY" | "SEO_REVIEW" | "LANGUAGE_REVIEW" | "PUBLISH_DRY_RUN" | "PUBLISH_COMMIT" | "TEST_CODEX";
export type ArticleCreateRequest = {
assigned_editor_id?: string | null;
brief_description: string;
content_type?: string;
language?: string;
primary_keyword?: string | null;
target_site_id: string;
working_title?: string | null;
};
export type ArticleCreateResponse = {
article: ArticleSummary;
};
export type ArticleDetailResponse = {
agent_jobs?: AgentJobSummary[];
article: ArticleSummary;
assets?: AssetSummary[];
claims?: ClaimSummary[];
draft?: DraftSummary | null;
evidence?: EvidenceSummary[];
plan?: PlanSummary | null;
publish_commit?: PublishCommitSummary | null;
research_manifests?: ResearchArtifactManifestSummary[];
reviews?: ReviewSummary[];
target_site?: TargetSiteConfig | null;
};
export type ArticleListResponse = {
articles: ArticleSummary[];
};
export type ArticleSummary = {
assigned_editor_id?: string | null;
brief_description: string;
content_type: string;
created_at: string;
id: string;
language: string;
primary_keyword?: string | null;
publishing_status: PublishingStatus;
status: ArticleWorkflowStatus;
target_site_id: string;
updated_at: string;
working_title?: string | null;
};
export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED";
export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED";
export type AssetSummary = {
alt_text?: string | null;
article_id: string;
asset_type: AssetType;
caption?: string | null;
file_url?: string | null;
id: string;
prompt?: string | null;
status: AssetStatus;
title: string;
};
export type AssetType = "hero_image" | "diagram" | "table" | "inline_image";
export type ClaimRiskLevel = "low" | "medium" | "high";
export type ClaimSummary = {
article_id: string;
claim_text: string;
evidence_item_ids?: string[];
id: string;
risk_level: ClaimRiskLevel;
section_id?: string | null;
support_status: ClaimSupportStatus;
};
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
export type DraftSummary = {
article_id: string;
body_object_key?: string | null;
id: string;
meta_description?: string | null;
meta_title?: string | null;
slug: string;
status: ArticleWorkflowStatus;
title: string;
updated_at: string;
version: number;
};
export type EvidenceSummary = {
article_id: string;
artifact_manifest_id: string;
id: string;
retrieved_at: string;
source_quality_score: number;
source_title: string;
source_type: string;
source_url: string;
summary: string;
supports_claims?: string[];
};
export type HTTPValidationError = {
detail?: ValidationError[];
};
export type PlanReviewStatus = "PENDING_REVIEW" | "APPROVED" | "REVISION_REQUESTED";
export type PlanSectionSummary = {
article_plan_id: string;
claims_to_support?: string[];
heading: string;
id: string;
purpose?: string | null;
sort_order: number;
target_word_count?: number | null;
};
export type PlanSummary = {
article_id: string;
claims_to_prove?: string[];
created_at: string;
id: string;
recommended_title?: string | null;
risks?: string[];
search_intent?: string | null;
sections?: PlanSectionSummary[];
status: PlanReviewStatus;
thesis?: string | null;
version: number;
};
export type PublishCommitSummary = {
article_id: string;
branch: string;
commit_sha?: string | null;
content_bundle_manifest?: Record<string, unknown>;
created_at: string;
deployment_status?: string | null;
id: string;
repository_url: string;
status: PublishingStatus;
target_site_id: string;
};
export type PublishingRules = {
asset_path_template: string;
content_format?: string;
content_path_template: string;
dry_run_renderer?: string | null;
frontmatter_mapping?: Record<string, unknown>;
production_branch: string;
repository_url: string;
transform_script_version_id?: string | null;
};
export type PublishingStatus = "PUBLISH_NOT_STARTED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_DRY_RUN_RUNNING" | "PUBLISH_DRY_RUN_FAILED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED" | "PUBLISH_VERIFICATION_FAILED";
export type ResearchArtifactManifestSummary = {
agent_job_id: string;
article_id: string;
artifacts?: ResearchArtifactSummary[];
created_at: string;
id: string;
s3_prefix: string;
};
export type ResearchArtifactSummary = {
artifact_type: string;
content_hash: string;
metadata?: Record<string, unknown>;
object_key: string;
source_url: string;
};
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
export type ReviewSummary = {
article_id: string;
created_at: string;
id: string;
notes?: string | null;
review_type: ReviewType;
reviewer_id?: string | null;
status: ReviewStatus;
};
export type ReviewType = "PLAN" | "EVIDENCE" | "ASSET" | "SEO_LANGUAGE" | "FINAL";
export type Role = "ADMIN" | "EDITOR";
export type RunnerFileRef = {
content_hash?: string | null;
path: string;
};
export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED";
export type ScriptConfigVersionSummary = {
created_at: string;
created_by: string;
id: string;
publishing_yaml_hash: string;
status: ScriptConfigVersionStatus;
target_site_id: string;
transform_script_hash: string;
version: number;
};
export type TargetSiteConfig = {
active_script_config_version_id?: string | null;
audience: string;
brand_voice: string;
created_at: string;
default_language?: string;
id: string;
name: string;
publishing_rules: PublishingRules;
publishing_type?: string;
seo_rules?: Record<string, unknown>;
slug: string;
source_rules?: Record<string, unknown>;
updated_at: string;
visual_rules?: Record<string, unknown>;
};
export type UserSummary = {
display_name: string;
id: string;
role: Role;
};
export type ValidationError = {
loc: (string | number)[];
msg: string;
type: string;
};
+1 -6
View File
@@ -1,6 +1 @@
export type {
BackendDependenciesHealthResponse,
BackendDependencyName,
HealthResponse,
ServiceName,
} from "./health";
export type * from "./api-types";
+605
View File
@@ -0,0 +1,605 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
apps/frontend:
dependencies:
'@pipeline/shared':
specifier: workspace:*
version: link:../../packages/shared
next:
specifier: 16.2.6
version: 16.2.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
devDependencies:
'@types/node':
specifier: 22.10.5
version: 22.10.5
'@types/react':
specifier: 19.0.2
version: 19.0.2
'@types/react-dom':
specifier: 19.0.2
version: 19.0.2(@types/react@19.0.2)
typescript:
specifier: 5.7.2
version: 5.7.2
optionalDependencies:
'@next/swc-linux-arm64-gnu':
specifier: 16.2.6
version: 16.2.6
'@next/swc-linux-x64-gnu':
specifier: 16.2.6
version: 16.2.6
packages/shared:
devDependencies:
typescript:
specifier: 5.7.2
version: 5.7.2
packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==, tarball: https://npm.yandex-team.ru/@img%2fcolour/-/colour-1.1.0.tgz?rbtorrent=}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==, tarball: https://npm.yandex-team.ru/@next%2fenv/-/env-16.2.6.tgz?rbtorrent=}
'@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==, tarball: https://npm.yandex-team.ru/@swc%2fhelpers/-/helpers-0.5.15.tgz?rbtorrent=}
'@types/node@22.10.5':
resolution: {integrity: sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==, tarball: https://npm.yandex-team.ru/@types%2fnode/-/node-22.10.5.tgz?rbtorrent=}
'@types/react-dom@19.0.2':
resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==, tarball: https://npm.yandex-team.ru/@types%2freact-dom/-/react-dom-19.0.2.tgz?rbtorrent=}
peerDependencies:
'@types/react': ^19.0.0
'@types/react@19.0.2':
resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==, tarball: https://npm.yandex-team.ru/@types%2freact/-/react-19.0.2.tgz?rbtorrent=}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==, tarball: https://npm.yandex-team.ru/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz?rbtorrent=}
engines: {node: '>=6.0.0'}
hasBin: true
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==, tarball: https://npm.yandex-team.ru/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz?rbtorrent=}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, tarball: https://npm.yandex-team.ru/client-only/-/client-only-0.0.1.tgz?rbtorrent=36b9ca4d2b7c37c1c85069c7059d07dd68f648e4}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, tarball: https://npm.yandex-team.ru/csstype/-/csstype-3.2.3.tgz?rbtorrent=}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, tarball: https://npm.yandex-team.ru/detect-libc/-/detect-libc-2.1.2.tgz?rbtorrent=}
engines: {node: '>=8'}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==, tarball: https://npm.yandex-team.ru/nanoid/-/nanoid-3.3.12.tgz?rbtorrent=}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==, tarball: https://npm.yandex-team.ru/next/-/next-16.2.6.tgz?rbtorrent=}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://npm.yandex-team.ru/picocolors/-/picocolors-1.1.1.tgz?rbtorrent=}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==, tarball: https://npm.yandex-team.ru/postcss/-/postcss-8.4.31.tgz?rbtorrent=217e20884324e43b47ce44404002fccc49a6ab44}
engines: {node: ^10 || ^12 || >=14}
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==, tarball: https://npm.yandex-team.ru/react-dom/-/react-dom-19.0.0.tgz?rbtorrent=}
peerDependencies:
react: ^19.0.0
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==, tarball: https://npm.yandex-team.ru/react/-/react-19.0.0.tgz?rbtorrent=}
engines: {node: '>=0.10.0'}
scheduler@0.25.0:
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==, tarball: https://npm.yandex-team.ru/scheduler/-/scheduler-0.25.0.tgz?rbtorrent=}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, tarball: https://npm.yandex-team.ru/semver/-/semver-7.7.4.tgz?rbtorrent=}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://npm.yandex-team.ru/source-map-js/-/source-map-js-1.2.1.tgz?rbtorrent=}
engines: {node: '>=0.10.0'}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==, tarball: https://npm.yandex-team.ru/styled-jsx/-/styled-jsx-5.1.6.tgz?rbtorrent=}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
babel-plugin-macros:
optional: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://npm.yandex-team.ru/tslib/-/tslib-2.8.1.tgz?rbtorrent=}
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==, tarball: https://npm.yandex-team.ru/typescript/-/typescript-5.7.2.tgz?rbtorrent=}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==, tarball: https://npm.yandex-team.ru/undici-types/-/undici-types-6.20.0.tgz?rbtorrent=}
snapshots:
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@next/env@16.2.6': {}
'@next/swc-darwin-arm64@16.2.6':
optional: true
'@next/swc-darwin-x64@16.2.6':
optional: true
'@next/swc-linux-arm64-gnu@16.2.6':
optional: true
'@next/swc-linux-arm64-musl@16.2.6':
optional: true
'@next/swc-linux-x64-gnu@16.2.6':
optional: true
'@next/swc-linux-x64-musl@16.2.6':
optional: true
'@next/swc-win32-arm64-msvc@16.2.6':
optional: true
'@next/swc-win32-x64-msvc@16.2.6':
optional: true
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@types/node@22.10.5':
dependencies:
undici-types: 6.20.0
'@types/react-dom@19.0.2(@types/react@19.0.2)':
dependencies:
'@types/react': 19.0.2
'@types/react@19.0.2':
dependencies:
csstype: 3.2.3
baseline-browser-mapping@2.10.27: {}
caniuse-lite@1.0.30001792: {}
client-only@0.0.1: {}
csstype@3.2.3: {}
detect-libc@2.1.2:
optional: true
nanoid@3.3.12: {}
next@16.2.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001792
postcss: 8.4.31
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.2.6
'@next/swc-linux-arm64-gnu': 16.2.6
'@next/swc-linux-arm64-musl': 16.2.6
'@next/swc-linux-x64-gnu': 16.2.6
'@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
picocolors@1.1.1: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
react-dom@19.0.0(react@19.0.0):
dependencies:
react: 19.0.0
scheduler: 0.25.0
react@19.0.0: {}
scheduler@0.25.0: {}
semver@7.7.4:
optional: true
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
source-map-js@1.2.1: {}
styled-jsx@5.1.6(react@19.0.0):
dependencies:
client-only: 0.0.1
react: 19.0.0
tslib@2.8.1: {}
typescript@5.7.2: {}
undici-types@6.20.0: {}
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import json
import keyword
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
OPENAPI_PATH = REPO_ROOT / "packages" / "shared" / "openapi.json"
TS_TYPES_PATH = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
sys.path.insert(0, str(BACKEND_ROOT))
from src.presentation.main import app # noqa: E402
GENERATED_HEADER = """// Generated from backend OpenAPI by scripts/generate_openapi_contracts.py.
// Do not edit by hand.
"""
def main() -> None:
openapi = app.openapi()
OPENAPI_PATH.write_text(
json.dumps(openapi, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
TS_TYPES_PATH.write_text(render_typescript(openapi), encoding="utf-8")
print(f"wrote {OPENAPI_PATH}")
print(f"wrote {TS_TYPES_PATH}")
def render_typescript(openapi: dict[str, Any]) -> str:
schemas = openapi.get("components", {}).get("schemas", {})
lines = [GENERATED_HEADER.rstrip(), ""]
for name in sorted(schemas):
lines.append(render_schema_type(name, schemas[name], schemas))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_schema_type(
name: str,
schema: dict[str, Any],
schemas: dict[str, Any],
) -> str:
if schema.get("type") == "object" or "properties" in schema:
return f"export type {name} = {render_object(schema, schemas, indent=0)};"
return f"export type {name} = {schema_to_ts(schema, schemas)};"
def render_object(
schema: dict[str, Any],
schemas: dict[str, Any],
*,
indent: int,
) -> str:
properties = schema.get("properties", {})
required = set(schema.get("required", []))
pad = " " * indent
child_pad = " " * (indent + 1)
if not properties:
additional = schema.get("additionalProperties")
if isinstance(additional, dict):
return f"Record<string, {schema_to_ts(additional, schemas)}>"
return "Record<string, unknown>"
lines = ["{"]
for property_name in sorted(properties):
property_schema = properties[property_name]
optional = "" if property_name in required else "?"
lines.append(
f"{child_pad}{ts_property_name(property_name)}{optional}: "
f"{schema_to_ts(property_schema, schemas)};"
)
lines.append(f"{pad}}}")
return "\n".join(lines)
def schema_to_ts(schema: dict[str, Any], schemas: dict[str, Any]) -> str:
if "$ref" in schema:
return schema["$ref"].removeprefix("#/components/schemas/")
if "enum" in schema:
values = schema["enum"]
return " | ".join(json.dumps(value) for value in values) or "never"
if "const" in schema:
return json.dumps(schema["const"])
if "anyOf" in schema:
return render_union(schema["anyOf"], schemas)
if "oneOf" in schema:
return render_union(schema["oneOf"], schemas)
if "allOf" in schema:
return " & ".join(schema_to_ts(item, schemas) for item in schema["allOf"])
schema_type = schema.get("type")
if isinstance(schema_type, list):
return " | ".join(
schema_type_to_ts(item, schema, schemas) for item in schema_type
)
return schema_type_to_ts(schema_type, schema, schemas)
def render_union(items: list[dict[str, Any]], schemas: dict[str, Any]) -> str:
rendered = [schema_to_ts(item, schemas) for item in items]
return " | ".join(rendered) or "unknown"
def schema_type_to_ts(
schema_type: str | None,
schema: dict[str, Any],
schemas: dict[str, Any],
) -> str:
if schema_type == "string":
return "string"
if schema_type in {"integer", "number"}:
return "number"
if schema_type == "boolean":
return "boolean"
if schema_type == "null":
return "null"
if schema_type == "array":
return f"{array_item_to_ts(schema.get('items', {}), schemas)}[]"
if schema_type == "object" or "properties" in schema:
return render_object(schema, schemas, indent=0)
return "unknown"
def array_item_to_ts(schema: dict[str, Any], schemas: dict[str, Any]) -> str:
rendered = schema_to_ts(schema, schemas)
if " | " in rendered or " & " in rendered:
return f"({rendered})"
return rendered
def ts_property_name(name: str) -> str:
if name.isidentifier() and not keyword.iskeyword(name):
return name
return json.dumps(name)
if __name__ == "__main__":
main()
+73 -12
View File
@@ -30,12 +30,12 @@ Development description: Implement the shared domain schema package that defines
## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write a failing contract test for one externally visible DTO and one invalid payload; proceed one schema at a time and record red-green evidence in `Result`.
- [ ] Workflow status transitions use shared constants rather than string literals spread across apps.
- [ ] Role names are exactly `ADMIN` and `EDITOR`.
- [ ] Generated frontend types match backend OpenAPI.
- [ ] Contract tests fail on missing required fields and invalid enum values.
- [ ] Runner job output schemas can be validated without importing frontend code.
- [x] TDD pre-requirement: before implementation, write a failing contract test for one externally visible DTO and one invalid payload; proceed one schema at a time and record red-green evidence in `Result`.
- [x] Workflow status transitions use shared constants rather than string literals spread across apps.
- [x] Role names are exactly `ADMIN` and `EDITOR`.
- [x] Generated frontend types match backend OpenAPI.
- [x] Contract tests fail on missing required fields and invalid enum values.
- [x] Runner job output schemas can be validated without importing frontend code.
## Verification
@@ -45,9 +45,70 @@ Development description: Implement the shared domain schema package that defines
## Result
- Status: Pending execution.
- TDD plan: To be filled during execution.
- Red evidence: To be filled during execution.
- Green evidence: To be filled during execution.
- Refactor notes: To be filled during execution.
- Verification output: To be filled during execution.
- Status: Completed.
- TDD plan:
- Add one backend public OpenAPI contract test before implementation.
- Verify `POST /api/articles` exposes `ArticleCreateRequest` as an external request DTO.
- Verify `ArticleCreateRequest` requires `brief_description` and `target_site_id`.
- Verify `Role` enum is exactly `ADMIN` and `EDITOR`.
- Verify the OpenAPI contract validation surface rejects a missing required article-create payload and invalid enum values once the schemas exist.
- Red evidence:
- Command: `/private/tmp/pupline-contract-venv/bin/python -m unittest apps/backend/tests/contracts/test_public_openapi_contract.py`
- Note: system Python did not have FastAPI installed; the red run used a temporary venv with `fastapi==0.115.6`. No Docker or HTTP server was used.
- Result: `FAILED (failures=1)`
- Failure summary:
- `components.schemas.ArticleCreateRequest is missing`
- `POST /api/articles must expose ArticleCreateRequest as its JSON request body; got no request body`
- `components.schemas.Role is missing`
- `components.schemas.ArticleWorkflowStatus is missing`
- Green evidence:
- Command: `/private/tmp/pupline-contract-venv/bin/python -m unittest apps/backend/tests/contracts/test_public_openapi_contract.py`
- Result: `OK`
- Command: `/private/tmp/pupline-contract-venv/bin/python -m unittest discover -s apps/backend/tests`
- Result: `Ran 6 tests in 0.015s` / `OK`
- Command: `/private/tmp/pupline-contract-venv/bin/python -m unittest discover -s apps/runner/tests`
- Result: `Ran 3 tests in 0.044s` / `OK`
- Refactor notes:
- Added backend domain contract package under `apps/backend/src/domain/contracts/` as the Pydantic source of truth.
- Added shared workflow transition constants in `apps/backend/src/domain/contracts/workflow.py`.
- Kept presentation thin: `POST /api/articles` imports domain request/response schemas and delegates deterministic placeholder response creation to application code.
- Added OpenAPI injection for canonical contract components so schemas not yet backed by business endpoints are still public in `/openapi.json`.
- Added runner job-output validation that loads backend contract models directly and does not import frontend code.
- Regression fix: runner Docker image now installs `apps/runner/requirements.txt` before copying source, so runner validation dependencies are present in the image.
- Regression fix: runner Docker image now bundles the backend contract package under `/app/backend_contracts/contracts`; the validator resolves that image path first, with monorepo fallback for local tests.
- Added a generated-artifact contract test to keep `packages/shared/openapi.json` in sync with backend OpenAPI.
- Replaced shared package exports with generated API types and wired frontend to import `ArticleListResponse` from `@pipeline/shared`.
- Regression fix: rewrote `apps/frontend/Dockerfile` to install from the repository root with pnpm workspace manifests, so Docker understands `@pipeline/shared: workspace:*`.
- Regression fix: pinned root `packageManager` to `pnpm@10.27.0`, copied `.npmrc` into the frontend image, and used `node:22-slim` plus linux SWC optional dependencies so Next does not try to download SWC at request time.
- Verification output:
- Command: `make contracts PYTHON=/private/tmp/pupline-contract-venv/bin/python`
- Result:
- `wrote /Users/gavrilovdev/tmp/pupline/packages/shared/openapi.json`
- `wrote /Users/gavrilovdev/tmp/pupline/packages/shared/src/api-types.ts`
- Command: `pnpm install`
- Result: `Done in 8.4s using pnpm v10.27.0`
- Command: `pnpm install --frozen-lockfile`
- Result: `Lockfile is up to date` / `Done in 13.3s using pnpm v10.27.0`
- Command: `pnpm typecheck`
- Result: `@pipeline/shared` and `@pipeline/frontend` `tsc --noEmit` completed successfully.
- Regression command: `bash tests/smoke/public-health.sh`
- Regression red result before fix:
- `npm error code EUNSUPPORTEDPROTOCOL`
- `Unsupported URL Type "workspace:": workspace:*`
- Regression green result after fix:
- `RUN pip install --no-cache-dir -r requirements.txt`
- `Successfully installed ... pydantic-2.13.4 ... pydantic-core-2.46.4`
- `RUN pnpm install --frozen-lockfile`
- `Done in 10.8s using pnpm v10.27.0`
- `pupline-frontend Built`
- `backend health ok`
- `runner health ok`
- `frontend health ok`
- `backend dependencies ok`
- `public health smoke ok`
- Runner image validation command: `docker compose run --rm runner python -c "from src.application.job_output_validation import validate_agent_job_output; result = validate_agent_job_output({'status':'SUCCEEDED'}); print(result.status.value)"`
- Runner image validation result: `SUCCEEDED`
- OpenAPI spot check:
- `POST /api/articles` request body is `#/components/schemas/ArticleCreateRequest`.
- `Role` enum is `['ADMIN', 'EDITOR']`.
- `ArticleWorkflowStatus` starts at `ARTICLE_BRIEF_CREATED` and ends at `PUBLISH_COMMIT_CREATED`.