diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 15e0449..7057f2a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -97,6 +97,13 @@ jobs: defaults: run: working-directory: backend + env: + ENVIRONMENT: development + POSTGRES_SERVER: localhost + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + POSTGRES_PORT: "5432" steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c56dfd6..bee73d6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,9 +32,17 @@ class Settings(BaseSettings): K8S_NAMESPACE: str = "default" K8S_IN_CLUSTER: bool = False - # Artifact保存先 + # Artifact保存先 (legacy, Phase 6 cleanup) ARTIFACTS_DIR: str = "/tmp/ui-recommender-artifacts" + # S3 / MinIO設定 + S3_BUCKET: str = "ui-recommender-artifacts" + S3_REGION: str = "us-east-1" + S3_ENDPOINT_URL: str = "" # MinIO: http://minio:9000 + S3_ENDPOINT_URL_K8S: str = "" # K8sワーカー用。未設定時はS3_ENDPOINT_URLを使用 + S3_ACCESS_KEY: str = "minioadmin" + S3_SECRET_KEY: str = "minioadmin" + # Worker設定 WORKER_IMAGE: str = "ui-recommender-worker:latest" WORKER_DEADLINE_SECONDS: int = 900 diff --git a/backend/app/di/container.py b/backend/app/di/container.py index 420a734..2c27830 100644 --- a/backend/app/di/container.py +++ b/backend/app/di/container.py @@ -3,8 +3,6 @@ from sqlalchemy.orm import Session from app.repository.database import SessionLocal -from app.repository.job_repository import JobRepository -from app.repository.proposal_repository import ProposalRepository from app.repository.setting_repository import SettingRepository @@ -20,14 +18,6 @@ def get_db() -> Generator[Session]: finally: db.close() - @staticmethod - def get_job_repository(db: Session) -> JobRepository: - return JobRepository(db) - - @staticmethod - def get_proposal_repository(db: Session) -> ProposalRepository: - return ProposalRepository(db) - @staticmethod def get_setting_repository(db: Session) -> SettingRepository: return SettingRepository(db) diff --git a/backend/app/di/dependencies.py b/backend/app/di/dependencies.py index 0b02fbd..1800329 100644 --- a/backend/app/di/dependencies.py +++ b/backend/app/di/dependencies.py @@ -4,10 +4,8 @@ from sqlalchemy.orm import Session from app.di.container import DIContainer -from app.repository.job_repository import JobRepository -from app.repository.proposal_repository import ProposalRepository from app.repository.setting_repository import SettingRepository -from app.service.artifact_service import ArtifactService +from app.service.s3_service import S3Service def get_db() -> Generator[Session]: @@ -15,17 +13,9 @@ def get_db() -> Generator[Session]: yield from DIContainer.get_db() -def get_job_repository(db: Session = Depends(get_db)) -> JobRepository: - return DIContainer.get_job_repository(db) - - -def get_proposal_repository(db: Session = Depends(get_db)) -> ProposalRepository: - return DIContainer.get_proposal_repository(db) - - def get_setting_repository(db: Session = Depends(get_db)) -> SettingRepository: return DIContainer.get_setting_repository(db) -def get_artifact_service() -> ArtifactService: - return ArtifactService() +def get_s3_service() -> S3Service: + return S3Service() diff --git a/backend/app/main.py b/backend/app/main.py index 4ab49df..b5cb340 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,27 @@ +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.middleware import ErrorHandlerMiddleware, RequestLoggingMiddleware from app.router import router -from app.router.jobs import router as jobs_router +from app.router.sessions import router as sessions_router from app.router.settings import router as settings_router +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Startup / shutdown lifecycle.""" + from app.usecase.session_usecase import recover_stuck_proposals + + await recover_stuck_proposals() + yield + + # FastAPIのインスタンスを作成(Swagger UIのメタデータを設定) app = FastAPI( title="UI Recommender API", @@ -13,6 +29,7 @@ version="0.1.0", docs_url="/docs", # Swagger UI redoc_url="/redoc", # ReDoc + lifespan=lifespan, ) # ミドルウェア登録(後に追加したものが先に実行される) @@ -30,5 +47,5 @@ # API層のルーティングを読み込む app.include_router(router) -app.include_router(jobs_router) +app.include_router(sessions_router) app.include_router(settings_router) diff --git a/backend/app/model/__init__.py b/backend/app/model/__init__.py index 3d66e13..b2ef83d 100644 --- a/backend/app/model/__init__.py +++ b/backend/app/model/__init__.py @@ -8,6 +8,22 @@ # - マイグレーション(Alembic)で使用 # - Repository層の実装で使用 -from .job import Job, JobStatus, Proposal, ProposalStatus, Setting +from .session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, + SessionStatus, + Setting, +) -__all__ = ["Job", "JobStatus", "Proposal", "ProposalStatus", "Setting"] +__all__ = [ + "Session", + "SessionStatus", + "Iteration", + "IterationStatus", + "Proposal", + "ProposalStatus", + "Setting", +] diff --git a/backend/app/model/base.py b/backend/app/model/base.py index 5a507f7..f69c101 100644 --- a/backend/app/model/base.py +++ b/backend/app/model/base.py @@ -1,17 +1,7 @@ -from typing import Any +from sqlalchemy.orm import DeclarativeBase, declared_attr -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import as_declarative - -@as_declarative() -class Base: - __allow_unmapped__ = True - - id: Any - __name__: str - - # Generate __tablename__ automatically - @declared_attr +class Base(DeclarativeBase): + @declared_attr.directive def __tablename__(cls) -> str: return cls.__name__.lower() diff --git a/backend/app/model/job.py b/backend/app/model/job.py deleted file mode 100644 index f48ff90..0000000 --- a/backend/app/model/job.py +++ /dev/null @@ -1,78 +0,0 @@ -import enum -import uuid -from datetime import UTC, datetime - -from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid -from sqlalchemy.orm import relationship - -from app.model.base import Base - - -class JobStatus(enum.StrEnum): - PENDING = "pending" - ANALYZING = "analyzing" - ANALYZED = "analyzed" - IMPLEMENTING = "implementing" - COMPLETED = "completed" - FAILED = "failed" - - -class ProposalStatus(enum.StrEnum): - PENDING = "pending" - IMPLEMENTING = "implementing" - COMPLETED = "completed" - FAILED = "failed" - - -def _utcnow() -> datetime: - return datetime.now(UTC) - - -class Job(Base): - __tablename__ = "jobs" - - id = Column(Uuid, primary_key=True, default=uuid.uuid4) - status = Column(Enum(JobStatus), nullable=False, default=JobStatus.PENDING) - repo_url = Column(String(500), nullable=False) - branch = Column(String(200), nullable=False, default="main") - instruction = Column(Text, nullable=False) - before_screenshot_path = Column(String(500), nullable=True) - error_message = Column(Text, nullable=True) - k8s_job_name = Column(String(200), nullable=True) - created_at = Column(DateTime(timezone=True), default=_utcnow) - updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) - - proposals: list["Proposal"] = relationship( - "Proposal", back_populates="job", cascade="all, delete-orphan" - ) - - -class Proposal(Base): - __tablename__ = "proposals" - - id = Column(Uuid, primary_key=True, default=uuid.uuid4) - job_id = Column(Uuid, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False) - proposal_index = Column(Integer, nullable=False) - title = Column(String(200), nullable=False) - concept = Column(Text, nullable=False) - plan = Column(Text, nullable=False) # JSON array stored as text - files = Column(Text, nullable=True) # JSON array stored as text - complexity = Column(String(20), nullable=True) - status = Column(Enum(ProposalStatus), nullable=False, default=ProposalStatus.PENDING) - after_screenshot_path = Column(String(500), nullable=True) - diff_path = Column(Text, nullable=True) - pr_url = Column(String(500), nullable=True) - pr_status = Column(String(20), nullable=True) # creating, created, failed - k8s_job_name = Column(String(200), nullable=True) - error_message = Column(Text, nullable=True) - created_at = Column(DateTime(timezone=True), default=_utcnow) - - job: "Job" = relationship("Job", back_populates="proposals") - - -class Setting(Base): - __tablename__ = "settings" - - key = Column(String(100), primary_key=True) - value = Column(Text, nullable=False) - updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) diff --git a/backend/app/model/session.py b/backend/app/model/session.py new file mode 100644 index 0000000..dc553d9 --- /dev/null +++ b/backend/app/model/session.py @@ -0,0 +1,147 @@ +import enum +import uuid +from datetime import UTC, datetime + +from sqlalchemy import ( + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + Uuid, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.base import Base + + +class SessionStatus(enum.StrEnum): + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class IterationStatus(enum.StrEnum): + PENDING = "pending" + ANALYZING = "analyzing" + ANALYZED = "analyzed" + IMPLEMENTING = "implementing" + COMPLETED = "completed" + FAILED = "failed" + + +class ProposalStatus(enum.StrEnum): + PENDING = "pending" + IMPLEMENTING = "implementing" + COMPLETED = "completed" + FAILED = "failed" + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + repo_url: Mapped[str] = mapped_column(String(500)) + base_branch: Mapped[str] = mapped_column(String(200), default="main") + status: Mapped[SessionStatus] = mapped_column( + Enum(SessionStatus, values_callable=lambda x: [e.value for e in x]), + default=SessionStatus.ACTIVE, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, onupdate=_utcnow + ) + + iterations: Mapped[list["Iteration"]] = relationship( + "Iteration", + back_populates="session", + cascade="all, delete-orphan", + order_by="Iteration.iteration_index", + ) + + +class Iteration(Base): + __tablename__ = "iterations" + __table_args__ = ( + UniqueConstraint("session_id", "iteration_index", name="uq_iteration_session_index"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + session_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("sessions.id", ondelete="CASCADE"), + ) + iteration_index: Mapped[int] = mapped_column(Integer) + instruction: Mapped[str] = mapped_column(Text) + selected_proposal_index: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[IterationStatus] = mapped_column( + Enum(IterationStatus, values_callable=lambda x: [e.value for e in x]), + default=IterationStatus.PENDING, + ) + before_screenshot_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + k8s_analyzer_job_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, onupdate=_utcnow + ) + + session: Mapped["Session"] = relationship("Session", back_populates="iterations") + proposals: Mapped[list["Proposal"]] = relationship( + "Proposal", + back_populates="iteration", + cascade="all, delete-orphan", + order_by="Proposal.proposal_index", + ) + + +class Proposal(Base): + __tablename__ = "proposals" + __table_args__ = ( + UniqueConstraint("iteration_id", "proposal_index", name="uq_proposal_iteration_index"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + iteration_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("iterations.id", ondelete="CASCADE"), + ) + proposal_index: Mapped[int] = mapped_column(Integer) + title: Mapped[str] = mapped_column(String(200)) + concept: Mapped[str] = mapped_column(Text) + plan: Mapped[str] = mapped_column(Text) + files: Mapped[str | None] = mapped_column(Text, nullable=True) + complexity: Mapped[str | None] = mapped_column(String(20), nullable=True) + status: Mapped[ProposalStatus] = mapped_column( + Enum( + ProposalStatus, + name="proposalstatus", + values_callable=lambda x: [e.value for e in x], + ), + default=ProposalStatus.PENDING, + ) + after_screenshot_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + diff_key: Mapped[str | None] = mapped_column(Text, nullable=True) + pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + pr_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + k8s_job_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + version: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + + iteration: Mapped["Iteration"] = relationship("Iteration", back_populates="proposals") + + +class Setting(Base): + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[str] = mapped_column(Text) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, onupdate=_utcnow + ) diff --git a/backend/app/repository/__init__.py b/backend/app/repository/__init__.py index 6ec7928..97c74b0 100644 --- a/backend/app/repository/__init__.py +++ b/backend/app/repository/__init__.py @@ -10,17 +10,18 @@ # - テスト時にモックに差し替え可能 # - 構成 # - database.py: DB接続設定 -# - *_repository_interface.py: リポジトリインターフェース -# - *_repository_sql.py: SQL実装 +# - *_repository.py: リポジトリ実装 from .database import SessionLocal -from .job_repository import JobRepository +from .iteration_repository import IterationRepository from .proposal_repository import ProposalRepository +from .session_repository import SessionRepository from .setting_repository import SettingRepository __all__ = [ "SessionLocal", - "JobRepository", + "SessionRepository", + "IterationRepository", "ProposalRepository", "SettingRepository", ] diff --git a/backend/app/repository/iteration_repository.py b/backend/app/repository/iteration_repository.py new file mode 100644 index 0000000..d06bec1 --- /dev/null +++ b/backend/app/repository/iteration_repository.py @@ -0,0 +1,82 @@ +from uuid import UUID + +from sqlalchemy.orm import Session as DbSession + +from app.model.session import Iteration, IterationStatus + + +class IterationRepository: + def __init__(self, db: DbSession) -> None: + self.db = db + + def create(self, iteration: Iteration) -> Iteration: + self.db.add(iteration) + self.db.commit() + self.db.refresh(iteration) + return iteration + + def get_by_id(self, iteration_id: UUID) -> Iteration | None: + return self.db.query(Iteration).filter(Iteration.id == iteration_id).first() + + def get_by_session_and_index(self, session_id: UUID, iteration_index: int) -> Iteration | None: + return ( + self.db.query(Iteration) + .filter( + Iteration.session_id == session_id, + Iteration.iteration_index == iteration_index, + ) + .first() + ) + + def get_latest_for_session(self, session_id: UUID) -> Iteration | None: + return ( + self.db.query(Iteration) + .filter(Iteration.session_id == session_id) + .order_by(Iteration.iteration_index.desc()) + .first() + ) + + def get_all_for_session(self, session_id: UUID) -> list[Iteration]: + return ( + self.db.query(Iteration) + .filter(Iteration.session_id == session_id) + .order_by(Iteration.iteration_index) + .all() + ) + + def update_status_optimistic( + self, + iteration_id: UUID, + expected_version: int, + status: IterationStatus, + **kwargs: object, + ) -> Iteration | None: + """Update status with optimistic locking. Returns None if version mismatch.""" + rows = ( + self.db.query(Iteration) + .filter( + Iteration.id == iteration_id, + Iteration.version == expected_version, + ) + .update( + { + "status": status, + "version": Iteration.version + 1, + **{k: v for k, v in kwargs.items() if hasattr(Iteration, k)}, + }, + synchronize_session="fetch", + ) + ) + if rows == 0: + self.db.rollback() + return None + self.db.commit() + return self.get_by_id(iteration_id) + + def update_selected_proposal(self, iteration_id: UUID, proposal_index: int) -> Iteration | None: + iteration = self.get_by_id(iteration_id) + if iteration: + iteration.selected_proposal_index = proposal_index + self.db.commit() + self.db.refresh(iteration) + return iteration diff --git a/backend/app/repository/job_repository.py b/backend/app/repository/job_repository.py deleted file mode 100644 index 6697573..0000000 --- a/backend/app/repository/job_repository.py +++ /dev/null @@ -1,33 +0,0 @@ -from uuid import UUID - -from sqlalchemy.orm import Session - -from app.model.job import Job, JobStatus - - -class JobRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def create(self, job: Job) -> Job: - self.db.add(job) - self.db.commit() - self.db.refresh(job) - return job - - def get_by_id(self, job_id: UUID) -> Job | None: - return self.db.query(Job).filter(Job.id == job_id).first() - - def update_status(self, job_id: UUID, status: JobStatus, **kwargs: object) -> Job | None: - job = self.get_by_id(job_id) - if job: - job.status = status - for key, value in kwargs.items(): - if hasattr(job, key): - setattr(job, key, value) - self.db.commit() - self.db.refresh(job) - return job - - def list_all(self) -> list[Job]: - return self.db.query(Job).order_by(Job.created_at.desc()).all() diff --git a/backend/app/repository/proposal_repository.py b/backend/app/repository/proposal_repository.py index 4af68ad..8870810 100644 --- a/backend/app/repository/proposal_repository.py +++ b/backend/app/repository/proposal_repository.py @@ -1,12 +1,12 @@ from uuid import UUID -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session as DbSession -from app.model.job import Proposal, ProposalStatus +from app.model.session import Proposal, ProposalStatus class ProposalRepository: - def __init__(self, db: Session) -> None: + def __init__(self, db: DbSession) -> None: self.db = db def create(self, proposal: Proposal) -> Proposal: @@ -18,33 +18,54 @@ def create(self, proposal: Proposal) -> Proposal: def get_by_id(self, proposal_id: UUID) -> Proposal | None: return self.db.query(Proposal).filter(Proposal.id == proposal_id).first() - def get_by_job_and_index(self, job_id: UUID, proposal_index: int) -> Proposal | None: + def get_by_iteration_and_index( + self, iteration_id: UUID, proposal_index: int + ) -> Proposal | None: return ( self.db.query(Proposal) .filter( - Proposal.job_id == job_id, + Proposal.iteration_id == iteration_id, Proposal.proposal_index == proposal_index, ) .first() ) - def get_all_for_job(self, job_id: UUID) -> list[Proposal]: + def get_all_by_status(self, status: ProposalStatus) -> list[Proposal]: + return self.db.query(Proposal).filter(Proposal.status == status).all() + + def get_all_for_iteration(self, iteration_id: UUID) -> list[Proposal]: return ( self.db.query(Proposal) - .filter(Proposal.job_id == job_id) + .filter(Proposal.iteration_id == iteration_id) .order_by(Proposal.proposal_index) .all() ) - def update_status( - self, proposal_id: UUID, status: ProposalStatus, **kwargs: object + def update_status_optimistic( + self, + proposal_id: UUID, + expected_version: int, + status: ProposalStatus, + **kwargs: object, ) -> Proposal | None: - proposal = self.get_by_id(proposal_id) - if proposal: - proposal.status = status - for key, value in kwargs.items(): - if hasattr(proposal, key): - setattr(proposal, key, value) - self.db.commit() - self.db.refresh(proposal) - return proposal + """Update status with optimistic locking. Returns None if version mismatch.""" + rows = ( + self.db.query(Proposal) + .filter( + Proposal.id == proposal_id, + Proposal.version == expected_version, + ) + .update( + { + "status": status, + "version": Proposal.version + 1, + **{k: v for k, v in kwargs.items() if hasattr(Proposal, k)}, + }, + synchronize_session="fetch", + ) + ) + if rows == 0: + self.db.rollback() + return None + self.db.commit() + return self.get_by_id(proposal_id) diff --git a/backend/app/repository/session_repository.py b/backend/app/repository/session_repository.py new file mode 100644 index 0000000..33dff22 --- /dev/null +++ b/backend/app/repository/session_repository.py @@ -0,0 +1,41 @@ +from uuid import UUID + +from sqlalchemy.orm import Session as DbSession +from sqlalchemy.orm import selectinload + +from app.model.session import Iteration, Session, SessionStatus + + +class SessionRepository: + def __init__(self, db: DbSession) -> None: + self.db = db + + def create(self, session: Session) -> Session: + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def get_by_id(self, session_id: UUID) -> Session | None: + return ( + self.db.query(Session) + .options(selectinload(Session.iterations).selectinload(Iteration.proposals)) + .filter(Session.id == session_id) + .first() + ) + + def update_status(self, session_id: UUID, status: SessionStatus) -> Session | None: + session = self.get_by_id(session_id) + if session: + session.status = status + self.db.commit() + self.db.refresh(session) + return session + + def list_all(self) -> list[Session]: + return ( + self.db.query(Session) + .options(selectinload(Session.iterations).selectinload(Iteration.proposals)) + .order_by(Session.created_at.desc()) + .all() + ) diff --git a/backend/app/repository/setting_repository.py b/backend/app/repository/setting_repository.py index 9389fbb..d2f43fc 100644 --- a/backend/app/repository/setting_repository.py +++ b/backend/app/repository/setting_repository.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session -from app.model.job import Setting +from app.model.session import Setting class SettingRepository: diff --git a/backend/app/router/jobs.py b/backend/app/router/jobs.py deleted file mode 100644 index 8e7ce08..0000000 --- a/backend/app/router/jobs.py +++ /dev/null @@ -1,159 +0,0 @@ -import json -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse -from sqlalchemy.orm import Session - -from app.di.dependencies import get_artifact_service, get_db -from app.model.job import Job, Proposal -from app.repository.job_repository import JobRepository -from app.schema.job_schema import ( - CreateJobRequest, - ImplementRequest, - JobResponse, - ProposalResponse, -) -from app.service.artifact_service import ArtifactService -from app.usecase.job_usecase import CreateJobUseCase, CreatePRUseCase, ImplementProposalUseCase - -router = APIRouter(prefix="/api/jobs", tags=["jobs"]) - - -def _to_proposal_response(proposal: Proposal, job_id: UUID) -> ProposalResponse: - """Convert Proposal model to ProposalResponse.""" - try: - plan = json.loads(proposal.plan) if proposal.plan else [] - except json.JSONDecodeError: - plan = [] - try: - files = json.loads(proposal.files) if proposal.files else [] - except json.JSONDecodeError: - files = [] - - return ProposalResponse( - id=proposal.id, - proposal_index=proposal.proposal_index, - title=proposal.title, - concept=proposal.concept, - plan=plan, - files=files, - complexity=proposal.complexity, - status=proposal.status.value if proposal.status else "pending", - after_screenshot_url=( - f"/api/jobs/{job_id}/proposals/{proposal.proposal_index}/screenshot" - if proposal.after_screenshot_path - else None - ), - pr_url=proposal.pr_url, - pr_status=proposal.pr_status, - error_message=proposal.error_message, - created_at=proposal.created_at, - ) - - -def _to_job_response(job: Job) -> JobResponse: - """Convert Job model to JobResponse.""" - job_id = job.id - proposals = [_to_proposal_response(p, job_id) for p in job.proposals] # type: ignore[arg-type] - return JobResponse( - id=job_id, - status=job.status.value if job.status else "pending", - repo_url=job.repo_url, - branch=job.branch, - instruction=job.instruction, - before_screenshot_url=( - f"/api/jobs/{job.id}/screenshot/before" if job.before_screenshot_path else None - ), - error_message=job.error_message, - proposals=proposals, - created_at=job.created_at, - updated_at=job.updated_at, - ) - - -@router.post("/", response_model=JobResponse, status_code=201) -async def create_job(request: CreateJobRequest, db: Session = Depends(get_db)) -> JobResponse: - usecase = CreateJobUseCase(db) - job = await usecase.execute( - repo_url=request.repo_url, - branch=request.branch, - instruction=request.instruction, - ) - return _to_job_response(job) - - -@router.get("/", response_model=list[JobResponse]) -async def list_jobs(db: Session = Depends(get_db)) -> list[JobResponse]: - job_repo = JobRepository(db) - jobs = job_repo.list_all() - return [_to_job_response(j) for j in jobs] - - -@router.get("/{job_id}", response_model=JobResponse) -async def get_job(job_id: UUID, db: Session = Depends(get_db)) -> JobResponse: - job_repo = JobRepository(db) - job = job_repo.get_by_id(job_id) - if not job: - raise HTTPException(status_code=404, detail="Job not found") - return _to_job_response(job) - - -@router.post("/{job_id}/implement", response_model=JobResponse) -async def implement_proposals( - job_id: UUID, request: ImplementRequest, db: Session = Depends(get_db) -) -> JobResponse: - usecase = ImplementProposalUseCase(db) - try: - job = await usecase.execute(job_id, request.proposal_indices) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - return _to_job_response(job) - - -@router.post("/{job_id}/proposals/{proposal_index}/create-pr", response_model=ProposalResponse) -async def create_pr( - job_id: UUID, - proposal_index: int, - db: Session = Depends(get_db), -) -> ProposalResponse: - usecase = CreatePRUseCase(db) - try: - proposal = await usecase.execute(job_id, proposal_index) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - return _to_proposal_response(proposal, job_id) - - -@router.get("/{job_id}/screenshot/before") -async def get_before_screenshot( - job_id: UUID, artifacts: ArtifactService = Depends(get_artifact_service) -) -> FileResponse: - path = artifacts.get_before_screenshot_path(str(job_id)) - if not path: - raise HTTPException(status_code=404, detail="Screenshot not found") - return FileResponse(str(path), media_type="image/png") - - -@router.get("/{job_id}/proposals/{proposal_index}/screenshot") -async def get_after_screenshot( - job_id: UUID, - proposal_index: int, - artifacts: ArtifactService = Depends(get_artifact_service), -) -> FileResponse: - path = artifacts.get_after_screenshot_path(str(job_id), proposal_index) - if not path: - raise HTTPException(status_code=404, detail="Screenshot not found") - return FileResponse(str(path), media_type="image/png") - - -@router.get("/{job_id}/proposals/{proposal_index}/diff") -async def get_diff( - job_id: UUID, - proposal_index: int, - artifacts: ArtifactService = Depends(get_artifact_service), -) -> dict: - diff = artifacts.get_diff(str(job_id), proposal_index) - if not diff: - raise HTTPException(status_code=404, detail="Diff not found") - return {"diff": diff} diff --git a/backend/app/router/sessions.py b/backend/app/router/sessions.py new file mode 100644 index 0000000..8d5f3f2 --- /dev/null +++ b/backend/app/router/sessions.py @@ -0,0 +1,202 @@ +import json +from io import BytesIO +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.di.dependencies import get_db, get_s3_service +from app.model.session import Iteration, Proposal +from app.model.session import Session as SessionModel +from app.schema.session_schema import ( + CreatePRRequest, + CreateSessionRequest, + IterateRequest, + IterationResponse, + ProposalResponse, + SessionResponse, +) +from app.service.s3_service import S3Service +from app.usecase.session_usecase import ( + CreateSessionPRUseCase, + CreateSessionUseCase, + IterateUseCase, +) + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +def _to_proposal_response( + proposal: Proposal, session_id: UUID, iteration_index: int +) -> ProposalResponse: + try: + plan = json.loads(proposal.plan) if proposal.plan else [] + except json.JSONDecodeError: + plan = [] + try: + files = json.loads(proposal.files) if proposal.files else [] + except json.JSONDecodeError: + files = [] + + return ProposalResponse( + id=proposal.id, + proposal_index=proposal.proposal_index, + title=proposal.title, + concept=proposal.concept, + plan=plan, + files=files, + complexity=proposal.complexity, + status=proposal.status.value if proposal.status else "pending", + after_screenshot_url=( + f"/api/sessions/{session_id}/iterations/{iteration_index}" + f"/proposals/{proposal.proposal_index}/screenshot" + if proposal.after_screenshot_key + else None + ), + diff_key=proposal.diff_key, + pr_url=proposal.pr_url, + pr_status=proposal.pr_status, + error_message=proposal.error_message, + created_at=proposal.created_at, + ) + + +def _to_iteration_response(iteration: Iteration, session_id: UUID) -> IterationResponse: + proposals = [ + _to_proposal_response(p, session_id, iteration.iteration_index) for p in iteration.proposals + ] + return IterationResponse( + id=iteration.id, + iteration_index=iteration.iteration_index, + instruction=iteration.instruction, + selected_proposal_index=iteration.selected_proposal_index, + status=iteration.status.value if iteration.status else "pending", + before_screenshot_url=( + f"/api/sessions/{session_id}/iterations/{iteration.iteration_index}/screenshot/before" + if iteration.before_screenshot_key + else None + ), + error_message=iteration.error_message, + proposals=proposals, + created_at=iteration.created_at, + ) + + +def _to_session_response( + session: "SessionModel", +) -> SessionResponse: + session_id = session.id + iterations = [_to_iteration_response(it, session_id) for it in session.iterations] + return SessionResponse( + id=session_id, + repo_url=session.repo_url, + base_branch=session.base_branch, + status=session.status.value if session.status else "active", + iterations=iterations, + created_at=session.created_at, + updated_at=session.updated_at, + ) + + +@router.post("/", response_model=SessionResponse, status_code=201) +async def create_session( + request: CreateSessionRequest, db: Session = Depends(get_db) +) -> SessionResponse: + usecase = CreateSessionUseCase(db) + session = await usecase.execute( + repo_url=request.repo_url, + branch=request.branch, + instruction=request.instruction, + ) + return _to_session_response(session) + + +@router.get("/", response_model=list[SessionResponse]) +async def list_sessions(db: Session = Depends(get_db)) -> list[SessionResponse]: + from app.repository.session_repository import SessionRepository + + repo = SessionRepository(db) + sessions = repo.list_all() + return [_to_session_response(s) for s in sessions] + + +@router.get("/{session_id}", response_model=SessionResponse) +async def get_session(session_id: UUID, db: Session = Depends(get_db)) -> SessionResponse: + from app.repository.session_repository import SessionRepository + + repo = SessionRepository(db) + session = repo.get_by_id(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return _to_session_response(session) + + +@router.post("/{session_id}/iterate", response_model=SessionResponse, status_code=201) +async def iterate( + session_id: UUID, + request: IterateRequest, + db: Session = Depends(get_db), +) -> SessionResponse: + usecase = IterateUseCase(db) + try: + session = await usecase.execute( + session_id, request.selected_proposal_index, request.instruction + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return _to_session_response(session) + + +@router.post("/{session_id}/create-pr", response_model=ProposalResponse) +async def create_pr( + session_id: UUID, + request: CreatePRRequest, + db: Session = Depends(get_db), +) -> ProposalResponse: + usecase = CreateSessionPRUseCase(db) + try: + proposal = await usecase.execute( + session_id, request.iteration_index, request.proposal_index + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return _to_proposal_response(proposal, session_id, request.iteration_index) + + +@router.get("/{session_id}/iterations/{iter_index}/screenshot/before") +async def get_before_screenshot( + session_id: UUID, + iter_index: int, + s3: S3Service = Depends(get_s3_service), +) -> StreamingResponse: + data = s3.get_before_screenshot(str(session_id), iter_index) + if not data: + raise HTTPException(status_code=404, detail="Screenshot not found") + return StreamingResponse(BytesIO(data), media_type="image/png") + + +@router.get("/{session_id}/iterations/{iter_index}/proposals/{prop_index}/screenshot") +async def get_after_screenshot( + session_id: UUID, + iter_index: int, + prop_index: int, + s3: S3Service = Depends(get_s3_service), +) -> StreamingResponse: + data = s3.get_after_screenshot(str(session_id), iter_index, prop_index) + if not data: + raise HTTPException(status_code=404, detail="Screenshot not found") + return StreamingResponse(BytesIO(data), media_type="image/png") + + +@router.get("/{session_id}/iterations/{iter_index}/proposals/{prop_index}/diff") +async def get_diff( + session_id: UUID, + iter_index: int, + prop_index: int, + s3: S3Service = Depends(get_s3_service), +) -> dict: + diff = s3.get_diff(str(session_id), iter_index, prop_index) + if not diff: + raise HTTPException(status_code=404, detail="Diff not found") + return {"diff": diff} diff --git a/backend/app/router/settings.py b/backend/app/router/settings.py index 0ddadb6..34ee19d 100644 --- a/backend/app/router/settings.py +++ b/backend/app/router/settings.py @@ -3,7 +3,7 @@ from app.di.dependencies import get_db from app.repository.setting_repository import SettingRepository -from app.schema.job_schema import SettingRequest, SettingResponse +from app.schema.setting_schema import SettingRequest, SettingResponse router = APIRouter(prefix="/api/settings", tags=["settings"]) diff --git a/backend/app/schema/__init__.py b/backend/app/schema/__init__.py index f5a179b..0131adb 100644 --- a/backend/app/schema/__init__.py +++ b/backend/app/schema/__init__.py @@ -7,22 +7,23 @@ # - Pydanticを使用 # - Request/Responseで分離 # - OpenAPI(Swagger)のドキュメント生成に使用 -# -# 例: from .user import UserRequest, UserResponse -from .job_schema import ( - CreateJobRequest, - ImplementRequest, - JobResponse, +from .session_schema import ( + CreatePRRequest, + CreateSessionRequest, + IterateRequest, + IterationResponse, ProposalResponse, - SettingRequest, - SettingResponse, + SessionResponse, ) +from .setting_schema import SettingRequest, SettingResponse __all__ = [ - "CreateJobRequest", - "ImplementRequest", - "JobResponse", + "CreateSessionRequest", + "IterateRequest", + "CreatePRRequest", + "SessionResponse", + "IterationResponse", "ProposalResponse", "SettingRequest", "SettingResponse", diff --git a/backend/app/schema/job_schema.py b/backend/app/schema/session_schema.py similarity index 57% rename from backend/app/schema/job_schema.py rename to backend/app/schema/session_schema.py index 1858875..dc000f4 100644 --- a/backend/app/schema/job_schema.py +++ b/backend/app/schema/session_schema.py @@ -4,12 +4,24 @@ from pydantic import BaseModel, ConfigDict, Field -class CreateJobRequest(BaseModel): +class CreateSessionRequest(BaseModel): repo_url: str = Field(..., max_length=500, description="GitHub repository URL") - branch: str = Field(default="main", max_length=200, description="Branch name") + branch: str = Field(default="main", max_length=200, description="Base branch name") instruction: str = Field(..., min_length=1, description="UI change instruction") +class IterateRequest(BaseModel): + selected_proposal_index: int = Field( + ..., ge=0, description="Index of the proposal selected from the previous iteration" + ) + instruction: str = Field(..., min_length=1, description="Additional UI refinement instruction") + + +class CreatePRRequest(BaseModel): + iteration_index: int = Field(..., ge=0, description="Iteration index") + proposal_index: int = Field(..., ge=0, description="Proposal index within the iteration") + + class ProposalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -22,39 +34,34 @@ class ProposalResponse(BaseModel): complexity: str | None status: str after_screenshot_url: str | None = None + diff_key: str | None = None pr_url: str | None = None pr_status: str | None = None error_message: str | None = None created_at: datetime -class JobResponse(BaseModel): +class IterationResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID - status: str - repo_url: str - branch: str + iteration_index: int instruction: str + selected_proposal_index: int | None = None + status: str before_screenshot_url: str | None = None error_message: str | None = None proposals: list[ProposalResponse] = [] created_at: datetime - updated_at: datetime -class ImplementRequest(BaseModel): - proposal_indices: list[int] = Field(..., description="Indices of proposals to implement") - - -class SettingRequest(BaseModel): - key: str = Field(..., max_length=100) - value: str - - -class SettingResponse(BaseModel): +class SessionResponse(BaseModel): model_config = ConfigDict(from_attributes=True) - key: str - value: str + id: UUID + repo_url: str + base_branch: str + status: str + iterations: list[IterationResponse] = [] + created_at: datetime updated_at: datetime diff --git a/backend/app/schema/setting_schema.py b/backend/app/schema/setting_schema.py new file mode 100644 index 0000000..447f24f --- /dev/null +++ b/backend/app/schema/setting_schema.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class SettingRequest(BaseModel): + key: str + value: str + + +class SettingResponse(BaseModel): + key: str + value: str + updated_at: datetime | None = None diff --git a/backend/app/service/k8s_service.py b/backend/app/service/k8s_service.py index 39c77a7..6fc2d30 100644 --- a/backend/app/service/k8s_service.py +++ b/backend/app/service/k8s_service.py @@ -102,6 +102,19 @@ def _build_job_spec( ), ) + def _append_parent_env_vars( + self, + env_vars: list[client.V1EnvVar], + parent_job_id: str | None, + parent_proposal_index: int | None, + ) -> None: + """Append PARENT_JOB_ID / PARENT_PROPOSAL_INDEX env vars if set.""" + if parent_job_id is not None and parent_proposal_index is not None: + env_vars.append(client.V1EnvVar(name="PARENT_JOB_ID", value=parent_job_id)) + env_vars.append( + client.V1EnvVar(name="PARENT_PROPOSAL_INDEX", value=str(parent_proposal_index)) + ) + def create_analyzer_job( self, job_id: str, @@ -109,6 +122,8 @@ def create_analyzer_job( branch: str, instruction: str, num_proposals: int, + parent_job_id: str | None = None, + parent_proposal_index: int | None = None, ) -> str: """Create a K8s Job for analysis. Returns the K8s job name.""" job_name = f"ui-worker-{job_id[:8]}-analyze" @@ -125,6 +140,7 @@ def create_analyzer_job( client.V1EnvVar(name="INSTRUCTION", value=instruction), client.V1EnvVar(name="NUM_PROPOSALS", value=str(num_proposals)), ] + self._append_parent_env_vars(env_vars, parent_job_id, parent_proposal_index) container = self._build_worker_container("analyze", env_vars) job = self._build_job_spec(job_name, labels, container) @@ -139,6 +155,8 @@ def create_implementation_job( branch: str, proposal_index: int, proposal_plan: str, + parent_job_id: str | None = None, + parent_proposal_index: int | None = None, ) -> str: """Create a K8s Job for implementation. Returns the K8s job name.""" job_name = f"ui-worker-{job_id[:8]}-impl-{proposal_index}" @@ -155,6 +173,7 @@ def create_implementation_job( client.V1EnvVar(name="PROPOSAL_INDEX", value=str(proposal_index)), client.V1EnvVar(name="PROPOSAL_PLAN", value=proposal_plan), ] + self._append_parent_env_vars(env_vars, parent_job_id, parent_proposal_index) container = self._build_worker_container("implement", env_vars) job = self._build_job_spec(job_name, labels, container) @@ -240,6 +259,206 @@ def get_job_logs(self, job_name: str) -> str | None: logger.error("Error getting logs for K8s Job %s: %s", job_name, e) return None + # ── Session-based methods (S3 artifacts, no hostPath) ── + + def _build_s3_env_vars(self) -> list[client.V1EnvVar]: + """Build S3-related env vars for session-based workers.""" + settings = get_settings() + env = [ + client.V1EnvVar(name="S3_BUCKET", value=settings.S3_BUCKET), + client.V1EnvVar(name="S3_REGION", value=settings.S3_REGION), + ] + endpoint = settings.S3_ENDPOINT_URL_K8S or settings.S3_ENDPOINT_URL + if endpoint: + env.append(client.V1EnvVar(name="S3_ENDPOINT_URL", value=endpoint)) + env.append(client.V1EnvVar(name="S3_ACCESS_KEY", value=settings.S3_ACCESS_KEY)) + env.append(client.V1EnvVar(name="S3_SECRET_KEY", value=settings.S3_SECRET_KEY)) + return env + + def _build_session_worker_container( + self, mode: str, env_vars: list[client.V1EnvVar] + ) -> client.V1Container: + """Build a session-based worker container (no hostPath artifacts volume).""" + base_env = [ + client.V1EnvVar(name="WORKER_MODE", value=mode), + client.V1EnvVar( + name="ANTHROPIC_API_KEY", + value_from=client.V1EnvVarSource( + secret_key_ref=client.V1SecretKeySelector( + name="ui-recommender-secrets", + key="anthropic-api-key", + ) + ), + ), + client.V1EnvVar(name="TERM", value="dumb"), + ] + self._build_s3_env_vars() + return client.V1Container( + name="worker", + image=self.worker_image, + image_pull_policy="Never", + env=base_env + env_vars, + resources=client.V1ResourceRequirements( + requests={"memory": "1Gi", "cpu": "1000m"}, + limits={"memory": "4Gi", "cpu": "4000m"}, + ), + volume_mounts=[ + client.V1VolumeMount(name="workspace", mount_path="/workspace"), + ], + ) + + def _build_session_job_spec( + self, + job_name: str, + labels: dict[str, str], + container: client.V1Container, + ) -> client.V1Job: + """Build a K8s Job spec without hostPath artifacts volume.""" + template = client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels=labels), + spec=client.V1PodSpec( + restart_policy="Never", + containers=[container], + volumes=[ + client.V1Volume( + name="workspace", + empty_dir=client.V1EmptyDirVolumeSource(), + ), + ], + ), + ) + return client.V1Job( + api_version="batch/v1", + kind="Job", + metadata=client.V1ObjectMeta(name=job_name, labels=labels), + spec=client.V1JobSpec( + template=template, + backoff_limit=1, + ttl_seconds_after_finished=1800, + active_deadline_seconds=self.worker_deadline_seconds, + ), + ) + + def _create_job_idempotent(self, job_name: str, job: client.V1Job) -> None: + """Create a K8s Job, handling 409 AlreadyExists gracefully.""" + try: + self.batch_v1.create_namespaced_job(namespace=self.namespace, body=job) + logger.info("Created K8s Job: %s", job_name) + except ApiException as e: + if e.status == 409: + logger.info("K8s Job %s already exists, re-attaching", job_name) + else: + raise + + def create_session_analyzer_job( + self, + session_id: str, + iteration_index: int, + repo_url: str, + branch: str, + instruction: str, + num_proposals: int, + selected_proposal_index: int | None = None, + ) -> str: + """Create a K8s Job for session-based analysis.""" + job_name = f"ui-worker-{session_id[:12]}-iter{iteration_index}-analyze" + labels = { + "app": "ui-recommender", + "component": "worker", + "session-id": session_id[:12], + "iteration": str(iteration_index), + "mode": "analyze", + } + env_vars = [ + client.V1EnvVar(name="SESSION_ID", value=session_id), + client.V1EnvVar(name="ITERATION_INDEX", value=str(iteration_index)), + client.V1EnvVar(name="REPO_URL", value=repo_url), + client.V1EnvVar(name="BRANCH", value=branch), + client.V1EnvVar(name="INSTRUCTION", value=instruction), + client.V1EnvVar(name="NUM_PROPOSALS", value=str(num_proposals)), + ] + if selected_proposal_index is not None: + env_vars.append( + client.V1EnvVar(name="SELECTED_PROPOSAL_INDEX", value=str(selected_proposal_index)) + ) + container = self._build_session_worker_container("analyze", env_vars) + job = self._build_session_job_spec(job_name, labels, container) + self._create_job_idempotent(job_name, job) + return job_name + + def create_session_implementation_job( + self, + session_id: str, + iteration_index: int, + repo_url: str, + branch: str, + proposal_index: int, + proposal_plan: str, + selected_proposal_index: int | None = None, + ) -> str: + """Create a K8s Job for session-based implementation.""" + job_name = f"ui-worker-{session_id[:12]}-iter{iteration_index}-impl-{proposal_index}" + labels = { + "app": "ui-recommender", + "component": "worker", + "session-id": session_id[:12], + "iteration": str(iteration_index), + "mode": "implement", + } + env_vars = [ + client.V1EnvVar(name="SESSION_ID", value=session_id), + client.V1EnvVar(name="ITERATION_INDEX", value=str(iteration_index)), + client.V1EnvVar(name="REPO_URL", value=repo_url), + client.V1EnvVar(name="BRANCH", value=branch), + client.V1EnvVar(name="PROPOSAL_INDEX", value=str(proposal_index)), + client.V1EnvVar(name="PROPOSAL_PLAN", value=proposal_plan), + ] + if selected_proposal_index is not None: + env_vars.append( + client.V1EnvVar(name="SELECTED_PROPOSAL_INDEX", value=str(selected_proposal_index)) + ) + container = self._build_session_worker_container("implement", env_vars) + job = self._build_session_job_spec(job_name, labels, container) + self._create_job_idempotent(job_name, job) + return job_name + + def create_session_pr_job( + self, + session_id: str, + iteration_index: int, + repo_url: str, + branch: str, + proposal_index: int, + ) -> str: + """Create a K8s Job for session-based PR creation.""" + job_name = f"ui-worker-{session_id[:12]}-iter{iteration_index}-pr-{proposal_index}" + labels = { + "app": "ui-recommender", + "component": "worker", + "session-id": session_id[:12], + "iteration": str(iteration_index), + "mode": "createpr", + } + env_vars = [ + client.V1EnvVar(name="SESSION_ID", value=session_id), + client.V1EnvVar(name="ITERATION_INDEX", value=str(iteration_index)), + client.V1EnvVar(name="REPO_URL", value=repo_url), + client.V1EnvVar(name="BRANCH", value=branch), + client.V1EnvVar(name="PROPOSAL_INDEX", value=str(proposal_index)), + client.V1EnvVar( + name="GITHUB_TOKEN", + value_from=client.V1EnvVarSource( + secret_key_ref=client.V1SecretKeySelector( + name="ui-recommender-secrets", + key="github-token", + ) + ), + ), + ] + container = self._build_session_worker_container("createpr", env_vars) + job = self._build_session_job_spec(job_name, labels, container) + self._create_job_idempotent(job_name, job) + return job_name + def delete_job(self, job_name: str) -> None: """Delete a completed job and its pods.""" try: diff --git a/backend/app/service/s3_service.py b/backend/app/service/s3_service.py new file mode 100644 index 0000000..c10ead9 --- /dev/null +++ b/backend/app/service/s3_service.py @@ -0,0 +1,152 @@ +import json +import logging +from typing import Any + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +class S3Service: + """S3-compatible artifact storage (works with AWS S3 and MinIO).""" + + def __init__(self) -> None: + settings = get_settings() + kwargs: dict[str, Any] = { + "service_name": "s3", + "region_name": settings.S3_REGION, + "config": Config(signature_version="s3v4"), + } + # MinIO / custom endpoint + if settings.S3_ENDPOINT_URL: + kwargs["endpoint_url"] = settings.S3_ENDPOINT_URL + kwargs["aws_access_key_id"] = settings.S3_ACCESS_KEY + kwargs["aws_secret_access_key"] = settings.S3_SECRET_KEY + self.client = boto3.client(**kwargs) + self.bucket = settings.S3_BUCKET + + def _ensure_bucket(self) -> None: + """Create bucket if it doesn't exist (for local MinIO).""" + try: + self.client.head_bucket(Bucket=self.bucket) + except ClientError: + logger.info("Creating S3 bucket: %s", self.bucket) + self.client.create_bucket(Bucket=self.bucket) + + # ── Key builders ── + + @staticmethod + def before_screenshot_key(session_id: str, iteration_index: int) -> str: + return f"sessions/{session_id}/iterations/{iteration_index}/before.png" + + @staticmethod + def proposals_json_key(session_id: str, iteration_index: int) -> str: + return f"sessions/{session_id}/iterations/{iteration_index}/proposals.json" + + @staticmethod + def after_screenshot_key(session_id: str, iteration_index: int, proposal_index: int) -> str: + base = f"sessions/{session_id}/iterations/{iteration_index}" + return f"{base}/proposals/{proposal_index}/after.png" + + @staticmethod + def diff_key(session_id: str, iteration_index: int, proposal_index: int) -> str: + base = f"sessions/{session_id}/iterations/{iteration_index}" + return f"{base}/proposals/{proposal_index}/changes.diff" + + @staticmethod + def plan_key(session_id: str, iteration_index: int, proposal_index: int) -> str: + base = f"sessions/{session_id}/iterations/{iteration_index}" + return f"{base}/proposals/{proposal_index}/plan.json" + + @staticmethod + def pr_url_key(session_id: str, iteration_index: int, proposal_index: int) -> str: + base = f"sessions/{session_id}/iterations/{iteration_index}" + return f"{base}/proposals/{proposal_index}/pr_url.txt" + + # ── Read / Write ── + + def upload_bytes( + self, key: str, data: bytes, content_type: str = "application/octet-stream" + ) -> None: + self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type) + logger.info("Uploaded %s (%d bytes)", key, len(data)) + + def upload_text(self, key: str, text: str) -> None: + self.upload_bytes(key, text.encode("utf-8"), content_type="text/plain") + + def upload_json(self, key: str, obj: Any) -> None: + self.upload_bytes( + key, + json.dumps(obj, ensure_ascii=False).encode("utf-8"), + content_type="application/json", + ) + + def download_bytes(self, key: str) -> bytes | None: + try: + resp = self.client.get_object(Bucket=self.bucket, Key=key) + body: bytes = resp["Body"].read() + return body + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchKey": + return None + raise + + def download_text(self, key: str) -> str | None: + data = self.download_bytes(key) + return data.decode("utf-8") if data else None + + def download_json(self, key: str) -> Any | None: + text = self.download_text(key) + if text is None: + return None + return json.loads(text) + + def exists(self, key: str) -> bool: + try: + self.client.head_object(Bucket=self.bucket, Key=key) + return True + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code in ("404", "NoSuchKey", "NotFound"): + return False + raise + + def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str | None: + """Generate a presigned URL for direct access (optional future use).""" + try: + url: str = self.client.generate_presigned_url( + "get_object", + Params={"Bucket": self.bucket, "Key": key}, + ExpiresIn=expires_in, + ) + return url + except ClientError: + return None + + # ── High-level artifact accessors ── + + def get_proposals(self, session_id: str, iteration_index: int) -> list[dict[str, Any]] | None: + data = self.download_json(self.proposals_json_key(session_id, iteration_index)) + if isinstance(data, dict) and "proposals" in data: + result: list[dict[str, Any]] = data["proposals"] + return result + if isinstance(data, list): + return data + return None + + def get_diff(self, session_id: str, iteration_index: int, proposal_index: int) -> str | None: + return self.download_text(self.diff_key(session_id, iteration_index, proposal_index)) + + def get_before_screenshot(self, session_id: str, iteration_index: int) -> bytes | None: + return self.download_bytes(self.before_screenshot_key(session_id, iteration_index)) + + def get_after_screenshot( + self, session_id: str, iteration_index: int, proposal_index: int + ) -> bytes | None: + return self.download_bytes( + self.after_screenshot_key(session_id, iteration_index, proposal_index) + ) diff --git a/backend/app/usecase/job_usecase.py b/backend/app/usecase/job_usecase.py deleted file mode 100644 index fedf2ea..0000000 --- a/backend/app/usecase/job_usecase.py +++ /dev/null @@ -1,399 +0,0 @@ -import asyncio -import json -import logging -from uuid import UUID - -from sqlalchemy.orm import Session - -from app.core.config import get_settings -from app.model.job import Job, JobStatus, Proposal, ProposalStatus -from app.repository.database import SessionLocal -from app.repository.job_repository import JobRepository -from app.repository.proposal_repository import ProposalRepository -from app.service.artifact_service import ArtifactService -from app.workflow.analyzer_graph import build_analyzer_graph -from app.workflow.create_pr_graph import build_create_pr_graph -from app.workflow.implementation_graph import build_implementation_graph - -logger = logging.getLogger(__name__) - - -class CreateJobUseCase: - """Create a new job and trigger the analysis workflow.""" - - def __init__(self, db: Session) -> None: - self.db = db - self.job_repo = JobRepository(db) - - async def execute(self, repo_url: str, branch: str, instruction: str) -> Job: - job = Job( - repo_url=repo_url, - branch=branch, - instruction=instruction, - status=JobStatus.PENDING, - ) - job = self.job_repo.create(job) - job_id = str(job.id) - - # Run analysis in background (separate DB session) - asyncio.create_task(self._run_analysis(job_id, repo_url, branch, instruction)) - - return job - - async def _run_analysis( - self, job_id: str, repo_url: str, branch: str, instruction: str - ) -> None: - """Background task: run the analyzer LangGraph workflow.""" - db = SessionLocal() - try: - job_repo = JobRepository(db) - proposal_repo = ProposalRepository(db) - - job_repo.update_status(UUID(job_id), JobStatus.ANALYZING) - - graph = build_analyzer_graph() - result = await graph.ainvoke( - { - "job_id": job_id, - "repo_url": repo_url, - "branch": branch, - "instruction": instruction, - "num_proposals": get_settings().MAX_PROPOSALS, - "k8s_job_name": None, - "status": "pending", - "error": None, - "proposals": None, - "before_screenshot_path": None, - } - ) - - if result.get("proposals"): - proposals = [] - for i, prop in enumerate(result["proposals"]): - proposal = Proposal( - job_id=UUID(job_id), # type: ignore[arg-type] - proposal_index=i, - title=prop.get("title", f"Proposal {i + 1}"), - concept=prop.get("concept", ""), - plan=json.dumps(prop.get("plan", []), ensure_ascii=False), - files=json.dumps(prop.get("files", []), ensure_ascii=False), - complexity=prop.get("complexity", "medium"), - status=ProposalStatus.PENDING, - ) - proposals.append(proposal_repo.create(proposal)) - - job_repo.update_status( - UUID(job_id), - JobStatus.ANALYZED, - before_screenshot_path=result.get("before_screenshot_path"), - ) - logger.info( - "Analysis completed for job %s: %d proposals", - job_id, - len(result["proposals"]), - ) - - # Auto-trigger implementation for ALL proposals - job_repo.update_status(UUID(job_id), JobStatus.IMPLEMENTING) - repo_url = result.get("repo_url", repo_url) - for proposal in proposals: - asyncio.create_task( - ImplementProposalUseCase._run_implementation_static( - str(job_id), - repo_url, - branch, - proposal.proposal_index or 0, - str(proposal.id), - str(proposal.plan), - ) - ) - logger.info( - "Auto-triggered implementation for all %d proposals of job %s", - len(proposals), - job_id, - ) - else: - error = result.get("error", "No proposals generated") - job_repo.update_status(UUID(job_id), JobStatus.FAILED, error_message=error) - logger.warning("Analysis failed for job %s: %s", job_id, error) - - except Exception: - logger.exception("Analysis failed for job %s", job_id) - try: - job_repo.update_status( - UUID(job_id), - JobStatus.FAILED, - error_message="Internal error during analysis", - ) - except Exception: - logger.exception("Failed to update job status for %s", job_id) - finally: - db.close() - - -class ImplementProposalUseCase: - """Trigger implementation of selected proposals.""" - - def __init__(self, db: Session) -> None: - self.db = db - self.job_repo = JobRepository(db) - self.proposal_repo = ProposalRepository(db) - - async def execute(self, job_id: UUID, proposal_indices: list[int]) -> Job: - job = self.job_repo.get_by_id(job_id) - if not job: - raise ValueError("Job not found") - if job.status != JobStatus.ANALYZED: - raise ValueError(f"Job is not in analyzed state: {job.status}") - - self.job_repo.update_status(job_id, JobStatus.IMPLEMENTING) - - for idx in proposal_indices: - proposal = self.proposal_repo.get_by_job_and_index(job_id, idx) - if proposal: - asyncio.create_task( - self._run_implementation( - str(job_id), - str(job.repo_url), - str(job.branch), - idx, - str(proposal.id), - str(proposal.plan), - ) - ) - - # Return the updated job - updated_job = self.job_repo.get_by_id(job_id) - if not updated_job: - raise ValueError("Job not found after update") - return updated_job - - async def _run_implementation( - self, - job_id: str, - repo_url: str, - branch: str, - proposal_index: int, - proposal_id: str, - plan_json: str, - ) -> None: - await self._run_implementation_static( - job_id, repo_url, branch, proposal_index, proposal_id, plan_json - ) - - @staticmethod - async def _run_implementation_static( - job_id: str, - repo_url: str, - branch: str, - proposal_index: int, - proposal_id: str, - plan_json: str, - ) -> None: - """Background task: run one implementation LangGraph workflow.""" - db = SessionLocal() - try: - proposal_repo = ProposalRepository(db) - - proposal_repo.update_status(UUID(proposal_id), ProposalStatus.IMPLEMENTING) - - # Write plan to artifact dir for the worker to read - artifacts = ArtifactService() - artifacts.write_proposal_plan(job_id, proposal_index, plan_json) - - graph = build_implementation_graph() - result = await graph.ainvoke( - { - "job_id": job_id, - "repo_url": repo_url, - "branch": branch, - "proposal_index": proposal_index, - "proposal_plan": plan_json, - "k8s_job_name": None, - "status": "pending", - "error": None, - "after_screenshot_path": None, - "diff_content": None, - } - ) - - if result.get("status") == "succeeded" or result.get("after_screenshot_path"): - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.COMPLETED, - after_screenshot_path=result.get("after_screenshot_path"), - diff_path=result.get("diff_content"), - ) - logger.info( - "Implementation completed for proposal %d of job %s", - proposal_index, - job_id, - ) - else: - error = result.get("error", "Implementation failed") - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.FAILED, - error_message=error, - ) - logger.warning( - "Implementation failed for proposal %d of job %s: %s", - proposal_index, - job_id, - error, - ) - - # Check if all proposals are done and update job status - ImplementProposalUseCase._check_job_completion(db, UUID(job_id)) - - except Exception: - logger.exception( - "Implementation failed for proposal %d of job %s", - proposal_index, - job_id, - ) - try: - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.FAILED, - error_message="Internal error during implementation", - ) - ImplementProposalUseCase._check_job_completion(db, UUID(job_id)) - except Exception: - logger.exception("Failed to update proposal status") - finally: - db.close() - - @staticmethod - def _check_job_completion(db: Session, job_id: UUID) -> None: - """Check if all proposals are in terminal state and update job accordingly.""" - proposal_repo = ProposalRepository(db) - job_repo = JobRepository(db) - - proposals = proposal_repo.get_all_for_job(job_id) - if not proposals: - return - - all_done = all( - p.status in (ProposalStatus.COMPLETED, ProposalStatus.FAILED) for p in proposals - ) - if all_done: - any_succeeded = any(p.status == ProposalStatus.COMPLETED for p in proposals) - new_status = JobStatus.COMPLETED if any_succeeded else JobStatus.FAILED - job_repo.update_status(job_id, new_status) - logger.info("Job %s completed with status: %s", job_id, new_status) - - -class CreatePRUseCase: - """Trigger PR creation for a completed proposal.""" - - def __init__(self, db: Session) -> None: - self.db = db - self.job_repo = JobRepository(db) - self.proposal_repo = ProposalRepository(db) - - async def execute(self, job_id: UUID, proposal_index: int) -> Proposal: - job = self.job_repo.get_by_id(job_id) - if not job: - raise ValueError("Job not found") - - proposal = self.proposal_repo.get_by_job_and_index(job_id, proposal_index) - if not proposal: - raise ValueError(f"Proposal {proposal_index} not found") - if proposal.status != ProposalStatus.COMPLETED: - raise ValueError(f"Proposal is not completed: {proposal.status}") - if proposal.pr_status == "created": - raise ValueError("PR already created") - if proposal.pr_status == "creating": - raise ValueError("PR creation already in progress") - - proposal_id = UUID(str(proposal.id)) - self.proposal_repo.update_status(proposal_id, proposal.status, pr_status="creating") - - asyncio.create_task( - self._run_create_pr( - str(job_id), - str(job.repo_url), - str(job.branch), - proposal_index, - str(proposal.id), - ) - ) - - updated = self.proposal_repo.get_by_id(proposal_id) - if not updated: - raise ValueError("Proposal not found after update") - return updated - - @staticmethod - async def _run_create_pr( - job_id: str, - repo_url: str, - branch: str, - proposal_index: int, - proposal_id: str, - ) -> None: - """Background task: run the PR creation LangGraph workflow.""" - db = SessionLocal() - try: - proposal_repo = ProposalRepository(db) - - graph = build_create_pr_graph() - result = await graph.ainvoke( - { - "job_id": job_id, - "repo_url": repo_url, - "branch": branch, - "proposal_index": proposal_index, - "k8s_job_name": None, - "status": "pending", - "error": None, - "pr_url": None, - } - ) - - if result.get("pr_url"): - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.COMPLETED, - pr_url=result["pr_url"], - pr_status="created", - ) - logger.info( - "PR created for proposal %d of job %s: %s", - proposal_index, - job_id, - result["pr_url"], - ) - else: - error = result.get("error", "PR creation failed") - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.COMPLETED, - pr_status="failed", - error_message=error, - ) - logger.warning( - "PR creation failed for proposal %d of job %s: %s", - proposal_index, - job_id, - error, - ) - - except Exception: - logger.exception( - "PR creation failed for proposal %d of job %s", - proposal_index, - job_id, - ) - try: - proposal_repo.update_status( - UUID(proposal_id), - ProposalStatus.COMPLETED, - pr_status="failed", - error_message="Internal error during PR creation", - ) - except Exception: - logger.exception("Failed to update proposal PR status") - finally: - db.close() diff --git a/backend/app/usecase/session_usecase.py b/backend/app/usecase/session_usecase.py new file mode 100644 index 0000000..f53268a --- /dev/null +++ b/backend/app/usecase/session_usecase.py @@ -0,0 +1,582 @@ +import asyncio +import json +import logging +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as DbSession + +from app.core.config import get_settings +from app.model.session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, + SessionStatus, +) +from app.repository.database import SessionLocal +from app.repository.iteration_repository import IterationRepository +from app.repository.proposal_repository import ProposalRepository +from app.repository.session_repository import SessionRepository +from app.service.s3_service import S3Service +from app.workflow.session_analyzer_graph import build_session_analyzer_graph +from app.workflow.session_create_pr_graph import build_session_create_pr_graph +from app.workflow.session_implementation_graph import build_session_implementation_graph + +logger = logging.getLogger(__name__) + + +class CreateSessionUseCase: + """Create a new session and trigger the first analysis.""" + + def __init__(self, db: DbSession) -> None: + self.db = db + self.session_repo = SessionRepository(db) + self.iteration_repo = IterationRepository(db) + + async def execute(self, repo_url: str, branch: str, instruction: str) -> Session: + # Create session + session = Session( + repo_url=repo_url, + base_branch=branch, + status=SessionStatus.ACTIVE, + ) + session = self.session_repo.create(session) + session_id = str(session.id) + + # Create iteration 0 + iteration = Iteration( + session_id=session.id, + iteration_index=0, + instruction=instruction, + status=IterationStatus.PENDING, + ) + iteration = self.iteration_repo.create(iteration) + + # Ensure S3 bucket exists + s3 = S3Service() + s3._ensure_bucket() + + # Run analysis in background + asyncio.create_task( + _run_session_analysis( + session_id=session_id, + iteration_id=str(iteration.id), + iteration_index=0, + repo_url=repo_url, + branch=branch, + instruction=instruction, + selected_proposal_index=None, + ) + ) + + return session + + +class IterateUseCase: + """Create the next iteration in a session.""" + + def __init__(self, db: DbSession) -> None: + self.db = db + self.session_repo = SessionRepository(db) + self.iteration_repo = IterationRepository(db) + self.proposal_repo = ProposalRepository(db) + + async def execute( + self, session_id: UUID, selected_proposal_index: int, instruction: str + ) -> Session: + session = self.session_repo.get_by_id(session_id) + if not session: + raise ValueError("Session not found") + if session.status != SessionStatus.ACTIVE: + raise ValueError(f"Session is not active: {session.status}") + + # Validate the previous iteration is completed + latest = self.iteration_repo.get_latest_for_session(session_id) + if not latest: + raise ValueError("No iterations found") + if latest.status != IterationStatus.COMPLETED: + raise ValueError(f"Previous iteration is not completed: {latest.status}") + + # Validate the selected proposal exists and is completed + proposal = self.proposal_repo.get_by_iteration_and_index(latest.id, selected_proposal_index) + if not proposal: + raise ValueError(f"Proposal {selected_proposal_index} not found") + if proposal.status != ProposalStatus.COMPLETED: + raise ValueError(f"Proposal is not completed: {proposal.status}") + + # Verify the patch exists in S3 + s3 = S3Service() + diff_k = s3.diff_key(str(session_id), latest.iteration_index, selected_proposal_index) + if not s3.exists(diff_k): + raise ValueError("No patch file found for the selected proposal") + + # Mark selected proposal on previous iteration + self.iteration_repo.update_selected_proposal(latest.id, selected_proposal_index) + + # Create new iteration + new_index = latest.iteration_index + 1 + iteration = Iteration( + session_id=session_id, + iteration_index=new_index, + instruction=instruction, + status=IterationStatus.PENDING, + ) + try: + iteration = self.iteration_repo.create(iteration) + except IntegrityError: + self.db.rollback() + # Idempotency: return existing iteration + existing = self.iteration_repo.get_by_session_and_index(session_id, new_index) + if existing: + session = self.session_repo.get_by_id(session_id) + if not session: + raise ValueError("Session not found") from None + return session + raise + + # Run analysis in background + asyncio.create_task( + _run_session_analysis( + session_id=str(session_id), + iteration_id=str(iteration.id), + iteration_index=new_index, + repo_url=str(session.repo_url), + branch=str(session.base_branch), + instruction=instruction, + selected_proposal_index=selected_proposal_index, + ) + ) + + session = self.session_repo.get_by_id(session_id) + if not session: + raise ValueError("Session not found") + return session + + +class CreateSessionPRUseCase: + """Create a PR from a specific proposal in a session.""" + + def __init__(self, db: DbSession) -> None: + self.db = db + self.session_repo = SessionRepository(db) + self.iteration_repo = IterationRepository(db) + self.proposal_repo = ProposalRepository(db) + + async def execute( + self, session_id: UUID, iteration_index: int, proposal_index: int + ) -> Proposal: + session = self.session_repo.get_by_id(session_id) + if not session: + raise ValueError("Session not found") + + iteration = self.iteration_repo.get_by_session_and_index(session_id, iteration_index) + if not iteration: + raise ValueError(f"Iteration {iteration_index} not found") + + proposal = self.proposal_repo.get_by_iteration_and_index(iteration.id, proposal_index) + if not proposal: + raise ValueError(f"Proposal {proposal_index} not found") + if proposal.status != ProposalStatus.COMPLETED: + raise ValueError(f"Proposal is not completed: {proposal.status}") + if proposal.pr_status == "created": + raise ValueError("PR already created") + if proposal.pr_status == "creating": + raise ValueError("PR creation already in progress") + + # Optimistic lock: update pr_status + updated = self.proposal_repo.update_status_optimistic( + proposal.id, + proposal.version, + ProposalStatus.COMPLETED, + pr_status="creating", + ) + if not updated: + raise ValueError("Concurrent update detected") + + asyncio.create_task( + _run_session_create_pr( + session_id=str(session_id), + iteration_index=iteration_index, + repo_url=str(session.repo_url), + branch=str(session.base_branch), + proposal_index=proposal_index, + proposal_id=str(proposal.id), + ) + ) + + return updated + + +# ── Background tasks (static, use own DB sessions) ── + + +async def _run_session_analysis( + session_id: str, + iteration_id: str, + iteration_index: int, + repo_url: str, + branch: str, + instruction: str, + selected_proposal_index: int | None, +) -> None: + """Background: run session analyzer workflow, then auto-implement all proposals.""" + db = SessionLocal() + iter_repo = IterationRepository(db) + proposal_repo = ProposalRepository(db) + try: + iteration = iter_repo.get_by_id(UUID(iteration_id)) + if not iteration: + logger.error("Iteration %s not found", iteration_id) + return + + iter_repo.update_status_optimistic( + UUID(iteration_id), iteration.version, IterationStatus.ANALYZING + ) + + graph = build_session_analyzer_graph() + result = await graph.ainvoke( + { + "session_id": session_id, + "iteration_index": iteration_index, + "repo_url": repo_url, + "branch": branch, + "instruction": instruction, + "num_proposals": get_settings().MAX_PROPOSALS, + "selected_proposal_index": selected_proposal_index, + "k8s_job_name": None, + "status": "pending", + "error": None, + "proposals": None, + "before_screenshot_key": None, + } + ) + + if result.get("proposals"): + # Refresh iteration for current version + iteration = iter_repo.get_by_id(UUID(iteration_id)) + if not iteration: + return + + proposals = [] + for i, prop in enumerate(result["proposals"]): + p = Proposal( + iteration_id=UUID(iteration_id), + proposal_index=i, + title=prop.get("title", f"Proposal {i + 1}"), + concept=prop.get("concept", ""), + plan=json.dumps(prop.get("plan", []), ensure_ascii=False), + files=json.dumps(prop.get("files", []), ensure_ascii=False), + complexity=prop.get("complexity", "medium"), + status=ProposalStatus.PENDING, + ) + proposals.append(proposal_repo.create(p)) + + iter_repo.update_status_optimistic( + UUID(iteration_id), + iteration.version, + IterationStatus.ANALYZED, + before_screenshot_key=result.get("before_screenshot_key"), + ) + logger.info( + "Session %s iter %d analysis done: %d proposals", + session_id, + iteration_index, + len(proposals), + ) + + # Auto-trigger implementation for ALL proposals + iteration = iter_repo.get_by_id(UUID(iteration_id)) + if iteration: + iter_repo.update_status_optimistic( + UUID(iteration_id), + iteration.version, + IterationStatus.IMPLEMENTING, + ) + for proposal in proposals: + asyncio.create_task( + _run_session_implementation( + session_id=session_id, + iteration_id=iteration_id, + iteration_index=iteration_index, + repo_url=repo_url, + branch=branch, + proposal_index=proposal.proposal_index, + proposal_id=str(proposal.id), + plan_json=str(proposal.plan), + selected_proposal_index=selected_proposal_index, + ) + ) + else: + error = result.get("error", "No proposals generated") + iteration = iter_repo.get_by_id(UUID(iteration_id)) + if iteration: + iter_repo.update_status_optimistic( + UUID(iteration_id), + iteration.version, + IterationStatus.FAILED, + error_message=error, + ) + logger.warning( + "Session %s iter %d analysis failed: %s", session_id, iteration_index, error + ) + + except Exception: + logger.exception("Session %s iter %d analysis failed", session_id, iteration_index) + try: + iteration = iter_repo.get_by_id(UUID(iteration_id)) + if iteration: + iter_repo.update_status_optimistic( + UUID(iteration_id), + iteration.version, + IterationStatus.FAILED, + error_message="Internal error during analysis", + ) + except Exception: + logger.exception("Failed to update iteration status") + finally: + db.close() + + +async def _run_session_implementation( + session_id: str, + iteration_id: str, + iteration_index: int, + repo_url: str, + branch: str, + proposal_index: int, + proposal_id: str, + plan_json: str, + selected_proposal_index: int | None, +) -> None: + """Background: run one session-based implementation workflow.""" + db = SessionLocal() + proposal_repo = ProposalRepository(db) + try: + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if not proposal: + logger.error("Proposal %s not found", proposal_id) + return + + proposal_repo.update_status_optimistic( + UUID(proposal_id), proposal.version, ProposalStatus.IMPLEMENTING + ) + + graph = build_session_implementation_graph() + result = await graph.ainvoke( + { + "session_id": session_id, + "iteration_index": iteration_index, + "repo_url": repo_url, + "branch": branch, + "proposal_index": proposal_index, + "proposal_plan": plan_json, + "selected_proposal_index": selected_proposal_index, + "k8s_job_name": None, + "status": "pending", + "error": None, + "after_screenshot_key": None, + "diff_key": None, + } + ) + + if result.get("status") == "succeeded" or result.get("after_screenshot_key"): + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if proposal: + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.COMPLETED, + after_screenshot_key=result.get("after_screenshot_key"), + diff_key=result.get("diff_key"), + ) + logger.info( + "Session %s iter %d proposal %d implementation done", + session_id, + iteration_index, + proposal_index, + ) + else: + error = result.get("error", "Implementation failed") + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if proposal: + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.FAILED, + error_message=error, + ) + + _check_iteration_completion(db, UUID(iteration_id)) + + except Exception: + logger.exception( + "Session %s iter %d proposal %d implementation failed", + session_id, + iteration_index, + proposal_index, + ) + try: + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if proposal: + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.FAILED, + error_message="Internal error during implementation", + ) + _check_iteration_completion(db, UUID(iteration_id)) + except Exception: + logger.exception("Failed to update proposal status") + finally: + db.close() + + +async def _run_session_create_pr( + session_id: str, + iteration_index: int, + repo_url: str, + branch: str, + proposal_index: int, + proposal_id: str, +) -> None: + """Background: run session-based PR creation workflow.""" + db = SessionLocal() + proposal_repo = ProposalRepository(db) + try: + graph = build_session_create_pr_graph() + result = await graph.ainvoke( + { + "session_id": session_id, + "iteration_index": iteration_index, + "repo_url": repo_url, + "branch": branch, + "proposal_index": proposal_index, + "k8s_job_name": None, + "status": "pending", + "error": None, + "pr_url": None, + } + ) + + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if not proposal: + return + + if result.get("pr_url"): + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.COMPLETED, + pr_url=result["pr_url"], + pr_status="created", + ) + logger.info("PR created for session %s: %s", session_id, result["pr_url"]) + else: + error = result.get("error", "PR creation failed") + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.COMPLETED, + pr_status="failed", + error_message=error, + ) + + except Exception: + logger.exception("PR creation failed for session %s", session_id) + try: + proposal = proposal_repo.get_by_id(UUID(proposal_id)) + if proposal: + proposal_repo.update_status_optimistic( + UUID(proposal_id), + proposal.version, + ProposalStatus.COMPLETED, + pr_status="failed", + error_message="Internal error during PR creation", + ) + except Exception: + logger.exception("Failed to update proposal PR status") + finally: + db.close() + + +async def recover_stuck_proposals() -> None: + """Recover proposals stuck in IMPLEMENTING status by checking S3 for results. + + Called on app startup to handle cases where the app restarted while + background implementation tasks were polling K8s jobs. + """ + db = SessionLocal() + try: + proposal_repo = ProposalRepository(db) + iter_repo = IterationRepository(db) + s3 = S3Service() + + stuck = proposal_repo.get_all_by_status(ProposalStatus.IMPLEMENTING) + if not stuck: + logger.info("No stuck proposals found") + return + + logger.info("Found %d stuck proposals, attempting recovery...", len(stuck)) + recovered = 0 + for proposal in stuck: + iteration = iter_repo.get_by_id(proposal.iteration_id) + if not iteration: + continue + session_id = str(iteration.session_id) + iter_idx = iteration.iteration_index + prop_idx = proposal.proposal_index + + after_key = s3.after_screenshot_key(session_id, iter_idx, prop_idx) + diff_k = s3.diff_key(session_id, iter_idx, prop_idx) + has_after = s3.exists(after_key) + has_diff = s3.exists(diff_k) + + if has_diff: + proposal_repo.update_status_optimistic( + proposal.id, + proposal.version, + ProposalStatus.COMPLETED, + after_screenshot_key=after_key if has_after else None, + diff_key=diff_k, + ) + recovered += 1 + logger.info( + "Recovered proposal %s (iter%d/prop%d)", + proposal.id, + iter_idx, + prop_idx, + ) + + # Check iteration completion for affected iterations + seen_iterations: set[UUID] = set() + for proposal in stuck: + if proposal.iteration_id not in seen_iterations: + seen_iterations.add(proposal.iteration_id) + _check_iteration_completion(db, proposal.iteration_id) + + logger.info("Recovery complete: %d/%d proposals recovered", recovered, len(stuck)) + except Exception: + logger.exception("Error during stuck proposal recovery") + finally: + db.close() + + +def _check_iteration_completion(db: DbSession, iteration_id: UUID) -> None: + """Check if all proposals are done and update iteration status.""" + proposal_repo = ProposalRepository(db) + iter_repo = IterationRepository(db) + + proposals = proposal_repo.get_all_for_iteration(iteration_id) + if not proposals: + return + + all_done = all(p.status in (ProposalStatus.COMPLETED, ProposalStatus.FAILED) for p in proposals) + if all_done: + any_succeeded = any(p.status == ProposalStatus.COMPLETED for p in proposals) + new_status = IterationStatus.COMPLETED if any_succeeded else IterationStatus.FAILED + iteration = iter_repo.get_by_id(iteration_id) + if iteration: + iter_repo.update_status_optimistic(iteration_id, iteration.version, new_status) + logger.info("Iteration %s completed: %s", iteration_id, new_status) diff --git a/backend/app/workflow/__init__.py b/backend/app/workflow/__init__.py index 81096a5..2128ad8 100644 --- a/backend/app/workflow/__init__.py +++ b/backend/app/workflow/__init__.py @@ -1,4 +1,9 @@ -from .analyzer_graph import build_analyzer_graph -from .implementation_graph import build_implementation_graph +from .session_analyzer_graph import build_session_analyzer_graph +from .session_create_pr_graph import build_session_create_pr_graph +from .session_implementation_graph import build_session_implementation_graph -__all__ = ["build_analyzer_graph", "build_implementation_graph"] +__all__ = [ + "build_session_analyzer_graph", + "build_session_implementation_graph", + "build_session_create_pr_graph", +] diff --git a/backend/app/workflow/implementation_graph.py b/backend/app/workflow/implementation_graph.py deleted file mode 100644 index 008b4d2..0000000 --- a/backend/app/workflow/implementation_graph.py +++ /dev/null @@ -1,93 +0,0 @@ -import logging -from typing import Any - -from langgraph.graph import END, START, StateGraph - -from app.service.artifact_service import ArtifactService -from app.service.k8s_service import K8sService -from app.workflow.state import ImplementationState - -logger = logging.getLogger(__name__) - - -async def create_k8s_job(state: ImplementationState) -> dict: - """Create the K8s Job for implementation.""" - k8s = K8sService() - - # Write proposal plan to artifact directory to avoid K8s env var size limits - artifacts = ArtifactService() - artifacts.write_proposal_plan(state["job_id"], state["proposal_index"], state["proposal_plan"]) - - job_name = k8s.create_implementation_job( - job_id=state["job_id"], - repo_url=state["repo_url"], - branch=state["branch"], - proposal_index=state["proposal_index"], - proposal_plan=state["proposal_plan"], - ) - return {"k8s_job_name": job_name, "status": "running"} - - -async def wait_for_job(state: ImplementationState) -> dict: - """Poll K8s Job until completion.""" - k8s = K8sService() - k8s_job_name = state["k8s_job_name"] - assert k8s_job_name is not None - result = await k8s.wait_for_job(k8s_job_name) - - if result == "succeeded": - return {"status": "succeeded"} - - logs = k8s.get_job_logs(k8s_job_name) - error_msg = f"Implementation job {result}." - if logs: - error_msg += f" Logs: {logs[:500]}" - return {"status": "failed", "error": error_msg} - - -async def extract_results(state: ImplementationState) -> dict: - """Extract artifacts from pod logs and read results.""" - k8s = K8sService() - artifacts = ArtifactService() - - # Get pod logs and extract embedded artifacts - k8s_job_name = state["k8s_job_name"] - assert k8s_job_name is not None - logs = k8s.get_job_logs(k8s_job_name) - if logs: - artifacts.extract_impl_artifacts_from_logs(state["job_id"], state["proposal_index"], logs) - - after_path = artifacts.get_after_screenshot_path(state["job_id"], state["proposal_index"]) - diff_content = artifacts.get_diff(state["job_id"], state["proposal_index"]) - - return { - "after_screenshot_path": str(after_path) if after_path else None, - "diff_content": diff_content, - } - - -def route_after_wait(state: ImplementationState) -> str: - """Route based on job completion status.""" - if state["status"] == "succeeded": - return "extract_results" - return END - - -def build_implementation_graph() -> Any: - """Build and compile the implementation LangGraph.""" - graph = StateGraph(ImplementationState) - - graph.add_node("create_k8s_job", create_k8s_job) - graph.add_node("wait_for_job", wait_for_job) - graph.add_node("extract_results", extract_results) - - graph.add_edge(START, "create_k8s_job") - graph.add_edge("create_k8s_job", "wait_for_job") - graph.add_conditional_edges( - "wait_for_job", - route_after_wait, - {"extract_results": "extract_results", END: END}, - ) - graph.add_edge("extract_results", END) - - return graph.compile() diff --git a/backend/app/workflow/analyzer_graph.py b/backend/app/workflow/session_analyzer_graph.py similarity index 53% rename from backend/app/workflow/analyzer_graph.py rename to backend/app/workflow/session_analyzer_graph.py index 9fb68ae..966e210 100644 --- a/backend/app/workflow/analyzer_graph.py +++ b/backend/app/workflow/session_analyzer_graph.py @@ -3,27 +3,29 @@ from langgraph.graph import END, START, StateGraph -from app.service.artifact_service import ArtifactService from app.service.k8s_service import K8sService -from app.workflow.state import AnalyzerState +from app.service.s3_service import S3Service +from app.workflow.state import SessionAnalyzerState logger = logging.getLogger(__name__) -async def create_k8s_job(state: AnalyzerState) -> dict: - """Create the K8s Job for analysis.""" +async def create_k8s_job(state: SessionAnalyzerState) -> dict: + """Create the K8s Job for session-based analysis.""" k8s = K8sService() - job_name = k8s.create_analyzer_job( - job_id=state["job_id"], + job_name = k8s.create_session_analyzer_job( + session_id=state["session_id"], + iteration_index=state["iteration_index"], repo_url=state["repo_url"], branch=state["branch"], instruction=state["instruction"], num_proposals=state["num_proposals"], + selected_proposal_index=state.get("selected_proposal_index"), ) return {"k8s_job_name": job_name, "status": "running"} -async def wait_for_job(state: AnalyzerState) -> dict: +async def wait_for_job(state: SessionAnalyzerState) -> dict: """Poll K8s Job until completion.""" k8s = K8sService() k8s_job_name = state["k8s_job_name"] @@ -40,39 +42,36 @@ async def wait_for_job(state: AnalyzerState) -> dict: return {"status": "failed", "error": error_msg} -async def extract_results(state: AnalyzerState) -> dict: - """Extract artifacts from pod logs and read results.""" - k8s = K8sService() - artifacts = ArtifactService() - - # Get pod logs and extract embedded artifacts - k8s_job_name = state["k8s_job_name"] - assert k8s_job_name is not None - logs = k8s.get_job_logs(k8s_job_name) - if logs: - artifacts.extract_artifacts_from_logs(state["job_id"], logs) +async def extract_results(state: SessionAnalyzerState) -> dict: + """Read analysis results from S3.""" + s3 = S3Service() + session_id = state["session_id"] + iteration_index = state["iteration_index"] - proposals = artifacts.get_proposals_json(state["job_id"]) - before_path = artifacts.get_before_screenshot_path(state["job_id"]) + proposals = s3.get_proposals(session_id, iteration_index) + before_key = s3.before_screenshot_key(session_id, iteration_index) + has_before = s3.exists(before_key) - if proposals: - return { - "proposals": proposals, - "before_screenshot_path": str(before_path) if before_path else None, - } - return {"error": "Failed to parse proposals from worker output", "status": "failed"} + if proposals is None: + return {"error": "Failed to read proposals from S3", "status": "failed"} + if len(proposals) == 0: + return {"error": "No proposals generated by analyzer", "status": "failed"} + return { + "proposals": proposals, + "before_screenshot_key": before_key if has_before else None, + } -def route_after_wait(state: AnalyzerState) -> str: +def route_after_wait(state: SessionAnalyzerState) -> str: """Route based on job completion status.""" if state["status"] == "succeeded": return "extract_results" return END -def build_analyzer_graph() -> Any: - """Build and compile the analyzer LangGraph.""" - graph = StateGraph(AnalyzerState) +def build_session_analyzer_graph() -> Any: + """Build and compile the session-based analyzer LangGraph.""" + graph = StateGraph(SessionAnalyzerState) graph.add_node("create_k8s_job", create_k8s_job) graph.add_node("wait_for_job", wait_for_job) diff --git a/backend/app/workflow/create_pr_graph.py b/backend/app/workflow/session_create_pr_graph.py similarity index 57% rename from backend/app/workflow/create_pr_graph.py rename to backend/app/workflow/session_create_pr_graph.py index 1902e84..9be0efb 100644 --- a/backend/app/workflow/create_pr_graph.py +++ b/backend/app/workflow/session_create_pr_graph.py @@ -3,18 +3,19 @@ from langgraph.graph import END, START, StateGraph -from app.service.artifact_service import ArtifactService from app.service.k8s_service import K8sService -from app.workflow.state import CreatePRState +from app.service.s3_service import S3Service +from app.workflow.state import SessionCreatePRState logger = logging.getLogger(__name__) -async def create_k8s_job(state: CreatePRState) -> dict: - """Create the K8s Job for PR creation.""" +async def create_k8s_job(state: SessionCreatePRState) -> dict: + """Create the K8s Job for session-based PR creation.""" k8s = K8sService() - job_name = k8s.create_pr_job( - job_id=state["job_id"], + job_name = k8s.create_session_pr_job( + session_id=state["session_id"], + iteration_index=state["iteration_index"], repo_url=state["repo_url"], branch=state["branch"], proposal_index=state["proposal_index"], @@ -22,7 +23,7 @@ async def create_k8s_job(state: CreatePRState) -> dict: return {"k8s_job_name": job_name, "status": "running"} -async def wait_for_job(state: CreatePRState) -> dict: +async def wait_for_job(state: SessionCreatePRState) -> dict: """Poll K8s Job until completion.""" k8s = K8sService() k8s_job_name = state["k8s_job_name"] @@ -39,31 +40,27 @@ async def wait_for_job(state: CreatePRState) -> dict: return {"status": "failed", "error": error_msg} -async def extract_results(state: CreatePRState) -> dict: - """Extract PR URL artifact from pod logs.""" - k8s = K8sService() - artifacts = ArtifactService() - - k8s_job_name = state["k8s_job_name"] - assert k8s_job_name is not None - logs = k8s.get_job_logs(k8s_job_name) - if logs: - artifacts.extract_impl_artifacts_from_logs(state["job_id"], state["proposal_index"], logs) - - pr_url = artifacts.get_pr_url(state["job_id"], state["proposal_index"]) - return {"pr_url": pr_url} +async def extract_results(state: SessionCreatePRState) -> dict: + """Read PR URL from S3.""" + s3 = S3Service() + pr_url_key = s3.pr_url_key( + state["session_id"], state["iteration_index"], state["proposal_index"] + ) + pr_url = s3.download_text(pr_url_key) + if pr_url: + return {"pr_url": pr_url.strip()} + return {"error": "PR URL not found in S3", "status": "failed"} -def route_after_wait(state: CreatePRState) -> str: - """Route based on job completion status.""" +def route_after_wait(state: SessionCreatePRState) -> str: if state["status"] == "succeeded": return "extract_results" return END -def build_create_pr_graph() -> Any: - """Build and compile the PR creation LangGraph.""" - graph = StateGraph(CreatePRState) +def build_session_create_pr_graph() -> Any: + """Build and compile the session-based PR creation LangGraph.""" + graph = StateGraph(SessionCreatePRState) graph.add_node("create_k8s_job", create_k8s_job) graph.add_node("wait_for_job", wait_for_job) diff --git a/backend/app/workflow/session_implementation_graph.py b/backend/app/workflow/session_implementation_graph.py new file mode 100644 index 0000000..737d8c7 --- /dev/null +++ b/backend/app/workflow/session_implementation_graph.py @@ -0,0 +1,94 @@ +import logging +from typing import Any + +from langgraph.graph import END, START, StateGraph + +from app.service.k8s_service import K8sService +from app.service.s3_service import S3Service +from app.workflow.state import SessionImplementationState + +logger = logging.getLogger(__name__) + + +async def create_k8s_job(state: SessionImplementationState) -> dict: + """Create the K8s Job for session-based implementation.""" + k8s = K8sService() + s3 = S3Service() + + # Write proposal plan to S3 for the worker to read + plan_key = s3.plan_key(state["session_id"], state["iteration_index"], state["proposal_index"]) + s3.upload_text(plan_key, state["proposal_plan"]) + + job_name = k8s.create_session_implementation_job( + session_id=state["session_id"], + iteration_index=state["iteration_index"], + repo_url=state["repo_url"], + branch=state["branch"], + proposal_index=state["proposal_index"], + proposal_plan=state["proposal_plan"], + selected_proposal_index=state.get("selected_proposal_index"), + ) + return {"k8s_job_name": job_name, "status": "running"} + + +async def wait_for_job(state: SessionImplementationState) -> dict: + """Poll K8s Job until completion.""" + k8s = K8sService() + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + result = await k8s.wait_for_job(k8s_job_name) + + if result == "succeeded": + return {"status": "succeeded"} + + logs = k8s.get_job_logs(k8s_job_name) + error_msg = f"Implementation job {result}." + if logs: + error_msg += f" Logs: {logs[:500]}" + return {"status": "failed", "error": error_msg} + + +async def extract_results(state: SessionImplementationState) -> dict: + """Read implementation results from S3.""" + s3 = S3Service() + session_id = state["session_id"] + iteration_index = state["iteration_index"] + proposal_index = state["proposal_index"] + + after_key = s3.after_screenshot_key(session_id, iteration_index, proposal_index) + diff_k = s3.diff_key(session_id, iteration_index, proposal_index) + + has_after = s3.exists(after_key) + has_diff = s3.exists(diff_k) + + return { + "after_screenshot_key": after_key if has_after else None, + "diff_key": diff_k if has_diff else None, + } + + +def route_after_wait(state: SessionImplementationState) -> str: + """Route based on job completion status.""" + if state["status"] == "succeeded": + return "extract_results" + return END + + +def build_session_implementation_graph() -> Any: + """Build and compile the session-based implementation LangGraph.""" + graph = StateGraph(SessionImplementationState) + + graph.add_node("create_k8s_job", create_k8s_job) + graph.add_node("wait_for_job", wait_for_job) + graph.add_node("extract_results", extract_results) + + graph.add_edge(START, "create_k8s_job") + graph.add_edge("create_k8s_job", "wait_for_job") + graph.add_conditional_edges( + "wait_for_job", + route_after_wait, + {"extract_results": "extract_results", END: END}, + ) + graph.add_edge("extract_results", END) + + return graph.compile() diff --git a/backend/app/workflow/state.py b/backend/app/workflow/state.py index 010d292..75dd7c0 100644 --- a/backend/app/workflow/state.py +++ b/backend/app/workflow/state.py @@ -1,16 +1,20 @@ from typing import TypedDict -class AnalyzerState(TypedDict): - """State for the analyzer workflow.""" +class SessionAnalyzerState(TypedDict): + """State for the session-based analyzer workflow.""" # Input - job_id: str + session_id: str + iteration_index: int repo_url: str branch: str instruction: str num_proposals: int + # Patch context (for iter > 0) + selected_proposal_index: int | None # from previous iteration + # Intermediate k8s_job_name: str | None status: str # pending, running, succeeded, failed, timeout @@ -18,34 +22,39 @@ class AnalyzerState(TypedDict): # Output proposals: list[dict] | None - before_screenshot_path: str | None + before_screenshot_key: str | None -class ImplementationState(TypedDict): - """State for the implementation workflow.""" +class SessionImplementationState(TypedDict): + """State for the session-based implementation workflow.""" # Input - job_id: str + session_id: str + iteration_index: int repo_url: str branch: str proposal_index: int proposal_plan: str + # Patch context (for iter > 0) + selected_proposal_index: int | None + # Intermediate k8s_job_name: str | None status: str error: str | None # Output - after_screenshot_path: str | None - diff_content: str | None + after_screenshot_key: str | None + diff_key: str | None -class CreatePRState(TypedDict): - """State for the PR creation workflow.""" +class SessionCreatePRState(TypedDict): + """State for the session-based PR creation workflow.""" # Input - job_id: str + session_id: str + iteration_index: int repo_url: str branch: str proposal_index: int diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index cb884cb..4e5190e 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -13,6 +13,8 @@ services: depends_on: db: condition: service_healthy + minio: + condition: service_healthy env_file: - .env healthcheck: @@ -24,6 +26,26 @@ services: networks: - ui_recommender_network + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - ui_recommender_minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - ui_recommender_network + db: image: postgres:17 volumes: @@ -46,6 +68,7 @@ services: volumes: ui_recommender_postgres_data: + ui_recommender_minio_data: networks: ui_recommender_network: diff --git a/backend/migration/env.py b/backend/migration/env.py index 3c6a314..bc9ee48 100755 --- a/backend/migration/env.py +++ b/backend/migration/env.py @@ -5,7 +5,7 @@ from app.core.config import get_settings from app.model.base import Base -from app.model.job import Job, Proposal, Setting # noqa: F401 +from app.model.session import Iteration, Proposal, Session, Setting # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/migration/versions/b2c3d4e5f6g7_add_job_chaining_columns.py b/backend/migration/versions/b2c3d4e5f6g7_add_job_chaining_columns.py new file mode 100644 index 0000000..394625b --- /dev/null +++ b/backend/migration/versions/b2c3d4e5f6g7_add_job_chaining_columns.py @@ -0,0 +1,37 @@ +"""add job chaining columns + +Revision ID: b2c3d4e5f6g7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-28 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b2c3d4e5f6g7' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add parent_job_id and parent_proposal_index columns to jobs table.""" + op.add_column('jobs', sa.Column('parent_job_id', sa.Uuid(), nullable=True)) + op.add_column('jobs', sa.Column('parent_proposal_index', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_jobs_parent_job_id', + 'jobs', 'jobs', + ['parent_job_id'], ['id'], + ondelete='SET NULL', + ) + + +def downgrade() -> None: + """Remove parent_job_id and parent_proposal_index columns from jobs table.""" + op.drop_constraint('fk_jobs_parent_job_id', 'jobs', type_='foreignkey') + op.drop_column('jobs', 'parent_proposal_index') + op.drop_column('jobs', 'parent_job_id') diff --git a/backend/migration/versions/c3d4e5f6g7h8_add_session_iteration_tables.py b/backend/migration/versions/c3d4e5f6g7h8_add_session_iteration_tables.py new file mode 100644 index 0000000..0d3d087 --- /dev/null +++ b/backend/migration/versions/c3d4e5f6g7h8_add_session_iteration_tables.py @@ -0,0 +1,181 @@ +"""add session and iteration tables, drop legacy job tables + +Revision ID: c3d4e5f6g7h8 +Revises: b2c3d4e5f6g7 +Create Date: 2026-02-28 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c3d4e5f6g7h8" +down_revision: Union[str, None] = "b2c3d4e5f6g7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Drop legacy tables and create sessions, iterations, proposals tables.""" + # Drop legacy tables (proposals has FK to jobs, drop first) + op.drop_table("proposals") + op.drop_table("jobs") + op.execute("DROP TYPE IF EXISTS proposalstatus") + op.execute("DROP TYPE IF EXISTS jobstatus") + + # Sessions table + op.create_table( + "sessions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("repo_url", sa.String(length=500), nullable=False), + sa.Column("base_branch", sa.String(length=200), nullable=False, server_default="main"), + sa.Column( + "status", + sa.Enum("active", "completed", "archived", name="sessionstatus"), + nullable=False, + server_default="active", + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + # Iterations table + op.create_table( + "iterations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("iteration_index", sa.Integer(), nullable=False), + sa.Column("instruction", sa.Text(), nullable=False), + sa.Column("selected_proposal_index", sa.Integer(), nullable=True), + sa.Column( + "status", + sa.Enum( + "pending", + "analyzing", + "analyzed", + "implementing", + "completed", + "failed", + name="iterationstatus", + ), + nullable=False, + server_default="pending", + ), + sa.Column("before_screenshot_key", sa.String(length=500), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("k8s_analyzer_job_name", sa.String(length=200), nullable=True), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["session_id"], ["sessions.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "session_id", "iteration_index", name="uq_iteration_session_index" + ), + ) + + # Proposals table (linked to iterations) + op.create_table( + "proposals", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("iteration_id", sa.Uuid(), nullable=False), + sa.Column("proposal_index", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("concept", sa.Text(), nullable=False), + sa.Column("plan", sa.Text(), nullable=False), + sa.Column("files", sa.Text(), nullable=True), + sa.Column("complexity", sa.String(length=20), nullable=True), + sa.Column( + "status", + sa.Enum( + "pending", + "implementing", + "completed", + "failed", + name="proposalstatus", + ), + nullable=False, + server_default="pending", + ), + sa.Column("after_screenshot_key", sa.String(length=500), nullable=True), + sa.Column("diff_key", sa.Text(), nullable=True), + sa.Column("pr_url", sa.String(length=500), nullable=True), + sa.Column("pr_status", sa.String(length=20), nullable=True), + sa.Column("k8s_job_name", sa.String(length=200), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["iteration_id"], ["iterations.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "iteration_id", "proposal_index", name="uq_proposal_iteration_index" + ), + ) + + +def downgrade() -> None: + """Drop new tables and recreate legacy tables.""" + op.drop_table("proposals") + op.drop_table("iterations") + op.drop_table("sessions") + op.execute("DROP TYPE IF EXISTS proposalstatus") + op.execute("DROP TYPE IF EXISTS iterationstatus") + op.execute("DROP TYPE IF EXISTS sessionstatus") + + # Recreate legacy tables + op.create_table( + "jobs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "status", + sa.Enum("pending", "analyzing", "analyzed", "implementing", "completed", "failed", name="jobstatus"), + nullable=False, + server_default="pending", + ), + sa.Column("repo_url", sa.String(length=500), nullable=False), + sa.Column("branch", sa.String(length=200), nullable=False, server_default="main"), + sa.Column("instruction", sa.Text(), nullable=False), + sa.Column("before_screenshot_path", sa.String(length=500), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("k8s_job_name", sa.String(length=200), nullable=True), + sa.Column("parent_job_id", sa.Uuid(), nullable=True), + sa.Column("parent_proposal_index", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["parent_job_id"], ["jobs.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "proposals", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("job_id", sa.Uuid(), nullable=False), + sa.Column("proposal_index", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("concept", sa.Text(), nullable=False), + sa.Column("plan", sa.Text(), nullable=False), + sa.Column("files", sa.Text(), nullable=True), + sa.Column("complexity", sa.String(length=20), nullable=True), + sa.Column( + "status", + sa.Enum("pending", "implementing", "completed", "failed", name="proposalstatus"), + nullable=False, + server_default="pending", + ), + sa.Column("after_screenshot_path", sa.String(length=500), nullable=True), + sa.Column("diff_path", sa.Text(), nullable=True), + sa.Column("pr_url", sa.String(length=500), nullable=True), + sa.Column("pr_status", sa.String(length=20), nullable=True), + sa.Column("k8s_job_name", sa.String(length=200), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b23dd6e..b62a424 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "alembic>=1.18.4,<2.0.0", "langgraph>=0.4.0", "kubernetes>=32.0.0", + "boto3>=1.35.0", ] @@ -66,7 +67,7 @@ module = "app.workflow.*" disallow_untyped_calls = false [[tool.mypy.overrides]] -module = ["langgraph.*", "kubernetes.*", "pytest.*", "pytest_asyncio.*", "_pytest.*"] +module = ["langgraph.*", "kubernetes.*", "pytest.*", "pytest_asyncio.*", "_pytest.*", "boto3.*", "botocore.*"] ignore_missing_imports = true [tool.hatch.build.targets.wheel] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2445715..c325ae1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,7 +5,8 @@ from sqlalchemy.orm import Session, sessionmaker from app.model.base import Base -from app.model.job import Job, Proposal, Setting # noqa: F401 (register models) +from app.model.session import Iteration, Proposal, Setting # noqa: F401 (register models) +from app.model.session import Session as SessionModel # noqa: F401 @pytest.fixture(scope="session") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a6f5e71..2b17744 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,130 +1,143 @@ import uuid -from pathlib import Path import pytest from fastapi.testclient import TestClient -from app.di.dependencies import get_artifact_service, get_db +from app.di.dependencies import get_db from app.main import app -from app.model.job import Job, JobStatus, Proposal -from app.service.artifact_service import ArtifactService +from app.model.session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, +) +from app.repository.iteration_repository import IterationRepository +from app.repository.proposal_repository import ProposalRepository +from app.repository.session_repository import SessionRepository @pytest.fixture() -def client(db, tmp_path): +def client(db): """Create a test client with overridden dependencies.""" def override_get_db(): yield db - def override_get_artifact_service() -> ArtifactService: - service = ArtifactService.__new__(ArtifactService) - service.base_dir = Path(tmp_path) - return service - app.dependency_overrides[get_db] = override_get_db - app.dependency_overrides[get_artifact_service] = override_get_artifact_service with TestClient(app) as c: yield c app.dependency_overrides.clear() @pytest.fixture() -def sample_job(db) -> Job: - """Create a sample job in the DB.""" - from app.repository.job_repository import JobRepository - - repo = JobRepository(db) - return repo.create( - Job( +def sample_session(db) -> Session: + """Create a sample session with iteration 0.""" + session_repo = SessionRepository(db) + session = session_repo.create( + Session( repo_url="https://github.com/test/repo", - branch="main", + base_branch="main", + ) + ) + iter_repo = IterationRepository(db) + iter_repo.create( + Iteration( + session_id=session.id, + iteration_index=0, instruction="Make the header blue", + status=IterationStatus.PENDING, ) ) + db.refresh(session) + return session @pytest.fixture() -def analyzed_job(db) -> Job: - """Create a job in analyzed state with proposals.""" - from app.repository.job_repository import JobRepository - from app.repository.proposal_repository import ProposalRepository - - job_repo = JobRepository(db) - job = job_repo.create( - Job( +def completed_session(db) -> Session: + """Create a session with completed iteration and proposals.""" + session_repo = SessionRepository(db) + session = session_repo.create( + Session( repo_url="https://github.com/test/repo", - branch="main", + base_branch="main", + ) + ) + iter_repo = IterationRepository(db) + iteration = iter_repo.create( + Iteration( + session_id=session.id, + iteration_index=0, instruction="Redesign the navbar", - status=JobStatus.ANALYZED, + status=IterationStatus.COMPLETED, ) ) - prop_repo = ProposalRepository(db) for i in range(2): prop_repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=i, title=f"Proposal {i}", concept=f"Concept {i}", plan='["step 1"]', files='[{"path": "src/App.tsx"}]', complexity="medium", + status=ProposalStatus.COMPLETED, ) ) - - db.refresh(job) - return job + db.refresh(session) + return session -class TestListJobs: +class TestListSessions: def test_empty_list(self, client): - response = client.get("/api/jobs/") + response = client.get("/api/sessions/") assert response.status_code == 200 assert isinstance(response.json(), list) - def test_list_with_jobs(self, client, sample_job): - response = client.get("/api/jobs/") + def test_list_with_sessions(self, client, sample_session): + response = client.get("/api/sessions/") assert response.status_code == 200 data = response.json() assert len(data) >= 1 assert data[0]["repo_url"] == "https://github.com/test/repo" -class TestGetJob: - def test_get_existing(self, client, sample_job): - response = client.get(f"/api/jobs/{sample_job.id}") +class TestGetSession: + def test_get_existing(self, client, sample_session): + response = client.get(f"/api/sessions/{sample_session.id}") assert response.status_code == 200 data = response.json() - assert data["id"] == str(sample_job.id) - assert data["status"] == "pending" - assert data["instruction"] == "Make the header blue" + assert data["id"] == str(sample_session.id) + assert data["status"] == "active" + assert len(data["iterations"]) == 1 + assert data["iterations"][0]["instruction"] == "Make the header blue" def test_get_nonexistent(self, client): - response = client.get(f"/api/jobs/{uuid.uuid4()}") + response = client.get(f"/api/sessions/{uuid.uuid4()}") assert response.status_code == 404 - def test_get_with_proposals(self, client, analyzed_job): - response = client.get(f"/api/jobs/{analyzed_job.id}") + def test_get_with_proposals(self, client, completed_session): + response = client.get(f"/api/sessions/{completed_session.id}") assert response.status_code == 200 data = response.json() - assert data["status"] == "analyzed" - assert len(data["proposals"]) == 2 - assert data["proposals"][0]["title"] == "Proposal 0" - assert data["proposals"][0]["plan"] == ["step 1"] - assert data["proposals"][0]["files"] == [{"path": "src/App.tsx"}] + iteration = data["iterations"][0] + assert iteration["status"] == "completed" + assert len(iteration["proposals"]) == 2 + assert iteration["proposals"][0]["title"] == "Proposal 0" + assert iteration["proposals"][0]["plan"] == ["step 1"] -class TestCreateJob: +class TestCreateSession: def test_create_success(self, client, monkeypatch): - # Mock the asyncio.create_task to avoid launching background analysis - import app.usecase.job_usecase as usecase_mod + import app.usecase.session_usecase as usecase_mod monkeypatch.setattr(usecase_mod.asyncio, "create_task", lambda coro: coro.close()) + monkeypatch.setattr("app.service.s3_service.S3Service._ensure_bucket", lambda self: None) response = client.post( - "/api/jobs/", + "/api/sessions/", json={ "repo_url": "https://github.com/new/repo", "branch": "develop", @@ -134,37 +147,14 @@ def test_create_success(self, client, monkeypatch): assert response.status_code == 201 data = response.json() assert data["repo_url"] == "https://github.com/new/repo" - assert data["branch"] == "develop" - assert data["status"] == "pending" + assert data["base_branch"] == "develop" + assert data["status"] == "active" def test_create_missing_fields(self, client): - response = client.post("/api/jobs/", json={}) - assert response.status_code == 422 - - def test_create_empty_instruction(self, client): - response = client.post( - "/api/jobs/", - json={"repo_url": "https://github.com/test/repo", "instruction": ""}, - ) + response = client.post("/api/sessions/", json={}) assert response.status_code == 422 -class TestGetScreenshot: - def test_before_screenshot_not_found(self, client, sample_job): - response = client.get(f"/api/jobs/{sample_job.id}/screenshot/before") - assert response.status_code == 404 - - def test_after_screenshot_not_found(self, client, sample_job): - response = client.get(f"/api/jobs/{sample_job.id}/proposals/0/screenshot") - assert response.status_code == 404 - - -class TestGetDiff: - def test_diff_not_found(self, client, sample_job): - response = client.get(f"/api/jobs/{sample_job.id}/proposals/0/diff") - assert response.status_code == 404 - - class TestSettingsAPI: def test_list_settings_empty(self, client): response = client.get("/api/settings/") diff --git a/backend/tests/test_repository.py b/backend/tests/test_repository.py index 4b72a0f..1c82ac7 100644 --- a/backend/tests/test_repository.py +++ b/backend/tests/test_repository.py @@ -1,101 +1,101 @@ import uuid -from app.model.job import Job, JobStatus, Proposal, ProposalStatus -from app.repository.job_repository import JobRepository +from app.model.session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, + SessionStatus, +) +from app.repository.iteration_repository import IterationRepository from app.repository.proposal_repository import ProposalRepository +from app.repository.session_repository import SessionRepository from app.repository.setting_repository import SettingRepository -class TestJobRepository: +class TestSessionRepository: def test_create_and_get(self, db): - repo = JobRepository(db) - job = Job( + repo = SessionRepository(db) + session = Session( repo_url="https://github.com/test/repo", - branch="main", - instruction="Fix the button color", + base_branch="main", ) - created = repo.create(job) + created = repo.create(session) assert created.id is not None - assert created.status == JobStatus.PENDING + assert created.status == SessionStatus.ACTIVE fetched = repo.get_by_id(created.id) assert fetched is not None assert fetched.repo_url == "https://github.com/test/repo" def test_get_by_id_not_found(self, db): - repo = JobRepository(db) + repo = SessionRepository(db) result = repo.get_by_id(uuid.uuid4()) assert result is None - def test_update_status(self, db): - repo = JobRepository(db) - job = repo.create( - Job( - repo_url="https://github.com/test/repo", - branch="main", - instruction="Update layout", - ) - ) + def test_list_all(self, db): + repo = SessionRepository(db) + repo.create(Session(repo_url="https://github.com/test/a", base_branch="main")) + repo.create(Session(repo_url="https://github.com/test/b", base_branch="main")) - updated = repo.update_status(job.id, JobStatus.ANALYZING, k8s_job_name="test-job") - assert updated is not None - assert updated.status == JobStatus.ANALYZING - assert updated.k8s_job_name == "test-job" - - def test_update_status_with_error(self, db): - repo = JobRepository(db) - job = repo.create( - Job( - repo_url="https://github.com/test/repo", - branch="main", - instruction="Update layout", - ) - ) + sessions = repo.list_all() + assert len(sessions) >= 2 - updated = repo.update_status(job.id, JobStatus.FAILED, error_message="Something went wrong") - assert updated is not None - assert updated.status == JobStatus.FAILED - assert updated.error_message == "Something went wrong" - def test_list_all(self, db): - repo = JobRepository(db) - repo.create( - Job( - repo_url="https://github.com/test/a", - branch="main", - instruction="A", - ) - ) - repo.create( - Job( - repo_url="https://github.com/test/b", - branch="main", - instruction="B", +class TestIterationRepository: + def _create_session(self, db) -> Session: + repo = SessionRepository(db) + return repo.create(Session(repo_url="https://github.com/test/repo", base_branch="main")) + + def test_create_and_get(self, db): + session = self._create_session(db) + repo = IterationRepository(db) + iteration = repo.create( + Iteration( + session_id=session.id, + iteration_index=0, + instruction="Make button blue", ) ) - jobs = repo.list_all() - assert len(jobs) >= 2 + assert iteration.id is not None + assert iteration.status == IterationStatus.PENDING + + fetched = repo.get_by_id(iteration.id) + assert fetched is not None + assert fetched.instruction == "Make button blue" + + def test_get_latest_for_session(self, db): + session = self._create_session(db) + repo = IterationRepository(db) + repo.create(Iteration(session_id=session.id, iteration_index=0, instruction="First")) + repo.create(Iteration(session_id=session.id, iteration_index=1, instruction="Second")) + + latest = repo.get_latest_for_session(session.id) + assert latest is not None + assert latest.iteration_index == 1 + assert latest.instruction == "Second" class TestProposalRepository: - def _create_job(self, db) -> Job: - repo = JobRepository(db) - return repo.create( - Job( - repo_url="https://github.com/test/repo", - branch="main", - instruction="Test", - ) + def _create_iteration(self, db) -> Iteration: + session_repo = SessionRepository(db) + session = session_repo.create( + Session(repo_url="https://github.com/test/repo", base_branch="main") + ) + iter_repo = IterationRepository(db) + return iter_repo.create( + Iteration(session_id=session.id, iteration_index=0, instruction="Test") ) def test_create_and_get(self, db): - job = self._create_job(db) + iteration = self._create_iteration(db) repo = ProposalRepository(db) proposal = repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=0, title="Modern Layout", concept="A clean modern layout", @@ -110,12 +110,12 @@ def test_create_and_get(self, db): assert fetched is not None assert fetched.title == "Modern Layout" - def test_get_by_job_and_index(self, db): - job = self._create_job(db) + def test_get_by_iteration_and_index(self, db): + iteration = self._create_iteration(db) repo = ProposalRepository(db) repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=0, title="Proposal A", concept="A", @@ -124,7 +124,7 @@ def test_get_by_job_and_index(self, db): ) repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=1, title="Proposal B", concept="B", @@ -132,20 +132,20 @@ def test_get_by_job_and_index(self, db): ) ) - result = repo.get_by_job_and_index(job.id, 1) + result = repo.get_by_iteration_and_index(iteration.id, 1) assert result is not None assert result.title == "Proposal B" - result = repo.get_by_job_and_index(job.id, 99) + result = repo.get_by_iteration_and_index(iteration.id, 99) assert result is None - def test_get_all_for_job(self, db): - job = self._create_job(db) + def test_get_all_for_iteration(self, db): + iteration = self._create_iteration(db) repo = ProposalRepository(db) for i in range(3): repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=i, title=f"Proposal {i}", concept=f"Concept {i}", @@ -153,17 +153,17 @@ def test_get_all_for_job(self, db): ) ) - proposals = repo.get_all_for_job(job.id) + proposals = repo.get_all_for_iteration(iteration.id) assert len(proposals) == 3 assert proposals[0].proposal_index == 0 assert proposals[2].proposal_index == 2 - def test_update_status(self, db): - job = self._create_job(db) + def test_update_status_optimistic(self, db): + iteration = self._create_iteration(db) repo = ProposalRepository(db) proposal = repo.create( Proposal( - job_id=job.id, + iteration_id=iteration.id, proposal_index=0, title="Test", concept="Test", @@ -171,9 +171,12 @@ def test_update_status(self, db): ) ) - updated = repo.update_status(proposal.id, ProposalStatus.COMPLETED) + updated = repo.update_status_optimistic( + proposal.id, proposal.version, ProposalStatus.COMPLETED + ) assert updated is not None assert updated.status == ProposalStatus.COMPLETED + assert updated.version == 2 class TestSettingRepository: diff --git a/backend/uv.lock b/backend/uv.lock index 88dc43a..876a9f5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -52,6 +52,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "boto3" }, { name = "fastapi" }, { name = "kubernetes" }, { name = "langgraph" }, @@ -75,6 +76,7 @@ test = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.4,<2.0.0" }, + { name = "boto3", specifier = ">=1.35.0" }, { name = "fastapi", specifier = ">=0.129.0,<1.0.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, { name = "kubernetes", specifier = ">=32.0.0" }, @@ -90,6 +92,34 @@ requires-dist = [ ] provides-extras = ["test", "dev"] +[[package]] +name = "boto3" +version = "1.42.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/ae/50fb33bdf1911c216d50f98d989dd032a506f054cf829ebd737c6fa7e3e6/botocore-1.42.59.tar.gz", hash = "sha256:5314f19e1da8fc0ebc41bdb8bbe17c9a7397d87f4d887076ac8bdef972a34138", size = 14950271, upload-time = "2026-02-27T20:25:20.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/df/9d52819e0d804ead073d53ab1823bc0f0cb172a250fba31107b0b43fbb04/botocore-1.42.59-py3-none-any.whl", hash = "sha256:d2f2ff7ecc31e86ef46b5daee112cfbca052c13801285fb23af909f7bff5b657", size = 14619293, upload-time = "2026-02-27T20:25:17.455Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -272,6 +302,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -930,6 +969,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index 556af61..3ab462d 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -1,17 +1,75 @@ #!/usr/bin/env python3 -"""Analyzer Worker: Clones repo, takes before screenshot, generates design proposals via Claude Agent SDK.""" +"""Analyzer Worker: Clones repo, takes before screenshot, generates design proposals via Claude Agent SDK. + +Session-based: Reads/writes all artifacts via S3. No hostPath, no stdout embedding. +""" import asyncio -import base64 import json import os +import re import subprocess import sys from pathlib import Path +import boto3 +from botocore.config import Config from claude_agent_sdk import ClaudeAgentOptions, query +def get_s3_client(): + kwargs = { + "service_name": "s3", + "region_name": os.environ.get("S3_REGION", "us-east-1"), + "config": Config(signature_version="s3v4"), + } + endpoint = os.environ.get("S3_ENDPOINT_URL") + if endpoint: + kwargs["endpoint_url"] = endpoint + kwargs["aws_access_key_id"] = os.environ.get("S3_ACCESS_KEY", "minioadmin") + kwargs["aws_secret_access_key"] = os.environ.get("S3_SECRET_KEY", "minioadmin") + return boto3.client(**kwargs) + + +def s3_download(s3, bucket, key, local_path): + try: + s3.download_file(bucket, key, local_path) + return True + except Exception as e: + print(f"S3 download failed for {key}: {e}", file=sys.stderr) + return False + + +def s3_upload_file(s3, bucket, key, local_path, content_type="application/octet-stream"): + for attempt in range(3): + try: + s3.upload_file( + local_path, bucket, key, + ExtraArgs={"ContentType": content_type}, + ) + print(f"Uploaded {key}") + return + except Exception as e: + print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + if attempt == 2: + raise + + +def s3_upload_text(s3, bucket, key, text, content_type="text/plain"): + for attempt in range(3): + try: + s3.put_object( + Bucket=bucket, Key=key, + Body=text.encode("utf-8"), ContentType=content_type, + ) + print(f"Uploaded {key}") + return + except Exception as e: + print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + if attempt == 2: + raise + + async def take_before_screenshot(repo_dir: str, screenshot_output: str) -> None: """Use Claude Agent SDK to start dev server and take a before screenshot.""" prompt = f"""You need to start the dev server for the web application at {repo_dir} and take a screenshot. @@ -44,6 +102,60 @@ async def take_before_screenshot(repo_dir: str, screenshot_output: str) -> None: print(f"Screenshot Agent: {block.text[:200]}") +def _extract_proposals_json(collected_text: list[str]) -> list[dict]: + """Extract proposals JSON from Claude Agent output, trying multiple strategies.""" + + # Strategy 1: Try each text block from the end (last output is most likely the JSON) + for text in reversed(collected_text): + text = text.strip() + if not text: + continue + # Strip markdown code block wrapping + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + try: + data = json.loads(text) + if isinstance(data, dict) and "proposals" in data: + return data["proposals"] + if isinstance(data, list): + return data + except json.JSONDecodeError: + continue + + # Strategy 2: Search for {"proposals": ...} in the concatenated text using regex + full_text = "\n".join(collected_text) + match = re.search(r'\{"proposals"\s*:\s*\[.*\]\s*\}', full_text, re.DOTALL) + if match: + try: + data = json.loads(match.group()) + if "proposals" in data: + return data["proposals"] + except json.JSONDecodeError: + pass + + # Strategy 3: Extract from markdown code blocks in full text + for pattern in [r'```json\s*(.*?)```', r'```\s*(.*?)```']: + m = re.search(pattern, full_text, re.DOTALL) + if m: + try: + data = json.loads(m.group(1).strip()) + if isinstance(data, dict) and "proposals" in data: + return data["proposals"] + if isinstance(data, list): + return data + except json.JSONDecodeError: + continue + + print("Failed to parse proposals JSON from all strategies", file=sys.stderr) + print(f"Collected {len(collected_text)} text blocks", file=sys.stderr) + for i, t in enumerate(collected_text): + print(f"Block {i}: {t[:200]}", file=sys.stderr) + + return [] + + async def generate_proposals( repo_dir: str, instruction: str, num_proposals: int ) -> list[dict]: @@ -53,10 +165,18 @@ async def generate_proposals( {instruction} -Analyze the codebase thoroughly and generate exactly {num_proposals} different design proposals. -Each proposal should take a meaningfully different approach. +## Your Task + +1. First, analyze the codebase using the available tools (Read, Glob, Grep) to understand the project structure, framework, and relevant files. +2. After sufficient analysis, generate exactly {num_proposals} different design proposals. +3. Each proposal should take a meaningfully different approach. + +## Output Format + +IMPORTANT: After your analysis, you MUST output EXACTLY ONE valid JSON object as your FINAL message. +Do not include any text before or after the JSON. Do not wrap it in markdown code blocks. +The JSON must follow this exact structure: -You MUST output ONLY a valid JSON object (no markdown, no explanation) with this exact structure: {{ "proposals": [ {{ @@ -68,6 +188,8 @@ async def generate_proposals( }} ] }} + +Remember: The JSON output is the MOST IMPORTANT part. Even if your analysis is incomplete, you MUST output the JSON before finishing. """ collected_text = [] @@ -76,7 +198,7 @@ async def generate_proposals( options=ClaudeAgentOptions( allowed_tools=["Read", "Glob", "Grep"], cwd=repo_dir, - max_turns=10, + max_turns=30, ), ): # Collect text content from assistant messages @@ -85,39 +207,26 @@ async def generate_proposals( if hasattr(block, "text"): collected_text.append(block.text) - full_text = "\n".join(collected_text) - - # Parse JSON from the response (handle potential markdown wrapping) - json_text = full_text - if "```json" in json_text: - json_text = json_text.split("```json")[1].split("```")[0] - elif "```" in json_text: - json_text = json_text.split("```")[1].split("```")[0] - - try: - data = json.loads(json_text.strip()) - if "proposals" in data: - return data["proposals"] - if isinstance(data, list): - return data - except json.JSONDecodeError as e: - print(f"Failed to parse proposals JSON: {e}", file=sys.stderr) - print(f"Raw text: {full_text[:1000]}", file=sys.stderr) - - return [] + return _extract_proposals_json(collected_text) async def main() -> None: # Read environment variables - job_id = os.environ["JOB_ID"] + session_id = os.environ["SESSION_ID"] + iteration_index = int(os.environ["ITERATION_INDEX"]) repo_url = os.environ["REPO_URL"] branch = os.environ.get("BRANCH", "main") instruction = os.environ["INSTRUCTION"] num_proposals = int(os.environ.get("NUM_PROPOSALS", "3")) + selected_proposal_index = os.environ.get("SELECTED_PROPOSAL_INDEX") - artifact_dir = f"/artifacts/{job_id}" + bucket = os.environ["S3_BUCKET"] + s3 = get_s3_client() + tmp_dir = "/tmp/artifacts" repo_dir = "/workspace/repo" - os.makedirs(artifact_dir, exist_ok=True) + os.makedirs(tmp_dir, exist_ok=True) + + s3_prefix = f"sessions/{session_id}/iterations/{iteration_index}" # Step 1: Clone repository print("=== Cloning repository ===") @@ -126,35 +235,79 @@ async def main() -> None: check=True, ) - # Step 2: Take before screenshot via Agent SDK + # Step 1.5: Apply cumulative patch from previous iteration (if iter > 0) + if iteration_index > 0 and selected_proposal_index is not None: + prev_iter = iteration_index - 1 + patch_key = ( + f"sessions/{session_id}/iterations/{prev_iter}" + f"/proposals/{selected_proposal_index}/changes.diff" + ) + local_patch = f"{tmp_dir}/parent.diff" + print(f"=== Downloading patch from S3: {patch_key} ===") + if s3_download(s3, bucket, patch_key, local_patch): + print("=== Applying cumulative patch ===") + result = subprocess.run( + ["git", "am", "--3way", local_patch], + cwd=repo_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"git am failed: {result.stderr}", file=sys.stderr) + print("Falling back to git apply...") + subprocess.run( + ["git", "am", "--abort"], cwd=repo_dir, capture_output=True + ) + subprocess.run( + ["git", "apply", "--3way", local_patch], + cwd=repo_dir, + check=True, + ) + subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True) + subprocess.run( + ["git", "commit", "-m", "apply base patch"], + cwd=repo_dir, + check=True, + ) + print("=== Cumulative patch applied successfully ===") + else: + print( + f"WARNING: Patch not found at s3://{bucket}/{patch_key}", + file=sys.stderr, + ) + + # Step 2: Take before screenshot print("=== Taking before screenshot ===") - await take_before_screenshot(repo_dir, f"{artifact_dir}/before.png") + before_path = f"{tmp_dir}/before.png" + await take_before_screenshot(repo_dir, before_path) # Step 3: Generate design proposals via Claude Agent SDK print("=== Generating design proposals ===") proposals = await generate_proposals(repo_dir, instruction, num_proposals) - - # Step 4: Save results to file and output to stdout for Backend retrieval print(f"=== Generated {len(proposals)} proposals ===") - result = {"proposals": proposals} - with open(f"{artifact_dir}/proposals.json", "w") as f: - json.dump(result, f, indent=2, ensure_ascii=False) - # Output artifacts to stdout with markers so Backend can extract from pod logs + # Step 4: Upload results to S3 + print("=== Uploading results to S3 ===") - proposals_json = json.dumps(result, ensure_ascii=False) - print(f"===ARTIFACT:proposals.json:START===") - print(proposals_json) - print(f"===ARTIFACT:proposals.json:END===") + # Upload before screenshot + if Path(before_path).exists(): + s3_upload_file( + s3, bucket, + f"{s3_prefix}/before.png", + before_path, + content_type="image/png", + ) - before_path = Path(f"{artifact_dir}/before.png") - if before_path.exists(): - b64 = base64.b64encode(before_path.read_bytes()).decode() - print(f"===ARTIFACT:before.png:START===") - print(b64) - print(f"===ARTIFACT:before.png:END===") + # Upload proposals.json + proposals_data = {"proposals": proposals} + s3_upload_text( + s3, bucket, + f"{s3_prefix}/proposals.json", + json.dumps(proposals_data, ensure_ascii=False, indent=2), + content_type="application/json", + ) - print(f"=== Analysis Complete ===") + print("=== Analysis Complete ===") if __name__ == "__main__": diff --git a/docker/worker-createpr.py b/docker/worker-createpr.py index 6f0d78a..20d9071 100644 --- a/docker/worker-createpr.py +++ b/docker/worker-createpr.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""PR Creation Worker: Applies a diff and creates a PR using Claude Agent SDK for quality descriptions.""" +"""PR Creation Worker: Downloads patch from S3, applies it, pushes branch, creates PR. + +Session-based: This is the ONLY worker that pushes to the remote repository. +""" import asyncio import os @@ -9,13 +12,50 @@ from datetime import datetime, timezone from pathlib import Path +import boto3 +from botocore.config import Config from claude_agent_sdk import ClaudeAgentOptions, query +def get_s3_client(): + kwargs = { + "service_name": "s3", + "region_name": os.environ.get("S3_REGION", "us-east-1"), + "config": Config(signature_version="s3v4"), + } + endpoint = os.environ.get("S3_ENDPOINT_URL") + if endpoint: + kwargs["endpoint_url"] = endpoint + kwargs["aws_access_key_id"] = os.environ.get("S3_ACCESS_KEY", "minioadmin") + kwargs["aws_secret_access_key"] = os.environ.get("S3_SECRET_KEY", "minioadmin") + return boto3.client(**kwargs) + + +def s3_download(s3, bucket, key, local_path): + try: + s3.download_file(bucket, key, local_path) + return True + except Exception as e: + print(f"S3 download failed for {key}: {e}", file=sys.stderr) + return False + + +def s3_upload_text(s3, bucket, key, text): + for attempt in range(3): + try: + s3.put_object(Bucket=bucket, Key=key, Body=text.encode("utf-8"), ContentType="text/plain") + print(f"Uploaded {key}") + return + except Exception as e: + print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + if attempt == 2: + raise + + async def push_and_create_pr( repo_dir: str, branch_name: str, base_branch: str, diff_summary: str ) -> str: - """Use Claude Agent SDK to push the branch and create a PR with a good description.""" + """Use Claude Agent SDK to push the branch and create a PR.""" prompt = f"""You are creating a GitHub Pull Request for UI design changes. The repository is at {repo_dir}. You are on branch "{branch_name}". @@ -46,15 +86,12 @@ async def push_and_create_pr( IMPORTANT: You MUST output the PR URL in that exact format so it can be extracted. """ - collected_text: list[str] = [] async for msg in query( prompt=prompt, options=ClaudeAgentOptions( allowed_tools=["Read", "Bash", "Glob", "Grep"], - cwd=repo_dir, - max_turns=15, - max_budget_usd=1.0, + cwd=repo_dir, max_turns=15, max_budget_usd=1.0, ), ): if hasattr(msg, "content"): @@ -64,41 +101,44 @@ async def push_and_create_pr( print(f"Agent: {block.text[:200]}") full_text = "\n".join(collected_text) - - # Extract PR URL from agent output for line in full_text.split("\n"): if "PR_URL:" in line: return line.split("PR_URL:")[-1].strip() - # Fallback: find a github.com PR URL in the output urls = re.findall(r"https://github\.com/[^\s)\"']+/pull/\d+", full_text) if urls: return urls[0] - return "" async def main() -> None: - job_id = os.environ["JOB_ID"] + session_id = os.environ["SESSION_ID"] + iteration_index = int(os.environ["ITERATION_INDEX"]) repo_url = os.environ["REPO_URL"] branch = os.environ.get("BRANCH", "main") proposal_index = os.environ["PROPOSAL_INDEX"] github_token = os.environ["GITHUB_TOKEN"] - artifact_dir = f"/artifacts/{job_id}/proposals/{proposal_index}" + bucket = os.environ["S3_BUCKET"] + s3 = get_s3_client() + tmp_dir = "/tmp/artifacts" + os.makedirs(tmp_dir, exist_ok=True) - # Configure gh CLI authentication os.environ["GH_TOKEN"] = github_token - # Step 1: Read the diff - diff_path = f"{artifact_dir}/changes.diff" - if not Path(diff_path).exists(): - print(f"Error: Diff not found at {diff_path}", file=sys.stderr) + # Step 1: Download patch from S3 + patch_key = ( + f"sessions/{session_id}/iterations/{iteration_index}" + f"/proposals/{proposal_index}/changes.diff" + ) + local_patch = f"{tmp_dir}/changes.diff" + if not s3_download(s3, bucket, patch_key, local_patch): + print(f"Error: Patch not found at s3://{bucket}/{patch_key}", file=sys.stderr) sys.exit(1) - diff_content = Path(diff_path).read_text() - print(f"=== Diff loaded ({len(diff_content)} chars) ===") + diff_content = Path(local_patch).read_text() + print(f"=== Patch loaded ({len(diff_content)} chars) ===") - # Step 2: Clone repository (shallow, with token auth for push) + # Step 2: Clone repository (with token auth for push) print("=== Cloning repository ===") repo_dir = "/workspace/repo" if repo_url.startswith("https://github.com/"): @@ -116,61 +156,42 @@ async def main() -> None: # Step 3: Create a new branch ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - branch_name = f"feat/{ts}-ui-proposal-{proposal_index}" + branch_name = f"feat/{ts}-ui-session-{session_id[:8]}" print(f"=== Creating branch: {branch_name} ===") - subprocess.run( - ["git", "checkout", "-b", branch_name], - cwd=repo_dir, - check=True, - ) + subprocess.run(["git", "checkout", "-b", branch_name], cwd=repo_dir, check=True) - # Step 4: Apply the diff using git am (format-patch output) - print("=== Applying diff ===") + # Step 4: Apply the patch + print("=== Applying patch ===") result = subprocess.run( - ["git", "am", "--3way", diff_path], - cwd=repo_dir, - capture_output=True, - text=True, + ["git", "am", "--3way", local_patch], + cwd=repo_dir, capture_output=True, text=True, ) if result.returncode != 0: print(f"git am failed: {result.stderr}", file=sys.stderr) print("Falling back to git apply...") - subprocess.run( - ["git", "am", "--abort"], cwd=repo_dir, capture_output=True - ) - subprocess.run( - ["git", "apply", "--3way", diff_path], - cwd=repo_dir, - check=True, - ) + subprocess.run(["git", "am", "--abort"], cwd=repo_dir, capture_output=True) + subprocess.run(["git", "apply", "--3way", local_patch], cwd=repo_dir, check=True) subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True) subprocess.run( - ["git", "commit", "-m", f"feat: apply UI proposal {proposal_index}"], - cwd=repo_dir, - check=True, + ["git", "commit", "-m", f"feat: apply UI changes from session {session_id[:8]}"], + cwd=repo_dir, check=True, ) - # Step 5: Unshallow for push compatibility - subprocess.run( - ["git", "fetch", "--unshallow"], - cwd=repo_dir, - capture_output=True, - ) + # Step 5: Unshallow for push + subprocess.run(["git", "fetch", "--unshallow"], cwd=repo_dir, capture_output=True) - # Step 6: Use Agent SDK to push and create PR with good description + # Step 6: Push and create PR via Agent SDK print("=== Creating PR via Agent SDK ===") - # Provide first 10000 chars of diff for context diff_summary = diff_content[:10000] pr_url = await push_and_create_pr(repo_dir, branch_name, branch, diff_summary) if pr_url: print(f"PR created: {pr_url}") - pr_url_file = f"{artifact_dir}/pr_url.txt" - Path(pr_url_file).write_text(pr_url) - - print("===ARTIFACT:pr_url.txt:START===") - print(pr_url) - print("===ARTIFACT:pr_url.txt:END===") + pr_url_key = ( + f"sessions/{session_id}/iterations/{iteration_index}" + f"/proposals/{proposal_index}/pr_url.txt" + ) + s3_upload_text(s3, bucket, pr_url_key, pr_url) else: print("Error: Failed to extract PR URL from agent output", file=sys.stderr) sys.exit(1) diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh index 458a389..b8eac72 100644 --- a/docker/worker-entrypoint.sh +++ b/docker/worker-entrypoint.sh @@ -3,7 +3,7 @@ set -e echo "=== Worker Starting ===" echo "Mode: ${WORKER_MODE}" -echo "Job ID: ${JOB_ID}" +echo "Session: ${SESSION_ID} Iter: ${ITERATION_INDEX}" echo "Repository: ${REPO_URL}" if [ -z "$WORKER_MODE" ]; then diff --git a/docker/worker-implement.py b/docker/worker-implement.py index 557380b..d43ab3f 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -1,31 +1,73 @@ #!/usr/bin/env python3 -"""Implementation Worker: Clones repo, implements a design proposal via Claude Agent SDK, takes after screenshot.""" +"""Implementation Worker: Clones repo, implements a design proposal via Claude Agent SDK, takes after screenshot. + +Session-based: Reads/writes all artifacts via S3. No push, no hostPath, no stdout embedding. +Generates cumulative patches (base_branch → all changes squashed into one format-patch). +""" import asyncio import json import os import subprocess import sys -from datetime import datetime, timezone from pathlib import Path +import boto3 +from botocore.config import Config from claude_agent_sdk import ClaudeAgentOptions, query -def generate_branch_name(proposal_index: int | str) -> str: - """Generate a unique branch name like feat/20260217-143052-claude_idea-1.""" - ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - return f"feat/{ts}-claude_idea-{proposal_index}" +def get_s3_client(): + kwargs = { + "service_name": "s3", + "region_name": os.environ.get("S3_REGION", "us-east-1"), + "config": Config(signature_version="s3v4"), + } + endpoint = os.environ.get("S3_ENDPOINT_URL") + if endpoint: + kwargs["endpoint_url"] = endpoint + kwargs["aws_access_key_id"] = os.environ.get("S3_ACCESS_KEY", "minioadmin") + kwargs["aws_secret_access_key"] = os.environ.get("S3_SECRET_KEY", "minioadmin") + return boto3.client(**kwargs) -def create_branch(repo_dir: str, branch_name: str) -> None: - """Create and checkout a new branch.""" - subprocess.run( - ["git", "checkout", "-b", branch_name], - cwd=repo_dir, - check=True, - ) - print(f"Created branch: {branch_name}") +def s3_download(s3, bucket, key, local_path): + try: + s3.download_file(bucket, key, local_path) + return True + except Exception as e: + print(f"S3 download failed for {key}: {e}", file=sys.stderr) + return False + + +def s3_upload_file(s3, bucket, key, local_path, content_type="application/octet-stream"): + for attempt in range(3): + try: + s3.upload_file( + local_path, bucket, key, + ExtraArgs={"ContentType": content_type}, + ) + print(f"Uploaded {key}") + return + except Exception as e: + print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + if attempt == 2: + raise + + +def s3_upload_text(s3, bucket, key, text, content_type="text/plain"): + for attempt in range(3): + try: + s3.put_object( + Bucket=bucket, Key=key, + Body=text.encode("utf-8"), ContentType=content_type, + ) + print(f"Uploaded {key}") + return + except Exception as e: + print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr) + if attempt == 2: + raise async def implement_proposal( @@ -78,44 +120,97 @@ async def implement_proposal( async def main() -> None: # Read environment variables - job_id = os.environ["JOB_ID"] + session_id = os.environ["SESSION_ID"] + iteration_index = int(os.environ["ITERATION_INDEX"]) repo_url = os.environ["REPO_URL"] branch = os.environ.get("BRANCH", "main") proposal_index = os.environ["PROPOSAL_INDEX"] - artifact_dir = f"/artifacts/{job_id}/proposals/{proposal_index}" + selected_proposal_index = os.environ.get("SELECTED_PROPOSAL_INDEX") + + bucket = os.environ["S3_BUCKET"] + s3 = get_s3_client() + tmp_dir = "/tmp/artifacts" repo_dir = "/workspace/repo" - os.makedirs(artifact_dir, exist_ok=True) + os.makedirs(tmp_dir, exist_ok=True) - # Read proposal plan from file (avoids K8s env var size limits) - plan_file = f"/artifacts/{job_id}/proposal_{proposal_index}_plan.txt" - if Path(plan_file).exists(): - proposal_plan = Path(plan_file).read_text() + s3_prefix = ( + f"sessions/{session_id}/iterations/{iteration_index}" + f"/proposals/{proposal_index}" + ) + + # Step 1: Download proposal plan from S3 + plan_key = f"{s3_prefix}/plan.json" + local_plan = f"{tmp_dir}/plan.json" + print(f"=== Downloading plan from S3: {plan_key} ===") + if s3_download(s3, bucket, plan_key, local_plan): + proposal_plan = Path(local_plan).read_text() else: + # Fallback to env var (for backward compat during transition) proposal_plan = os.environ.get("PROPOSAL_PLAN", "") if not proposal_plan: print("Error: No proposal plan provided", file=sys.stderr) sys.exit(1) - # Step 1: Clone repository + # Step 2: Clone repository print("=== Cloning repository ===") subprocess.run( ["git", "clone", "--depth", "1", "--branch", branch, repo_url, repo_dir], check=True, ) - # Step 2: Create feature branch - branch_name = generate_branch_name(proposal_index) - print(f"=== Creating branch: {branch_name} ===") - create_branch(repo_dir, branch_name) + # Step 2.5: Apply cumulative patch from previous iteration (if iter > 0) + if iteration_index > 0 and selected_proposal_index is not None: + prev_iter = iteration_index - 1 + patch_key = ( + f"sessions/{session_id}/iterations/{prev_iter}" + f"/proposals/{selected_proposal_index}/changes.diff" + ) + local_patch = f"{tmp_dir}/parent.diff" + print(f"=== Downloading patch from S3: {patch_key} ===") + if s3_download(s3, bucket, patch_key, local_patch): + print("=== Applying cumulative patch ===") + result = subprocess.run( + ["git", "am", "--3way", local_patch], + cwd=repo_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"git am failed: {result.stderr}", file=sys.stderr) + print("Falling back to git apply...") + subprocess.run( + ["git", "am", "--abort"], cwd=repo_dir, capture_output=True + ) + subprocess.run( + ["git", "apply", "--3way", local_patch], + cwd=repo_dir, + check=True, + ) + subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True) + subprocess.run( + ["git", "commit", "-m", "apply base patch"], + cwd=repo_dir, + check=True, + ) + print("=== Cumulative patch applied successfully ===") + else: + print( + f"WARNING: Patch not found at s3://{bucket}/{patch_key}", + file=sys.stderr, + ) + + # Step 3: Create local branch and implement the proposal + branch_name = f"local/session-{session_id[:8]}-iter{iteration_index}-prop{proposal_index}" + subprocess.run(["git", "checkout", "-b", branch_name], cwd=repo_dir, check=True) + print(f"=== Created local branch: {branch_name} ===") - # Step 3: Implement the proposal + take screenshot via Claude Agent SDK - screenshot_path = f"{artifact_dir}/after.png" + screenshot_path = f"{tmp_dir}/after.png" print("=== Implementing design proposal ===") await implement_proposal(repo_dir, proposal_plan, screenshot_path) - # Step 4: Commit all changes and generate patch - print("=== Saving changes diff ===") + # Step 4: Generate cumulative patch (squash everything since base_branch) + print("=== Generating cumulative patch ===") # Parse proposal plan to get title for commit message try: plan_data = json.loads(proposal_plan) @@ -123,37 +218,48 @@ async def main() -> None: except (json.JSONDecodeError, AttributeError): commit_title = f"variant-{proposal_index}" - # Stage all changes (new, modified, deleted files) + # Stage all changes subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True) - # Commit with proposal title as message + # Squash all changes into a single commit relative to base_branch + subprocess.run( + ["git", "reset", "--soft", f"origin/{branch}"], + cwd=repo_dir, + check=True, + ) subprocess.run( ["git", "commit", "-m", f"feat: {commit_title}"], cwd=repo_dir, check=True, ) - # Generate patch using format-patch (stable, portable format) + # Generate format-patch (cumulative: base_branch → HEAD) patch_result = subprocess.run( ["git", "format-patch", "-1", "HEAD", "--stdout"], capture_output=True, text=True, cwd=repo_dir, + check=True, ) - with open(f"{artifact_dir}/changes.diff", "w") as f: + local_diff = f"{tmp_dir}/changes.diff" + with open(local_diff, "w") as f: f.write(patch_result.stdout) - # Output artifacts to stdout with markers so Backend can extract from pod logs - import base64 - - diff_file = Path(f"{artifact_dir}/changes.diff") - if diff_file.exists(): - print("===ARTIFACT:changes.diff:START===") - print(diff_file.read_text()) - print("===ARTIFACT:changes.diff:END===") - - after_path = Path(f"{artifact_dir}/after.png") - if after_path.exists(): - b64 = base64.b64encode(after_path.read_bytes()).decode() - print("===ARTIFACT:after.png:START===") - print(b64) - print("===ARTIFACT:after.png:END===") + # Step 5: Upload results to S3 + print("=== Uploading results to S3 ===") + + # Upload after screenshot + if Path(screenshot_path).exists(): + s3_upload_file( + s3, bucket, + f"{s3_prefix}/after.png", + screenshot_path, + content_type="image/png", + ) + + # Upload cumulative patch + s3_upload_text( + s3, bucket, + f"{s3_prefix}/changes.diff", + patch_result.stdout, + content_type="text/plain", + ) print("=== Implementation Complete ===") diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index 307fdd3..6c022a9 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -58,8 +58,8 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \ tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/* -# Install Claude Agent SDK -RUN pip install --no-cache-dir claude-agent-sdk +# Install Claude Agent SDK and boto3 (S3 access) +RUN pip install --no-cache-dir claude-agent-sdk==0.1.44 boto3==1.42.58 # Configure git identity for commits inside worker pods RUN git config --global user.name "Claude Code" && \ diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index bdd9af2..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' - -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

Click on the Vite and React logos to learn more

- - ) -} - -export default App diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f425c32..c1871dc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,29 +1,29 @@ import { useState, useEffect, useCallback } from 'react' import { Outlet } from 'react-router-dom' -import { listJobs, type Job } from '../services/api' +import { listSessions, type Session } from '../services/api' import Sidebar from './Sidebar' type LayoutContext = { - refreshJobs: () => void + refreshSessions: () => void } export default function Layout() { const [sidebarOpen, setSidebarOpen] = useState(true) - const [jobs, setJobs] = useState([]) + const [sessions, setSessions] = useState([]) - const refreshJobs = useCallback(() => { - listJobs() - .then(setJobs) + const refreshSessions = useCallback(() => { + listSessions() + .then(setSessions) .catch(() => {}) }, []) useEffect(() => { - refreshJobs() - }, [refreshJobs]) + refreshSessions() + }, [refreshSessions]) return (
- setSidebarOpen(false)} /> + setSidebarOpen(false)} />
- +
) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 45d8b4c..a581893 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,15 +1,26 @@ import { useNavigate, useLocation } from 'react-router-dom' -import { type Job } from '../services/api' +import { type Session } from '../services/api' import StatusBadge from './StatusBadge' import logoIcon from '../assets/claude_desgin.png' type SidebarProps = { - jobs: Job[] + sessions: Session[] isOpen: boolean onToggle: () => void } -export default function Sidebar({ jobs, isOpen, onToggle }: SidebarProps) { +function getSessionStatus(session: Session): string { + if (session.iterations.length === 0) return 'pending' + const latest = session.iterations[session.iterations.length - 1] + return latest.status +} + +function getSessionInstruction(session: Session): string { + if (session.iterations.length === 0) return '' + return session.iterations[0].instruction +} + +export default function Sidebar({ sessions, isOpen, onToggle }: SidebarProps) { const navigate = useNavigate() const location = useLocation() @@ -102,17 +113,19 @@ export default function Sidebar({ jobs, isOpen, onToggle }: SidebarProps) { cursor: 'pointer', }} > - + New Job + + New Session
- {jobs.map((job) => { - const isActive = location.pathname === `/jobs/${job.id}` + {sessions.map((session) => { + const isActive = location.pathname === `/sessions/${session.id}` + const instruction = getSessionInstruction(session) + const status = getSessionStatus(session) return (
navigate(`/jobs/${job.id}`)} + key={session.id} + onClick={() => navigate(`/sessions/${session.id}`)} style={{ padding: '0 12px', height: '63px', @@ -136,7 +149,7 @@ export default function Sidebar({ jobs, isOpen, onToggle }: SidebarProps) { textOverflow: 'ellipsis', }} > - {job.repo_url.replace('https://github.com/', '')} + {session.repo_url.replace('https://github.com/', '')}
- {job.instruction.substring(0, 80)} - {job.instruction.length > 80 ? '...' : ''} + {instruction.substring(0, 80)} + {instruction.length > 80 ? '...' : ''}
- + ) })} diff --git a/frontend/src/hooks/useJobPolling.ts b/frontend/src/hooks/useJobPolling.ts deleted file mode 100644 index df2cef7..0000000 --- a/frontend/src/hooks/useJobPolling.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { getJob, type Job } from '../services/api' - -const TERMINAL_STATUSES = ['analyzed', 'completed', 'failed'] - -export function useJobPolling(jobId: string | null, intervalMs: number = 3000) { - const [job, setJob] = useState(null) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const timerRef = useRef(null) - - const stopPolling = useCallback(() => { - if (timerRef.current !== null) { - clearInterval(timerRef.current) - timerRef.current = null - } - }, []) - - useEffect(() => { - if (!jobId) { - return - } - - let isFirstPoll = true - const poll = async () => { - if (isFirstPoll) { - setIsLoading(true) - setError(null) - isFirstPoll = false - } - try { - const data = await getJob(jobId) - setJob(data) - setIsLoading(false) - - if (TERMINAL_STATUSES.includes(data.status)) { - stopPolling() - } - } catch (e) { - setError((e as Error).message) - setIsLoading(false) - } - } - - poll() - timerRef.current = window.setInterval(poll, intervalMs) - - return stopPolling - }, [jobId, intervalMs, stopPolling]) - - const refetch = useCallback(async () => { - if (!jobId) return - try { - const data = await getJob(jobId) - setJob(data) - } catch (e) { - setError((e as Error).message) - } - }, [jobId]) - - return { job, error, isLoading, refetch } -} diff --git a/frontend/src/hooks/useLayoutContext.ts b/frontend/src/hooks/useLayoutContext.ts index 0b4ff77..c8b8fdd 100644 --- a/frontend/src/hooks/useLayoutContext.ts +++ b/frontend/src/hooks/useLayoutContext.ts @@ -1,7 +1,7 @@ import { useOutletContext } from 'react-router-dom' type LayoutContext = { - refreshJobs: () => void + refreshSessions: () => void } export function useLayoutContext() { diff --git a/frontend/src/hooks/useSessionPolling.ts b/frontend/src/hooks/useSessionPolling.ts new file mode 100644 index 0000000..33c07c9 --- /dev/null +++ b/frontend/src/hooks/useSessionPolling.ts @@ -0,0 +1,72 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { getSession, type Session } from '../services/api' + +function isTerminal(session: Session): boolean { + if (session.iterations.length === 0) return false + const latest = session.iterations[session.iterations.length - 1] + // Terminal: latest iteration completed or failed, and no proposals are still implementing + if (latest.status === 'failed') return true + if (latest.status === 'completed') { + const hasImplementing = latest.proposals.some((p) => p.status === 'implementing') + return !hasImplementing + } + return false +} + +export function useSessionPolling(sessionId: string | null, intervalMs: number = 3000) { + const [session, setSession] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const timerRef = useRef(null) + + const stopPolling = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => { + if (!sessionId) { + return + } + + let isFirstPoll = true + const poll = async () => { + if (isFirstPoll) { + setIsLoading(true) + setError(null) + isFirstPoll = false + } + try { + const data = await getSession(sessionId) + setSession(data) + setIsLoading(false) + + if (isTerminal(data)) { + stopPolling() + } + } catch (e) { + setError((e as Error).message) + setIsLoading(false) + } + } + + poll() + timerRef.current = window.setInterval(poll, intervalMs) + + return stopPolling + }, [sessionId, intervalMs, stopPolling]) + + const refetch = useCallback(async () => { + if (!sessionId) return + try { + const data = await getSession(sessionId) + setSession(data) + } catch (e) { + setError((e as Error).message) + } + }, [sessionId]) + + return { session, error, isLoading, refetch } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e202e15..9ca5906 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom' import './index.css' import Layout from './components/Layout' import Dashboard from './pages/Dashboard' -import JobDetail from './pages/JobDetail' +import SessionDetail from './pages/SessionDetail' import Settings from './pages/Settings' import ErrorPage from './pages/ErrorPage' @@ -15,7 +15,7 @@ const router = createBrowserRouter([ errorElement: , children: [ { index: true, element: }, - { path: 'jobs/:jobId', element: }, + { path: 'sessions/:sessionId', element: }, { path: 'settings', element: }, ], }, diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx deleted file mode 100644 index c4d4740..0000000 --- a/frontend/src/pages/About.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Link } from 'react-router-dom' - -export default function About() { - return ( -
-

About Page

-

This is an example page using React Router v7.

-
- - Go back to Home - -
-
- ) -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7b1cfbe..f4d8646 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { createJob, getSettings } from '../services/api' +import { createSession, getSettings } from '../services/api' import { useLayoutContext } from '../hooks/useLayoutContext' export default function Dashboard() { const navigate = useNavigate() - const { refreshJobs } = useLayoutContext() + const { refreshSessions } = useLayoutContext() const [instruction, setInstruction] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) @@ -35,9 +35,13 @@ export default function Dashboard() { setSubmitError(null) try { - const job = await createJob({ repo_url: repoUrl, branch: branch || 'main', instruction }) - refreshJobs() - navigate(`/jobs/${job.id}`) + const session = await createSession({ + repo_url: repoUrl, + branch: branch || 'main', + instruction, + }) + refreshSessions() + navigate(`/sessions/${session.id}`) } catch (err) { setSubmitError((err as Error).message) } finally { @@ -132,7 +136,7 @@ export default function Dashboard() { cursor: isSubmitting || !repoUrl ? 'not-allowed' : 'pointer', }} > - {isSubmitting ? 'Creating...' : 'Create Job'} + {isSubmitting ? 'Creating...' : 'Start Session'} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx deleted file mode 100644 index cf736ec..0000000 --- a/frontend/src/pages/Home.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react' -import { Link } from 'react-router-dom' -import reactLogo from '../assets/react.svg' -import viteLogo from '/vite.svg' -import '../App.css' - -export default function Home() { - const [count, setCount] = useState(0) - - return ( - <> - -

Vite + React

-
- -

- Edit src/pages/Home.tsx and save to test HMR -

-
-

Click on the Vite and React logos to learn more

-
- - Go to About - -
- - ) -} diff --git a/frontend/src/pages/JobDetail.tsx b/frontend/src/pages/JobDetail.tsx deleted file mode 100644 index fd962fb..0000000 --- a/frontend/src/pages/JobDetail.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useState, useCallback, useEffect, useRef } from 'react' -import { useParams } from 'react-router-dom' -import { useJobPolling } from '../hooks/useJobPolling' -import { createPR, getJob } from '../services/api' -import StatusBadge from '../components/StatusBadge' -import ProposalCard from '../components/ProposalCard' - -export default function JobDetail() { - const { jobId } = useParams<{ jobId: string }>() - const { job, error, isLoading, refetch } = useJobPolling(jobId ?? null) - const [selectedIndex, setSelectedIndex] = useState(null) - const [prLoading, setPrLoading] = useState(false) - const [prError, setPrError] = useState(null) - const prPollRef = useRef | null>(null) - - const toggleProposal = useCallback((index: number) => { - setSelectedIndex((prev) => (prev === index ? null : index)) - }, []) - - const handleCreatePR = useCallback(async () => { - if (selectedIndex === null || !jobId) return - setPrLoading(true) - setPrError(null) - try { - await createPR(jobId, selectedIndex) - refetch() - } catch (e) { - setPrError((e as Error).message) - } finally { - setPrLoading(false) - } - }, [selectedIndex, jobId, refetch]) - - // Poll for PR status changes when a proposal is "creating" - useEffect(() => { - if (!job || !jobId) return - - const hasCreating = job.proposals.some((p) => p.pr_status === 'creating') - if (hasCreating && !prPollRef.current) { - prPollRef.current = setInterval(async () => { - try { - const updated = await getJob(jobId) - const stillCreating = updated.proposals.some((p) => p.pr_status === 'creating') - if (!stillCreating) { - if (prPollRef.current) { - clearInterval(prPollRef.current) - prPollRef.current = null - } - refetch() - } - } catch { - // Ignore polling errors - } - }, 3000) - } - - if (!hasCreating && prPollRef.current) { - clearInterval(prPollRef.current) - prPollRef.current = null - } - - return () => { - if (prPollRef.current) { - clearInterval(prPollRef.current) - prPollRef.current = null - } - } - }, [job, jobId, refetch]) - - if (!jobId) return

Invalid job ID

- - if (isLoading && !job) { - return ( -
-

Loading...

-
- ) - } - - if (error) { - return ( -
-

Error: {error}

-
- ) - } - - if (!job) return null - - const isInProgress = ['pending', 'analyzing', 'implementing'].includes(job.status) - const completedProposals = job.proposals.filter( - (p) => p.status === 'completed' && p.after_screenshot_url, - ) - - const selectedProposal = - selectedIndex !== null - ? completedProposals.find((p) => p.proposal_index === selectedIndex) - : null - - return ( -
-
-
-

Job Detail

- -
-
-
Repository: {job.repo_url}
-
Branch: {job.branch}
-
Instruction: {job.instruction}
-
-
- - {job.error_message && ( -
- {job.error_message} -
- )} - - {isInProgress && ( -
- {job.status === 'pending' && 'Job is queued...'} - {job.status === 'analyzing' && 'Analyzing repository and generating proposals...'} - {job.status === 'implementing' && - 'Implementing all proposals... This may take a few minutes.'} -
- )} - - {job.status === 'completed' && ( - <> - {job.before_screenshot_url && ( -
-

Before

- Before -
- )} - - {completedProposals.length > 0 && ( -
-

- Select a design ({completedProposals.length} proposals) -

-
- {completedProposals.map((proposal) => ( - - ))} -
- - {selectedProposal && ( -
- {selectedProposal.pr_url && ( - - View PR - - )} - {selectedProposal.pr_status === 'creating' && ( -
- Creating PR... -
- )} - {selectedProposal.pr_status === 'failed' && ( -
-

- PR creation failed -

- -
- )} - {!selectedProposal.pr_status && !selectedProposal.pr_url && ( - - )} - {prError && ( -

- {prError} -

- )} -
- )} -
- )} - - )} -
- ) -} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx new file mode 100644 index 0000000..7c4b9dc --- /dev/null +++ b/frontend/src/pages/SessionDetail.tsx @@ -0,0 +1,457 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useParams } from 'react-router-dom' +import { useSessionPolling } from '../hooks/useSessionPolling' +import { createPR, iterate, getSession } from '../services/api' +import type { Iteration, Proposal } from '../services/api' +import { useLayoutContext } from '../hooks/useLayoutContext' +import StatusBadge from '../components/StatusBadge' +import ProposalCard from '../components/ProposalCard' + +export default function SessionDetail() { + const { sessionId } = useParams<{ sessionId: string }>() + const { refreshSessions } = useLayoutContext() + const { session, error, isLoading, refetch } = useSessionPolling(sessionId ?? null) + const [selectedIndex, setSelectedIndex] = useState(null) + const [prLoading, setPrLoading] = useState(false) + const [prError, setPrError] = useState(null) + const prPollRef = useRef | null>(null) + const [showContinueForm, setShowContinueForm] = useState(false) + const [continueInstruction, setContinueInstruction] = useState('') + const [continueLoading, setContinueLoading] = useState(false) + const [continueError, setContinueError] = useState(null) + + // Reset selection when session changes + useEffect(() => { + setSelectedIndex(null) + setShowContinueForm(false) + setContinueInstruction('') + }, [sessionId]) + + const latestIteration: Iteration | null = + session && session.iterations.length > 0 + ? session.iterations[session.iterations.length - 1] + : null + + const toggleProposal = useCallback((index: number) => { + setSelectedIndex((prev) => (prev === index ? null : index)) + }, []) + + const handleCreatePR = useCallback(async () => { + if (selectedIndex === null || !sessionId || !latestIteration) return + setPrLoading(true) + setPrError(null) + try { + await createPR(sessionId, latestIteration.iteration_index, selectedIndex) + refetch() + } catch (e) { + setPrError((e as Error).message) + } finally { + setPrLoading(false) + } + }, [selectedIndex, sessionId, latestIteration, refetch]) + + const handleContinue = useCallback(async () => { + if (selectedIndex === null || !sessionId || !continueInstruction.trim()) return + setContinueLoading(true) + setContinueError(null) + try { + await iterate(sessionId, selectedIndex, continueInstruction) + refreshSessions() + refetch() + setShowContinueForm(false) + setContinueInstruction('') + setSelectedIndex(null) + } catch (e) { + setContinueError((e as Error).message) + } finally { + setContinueLoading(false) + } + }, [selectedIndex, sessionId, continueInstruction, refreshSessions, refetch]) + + // Poll for PR status changes + useEffect(() => { + if (!session || !sessionId || !latestIteration) return + + const hasCreating = latestIteration.proposals.some((p) => p.pr_status === 'creating') + if (hasCreating && !prPollRef.current) { + prPollRef.current = setInterval(async () => { + try { + const updated = await getSession(sessionId) + const latestIter = updated.iterations[updated.iterations.length - 1] + const stillCreating = latestIter?.proposals.some((p) => p.pr_status === 'creating') + if (!stillCreating) { + if (prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + refetch() + } + } catch { + // Ignore polling errors + } + }, 3000) + } + + if (!hasCreating && prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + + return () => { + if (prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + } + }, [session, sessionId, latestIteration, refetch]) + + if (!sessionId) return

Invalid session ID

+ + if (isLoading && !session) { + return ( +
+

Loading...

+
+ ) + } + + if (error) { + return ( +
+

Error: {error}

+
+ ) + } + + if (!session) return null + + const iterationStatus = latestIteration?.status ?? 'pending' + const isInProgress = ['pending', 'analyzing', 'implementing'].includes(iterationStatus) + const completedProposals = + latestIteration?.proposals.filter((p) => p.status === 'completed' && p.after_screenshot_url) ?? + [] + + const selectedProposal: Proposal | null = + selectedIndex !== null + ? (completedProposals.find((p) => p.proposal_index === selectedIndex) ?? null) + : null + + return ( +
+ {/* Session header */} +
+
+

Session Detail

+ +
+
+
Repository: {session.repo_url}
+
Branch: {session.base_branch}
+
+
+ + {/* Iteration timeline */} + {session.iterations.length > 1 && ( +
+

+ Iterations ({session.iterations.length}) +

+
+ {session.iterations.map((iter, idx) => ( +
+ #{idx + 1}: {iter.instruction.substring(0, 40)} + {iter.instruction.length > 40 ? '...' : ''} + {iter.selected_proposal_index !== null && ( + + (selected #{iter.selected_proposal_index + 1}) + + )} +
+ ))} +
+
+ )} + + {/* Current instruction */} + {latestIteration && ( +
+ + Instruction (iteration #{latestIteration.iteration_index + 1}): + +
{latestIteration.instruction}
+
+ )} + + {/* Error message */} + {latestIteration?.error_message && ( +
+ {latestIteration.error_message} +
+ )} + + {/* Progress indicator */} + {isInProgress && ( +
+ {iterationStatus === 'pending' && 'Queued...'} + {iterationStatus === 'analyzing' && 'Analyzing repository and generating proposals...'} + {iterationStatus === 'implementing' && + 'Implementing all proposals... This may take a few minutes.'} +
+ )} + + {/* Completed: show proposals */} + {iterationStatus === 'completed' && latestIteration && ( + <> + {latestIteration.before_screenshot_url && ( +
+

Before

+
+ Before +
+
+ )} + + {completedProposals.length > 0 && ( +
+

+ Select a design ({completedProposals.length} proposals) +

+
+ {completedProposals.map((proposal) => ( + + ))} +
+ + {selectedProposal && ( +
+ {/* PR URL */} + {selectedProposal.pr_url && ( + + View PR + + )} + + {/* PR creating */} + {selectedProposal.pr_status === 'creating' && ( +
+ Creating PR... +
+ )} + + {/* PR failed */} + {selectedProposal.pr_status === 'failed' && ( +
+

+ PR creation failed +

+ +
+ )} + + {/* Create PR button */} + {!selectedProposal.pr_status && !selectedProposal.pr_url && ( + + )} + + {prError && ( +

+ {prError} +

+ )} + + {/* Continue Refining */} +
+ +
+ + {showContinueForm && ( +
+