Task 002: add shared domain contracts
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from src.domain.contracts import (
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleSummary,
|
||||
ArticleWorkflowStatus,
|
||||
PublishingStatus,
|
||||
)
|
||||
|
||||
|
||||
PLACEHOLDER_CREATED_AT = datetime(1970, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def build_placeholder_article_response(
|
||||
request: ArticleCreateRequest,
|
||||
) -> ArticleCreateResponse:
|
||||
article_id = uuid5(
|
||||
NAMESPACE_URL,
|
||||
f"article:{request.target_site_id}:{request.brief_description}",
|
||||
)
|
||||
article = ArticleSummary(
|
||||
id=article_id,
|
||||
target_site_id=request.target_site_id,
|
||||
status=ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
publishing_status=PublishingStatus.PUBLISH_NOT_STARTED,
|
||||
brief_description=request.brief_description,
|
||||
working_title=request.working_title,
|
||||
language=request.language,
|
||||
content_type=request.content_type,
|
||||
primary_keyword=request.primary_keyword,
|
||||
assigned_editor_id=request.assigned_editor_id,
|
||||
created_at=PLACEHOLDER_CREATED_AT,
|
||||
updated_at=PLACEHOLDER_CREATED_AT,
|
||||
)
|
||||
return ArticleCreateResponse(article=article)
|
||||
@@ -0,0 +1,87 @@
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
UserSummary,
|
||||
)
|
||||
from .workflow import (
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
next_workflow_statuses,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ARTICLE_WORKFLOW_STATUSES",
|
||||
"ARTICLE_WORKFLOW_TRANSITIONS",
|
||||
"AgentJobErrorCategory",
|
||||
"AgentJobOutput",
|
||||
"AgentJobStatus",
|
||||
"AgentJobSummary",
|
||||
"AgentJobType",
|
||||
"ArticleCreateRequest",
|
||||
"ArticleCreateResponse",
|
||||
"ArticleDetailResponse",
|
||||
"ArticleListResponse",
|
||||
"ArticleSummary",
|
||||
"ArticleWorkflowStatus",
|
||||
"AssetStatus",
|
||||
"AssetSummary",
|
||||
"AssetType",
|
||||
"ClaimRiskLevel",
|
||||
"ClaimSummary",
|
||||
"ClaimSupportStatus",
|
||||
"DraftSummary",
|
||||
"EvidenceSummary",
|
||||
"PlanReviewStatus",
|
||||
"PlanSectionSummary",
|
||||
"PlanSummary",
|
||||
"PublishCommitSummary",
|
||||
"PublishingRules",
|
||||
"PublishingStatus",
|
||||
"ResearchArtifactManifestSummary",
|
||||
"ResearchArtifactSummary",
|
||||
"ReviewStatus",
|
||||
"ReviewSummary",
|
||||
"ReviewType",
|
||||
"Role",
|
||||
"RunnerFileRef",
|
||||
"ScriptConfigVersionStatus",
|
||||
"ScriptConfigVersionSummary",
|
||||
"TargetSiteConfig",
|
||||
"UserSummary",
|
||||
"next_workflow_statuses",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
class ContractModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class UserSummary(ContractModel):
|
||||
id: UUID
|
||||
display_name: str = Field(min_length=1)
|
||||
role: Role
|
||||
|
||||
|
||||
class PublishingRules(ContractModel):
|
||||
repository_url: str = Field(min_length=1)
|
||||
production_branch: str = Field(min_length=1)
|
||||
content_format: str = Field(default="mdx", min_length=1)
|
||||
content_path_template: str = Field(min_length=1)
|
||||
asset_path_template: str = Field(min_length=1)
|
||||
frontmatter_mapping: JsonObject = Field(default_factory=dict)
|
||||
transform_script_version_id: UUID | None = None
|
||||
dry_run_renderer: str | None = None
|
||||
|
||||
|
||||
class TargetSiteConfig(ContractModel):
|
||||
id: UUID
|
||||
name: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
publishing_type: str = Field(default="git_next", min_length=1)
|
||||
default_language: str = Field(default="en", min_length=2)
|
||||
brand_voice: str = Field(min_length=1)
|
||||
audience: str = Field(min_length=1)
|
||||
seo_rules: JsonObject = Field(default_factory=dict)
|
||||
visual_rules: JsonObject = Field(default_factory=dict)
|
||||
source_rules: JsonObject = Field(default_factory=dict)
|
||||
publishing_rules: PublishingRules
|
||||
active_script_config_version_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ScriptConfigVersionSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: ScriptConfigVersionStatus
|
||||
publishing_yaml_hash: str = Field(min_length=1)
|
||||
transform_script_hash: str = Field(min_length=1)
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ArticleCreateRequest(ContractModel):
|
||||
target_site_id: UUID
|
||||
brief_description: str = Field(min_length=1)
|
||||
working_title: str | None = None
|
||||
language: str = Field(default="en", min_length=2)
|
||||
content_type: str = Field(default="longform_guide", min_length=1)
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
|
||||
|
||||
class ArticleSummary(ContractModel):
|
||||
id: UUID
|
||||
target_site_id: UUID
|
||||
status: ArticleWorkflowStatus
|
||||
publishing_status: PublishingStatus
|
||||
brief_description: str
|
||||
working_title: str | None = None
|
||||
language: str
|
||||
content_type: str
|
||||
primary_keyword: str | None = None
|
||||
assigned_editor_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ArticleCreateResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
|
||||
|
||||
class ArticleListResponse(ContractModel):
|
||||
articles: list[ArticleSummary]
|
||||
|
||||
|
||||
class PlanSectionSummary(ContractModel):
|
||||
id: UUID
|
||||
article_plan_id: UUID
|
||||
sort_order: int = Field(ge=1)
|
||||
heading: str = Field(min_length=1)
|
||||
purpose: str | None = None
|
||||
claims_to_support: list[str] = Field(default_factory=list)
|
||||
target_word_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PlanSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
status: PlanReviewStatus
|
||||
recommended_title: str | None = None
|
||||
search_intent: str | None = None
|
||||
thesis: str | None = None
|
||||
sections: list[PlanSectionSummary] = Field(default_factory=list)
|
||||
claims_to_prove: list[str] = Field(default_factory=list)
|
||||
risks: list[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ResearchArtifactSummary(ContractModel):
|
||||
artifact_type: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
object_key: str = Field(min_length=1)
|
||||
content_hash: str = Field(min_length=1)
|
||||
metadata: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ResearchArtifactManifestSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
agent_job_id: UUID
|
||||
s3_prefix: str = Field(min_length=1)
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EvidenceSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
source_title: str = Field(min_length=1)
|
||||
source_url: str = Field(min_length=1)
|
||||
source_type: str = Field(min_length=1)
|
||||
source_quality_score: float = Field(ge=0, le=1)
|
||||
summary: str = Field(min_length=1)
|
||||
supports_claims: list[UUID] = Field(default_factory=list)
|
||||
artifact_manifest_id: UUID
|
||||
retrieved_at: datetime
|
||||
|
||||
|
||||
class ClaimSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
section_id: UUID | None = None
|
||||
claim_text: str = Field(min_length=1)
|
||||
support_status: ClaimSupportStatus
|
||||
risk_level: ClaimRiskLevel
|
||||
evidence_item_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DraftSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
version: int = Field(ge=1)
|
||||
title: str = Field(min_length=1)
|
||||
slug: str = Field(min_length=1)
|
||||
meta_title: str | None = None
|
||||
meta_description: str | None = None
|
||||
body_object_key: str | None = None
|
||||
status: ArticleWorkflowStatus
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AssetSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
asset_type: AssetType
|
||||
title: str = Field(min_length=1)
|
||||
prompt: str | None = None
|
||||
file_url: str | None = None
|
||||
alt_text: str | None = None
|
||||
caption: str | None = None
|
||||
status: AssetStatus
|
||||
|
||||
|
||||
class ReviewSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
review_type: ReviewType
|
||||
status: ReviewStatus
|
||||
reviewer_id: UUID | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublishCommitSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
target_site_id: UUID
|
||||
repository_url: str = Field(min_length=1)
|
||||
branch: str = Field(min_length=1)
|
||||
commit_sha: str | None = None
|
||||
content_bundle_manifest: JsonObject = Field(default_factory=dict)
|
||||
status: PublishingStatus
|
||||
deployment_status: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunnerFileRef(ContractModel):
|
||||
path: str = Field(min_length=1)
|
||||
content_hash: str | None = None
|
||||
|
||||
|
||||
class AgentJobSummary(ContractModel):
|
||||
id: UUID
|
||||
article_id: UUID
|
||||
job_type: AgentJobType
|
||||
agent_profile: str = Field(min_length=1)
|
||||
status: AgentJobStatus
|
||||
workspace_path: str | None = None
|
||||
input_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
class AgentJobOutput(ContractModel):
|
||||
status: AgentJobStatus
|
||||
output_files: list[RunnerFileRef] = Field(default_factory=list)
|
||||
error_category: AgentJobErrorCategory | None = None
|
||||
error_message: str | None = None
|
||||
artifacts: list[ResearchArtifactSummary] = Field(default_factory=list)
|
||||
payload: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ArticleDetailResponse(ContractModel):
|
||||
article: ArticleSummary
|
||||
target_site: TargetSiteConfig | None = None
|
||||
plan: PlanSummary | None = None
|
||||
draft: DraftSummary | None = None
|
||||
evidence: list[EvidenceSummary] = Field(default_factory=list)
|
||||
claims: list[ClaimSummary] = Field(default_factory=list)
|
||||
assets: list[AssetSummary] = Field(default_factory=list)
|
||||
reviews: list[ReviewSummary] = Field(default_factory=list)
|
||||
agent_jobs: list[AgentJobSummary] = Field(default_factory=list)
|
||||
research_manifests: list[ResearchArtifactManifestSummary] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
publish_commit: PublishCommitSummary | None = None
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from .enums import (
|
||||
AgentJobErrorCategory,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
ArticleWorkflowStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
ClaimRiskLevel,
|
||||
ClaimSupportStatus,
|
||||
PlanReviewStatus,
|
||||
PublishingStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
Role,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
from .models import (
|
||||
AgentJobOutput,
|
||||
AgentJobSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleCreateResponse,
|
||||
ArticleDetailResponse,
|
||||
ArticleListResponse,
|
||||
ArticleSummary,
|
||||
AssetSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
EvidenceSummary,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
PublishCommitSummary,
|
||||
PublishingRules,
|
||||
ResearchArtifactManifestSummary,
|
||||
ResearchArtifactSummary,
|
||||
ReviewSummary,
|
||||
RunnerFileRef,
|
||||
ScriptConfigVersionSummary,
|
||||
TargetSiteConfig,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
CONTRACT_ENUMS: tuple[type[Enum], ...] = (
|
||||
Role,
|
||||
ArticleWorkflowStatus,
|
||||
AgentJobStatus,
|
||||
AgentJobType,
|
||||
AgentJobErrorCategory,
|
||||
ClaimSupportStatus,
|
||||
ClaimRiskLevel,
|
||||
PublishingStatus,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
PlanReviewStatus,
|
||||
ReviewStatus,
|
||||
ReviewType,
|
||||
ScriptConfigVersionStatus,
|
||||
)
|
||||
|
||||
CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
|
||||
UserSummary,
|
||||
PublishingRules,
|
||||
TargetSiteConfig,
|
||||
ScriptConfigVersionSummary,
|
||||
ArticleCreateRequest,
|
||||
ArticleSummary,
|
||||
ArticleCreateResponse,
|
||||
ArticleListResponse,
|
||||
PlanSectionSummary,
|
||||
PlanSummary,
|
||||
ResearchArtifactSummary,
|
||||
ResearchArtifactManifestSummary,
|
||||
EvidenceSummary,
|
||||
ClaimSummary,
|
||||
DraftSummary,
|
||||
AssetSummary,
|
||||
ReviewSummary,
|
||||
PublishCommitSummary,
|
||||
RunnerFileRef,
|
||||
AgentJobSummary,
|
||||
AgentJobOutput,
|
||||
ArticleDetailResponse,
|
||||
)
|
||||
|
||||
|
||||
def inject_contract_schemas(openapi_schema: dict[str, Any]) -> None:
|
||||
schemas = openapi_schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
|
||||
for enum in CONTRACT_ENUMS:
|
||||
schemas[enum.__name__] = TypeAdapter(enum).json_schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
)
|
||||
|
||||
for model in CONTRACT_SCHEMA_MODELS:
|
||||
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
||||
for name, definition in schema.pop("$defs", {}).items():
|
||||
schemas.setdefault(name, definition)
|
||||
schemas[model.__name__] = schema
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .enums import ArticleWorkflowStatus
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_STATUSES: tuple[ArticleWorkflowStatus, ...] = (
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
)
|
||||
|
||||
|
||||
ARTICLE_WORKFLOW_TRANSITIONS: dict[
|
||||
ArticleWorkflowStatus, tuple[ArticleWorkflowStatus, ...]
|
||||
] = {
|
||||
ArticleWorkflowStatus.ARTICLE_BRIEF_CREATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_QUESTIONS_GENERATED: (
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED,
|
||||
),
|
||||
ArticleWorkflowStatus.BOUNDARY_ANSWERS_SUBMITTED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_GENERATED: (
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PLAN_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.PLAN_GENERATED,
|
||||
),
|
||||
ArticleWorkflowStatus.RESEARCH_RUNNING: (
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.EVIDENCE_MATRIX_READY: (
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING,
|
||||
),
|
||||
ArticleWorkflowStatus.PARALLEL_PRODUCTION_RUNNING: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED: (
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.SEO_AND_LANGUAGE_REVIEW_READY: (
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVIEW_REQUIRED: (
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED,
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
|
||||
),
|
||||
ArticleWorkflowStatus.FINAL_REVISION_REQUIRED: (
|
||||
ArticleWorkflowStatus.DRAFT_ASSEMBLED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_READY: (
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
|
||||
),
|
||||
ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED: (),
|
||||
}
|
||||
|
||||
|
||||
def next_workflow_statuses(
|
||||
status: ArticleWorkflowStatus,
|
||||
) -> tuple[ArticleWorkflowStatus, ...]:
|
||||
return ARTICLE_WORKFLOW_TRANSITIONS[status]
|
||||
@@ -1,10 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
from src.domain.contracts.openapi import inject_contract_schemas
|
||||
from src.presentation.routes.articles import router as articles_router
|
||||
|
||||
|
||||
app = FastAPI(title="AI Content Pipeline Backend")
|
||||
app.include_router(articles_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -14,6 +19,8 @@ def health() -> dict[str, str]:
|
||||
|
||||
@app.get("/health/dependencies")
|
||||
def dependency_health() -> JSONResponse:
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
|
||||
dependencies = check_dependencies()
|
||||
is_ok = all(status == "ok" for status in dependencies.values())
|
||||
|
||||
@@ -25,3 +32,20 @@ def dependency_health() -> JSONResponse:
|
||||
"dependencies": dependencies,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
routes=app.routes,
|
||||
)
|
||||
inject_contract_schemas(openapi_schema)
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend HTTP route modules."""
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from src.application.articles import build_placeholder_article_response
|
||||
from src.domain.contracts import ArticleCreateRequest, ArticleCreateResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["articles"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/articles",
|
||||
response_model=ArticleCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_article(request: ArticleCreateRequest) -> ArticleCreateResponse:
|
||||
return build_placeholder_article_response(request)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.domain.contracts import ( # noqa: E402
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
ArticleCreateRequest,
|
||||
ArticleWorkflowStatus,
|
||||
Role,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
class DomainContractTest(unittest.TestCase):
|
||||
def test_role_enum_is_canonical(self) -> None:
|
||||
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
UserSummary.model_validate(
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"display_name": "Writer",
|
||||
"role": "WRITER",
|
||||
}
|
||||
)
|
||||
|
||||
def test_article_create_rejects_missing_required_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
ArticleCreateRequest.model_validate({})
|
||||
|
||||
missing_fields = {
|
||||
issue["loc"][0]
|
||||
for issue in error.exception.errors()
|
||||
if issue["type"] == "missing"
|
||||
}
|
||||
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
|
||||
|
||||
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
|
||||
self.assertEqual(
|
||||
[
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
"BOUNDARY_QUESTIONS_GENERATED",
|
||||
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||
"PLAN_GENERATED",
|
||||
"PLAN_REVIEW_REQUIRED",
|
||||
"PLAN_REVISION_REQUIRED",
|
||||
"RESEARCH_RUNNING",
|
||||
"EVIDENCE_MATRIX_READY",
|
||||
"PARALLEL_PRODUCTION_RUNNING",
|
||||
"DRAFT_ASSEMBLED",
|
||||
"SEO_AND_LANGUAGE_REVIEW_READY",
|
||||
"FINAL_REVIEW_REQUIRED",
|
||||
"FINAL_REVISION_REQUIRED",
|
||||
"PUBLISH_DRY_RUN_REQUIRED",
|
||||
"PUBLISH_COMMIT_READY",
|
||||
"PUBLISH_COMMIT_CREATED",
|
||||
],
|
||||
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
|
||||
)
|
||||
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
|
||||
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
|
||||
self.assertIsInstance(status, ArticleWorkflowStatus)
|
||||
for next_status in next_statuses:
|
||||
self.assertIsInstance(next_status, ArticleWorkflowStatus)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class GeneratedContractArtifactsTest(unittest.TestCase):
|
||||
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
|
||||
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(app.openapi(), snapshot)
|
||||
|
||||
def test_typescript_types_are_marked_generated(self) -> None:
|
||||
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
|
||||
content = types_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(
|
||||
content.startswith(
|
||||
"// Generated from backend OpenAPI by "
|
||||
"scripts/generate_openapi_contracts.py."
|
||||
)
|
||||
)
|
||||
self.assertIn("export type ArticleCreateRequest", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
def _module_available(name: str) -> bool:
|
||||
try:
|
||||
return find_spec(name) is not None
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _install_optional_dependency_stubs() -> None:
|
||||
# Health routes import external clients; this contract test only reads OpenAPI.
|
||||
if not _module_available("boto3"):
|
||||
boto3 = types.ModuleType("boto3")
|
||||
boto3.client = lambda *args, **kwargs: None
|
||||
sys.modules["boto3"] = boto3
|
||||
|
||||
if not _module_available("psycopg"):
|
||||
psycopg = types.ModuleType("psycopg")
|
||||
psycopg.connect = lambda *args, **kwargs: None
|
||||
sys.modules["psycopg"] = psycopg
|
||||
|
||||
if not _module_available("redis"):
|
||||
redis = types.ModuleType("redis")
|
||||
|
||||
class Redis:
|
||||
@staticmethod
|
||||
def from_url(*args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
redis.Redis = Redis
|
||||
sys.modules["redis"] = redis
|
||||
|
||||
if not _module_available("botocore.config"):
|
||||
botocore = types.ModuleType("botocore")
|
||||
config = types.ModuleType("botocore.config")
|
||||
|
||||
class Config:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
config.Config = Config
|
||||
sys.modules["botocore"] = botocore
|
||||
sys.modules["botocore.config"] = config
|
||||
|
||||
|
||||
_install_optional_dependency_stubs()
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class BackendPublicOpenApiContractTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.openapi = app.openapi()
|
||||
self.schemas = self.openapi.get("components", {}).get("schemas", {})
|
||||
|
||||
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
|
||||
self,
|
||||
) -> None:
|
||||
failures: list[str] = []
|
||||
|
||||
article_schema = self.schemas.get("ArticleCreateRequest")
|
||||
if article_schema is None:
|
||||
failures.append("components.schemas.ArticleCreateRequest is missing")
|
||||
else:
|
||||
required = set(article_schema.get("required", []))
|
||||
for field in ("brief_description", "target_site_id"):
|
||||
if field not in required:
|
||||
failures.append(f"ArticleCreateRequest must require {field!r}")
|
||||
|
||||
invalid_article_errors = self._validation_errors(
|
||||
schema_name="ArticleCreateRequest",
|
||||
payload={},
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "brief_description"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'brief_description'"
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "target_site_id"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'target_site_id'"
|
||||
)
|
||||
|
||||
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
|
||||
if request_ref != "#/components/schemas/ArticleCreateRequest":
|
||||
failures.append(
|
||||
"POST /api/articles must expose ArticleCreateRequest as its JSON "
|
||||
f"request body; got {request_ref or 'no request body'}"
|
||||
)
|
||||
|
||||
role_schema = self.schemas.get("Role")
|
||||
if role_schema is None:
|
||||
failures.append("components.schemas.Role is missing")
|
||||
else:
|
||||
role_values = self._enum_values(role_schema)
|
||||
if role_values != ["ADMIN", "EDITOR"]:
|
||||
failures.append(
|
||||
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
|
||||
f"got {role_values!r}"
|
||||
)
|
||||
if not self._has_enum_error("Role", "WRITER"):
|
||||
failures.append("Role contract must reject invalid value 'WRITER'")
|
||||
|
||||
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
|
||||
if workflow_status_schema is None:
|
||||
failures.append("components.schemas.ArticleWorkflowStatus is missing")
|
||||
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
|
||||
failures.append(
|
||||
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
|
||||
)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
|
||||
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
|
||||
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
|
||||
content = operation.get("requestBody", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
return schema.get("$ref")
|
||||
|
||||
def _has_required_error(self, errors: list[str], field: str) -> bool:
|
||||
return any(error == f"missing required field: {field}" for error in errors)
|
||||
|
||||
def _has_enum_error(self, schema_name: str, value: str) -> bool:
|
||||
return f"invalid enum value: {value}" in self._validation_errors(
|
||||
schema_name=schema_name,
|
||||
payload=value,
|
||||
)
|
||||
|
||||
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
|
||||
schema = self.schemas.get(schema_name)
|
||||
if schema is None:
|
||||
return [f"missing schema: {schema_name}"]
|
||||
|
||||
enum_values = self._enum_values(schema)
|
||||
if enum_values is not None:
|
||||
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
|
||||
|
||||
if schema.get("type") != "object" or not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
required = schema.get("required", [])
|
||||
return [
|
||||
f"missing required field: {field}"
|
||||
for field in required
|
||||
if field not in payload
|
||||
]
|
||||
|
||||
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list):
|
||||
return enum_values
|
||||
|
||||
for composite_key in ("allOf", "anyOf", "oneOf"):
|
||||
for item in schema.get(composite_key, []):
|
||||
resolved = self._resolve_schema(item)
|
||||
values = self._enum_values(resolved)
|
||||
if values is not None:
|
||||
return values
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
prefix = "#/components/schemas/"
|
||||
if not ref.startswith(prefix):
|
||||
return schema
|
||||
|
||||
return self.schemas.get(ref.removeprefix(prefix), schema)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { ArticleListResponse } from "@pipeline/shared";
|
||||
|
||||
const initialArticles: ArticleListResponse = {
|
||||
articles: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>Dockerized monorepo foundation is running.</p>
|
||||
<p>
|
||||
{initialArticles.articles.length} articles in the contract-backed dashboard.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pydantic==2.13.4
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _backend_contracts_module() -> Any:
|
||||
contracts_dir = _backend_contracts_dir()
|
||||
init_file = contracts_dir / "__init__.py"
|
||||
module_name = "_pipeline_backend_domain_contracts"
|
||||
|
||||
existing = sys.modules.get(module_name)
|
||||
if existing is not None and Path(str(existing.__file__)).resolve() == init_file.resolve():
|
||||
return existing
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
init_file,
|
||||
submodule_search_locations=[str(contracts_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Cannot load backend contract module from {init_file}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _backend_contracts_dir() -> Path:
|
||||
for candidate in _backend_contract_candidates():
|
||||
init_file = candidate / "__init__.py"
|
||||
if init_file.is_file():
|
||||
return candidate
|
||||
|
||||
candidates = ", ".join(str(path) for path in _backend_contract_candidates())
|
||||
raise RuntimeError(f"Cannot find backend contract package. Checked: {candidates}")
|
||||
|
||||
|
||||
def _backend_contract_candidates() -> tuple[Path, ...]:
|
||||
configured = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
|
||||
candidates: list[Path] = []
|
||||
if configured:
|
||||
candidates.append(Path(configured))
|
||||
|
||||
current_file = Path(__file__).resolve()
|
||||
candidates.append(Path("/app/backend_contracts/contracts"))
|
||||
|
||||
for parent in current_file.parents:
|
||||
candidates.append(parent / "apps" / "backend" / "src" / "domain" / "contracts")
|
||||
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def validate_agent_job_output(payload: Mapping[str, Any]) -> Any:
|
||||
contracts = _backend_contracts_module()
|
||||
return contracts.AgentJobOutput.model_validate(payload)
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
RUNNER_ROOT = REPO_ROOT / "apps" / "runner"
|
||||
sys.path.insert(0, str(RUNNER_ROOT))
|
||||
|
||||
from src.application.job_output_validation import ( # noqa: E402
|
||||
_backend_contracts_module,
|
||||
validate_agent_job_output,
|
||||
)
|
||||
|
||||
|
||||
class RunnerJobOutputValidationTest(unittest.TestCase):
|
||||
def test_valid_runner_job_output_uses_backend_contracts(self) -> None:
|
||||
output = validate_agent_job_output(
|
||||
{
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": "outputs/plan.json"}],
|
||||
"payload": {"artifact": "plan"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("SUCCEEDED", output.status.value)
|
||||
|
||||
def test_invalid_runner_job_output_status_is_rejected(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_agent_job_output({"status": "NOT_A_STATUS"})
|
||||
|
||||
def test_runner_image_layout_can_use_copied_backend_contracts(self) -> None:
|
||||
source_contracts = REPO_ROOT / "apps" / "backend" / "src" / "domain" / "contracts"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
copied_contracts = Path(temp_dir) / "backend_contracts" / "contracts"
|
||||
shutil.copytree(source_contracts, copied_contracts)
|
||||
|
||||
previous = os.environ.get("PIPELINE_BACKEND_CONTRACTS_DIR")
|
||||
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = str(copied_contracts)
|
||||
_backend_contracts_module.cache_clear()
|
||||
try:
|
||||
output = validate_agent_job_output({"status": "SUCCEEDED"})
|
||||
finally:
|
||||
_backend_contracts_module.cache_clear()
|
||||
if previous is None:
|
||||
os.environ.pop("PIPELINE_BACKEND_CONTRACTS_DIR", None)
|
||||
else:
|
||||
os.environ["PIPELINE_BACKEND_CONTRACTS_DIR"] = previous
|
||||
|
||||
self.assertEqual("SUCCEEDED", output.status.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "ai-content-pipeline",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"scripts": {
|
||||
"dev:stack": "docker compose up --build",
|
||||
"smoke": "bash tests/smoke/public-health.sh"
|
||||
"generate:contracts": "python3 scripts/generate_openapi_contracts.py",
|
||||
"smoke": "bash tests/smoke/public-health.sh",
|
||||
"typecheck": "pnpm --filter @pipeline/shared typecheck && pnpm --filter @pipeline/frontend typecheck"
|
||||
},
|
||||
"workspaces": [
|
||||
"apps/frontend",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
// Generated from backend OpenAPI by scripts/generate_openapi_contracts.py.
|
||||
// Do not edit by hand.
|
||||
|
||||
export type AgentJobErrorCategory = "FAILED_SCHEMA_VALIDATION" | "CLI_EXIT_CODE_FAILURE" | "TIMEOUT" | "MISSING_OUTPUT_FILE" | "UNSUPPORTED_CLAIMS_FOUND" | "SOURCE_RETRIEVAL_FAILED" | "RESEARCH_ARTIFACT_UPLOAD_FAILED" | "PUBLISH_DRY_RUN_FAILED" | "GIT_CHECKOUT_FAILED" | "GIT_COMMIT_FAILED" | "GIT_PUSH_NON_FAST_FORWARD" | "PUBLISH_VERIFICATION_FAILED";
|
||||
|
||||
export type AgentJobOutput = {
|
||||
artifacts?: ResearchArtifactSummary[];
|
||||
error_category?: AgentJobErrorCategory | null;
|
||||
error_message?: string | null;
|
||||
output_files?: RunnerFileRef[];
|
||||
payload?: Record<string, unknown>;
|
||||
status: AgentJobStatus;
|
||||
};
|
||||
|
||||
export type AgentJobStatus = "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
|
||||
|
||||
export type AgentJobSummary = {
|
||||
agent_profile: string;
|
||||
article_id: string;
|
||||
error_category?: AgentJobErrorCategory | null;
|
||||
error_message?: string | null;
|
||||
finished_at?: string | null;
|
||||
id: string;
|
||||
input_files?: RunnerFileRef[];
|
||||
job_type: AgentJobType;
|
||||
output_files?: RunnerFileRef[];
|
||||
started_at?: string | null;
|
||||
status: AgentJobStatus;
|
||||
workspace_path?: string | null;
|
||||
};
|
||||
|
||||
export type AgentJobType = "BOUNDARY_QUESTIONS" | "PLAN_GENERATION" | "RESEARCH" | "EVIDENCE_MATRIX" | "SECTION_SCAFFOLD" | "DRAFT_ASSEMBLY" | "SEO_REVIEW" | "LANGUAGE_REVIEW" | "PUBLISH_DRY_RUN" | "PUBLISH_COMMIT" | "TEST_CODEX";
|
||||
|
||||
export type ArticleCreateRequest = {
|
||||
assigned_editor_id?: string | null;
|
||||
brief_description: string;
|
||||
content_type?: string;
|
||||
language?: string;
|
||||
primary_keyword?: string | null;
|
||||
target_site_id: string;
|
||||
working_title?: string | null;
|
||||
};
|
||||
|
||||
export type ArticleCreateResponse = {
|
||||
article: ArticleSummary;
|
||||
};
|
||||
|
||||
export type ArticleDetailResponse = {
|
||||
agent_jobs?: AgentJobSummary[];
|
||||
article: ArticleSummary;
|
||||
assets?: AssetSummary[];
|
||||
claims?: ClaimSummary[];
|
||||
draft?: DraftSummary | null;
|
||||
evidence?: EvidenceSummary[];
|
||||
plan?: PlanSummary | null;
|
||||
publish_commit?: PublishCommitSummary | null;
|
||||
research_manifests?: ResearchArtifactManifestSummary[];
|
||||
reviews?: ReviewSummary[];
|
||||
target_site?: TargetSiteConfig | null;
|
||||
};
|
||||
|
||||
export type ArticleListResponse = {
|
||||
articles: ArticleSummary[];
|
||||
};
|
||||
|
||||
export type ArticleSummary = {
|
||||
assigned_editor_id?: string | null;
|
||||
brief_description: string;
|
||||
content_type: string;
|
||||
created_at: string;
|
||||
id: string;
|
||||
language: string;
|
||||
primary_keyword?: string | null;
|
||||
publishing_status: PublishingStatus;
|
||||
status: ArticleWorkflowStatus;
|
||||
target_site_id: string;
|
||||
updated_at: string;
|
||||
working_title?: string | null;
|
||||
};
|
||||
|
||||
export type ArticleWorkflowStatus = "ARTICLE_BRIEF_CREATED" | "BOUNDARY_QUESTIONS_GENERATED" | "BOUNDARY_ANSWERS_SUBMITTED" | "PLAN_GENERATED" | "PLAN_REVIEW_REQUIRED" | "PLAN_REVISION_REQUIRED" | "RESEARCH_RUNNING" | "EVIDENCE_MATRIX_READY" | "PARALLEL_PRODUCTION_RUNNING" | "DRAFT_ASSEMBLED" | "SEO_AND_LANGUAGE_REVIEW_READY" | "FINAL_REVIEW_REQUIRED" | "FINAL_REVISION_REQUIRED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED";
|
||||
|
||||
export type AssetStatus = "PENDING" | "GENERATED" | "APPROVED" | "REJECTED";
|
||||
|
||||
export type AssetSummary = {
|
||||
alt_text?: string | null;
|
||||
article_id: string;
|
||||
asset_type: AssetType;
|
||||
caption?: string | null;
|
||||
file_url?: string | null;
|
||||
id: string;
|
||||
prompt?: string | null;
|
||||
status: AssetStatus;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type AssetType = "hero_image" | "diagram" | "table" | "inline_image";
|
||||
|
||||
export type ClaimRiskLevel = "low" | "medium" | "high";
|
||||
|
||||
export type ClaimSummary = {
|
||||
article_id: string;
|
||||
claim_text: string;
|
||||
evidence_item_ids?: string[];
|
||||
id: string;
|
||||
risk_level: ClaimRiskLevel;
|
||||
section_id?: string | null;
|
||||
support_status: ClaimSupportStatus;
|
||||
};
|
||||
|
||||
export type ClaimSupportStatus = "SUPPORTED" | "UNSUPPORTED" | "NEEDS_REVIEW";
|
||||
|
||||
export type DraftSummary = {
|
||||
article_id: string;
|
||||
body_object_key?: string | null;
|
||||
id: string;
|
||||
meta_description?: string | null;
|
||||
meta_title?: string | null;
|
||||
slug: string;
|
||||
status: ArticleWorkflowStatus;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type EvidenceSummary = {
|
||||
article_id: string;
|
||||
artifact_manifest_id: string;
|
||||
id: string;
|
||||
retrieved_at: string;
|
||||
source_quality_score: number;
|
||||
source_title: string;
|
||||
source_type: string;
|
||||
source_url: string;
|
||||
summary: string;
|
||||
supports_claims?: string[];
|
||||
};
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: ValidationError[];
|
||||
};
|
||||
|
||||
export type PlanReviewStatus = "PENDING_REVIEW" | "APPROVED" | "REVISION_REQUESTED";
|
||||
|
||||
export type PlanSectionSummary = {
|
||||
article_plan_id: string;
|
||||
claims_to_support?: string[];
|
||||
heading: string;
|
||||
id: string;
|
||||
purpose?: string | null;
|
||||
sort_order: number;
|
||||
target_word_count?: number | null;
|
||||
};
|
||||
|
||||
export type PlanSummary = {
|
||||
article_id: string;
|
||||
claims_to_prove?: string[];
|
||||
created_at: string;
|
||||
id: string;
|
||||
recommended_title?: string | null;
|
||||
risks?: string[];
|
||||
search_intent?: string | null;
|
||||
sections?: PlanSectionSummary[];
|
||||
status: PlanReviewStatus;
|
||||
thesis?: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type PublishCommitSummary = {
|
||||
article_id: string;
|
||||
branch: string;
|
||||
commit_sha?: string | null;
|
||||
content_bundle_manifest?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
deployment_status?: string | null;
|
||||
id: string;
|
||||
repository_url: string;
|
||||
status: PublishingStatus;
|
||||
target_site_id: string;
|
||||
};
|
||||
|
||||
export type PublishingRules = {
|
||||
asset_path_template: string;
|
||||
content_format?: string;
|
||||
content_path_template: string;
|
||||
dry_run_renderer?: string | null;
|
||||
frontmatter_mapping?: Record<string, unknown>;
|
||||
production_branch: string;
|
||||
repository_url: string;
|
||||
transform_script_version_id?: string | null;
|
||||
};
|
||||
|
||||
export type PublishingStatus = "PUBLISH_NOT_STARTED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_DRY_RUN_RUNNING" | "PUBLISH_DRY_RUN_FAILED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED" | "PUBLISH_VERIFICATION_FAILED";
|
||||
|
||||
export type ResearchArtifactManifestSummary = {
|
||||
agent_job_id: string;
|
||||
article_id: string;
|
||||
artifacts?: ResearchArtifactSummary[];
|
||||
created_at: string;
|
||||
id: string;
|
||||
s3_prefix: string;
|
||||
};
|
||||
|
||||
export type ResearchArtifactSummary = {
|
||||
artifact_type: string;
|
||||
content_hash: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
object_key: string;
|
||||
source_url: string;
|
||||
};
|
||||
|
||||
export type ReviewStatus = "PENDING" | "APPROVED" | "CHANGES_REQUESTED";
|
||||
|
||||
export type ReviewSummary = {
|
||||
article_id: string;
|
||||
created_at: string;
|
||||
id: string;
|
||||
notes?: string | null;
|
||||
review_type: ReviewType;
|
||||
reviewer_id?: string | null;
|
||||
status: ReviewStatus;
|
||||
};
|
||||
|
||||
export type ReviewType = "PLAN" | "EVIDENCE" | "ASSET" | "SEO_LANGUAGE" | "FINAL";
|
||||
|
||||
export type Role = "ADMIN" | "EDITOR";
|
||||
|
||||
export type RunnerFileRef = {
|
||||
content_hash?: string | null;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ScriptConfigVersionStatus = "DRAFT" | "ACTIVE" | "DEPRECATED";
|
||||
|
||||
export type ScriptConfigVersionSummary = {
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
id: string;
|
||||
publishing_yaml_hash: string;
|
||||
status: ScriptConfigVersionStatus;
|
||||
target_site_id: string;
|
||||
transform_script_hash: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type TargetSiteConfig = {
|
||||
active_script_config_version_id?: string | null;
|
||||
audience: string;
|
||||
brand_voice: string;
|
||||
created_at: string;
|
||||
default_language?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
publishing_rules: PublishingRules;
|
||||
publishing_type?: string;
|
||||
seo_rules?: Record<string, unknown>;
|
||||
slug: string;
|
||||
source_rules?: Record<string, unknown>;
|
||||
updated_at: string;
|
||||
visual_rules?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type UserSummary = {
|
||||
display_name: string;
|
||||
id: string;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type ValidationError = {
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -1,6 +1 @@
|
||||
export type {
|
||||
BackendDependenciesHealthResponse,
|
||||
BackendDependencyName,
|
||||
HealthResponse,
|
||||
ServiceName,
|
||||
} from "./health";
|
||||
export type * from "./api-types";
|
||||
|
||||
Generated
+605
@@ -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: {}
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import keyword
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
OPENAPI_PATH = REPO_ROOT / "packages" / "shared" / "openapi.json"
|
||||
TS_TYPES_PATH = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
|
||||
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
GENERATED_HEADER = """// Generated from backend OpenAPI by scripts/generate_openapi_contracts.py.
|
||||
// Do not edit by hand.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
openapi = app.openapi()
|
||||
OPENAPI_PATH.write_text(
|
||||
json.dumps(openapi, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
TS_TYPES_PATH.write_text(render_typescript(openapi), encoding="utf-8")
|
||||
print(f"wrote {OPENAPI_PATH}")
|
||||
print(f"wrote {TS_TYPES_PATH}")
|
||||
|
||||
|
||||
def render_typescript(openapi: dict[str, Any]) -> str:
|
||||
schemas = openapi.get("components", {}).get("schemas", {})
|
||||
lines = [GENERATED_HEADER.rstrip(), ""]
|
||||
for name in sorted(schemas):
|
||||
lines.append(render_schema_type(name, schemas[name], schemas))
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_schema_type(
|
||||
name: str,
|
||||
schema: dict[str, Any],
|
||||
schemas: dict[str, Any],
|
||||
) -> str:
|
||||
if schema.get("type") == "object" or "properties" in schema:
|
||||
return f"export type {name} = {render_object(schema, schemas, indent=0)};"
|
||||
return f"export type {name} = {schema_to_ts(schema, schemas)};"
|
||||
|
||||
|
||||
def render_object(
|
||||
schema: dict[str, Any],
|
||||
schemas: dict[str, Any],
|
||||
*,
|
||||
indent: int,
|
||||
) -> str:
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
pad = " " * indent
|
||||
child_pad = " " * (indent + 1)
|
||||
|
||||
if not properties:
|
||||
additional = schema.get("additionalProperties")
|
||||
if isinstance(additional, dict):
|
||||
return f"Record<string, {schema_to_ts(additional, schemas)}>"
|
||||
return "Record<string, unknown>"
|
||||
|
||||
lines = ["{"]
|
||||
for property_name in sorted(properties):
|
||||
property_schema = properties[property_name]
|
||||
optional = "" if property_name in required else "?"
|
||||
lines.append(
|
||||
f"{child_pad}{ts_property_name(property_name)}{optional}: "
|
||||
f"{schema_to_ts(property_schema, schemas)};"
|
||||
)
|
||||
lines.append(f"{pad}}}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def schema_to_ts(schema: dict[str, Any], schemas: dict[str, Any]) -> str:
|
||||
if "$ref" in schema:
|
||||
return schema["$ref"].removeprefix("#/components/schemas/")
|
||||
|
||||
if "enum" in schema:
|
||||
values = schema["enum"]
|
||||
return " | ".join(json.dumps(value) for value in values) or "never"
|
||||
|
||||
if "const" in schema:
|
||||
return json.dumps(schema["const"])
|
||||
|
||||
if "anyOf" in schema:
|
||||
return render_union(schema["anyOf"], schemas)
|
||||
|
||||
if "oneOf" in schema:
|
||||
return render_union(schema["oneOf"], schemas)
|
||||
|
||||
if "allOf" in schema:
|
||||
return " & ".join(schema_to_ts(item, schemas) for item in schema["allOf"])
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if isinstance(schema_type, list):
|
||||
return " | ".join(
|
||||
schema_type_to_ts(item, schema, schemas) for item in schema_type
|
||||
)
|
||||
|
||||
return schema_type_to_ts(schema_type, schema, schemas)
|
||||
|
||||
|
||||
def render_union(items: list[dict[str, Any]], schemas: dict[str, Any]) -> str:
|
||||
rendered = [schema_to_ts(item, schemas) for item in items]
|
||||
return " | ".join(rendered) or "unknown"
|
||||
|
||||
|
||||
def schema_type_to_ts(
|
||||
schema_type: str | None,
|
||||
schema: dict[str, Any],
|
||||
schemas: dict[str, Any],
|
||||
) -> str:
|
||||
if schema_type == "string":
|
||||
return "string"
|
||||
if schema_type in {"integer", "number"}:
|
||||
return "number"
|
||||
if schema_type == "boolean":
|
||||
return "boolean"
|
||||
if schema_type == "null":
|
||||
return "null"
|
||||
if schema_type == "array":
|
||||
return f"{array_item_to_ts(schema.get('items', {}), schemas)}[]"
|
||||
if schema_type == "object" or "properties" in schema:
|
||||
return render_object(schema, schemas, indent=0)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def array_item_to_ts(schema: dict[str, Any], schemas: dict[str, Any]) -> str:
|
||||
rendered = schema_to_ts(schema, schemas)
|
||||
if " | " in rendered or " & " in rendered:
|
||||
return f"({rendered})"
|
||||
return rendered
|
||||
|
||||
|
||||
def ts_property_name(name: str) -> str:
|
||||
if name.isidentifier() and not keyword.iskeyword(name):
|
||||
return name
|
||||
return json.dumps(name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user