From a0ae06adfad7f6b5764b470f523af7aa1feffa27 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 21 May 2026 17:45:00 +0300 Subject: [PATCH] Task 002: add shared domain contracts --- .gitignore | 1 + .npmrc | 7 + Makefile | 7 +- apps/backend/src/application/articles.py | 39 + apps/backend/src/domain/contracts/__init__.py | 87 + apps/backend/src/domain/contracts/enums.py | 126 ++ apps/backend/src/domain/contracts/models.py | 266 +++ apps/backend/src/domain/contracts/openapi.py | 105 + apps/backend/src/domain/contracts/workflow.py | 83 + apps/backend/src/presentation/main.py | 26 +- .../src/presentation/routes/__init__.py | 1 + .../src/presentation/routes/articles.py | 18 + apps/backend/tests/__init__.py | 1 + apps/backend/tests/contracts/__init__.py | 1 + .../tests/contracts/test_domain_contracts.py | 77 + .../test_generated_contract_artifacts.py | 37 + .../contracts/test_public_openapi_contract.py | 188 ++ apps/frontend/Dockerfile | 17 +- apps/frontend/package.json | 8 +- apps/frontend/src/app/page.tsx | 9 + apps/runner/Dockerfile | 4 + apps/runner/requirements.txt | 1 + .../src/application/job_output_validation.py | 63 + .../tests/test_job_output_validation.py | 62 + package.json | 5 +- packages/shared/openapi.json | 1708 +++++++++++++++++ packages/shared/src/api-types.ts | 273 +++ packages/shared/src/index.ts | 7 +- pnpm-lock.yaml | 605 ++++++ scripts/generate_openapi_contracts.py | 153 ++ tasks/002-shared-domain-contracts.md | 85 +- 31 files changed, 4043 insertions(+), 27 deletions(-) create mode 100644 .npmrc create mode 100644 apps/backend/src/application/articles.py create mode 100644 apps/backend/src/domain/contracts/__init__.py create mode 100644 apps/backend/src/domain/contracts/enums.py create mode 100644 apps/backend/src/domain/contracts/models.py create mode 100644 apps/backend/src/domain/contracts/openapi.py create mode 100644 apps/backend/src/domain/contracts/workflow.py create mode 100644 apps/backend/src/presentation/routes/__init__.py create mode 100644 apps/backend/src/presentation/routes/articles.py create mode 100644 apps/backend/tests/__init__.py create mode 100644 apps/backend/tests/contracts/__init__.py create mode 100644 apps/backend/tests/contracts/test_domain_contracts.py create mode 100644 apps/backend/tests/contracts/test_generated_contract_artifacts.py create mode 100644 apps/backend/tests/contracts/test_public_openapi_contract.py create mode 100644 apps/runner/src/application/job_output_validation.py create mode 100644 apps/runner/tests/test_job_output_validation.py create mode 100644 packages/shared/openapi.json create mode 100644 packages/shared/src/api-types.ts create mode 100644 pnpm-lock.yaml create mode 100644 scripts/generate_openapi_contracts.py diff --git a/.gitignore b/.gitignore index 291de22..72cc626 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store node_modules/ .next/ +*.tsbuildinfo __pycache__/ *.pyc .pytest_cache/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cd9eb3a --- /dev/null +++ b/.npmrc @@ -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 diff --git a/Makefile b/Makefile index 1d67cda..1d97657 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/apps/backend/src/application/articles.py b/apps/backend/src/application/articles.py new file mode 100644 index 0000000..61b70f5 --- /dev/null +++ b/apps/backend/src/application/articles.py @@ -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) diff --git a/apps/backend/src/domain/contracts/__init__.py b/apps/backend/src/domain/contracts/__init__.py new file mode 100644 index 0000000..c7fce44 --- /dev/null +++ b/apps/backend/src/domain/contracts/__init__.py @@ -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", +] diff --git a/apps/backend/src/domain/contracts/enums.py b/apps/backend/src/domain/contracts/enums.py new file mode 100644 index 0000000..4499603 --- /dev/null +++ b/apps/backend/src/domain/contracts/enums.py @@ -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" diff --git a/apps/backend/src/domain/contracts/models.py b/apps/backend/src/domain/contracts/models.py new file mode 100644 index 0000000..6795ff5 --- /dev/null +++ b/apps/backend/src/domain/contracts/models.py @@ -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 diff --git a/apps/backend/src/domain/contracts/openapi.py b/apps/backend/src/domain/contracts/openapi.py new file mode 100644 index 0000000..068cce6 --- /dev/null +++ b/apps/backend/src/domain/contracts/openapi.py @@ -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 diff --git a/apps/backend/src/domain/contracts/workflow.py b/apps/backend/src/domain/contracts/workflow.py new file mode 100644 index 0000000..dc9be84 --- /dev/null +++ b/apps/backend/src/domain/contracts/workflow.py @@ -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] diff --git a/apps/backend/src/presentation/main.py b/apps/backend/src/presentation/main.py index 3127ac2..00c6963 100644 --- a/apps/backend/src/presentation/main.py +++ b/apps/backend/src/presentation/main.py @@ -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 diff --git a/apps/backend/src/presentation/routes/__init__.py b/apps/backend/src/presentation/routes/__init__.py new file mode 100644 index 0000000..4f0cac4 --- /dev/null +++ b/apps/backend/src/presentation/routes/__init__.py @@ -0,0 +1 @@ +"""Backend HTTP route modules.""" diff --git a/apps/backend/src/presentation/routes/articles.py b/apps/backend/src/presentation/routes/articles.py new file mode 100644 index 0000000..38fac05 --- /dev/null +++ b/apps/backend/src/presentation/routes/articles.py @@ -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) diff --git a/apps/backend/tests/__init__.py b/apps/backend/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/tests/contracts/__init__.py b/apps/backend/tests/contracts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/tests/contracts/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/tests/contracts/test_domain_contracts.py b/apps/backend/tests/contracts/test_domain_contracts.py new file mode 100644 index 0000000..d494419 --- /dev/null +++ b/apps/backend/tests/contracts/test_domain_contracts.py @@ -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() diff --git a/apps/backend/tests/contracts/test_generated_contract_artifacts.py b/apps/backend/tests/contracts/test_generated_contract_artifacts.py new file mode 100644 index 0000000..cb1a0b8 --- /dev/null +++ b/apps/backend/tests/contracts/test_generated_contract_artifacts.py @@ -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() diff --git a/apps/backend/tests/contracts/test_public_openapi_contract.py b/apps/backend/tests/contracts/test_public_openapi_contract.py new file mode 100644 index 0000000..49352a8 --- /dev/null +++ b/apps/backend/tests/contracts/test_public_openapi_contract.py @@ -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() diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index b693e1a..7ed20a0 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -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"] diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 89c0ca3..defc29d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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" } diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index 3b5dfd7..cd0d019 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,8 +1,17 @@ +import type { ArticleListResponse } from "@pipeline/shared"; + +const initialArticles: ArticleListResponse = { + articles: [], +}; + export default function HomePage() { return (

AI Content Pipeline

Dockerized monorepo foundation is running.

+

+ {initialArticles.articles.length} articles in the contract-backed dashboard. +

); } diff --git a/apps/runner/Dockerfile b/apps/runner/Dockerfile index e0665b2..f1318fd 100644 --- a/apps/runner/Dockerfile +++ b/apps/runner/Dockerfile @@ -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 diff --git a/apps/runner/requirements.txt b/apps/runner/requirements.txt index e69de29..4b5fe4d 100644 --- a/apps/runner/requirements.txt +++ b/apps/runner/requirements.txt @@ -0,0 +1 @@ +pydantic==2.13.4 diff --git a/apps/runner/src/application/job_output_validation.py b/apps/runner/src/application/job_output_validation.py new file mode 100644 index 0000000..075323c --- /dev/null +++ b/apps/runner/src/application/job_output_validation.py @@ -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) diff --git a/apps/runner/tests/test_job_output_validation.py b/apps/runner/tests/test_job_output_validation.py new file mode 100644 index 0000000..832fdf2 --- /dev/null +++ b/apps/runner/tests/test_job_output_validation.py @@ -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() diff --git a/package.json b/package.json index a046e2b..bbef9f7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/openapi.json b/packages/shared/openapi.json new file mode 100644 index 0000000..7a7cbe0 --- /dev/null +++ b/packages/shared/openapi.json @@ -0,0 +1,1708 @@ +{ + "components": { + "schemas": { + "AgentJobErrorCategory": { + "enum": [ + "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" + ], + "title": "AgentJobErrorCategory", + "type": "string" + }, + "AgentJobOutput": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/ResearchArtifactSummary" + }, + "title": "Artifacts", + "type": "array" + }, + "error_category": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentJobErrorCategory" + }, + { + "type": "null" + } + ], + "default": null + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + }, + "output_files": { + "items": { + "$ref": "#/components/schemas/RunnerFileRef" + }, + "title": "Output Files", + "type": "array" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "status": { + "$ref": "#/components/schemas/AgentJobStatus" + } + }, + "required": [ + "status" + ], + "title": "AgentJobOutput", + "type": "object" + }, + "AgentJobStatus": { + "enum": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ], + "title": "AgentJobStatus", + "type": "string" + }, + "AgentJobSummary": { + "additionalProperties": false, + "properties": { + "agent_profile": { + "minLength": 1, + "title": "Agent Profile", + "type": "string" + }, + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "error_category": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentJobErrorCategory" + }, + { + "type": "null" + } + ], + "default": null + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "input_files": { + "items": { + "$ref": "#/components/schemas/RunnerFileRef" + }, + "title": "Input Files", + "type": "array" + }, + "job_type": { + "$ref": "#/components/schemas/AgentJobType" + }, + "output_files": { + "items": { + "$ref": "#/components/schemas/RunnerFileRef" + }, + "title": "Output Files", + "type": "array" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "$ref": "#/components/schemas/AgentJobStatus" + }, + "workspace_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Workspace Path" + } + }, + "required": [ + "id", + "article_id", + "job_type", + "agent_profile", + "status" + ], + "title": "AgentJobSummary", + "type": "object" + }, + "AgentJobType": { + "enum": [ + "BOUNDARY_QUESTIONS", + "PLAN_GENERATION", + "RESEARCH", + "EVIDENCE_MATRIX", + "SECTION_SCAFFOLD", + "DRAFT_ASSEMBLY", + "SEO_REVIEW", + "LANGUAGE_REVIEW", + "PUBLISH_DRY_RUN", + "PUBLISH_COMMIT", + "TEST_CODEX" + ], + "title": "AgentJobType", + "type": "string" + }, + "ArticleCreateRequest": { + "additionalProperties": false, + "properties": { + "assigned_editor_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Assigned Editor Id" + }, + "brief_description": { + "minLength": 1, + "title": "Brief Description", + "type": "string" + }, + "content_type": { + "default": "longform_guide", + "minLength": 1, + "title": "Content Type", + "type": "string" + }, + "language": { + "default": "en", + "minLength": 2, + "title": "Language", + "type": "string" + }, + "primary_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Keyword" + }, + "target_site_id": { + "format": "uuid", + "title": "Target Site Id", + "type": "string" + }, + "working_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Working Title" + } + }, + "required": [ + "target_site_id", + "brief_description" + ], + "title": "ArticleCreateRequest", + "type": "object" + }, + "ArticleCreateResponse": { + "additionalProperties": false, + "properties": { + "article": { + "$ref": "#/components/schemas/ArticleSummary" + } + }, + "required": [ + "article" + ], + "title": "ArticleCreateResponse", + "type": "object" + }, + "ArticleDetailResponse": { + "additionalProperties": false, + "properties": { + "agent_jobs": { + "items": { + "$ref": "#/components/schemas/AgentJobSummary" + }, + "title": "Agent Jobs", + "type": "array" + }, + "article": { + "$ref": "#/components/schemas/ArticleSummary" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/AssetSummary" + }, + "title": "Assets", + "type": "array" + }, + "claims": { + "items": { + "$ref": "#/components/schemas/ClaimSummary" + }, + "title": "Claims", + "type": "array" + }, + "draft": { + "anyOf": [ + { + "$ref": "#/components/schemas/DraftSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/EvidenceSummary" + }, + "title": "Evidence", + "type": "array" + }, + "plan": { + "anyOf": [ + { + "$ref": "#/components/schemas/PlanSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "publish_commit": { + "anyOf": [ + { + "$ref": "#/components/schemas/PublishCommitSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "research_manifests": { + "items": { + "$ref": "#/components/schemas/ResearchArtifactManifestSummary" + }, + "title": "Research Manifests", + "type": "array" + }, + "reviews": { + "items": { + "$ref": "#/components/schemas/ReviewSummary" + }, + "title": "Reviews", + "type": "array" + }, + "target_site": { + "anyOf": [ + { + "$ref": "#/components/schemas/TargetSiteConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "article" + ], + "title": "ArticleDetailResponse", + "type": "object" + }, + "ArticleListResponse": { + "additionalProperties": false, + "properties": { + "articles": { + "items": { + "$ref": "#/components/schemas/ArticleSummary" + }, + "title": "Articles", + "type": "array" + } + }, + "required": [ + "articles" + ], + "title": "ArticleListResponse", + "type": "object" + }, + "ArticleSummary": { + "additionalProperties": false, + "properties": { + "assigned_editor_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Assigned Editor Id" + }, + "brief_description": { + "title": "Brief Description", + "type": "string" + }, + "content_type": { + "title": "Content Type", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "language": { + "title": "Language", + "type": "string" + }, + "primary_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Keyword" + }, + "publishing_status": { + "$ref": "#/components/schemas/PublishingStatus" + }, + "status": { + "$ref": "#/components/schemas/ArticleWorkflowStatus" + }, + "target_site_id": { + "format": "uuid", + "title": "Target Site Id", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "working_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Working Title" + } + }, + "required": [ + "id", + "target_site_id", + "status", + "publishing_status", + "brief_description", + "language", + "content_type", + "created_at", + "updated_at" + ], + "title": "ArticleSummary", + "type": "object" + }, + "ArticleWorkflowStatus": { + "enum": [ + "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" + ], + "title": "ArticleWorkflowStatus", + "type": "string" + }, + "AssetStatus": { + "enum": [ + "PENDING", + "GENERATED", + "APPROVED", + "REJECTED" + ], + "title": "AssetStatus", + "type": "string" + }, + "AssetSummary": { + "additionalProperties": false, + "properties": { + "alt_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alt Text" + }, + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "asset_type": { + "$ref": "#/components/schemas/AssetType" + }, + "caption": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Caption" + }, + "file_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File Url" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "status": { + "$ref": "#/components/schemas/AssetStatus" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "article_id", + "asset_type", + "title", + "status" + ], + "title": "AssetSummary", + "type": "object" + }, + "AssetType": { + "enum": [ + "hero_image", + "diagram", + "table", + "inline_image" + ], + "title": "AssetType", + "type": "string" + }, + "ClaimRiskLevel": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "ClaimRiskLevel", + "type": "string" + }, + "ClaimSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "claim_text": { + "minLength": 1, + "title": "Claim Text", + "type": "string" + }, + "evidence_item_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Evidence Item Ids", + "type": "array" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "risk_level": { + "$ref": "#/components/schemas/ClaimRiskLevel" + }, + "section_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Section Id" + }, + "support_status": { + "$ref": "#/components/schemas/ClaimSupportStatus" + } + }, + "required": [ + "id", + "article_id", + "claim_text", + "support_status", + "risk_level" + ], + "title": "ClaimSummary", + "type": "object" + }, + "ClaimSupportStatus": { + "enum": [ + "SUPPORTED", + "UNSUPPORTED", + "NEEDS_REVIEW" + ], + "title": "ClaimSupportStatus", + "type": "string" + }, + "DraftSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "body_object_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Body Object Key" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "meta_description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Meta Description" + }, + "meta_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Meta Title" + }, + "slug": { + "minLength": 1, + "title": "Slug", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ArticleWorkflowStatus" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "id", + "article_id", + "version", + "title", + "slug", + "status", + "updated_at" + ], + "title": "DraftSummary", + "type": "object" + }, + "EvidenceSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "artifact_manifest_id": { + "format": "uuid", + "title": "Artifact Manifest Id", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "retrieved_at": { + "format": "date-time", + "title": "Retrieved At", + "type": "string" + }, + "source_quality_score": { + "maximum": 1, + "minimum": 0, + "title": "Source Quality Score", + "type": "number" + }, + "source_title": { + "minLength": 1, + "title": "Source Title", + "type": "string" + }, + "source_type": { + "minLength": 1, + "title": "Source Type", + "type": "string" + }, + "source_url": { + "minLength": 1, + "title": "Source Url", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "supports_claims": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Supports Claims", + "type": "array" + } + }, + "required": [ + "id", + "article_id", + "source_title", + "source_url", + "source_type", + "source_quality_score", + "summary", + "artifact_manifest_id", + "retrieved_at" + ], + "title": "EvidenceSummary", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "PlanReviewStatus": { + "enum": [ + "PENDING_REVIEW", + "APPROVED", + "REVISION_REQUESTED" + ], + "title": "PlanReviewStatus", + "type": "string" + }, + "PlanSectionSummary": { + "additionalProperties": false, + "properties": { + "article_plan_id": { + "format": "uuid", + "title": "Article Plan Id", + "type": "string" + }, + "claims_to_support": { + "items": { + "type": "string" + }, + "title": "Claims To Support", + "type": "array" + }, + "heading": { + "minLength": 1, + "title": "Heading", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "purpose": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Purpose" + }, + "sort_order": { + "minimum": 1, + "title": "Sort Order", + "type": "integer" + }, + "target_word_count": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Word Count" + } + }, + "required": [ + "id", + "article_plan_id", + "sort_order", + "heading" + ], + "title": "PlanSectionSummary", + "type": "object" + }, + "PlanSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "claims_to_prove": { + "items": { + "type": "string" + }, + "title": "Claims To Prove", + "type": "array" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "recommended_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recommended Title" + }, + "risks": { + "items": { + "type": "string" + }, + "title": "Risks", + "type": "array" + }, + "search_intent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Search Intent" + }, + "sections": { + "items": { + "$ref": "#/components/schemas/PlanSectionSummary" + }, + "title": "Sections", + "type": "array" + }, + "status": { + "$ref": "#/components/schemas/PlanReviewStatus" + }, + "thesis": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Thesis" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "id", + "article_id", + "version", + "status", + "created_at" + ], + "title": "PlanSummary", + "type": "object" + }, + "PublishCommitSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "branch": { + "minLength": 1, + "title": "Branch", + "type": "string" + }, + "commit_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Sha" + }, + "content_bundle_manifest": { + "additionalProperties": true, + "title": "Content Bundle Manifest", + "type": "object" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "deployment_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deployment Status" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "repository_url": { + "minLength": 1, + "title": "Repository Url", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/PublishingStatus" + }, + "target_site_id": { + "format": "uuid", + "title": "Target Site Id", + "type": "string" + } + }, + "required": [ + "id", + "article_id", + "target_site_id", + "repository_url", + "branch", + "status", + "created_at" + ], + "title": "PublishCommitSummary", + "type": "object" + }, + "PublishingRules": { + "additionalProperties": false, + "properties": { + "asset_path_template": { + "minLength": 1, + "title": "Asset Path Template", + "type": "string" + }, + "content_format": { + "default": "mdx", + "minLength": 1, + "title": "Content Format", + "type": "string" + }, + "content_path_template": { + "minLength": 1, + "title": "Content Path Template", + "type": "string" + }, + "dry_run_renderer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dry Run Renderer" + }, + "frontmatter_mapping": { + "additionalProperties": true, + "title": "Frontmatter Mapping", + "type": "object" + }, + "production_branch": { + "minLength": 1, + "title": "Production Branch", + "type": "string" + }, + "repository_url": { + "minLength": 1, + "title": "Repository Url", + "type": "string" + }, + "transform_script_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transform Script Version Id" + } + }, + "required": [ + "repository_url", + "production_branch", + "content_path_template", + "asset_path_template" + ], + "title": "PublishingRules", + "type": "object" + }, + "PublishingStatus": { + "enum": [ + "PUBLISH_NOT_STARTED", + "PUBLISH_DRY_RUN_REQUIRED", + "PUBLISH_DRY_RUN_RUNNING", + "PUBLISH_DRY_RUN_FAILED", + "PUBLISH_COMMIT_READY", + "PUBLISH_COMMIT_CREATED", + "PUBLISH_VERIFICATION_FAILED" + ], + "title": "PublishingStatus", + "type": "string" + }, + "ResearchArtifactManifestSummary": { + "additionalProperties": false, + "properties": { + "agent_job_id": { + "format": "uuid", + "title": "Agent Job Id", + "type": "string" + }, + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "artifacts": { + "items": { + "$ref": "#/components/schemas/ResearchArtifactSummary" + }, + "title": "Artifacts", + "type": "array" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "s3_prefix": { + "minLength": 1, + "title": "S3 Prefix", + "type": "string" + } + }, + "required": [ + "id", + "article_id", + "agent_job_id", + "s3_prefix", + "created_at" + ], + "title": "ResearchArtifactManifestSummary", + "type": "object" + }, + "ResearchArtifactSummary": { + "additionalProperties": false, + "properties": { + "artifact_type": { + "minLength": 1, + "title": "Artifact Type", + "type": "string" + }, + "content_hash": { + "minLength": 1, + "title": "Content Hash", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "object_key": { + "minLength": 1, + "title": "Object Key", + "type": "string" + }, + "source_url": { + "minLength": 1, + "title": "Source Url", + "type": "string" + } + }, + "required": [ + "artifact_type", + "source_url", + "object_key", + "content_hash" + ], + "title": "ResearchArtifactSummary", + "type": "object" + }, + "ReviewStatus": { + "enum": [ + "PENDING", + "APPROVED", + "CHANGES_REQUESTED" + ], + "title": "ReviewStatus", + "type": "string" + }, + "ReviewSummary": { + "additionalProperties": false, + "properties": { + "article_id": { + "format": "uuid", + "title": "Article Id", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notes" + }, + "review_type": { + "$ref": "#/components/schemas/ReviewType" + }, + "reviewer_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reviewer Id" + }, + "status": { + "$ref": "#/components/schemas/ReviewStatus" + } + }, + "required": [ + "id", + "article_id", + "review_type", + "status", + "created_at" + ], + "title": "ReviewSummary", + "type": "object" + }, + "ReviewType": { + "enum": [ + "PLAN", + "EVIDENCE", + "ASSET", + "SEO_LANGUAGE", + "FINAL" + ], + "title": "ReviewType", + "type": "string" + }, + "Role": { + "enum": [ + "ADMIN", + "EDITOR" + ], + "title": "Role", + "type": "string" + }, + "RunnerFileRef": { + "additionalProperties": false, + "properties": { + "content_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Hash" + }, + "path": { + "minLength": 1, + "title": "Path", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "RunnerFileRef", + "type": "object" + }, + "ScriptConfigVersionStatus": { + "enum": [ + "DRAFT", + "ACTIVE", + "DEPRECATED" + ], + "title": "ScriptConfigVersionStatus", + "type": "string" + }, + "ScriptConfigVersionSummary": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "created_by": { + "format": "uuid", + "title": "Created By", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "publishing_yaml_hash": { + "minLength": 1, + "title": "Publishing Yaml Hash", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ScriptConfigVersionStatus" + }, + "target_site_id": { + "format": "uuid", + "title": "Target Site Id", + "type": "string" + }, + "transform_script_hash": { + "minLength": 1, + "title": "Transform Script Hash", + "type": "string" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "id", + "target_site_id", + "version", + "status", + "publishing_yaml_hash", + "transform_script_hash", + "created_by", + "created_at" + ], + "title": "ScriptConfigVersionSummary", + "type": "object" + }, + "TargetSiteConfig": { + "additionalProperties": false, + "properties": { + "active_script_config_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Script Config Version Id" + }, + "audience": { + "minLength": 1, + "title": "Audience", + "type": "string" + }, + "brand_voice": { + "minLength": 1, + "title": "Brand Voice", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "default_language": { + "default": "en", + "minLength": 2, + "title": "Default Language", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "publishing_rules": { + "$ref": "#/components/schemas/PublishingRules" + }, + "publishing_type": { + "default": "git_next", + "minLength": 1, + "title": "Publishing Type", + "type": "string" + }, + "seo_rules": { + "additionalProperties": true, + "title": "Seo Rules", + "type": "object" + }, + "slug": { + "minLength": 1, + "title": "Slug", + "type": "string" + }, + "source_rules": { + "additionalProperties": true, + "title": "Source Rules", + "type": "object" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "visual_rules": { + "additionalProperties": true, + "title": "Visual Rules", + "type": "object" + } + }, + "required": [ + "id", + "name", + "slug", + "brand_voice", + "audience", + "publishing_rules", + "created_at", + "updated_at" + ], + "title": "TargetSiteConfig", + "type": "object" + }, + "UserSummary": { + "additionalProperties": false, + "properties": { + "display_name": { + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "id", + "display_name", + "role" + ], + "title": "UserSummary", + "type": "object" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "AI Content Pipeline Backend", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/articles": { + "post": { + "operationId": "create_article_api_articles_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleCreateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Article", + "tags": [ + "articles" + ] + } + }, + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Health Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/health/dependencies": { + "get": { + "operationId": "dependency_health_health_dependencies_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Dependency Health" + } + } + } +} diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts new file mode 100644 index 0000000..b336379 --- /dev/null +++ b/packages/shared/src/api-types.ts @@ -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; + 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; + 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; + 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; + 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; + slug: string; + source_rules?: Record; + updated_at: string; + visual_rules?: Record; +}; + +export type UserSummary = { + display_name: string; + id: string; + role: Role; +}; + +export type ValidationError = { + loc: (string | number)[]; + msg: string; + type: string; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ce7d76e..8d48c7c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1 @@ -export type { - BackendDependenciesHealthResponse, - BackendDependencyName, - HealthResponse, - ServiceName, -} from "./health"; +export type * from "./api-types"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..088187a --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/generate_openapi_contracts.py b/scripts/generate_openapi_contracts.py new file mode 100644 index 0000000..15ca7ba --- /dev/null +++ b/scripts/generate_openapi_contracts.py @@ -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" + return "Record" + + 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() diff --git a/tasks/002-shared-domain-contracts.md b/tasks/002-shared-domain-contracts.md index a98b373..a6e1624 100644 --- a/tasks/002-shared-domain-contracts.md +++ b/tasks/002-shared-domain-contracts.md @@ -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`.