Spaces:
Running
Running
| """Workflow persistence repository (Batch C1). | |
| Thin data-access over the ORM for the Continuous Build tables. Every write stamps ``owner_id`` | |
| so row-level security isolates rows per user; callers run inside ``session_scope(user_id=...)`` | |
| so the same user id is set as the RLS GUC. | |
| """ | |
| from __future__ import annotations | |
| import uuid | |
| from sqlalchemy import func, select | |
| from sqlalchemy.orm import Session | |
| from app.db.orm import ( | |
| Artifact, | |
| BuildBatch, | |
| BundleVersion, | |
| MatrixCommit, | |
| Project, | |
| PromptVersion, | |
| RunEvent, | |
| User, | |
| ValidationFinding, | |
| ValidationRun, | |
| ) | |
| def _new_id() -> str: | |
| return str(uuid.uuid4()) | |
| class WorkflowRepository: | |
| def __init__(self, session: Session) -> None: | |
| self.session = session | |
| # --- users ------------------------------------------------------------- | |
| def upsert_user(self, user_id: str, *, email: str | None = None, display_name: str | None = None) -> User: | |
| user = self.session.get(User, user_id) | |
| if user is None: | |
| user = User(id=user_id, email=email, display_name=display_name, plan="free") | |
| self.session.add(user) | |
| else: | |
| if email is not None: | |
| user.email = email | |
| if display_name is not None: | |
| user.display_name = display_name | |
| self.session.flush() | |
| return user | |
| # --- upsert (Track L2 sync: insert-or-update by primary key) ----------- | |
| def upsert(self, obj): | |
| merged = self.session.merge(obj) | |
| self.session.flush() | |
| return merged | |
| # --- projects ---------------------------------------------------------- | |
| def create_project(self, *, owner_id: str, title: str, slug: str, description: str = "") -> Project: | |
| project = Project( | |
| id=_new_id(), owner_id=owner_id, title=title, slug=slug, description=description | |
| ) | |
| self.session.add(project) | |
| self.session.flush() | |
| return project | |
| def get_project(self, project_id: str) -> Project | None: | |
| return self.session.get(Project, project_id) | |
| def list_projects(self, owner_id: str) -> list[Project]: | |
| # RLS already restricts to the current user; the filter is belt-and-suspenders. | |
| return list( | |
| self.session.scalars(select(Project).where(Project.owner_id == owner_id)).all() | |
| ) | |
| # --- versions ---------------------------------------------------------- | |
| def create_version( | |
| self, | |
| *, | |
| owner_id: str, | |
| project_id: str, | |
| version_label: str, | |
| title: str, | |
| requirements_md: str = "", | |
| parent_version_id: str | None = None, | |
| blueprint_artifact_id: str | None = None, | |
| ) -> BundleVersion: | |
| version = BundleVersion( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| project_id=project_id, | |
| parent_version_id=parent_version_id, | |
| version_label=version_label, | |
| title=title, | |
| requirements_md=requirements_md, | |
| blueprint_artifact_id=blueprint_artifact_id, | |
| created_by=owner_id, | |
| ) | |
| self.session.add(version) | |
| self.session.flush() | |
| return version | |
| def get_version(self, version_id: str) -> BundleVersion | None: | |
| return self.session.get(BundleVersion, version_id) | |
| def list_versions(self, project_id: str) -> list[BundleVersion]: | |
| return list( | |
| self.session.scalars( | |
| select(BundleVersion) | |
| .where(BundleVersion.project_id == project_id) | |
| .order_by(BundleVersion.created_at) | |
| ).all() | |
| ) | |
| def latest_version(self, project_id: str) -> BundleVersion | None: | |
| versions = self.list_versions(project_id) | |
| return versions[-1] if versions else None | |
| # --- batches ----------------------------------------------------------- | |
| def create_batch( | |
| self, | |
| *, | |
| owner_id: str, | |
| version_id: str, | |
| ordinal: int, | |
| title: str, | |
| goal_md: str, | |
| change_type: str, | |
| parent_commit_id: str | None = None, | |
| ) -> BuildBatch: | |
| batch = BuildBatch( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| version_id=version_id, | |
| ordinal=ordinal, | |
| title=title, | |
| goal_md=goal_md, | |
| change_type=change_type, | |
| parent_commit_id=parent_commit_id, | |
| requested_by=owner_id, | |
| ) | |
| self.session.add(batch) | |
| self.session.flush() | |
| return batch | |
| def get_batch(self, batch_id: str) -> BuildBatch | None: | |
| return self.session.get(BuildBatch, batch_id) | |
| def list_batches(self, version_id: str) -> list[BuildBatch]: | |
| return list( | |
| self.session.scalars( | |
| select(BuildBatch) | |
| .where(BuildBatch.version_id == version_id) | |
| .order_by(BuildBatch.ordinal) | |
| ).all() | |
| ) | |
| def set_batch_status(self, batch: BuildBatch, status: str) -> BuildBatch: | |
| batch.status = status | |
| self.session.flush() | |
| return batch | |
| def next_batch_ordinal(self, version_id: str) -> int: | |
| current = self.session.scalar( | |
| select(func.max(BuildBatch.ordinal)).where(BuildBatch.version_id == version_id) | |
| ) | |
| return (current or 0) + 1 | |
| # --- prompt versions --------------------------------------------------- | |
| def create_prompt_version( | |
| self, | |
| *, | |
| owner_id: str, | |
| batch_id: str, | |
| coder: str, | |
| prompt_text: str, | |
| constraints: dict | None = None, | |
| artifact_id: str | None = None, | |
| ) -> PromptVersion: | |
| prompt = PromptVersion( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| batch_id=batch_id, | |
| coder=coder, | |
| prompt_text=prompt_text, | |
| constraints=constraints or {}, | |
| artifact_id=artifact_id, | |
| ) | |
| self.session.add(prompt) | |
| self.session.flush() | |
| return prompt | |
| def list_prompt_versions(self, batch_id: str) -> list[PromptVersion]: | |
| return list( | |
| self.session.scalars( | |
| select(PromptVersion) | |
| .where(PromptVersion.batch_id == batch_id) | |
| .order_by(PromptVersion.created_at) | |
| ).all() | |
| ) | |
| # --- commits ----------------------------------------------------------- | |
| def create_commit( | |
| self, | |
| *, | |
| owner_id: str, | |
| batch_id: str, | |
| version_id: str, | |
| commit_no: int, | |
| tree_hash: str, | |
| summary: str = "", | |
| validation_status: str = "not-run", | |
| parent_commit_id: str | None = None, | |
| manifest: dict | None = None, | |
| ) -> MatrixCommit: | |
| commit = MatrixCommit( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| batch_id=batch_id, | |
| version_id=version_id, | |
| commit_no=commit_no, | |
| tree_hash=tree_hash, | |
| summary=summary, | |
| validation_status=validation_status, | |
| parent_commit_id=parent_commit_id, | |
| manifest=manifest or {}, | |
| ) | |
| self.session.add(commit) | |
| self.session.flush() | |
| return commit | |
| def get_commit(self, commit_id: str) -> MatrixCommit | None: | |
| return self.session.get(MatrixCommit, commit_id) | |
| def list_commits(self, version_id: str) -> list[MatrixCommit]: | |
| return list( | |
| self.session.scalars( | |
| select(MatrixCommit) | |
| .where(MatrixCommit.version_id == version_id) | |
| .order_by(MatrixCommit.commit_no) | |
| ).all() | |
| ) | |
| def latest_commit(self, version_id: str) -> MatrixCommit | None: | |
| commits = self.list_commits(version_id) | |
| return commits[-1] if commits else None | |
| def next_commit_no(self, version_id: str) -> int: | |
| current = self.session.scalar( | |
| select(func.max(MatrixCommit.commit_no)).where(MatrixCommit.version_id == version_id) | |
| ) | |
| return (current or 0) + 1 | |
| def set_commit_validation_status(self, commit: MatrixCommit, status: str) -> MatrixCommit: | |
| commit.validation_status = status | |
| self.session.flush() | |
| return commit | |
| # --- validation runs + findings --------------------------------------- | |
| def create_validation_run( | |
| self, | |
| *, | |
| owner_id: str, | |
| commit_id: str | None = None, | |
| batch_id: str | None = None, | |
| status: str, | |
| score: int | None = None, | |
| runner: str = "local", | |
| ) -> ValidationRun: | |
| run = ValidationRun( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| commit_id=commit_id, | |
| batch_id=batch_id, | |
| status=status, | |
| score=score, | |
| runner=runner, | |
| ) | |
| self.session.add(run) | |
| self.session.flush() | |
| return run | |
| def get_validation_run(self, run_id: str) -> ValidationRun | None: | |
| return self.session.get(ValidationRun, run_id) | |
| def list_runs_for_version(self, version_id: str) -> list[ValidationRun]: | |
| return list( | |
| self.session.scalars( | |
| select(ValidationRun) | |
| .join(BuildBatch, BuildBatch.id == ValidationRun.batch_id) | |
| .where(BuildBatch.version_id == version_id) | |
| .order_by(ValidationRun.started_at) | |
| ).all() | |
| ) | |
| def finalize_validation_run( | |
| self, | |
| run: ValidationRun, | |
| *, | |
| status: str, | |
| score: int | None = None, | |
| commit_id: str | None = None, | |
| ) -> ValidationRun: | |
| from app.utils.time import utc_now | |
| run.status = status | |
| if score is not None: | |
| run.score = score | |
| if commit_id is not None: | |
| run.commit_id = commit_id | |
| run.ended_at = utc_now() | |
| self.session.flush() | |
| return run | |
| # --- run events (append-only) ----------------------------------------- | |
| def append_run_event( | |
| self, | |
| *, | |
| owner_id: str, | |
| run_id: str, | |
| event_type: str, | |
| payload: dict | None = None, | |
| ) -> RunEvent: | |
| next_seq = ( | |
| self.session.scalar( | |
| select(func.max(RunEvent.seq)).where(RunEvent.run_id == run_id) | |
| ) | |
| or 0 | |
| ) + 1 | |
| event = RunEvent( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| run_id=run_id, | |
| seq=next_seq, | |
| event_type=event_type, | |
| payload=payload or {}, | |
| ) | |
| self.session.add(event) | |
| self.session.flush() | |
| return event | |
| def list_run_events_after(self, run_id: str, after: int = 0) -> list[RunEvent]: | |
| return list( | |
| self.session.scalars( | |
| select(RunEvent) | |
| .where(RunEvent.run_id == run_id, RunEvent.seq > after) | |
| .order_by(RunEvent.seq) | |
| ).all() | |
| ) | |
| def create_validation_finding( | |
| self, | |
| *, | |
| owner_id: str, | |
| validation_run_id: str, | |
| severity: str, | |
| status: str, | |
| check_name: str, | |
| message: str, | |
| file_path: str | None = None, | |
| remediation: str | None = None, | |
| ) -> ValidationFinding: | |
| finding = ValidationFinding( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| validation_run_id=validation_run_id, | |
| severity=severity, | |
| status=status, | |
| check_name=check_name, | |
| message=message, | |
| file_path=file_path, | |
| remediation=remediation, | |
| ) | |
| self.session.add(finding) | |
| self.session.flush() | |
| return finding | |
| def list_findings(self, validation_run_id: str) -> list[ValidationFinding]: | |
| return list( | |
| self.session.scalars( | |
| select(ValidationFinding).where( | |
| ValidationFinding.validation_run_id == validation_run_id | |
| ) | |
| ).all() | |
| ) | |
| # --- artifacts --------------------------------------------------------- | |
| def create_artifact( | |
| self, | |
| *, | |
| owner_id: str, | |
| project_id: str, | |
| artifact_type: str, | |
| storage_key: str, | |
| sha256: str, | |
| size_bytes: int, | |
| version_id: str | None = None, | |
| commit_id: str | None = None, | |
| ) -> Artifact: | |
| artifact = Artifact( | |
| id=_new_id(), | |
| owner_id=owner_id, | |
| project_id=project_id, | |
| version_id=version_id, | |
| commit_id=commit_id, | |
| artifact_type=artifact_type, | |
| storage_key=storage_key, | |
| sha256=sha256, | |
| size_bytes=size_bytes, | |
| ) | |
| self.session.add(artifact) | |
| self.session.flush() | |
| return artifact | |
| def find_artifact_by_key(self, storage_key: str) -> Artifact | None: | |
| return self.session.scalars( | |
| select(Artifact).where(Artifact.storage_key == storage_key).limit(1) | |
| ).first() | |
| def list_artifacts_for_commit(self, commit_id: str) -> list[Artifact]: | |
| return list( | |
| self.session.scalars( | |
| select(Artifact).where(Artifact.commit_id == commit_id) | |
| ).all() | |
| ) | |
| def persist_saved_bundle( | |
| repo: WorkflowRepository, | |
| *, | |
| owner_id: str, | |
| title: str, | |
| slug: str, | |
| requirements_md: str = "", | |
| version_label: str = "v1.0.0", | |
| ) -> tuple[Project, BundleVersion]: | |
| """Save a guest bundle privately: create the owning project and its initial version. | |
| This is what the "Save privately" action persists, so a saved bundle survives a restart. | |
| """ | |
| repo.upsert_user(owner_id) | |
| project = repo.create_project(owner_id=owner_id, title=title, slug=slug) | |
| version = repo.create_version( | |
| owner_id=owner_id, | |
| project_id=project.id, | |
| version_label=version_label, | |
| title=title, | |
| requirements_md=requirements_md, | |
| ) | |
| return project, version | |
| __all__ = ["WorkflowRepository", "persist_saved_bundle"] | |