diff --git a/Makefile b/Makefile index 9b4a16aa..57828c46 100644 --- a/Makefile +++ b/Makefile @@ -577,3 +577,12 @@ foundation-check: # `foundation-check` is now the canonical drift gate; this alias just forwards. .PHONY: ci-lifecycle-check ci-lifecycle-check: foundation-check + +# Write counterpart to foundation-check's read: regenerates the same checked-in +# artifacts IN PLACE (no diff/exit-code guard) so a project can point its +# `codegen_command` at this and have drift committed before a push, instead of +# only ever discovering it at CI's foundation-check hard-fail. +.PHONY: codegen +codegen: + @$(MAKE) lifecycle + @uv run python scripts/regenerate_verb_tables.py diff --git a/alembic/versions/078_project_codegen_command.py b/alembic/versions/078_project_codegen_command.py new file mode 100644 index 00000000..85bc5334 --- /dev/null +++ b/alembic/versions/078_project_codegen_command.py @@ -0,0 +1,37 @@ +"""Per-project codegen-drift regeneration command. + +Some projects check in generated artifacts (rendered docs, generated verb/prompt +tables, ...) that drift whenever their source changes. The agent pre-submit +gate (``quality_command``, e.g. ``make gate``) never regenerates them, so drift +only ever surfaces later as CI's own foundation-check-style `git diff +--exit-code` hard-fail — a failure an agent has no way to trace back to its own +task. ``codegen_command`` (e.g. ``make codegen``) is run in the task's +workspace before every push; any resulting drift is committed so the pushed PR +head is never stale. Additive and nullable — null (the default) is a pure +no-op for projects with no generated artifacts, mirroring ``quality_command``. + +Revision ID: 078_project_codegen_command +Revises: 077_github_app +Create Date: 2026-07-21 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "078_project_codegen_command" +down_revision = "077_github_app" +branch_labels: dict[str, str] | None = None +depends_on: dict[str, str] | None = None + + +def upgrade() -> None: + op.add_column( + "projects", + sa.Column("codegen_command", sa.String(length=500), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("projects", "codegen_command") diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx index 59a1acb5..4bb500b9 100644 --- a/panel/src/components/projects/edit-project-dialog.tsx +++ b/panel/src/components/projects/edit-project-dialog.tsx @@ -148,6 +148,9 @@ function EditProjectForm({ const [qualityCommand, setQualityCommand] = useState( project.quality_command || "", ); + const [codegenCommand, setCodegenCommand] = useState( + project.codegen_command || "", + ); const [ciWatchEnabled, setCiWatchEnabled] = useState( project.ci_watch_enabled, ); @@ -226,6 +229,7 @@ function EditProjectForm({ typecheck_command: typecheckCommand || undefined, build_command: buildCommand || undefined, quality_command: qualityCommand || undefined, + codegen_command: codegenCommand || undefined, ci_watch_enabled: ciWatchEnabled, ci_watch_workflow: ciWatchWorkflow || undefined, video_engine_enabled: videoEngineEnabled, @@ -552,6 +556,22 @@ function EditProjectForm({ in the dev's workspace at hand-off to QA.

+ +
+ + + + setCodegenCommand(e.target.value)} + placeholder="make codegen" + /> +

+ Regenerates checked-in generated artifacts; any drift is + committed in the task's workspace before each push. +

+
)} diff --git a/panel/src/lib/api/projects.ts b/panel/src/lib/api/projects.ts index 4e4f3f6e..291ada52 100644 --- a/panel/src/lib/api/projects.ts +++ b/panel/src/lib/api/projects.ts @@ -168,6 +168,7 @@ export const projectsApi = { typecheck_command: project.typecheck_command ?? null, build_command: project.build_command ?? null, quality_command: project.quality_command ?? null, + codegen_command: project.codegen_command ?? null, ci_watch_enabled: false, ci_watch_workflow: null, video_engine_enabled: false, diff --git a/panel/src/types/index.ts b/panel/src/types/index.ts index 4d24cc38..2bb0eb57 100644 --- a/panel/src/types/index.ts +++ b/panel/src/types/index.ts @@ -1052,6 +1052,7 @@ export interface Project { typecheck_command: string | null; build_command: string | null; quality_command: string | null; + codegen_command: string | null; // Autonomous maintenance opt-in ci_watch_enabled: boolean; ci_watch_workflow: string | null; @@ -1091,6 +1092,7 @@ export interface ProjectCreate { typecheck_command?: string; build_command?: string; quality_command?: string; + codegen_command?: string; } export interface ProjectUpdate { @@ -1114,6 +1116,7 @@ export interface ProjectUpdate { typecheck_command?: string; build_command?: string; quality_command?: string; + codegen_command?: string; // Autonomous maintenance opt-in ci_watch_enabled?: boolean; ci_watch_workflow?: string; diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index a30bc624..d99f4ad7 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -49,6 +49,7 @@ class ProjectResponse(BaseModel): typecheck_command: str | None = None build_command: str | None = None quality_command: str | None = None + codegen_command: str | None = None # Autonomous maintenance opt-in ci_watch_enabled: bool = False @@ -162,6 +163,7 @@ class ProjectCreateRequest(BaseModel): typecheck_command: str | None = None build_command: str | None = None quality_command: str | None = None + codegen_command: str | None = None class ProjectUpdateRequest(BaseModel): @@ -201,6 +203,7 @@ class ProjectUpdateRequest(BaseModel): typecheck_command: str | None = None build_command: str | None = None quality_command: str | None = None + codegen_command: str | None = None # Autonomous maintenance opt-in ci_watch_enabled: bool | None = None @@ -294,6 +297,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse: typecheck_command=project.typecheck_command, build_command=project.build_command, quality_command=project.quality_command, + codegen_command=project.codegen_command, ci_watch_enabled=bool(project.ci_watch_enabled), ci_watch_workflow=project.ci_watch_workflow, video_engine_enabled=bool(project.video_engine_enabled), diff --git a/roboco/db/tables.py b/roboco/db/tables.py index f1116288..d1e33847 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -532,6 +532,10 @@ class ProjectTable(Base): # the agent i_am_done gate runs this in the dev's workspace instead of the # lint/typecheck pair — e.g. "make gate". quality_command: Mapped[str | None] = mapped_column(String(500), nullable=True) + # Regenerates checked-in generated artifacts (e.g. "make codegen"); run and + # any drift committed in the task's workspace before every push, so CI's + # codegen-drift gate never fails on inherited drift. Null = no codegen step. + codegen_command: Mapped[str | None] = mapped_column(String(500), nullable=True) # Autonomous maintenance opt-in (multi-repo CI-watch). Default-off: a # project is watched only when ci_watch_enabled is set; ci_watch_workflow diff --git a/roboco/models/project.py b/roboco/models/project.py index 3908be2a..bd8208af 100644 --- a/roboco/models/project.py +++ b/roboco/models/project.py @@ -189,6 +189,15 @@ class Project(TimestampMixin): "When set it replaces the lint/typecheck pair in the gate." ), ) + codegen_command: str | None = Field( + default=None, + description=( + "Command that regenerates checked-in generated artifacts " + "(e.g. 'make codegen'); run and any drift committed before push " + "so CI's codegen-drift gate never fails on inherited drift. " + "Null = no codegen step." + ), + ) # Access Control assigned_cell: Team = Field(..., description="Which cell owns this project") @@ -309,6 +318,7 @@ class ProjectCreate(RobocoBase): typecheck_command: str | None = None build_command: str | None = None quality_command: str | None = None + codegen_command: str | None = None class ProjectUpdate(RobocoBase): @@ -339,6 +349,7 @@ class ProjectUpdate(RobocoBase): typecheck_command: str | None = None build_command: str | None = None quality_command: str | None = None + codegen_command: str | None = None assigned_cell: Team | None = None allowed_agents: list[UUID] | None = None is_active: bool | None = None diff --git a/roboco/services/git.py b/roboco/services/git.py index e706d68a..51e7a0e4 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -1782,6 +1782,9 @@ class GitService(BaseService): if project is None: return 0 workspace = await self.get_workspace(project.slug, agent_id) + # Regenerate + commit any codegen drift BEFORE the push carries it — + # a no-op unless the project sets codegen_command. + await self._run_codegen_and_commit(str(task.branch_name), workspace) # Push the task's branch BY NAME, independent of the current checkout. # The dev's clone is shared across tasks, so by the QA-submission / # open_pr boundary it is usually parked on a LATER task's branch; the @@ -4242,6 +4245,77 @@ class GitService(BaseService): workspace = await self.get_workspace(project.slug, actor_agent_id) return await run_quality_commands(workspace, commands) + @staticmethod + def _codegen_command_for(project: Any) -> str | None: + """The project's ``codegen_command``, or ``None`` if unset. + + The ``isinstance`` check is defensive (the column is ``str | None``) + and also keeps a loosely-specced test double inert: a bare + ``MagicMock`` auto-vivifies any attribute access, so without it every + unrelated GitService test would spuriously trip the codegen path. + """ + command = getattr(project, "codegen_command", None) + return command if isinstance(command, str) and command else None + + async def _run_codegen_and_commit(self, branch_name: str, workspace: Path) -> None: + """Regenerate + commit codegen drift in the branch's worktree before push. + + Some projects check in generated artifacts (rendered docs, generated + verb tables, ...) that drift whenever their source changes. Left + unregenerated, drift only ever surfaces later as CI's own drift gate + (a `git diff --exit-code` hard-fail) — a failure with no obvious link + back to the task that caused it. Running the project's + ``codegen_command`` here means any drift lands in the SAME push that's + about to open/update the PR, so CI never sees it stale. + + Fail-open by design: a broken/timing-out codegen command, or any + resolution failure (missing task/project, worktree trouble), logs a + warning and is skipped — the push proceeds without a commit rather + than blocking delivery. A red CI drift-gate on the resulting PR is the + safety net, not a silent pass. A null/absent ``codegen_command`` (most + projects) is a pure no-op. + """ + try: + task = await self._task_for_branch(branch_name) + if task is None: + return + project = await self._project_for_task(task) + if project is None: + return + command = self._codegen_command_for(project) + if command is None: + return + task_id = require_uuid(task.id) + worktree = self._worktree_for_task(workspace, task_id) + await self._ensure_worktree_for_commit(workspace, worktree, branch_name) + result = await run_quality_commands(worktree, [("codegen", command)]) + if not result.passed: + self.log.warning( + "codegen_command_failed", + project=getattr(project, "slug", None), + task_id=str(task_id), + output=result.output_excerpt, + ) + return + status = await self._run_git( + worktree, ["status", "--porcelain"], check=False + ) + if not status.stdout.strip(): + return # codegen ran clean — nothing to commit + await self._run_git(worktree, ["add", "-A"]) + await self._run_git( + worktree, + [ + "commit", + "-m", + f"[{str(task_id)[:8]}] regenerate generated artifacts", + ], + ) + except Exception as exc: + self.log.warning( + "codegen_and_commit_failed", branch=branch_name, error=str(exc) + ) + async def toolchain_status_for_task( self, actor_agent_id: UUID, task: Any ) -> str | None: @@ -4377,6 +4451,9 @@ class GitService(BaseService): workspace = await self._workspace_for_branch( branch_name, actor_agent_id=actor_agent_id ) + # Regenerate + commit any codegen drift BEFORE this first push opens + # the PR — a no-op unless the project sets codegen_command. + await self._run_codegen_and_commit(branch_name, workspace) # Push the NAMED branch, not the workspace's current checkout. The # clone root is shared across a dev's tasks and (F123) parked on the # default branch while the task branch lives in a per-task worktree; diff --git a/roboco/services/project.py b/roboco/services/project.py index c10674f2..71559011 100644 --- a/roboco/services/project.py +++ b/roboco/services/project.py @@ -140,6 +140,7 @@ class ProjectService(BaseService): typecheck_command=data.typecheck_command, build_command=data.build_command, quality_command=data.quality_command, + codegen_command=data.codegen_command, created_by=created_by, ) diff --git a/tests/integration/test_migration_codegen_command.py b/tests/integration/test_migration_codegen_command.py new file mode 100644 index 00000000..5406240c --- /dev/null +++ b/tests/integration/test_migration_codegen_command.py @@ -0,0 +1,67 @@ +"""Per-project codegen-drift regeneration command (migration 078). + +Migration 078 adds ``projects.codegen_command`` (varchar null). The real +upgrade/downgrade chain is verified separately against a throwaway Postgres; +these assertions guard the resulting schema shape and a value round-trip. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from roboco.db.tables import AgentTable, ProjectTable +from roboco.models import AgentRole, AgentStatus, Team +from sqlalchemy import select + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def _seed_project(db_session: AsyncSession) -> ProjectTable: + agent = AgentTable( + id=uuid4(), + name="Dev", + slug=f"be-dev-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=Team.BACKEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(agent) + await db_session.flush() + project = ProjectTable( + id=uuid4(), + name="C-Proj", + slug=f"c-proj-{uuid4().hex[:8]}", + git_url="https://example.com/r.git", + assigned_cell=Team.BACKEND, + created_by=agent.id, + ) + db_session.add(project) + await db_session.flush() + return project + + +@pytest.mark.asyncio +async def test_codegen_command_column_defaults_null(db_session: AsyncSession) -> None: + project = await _seed_project(db_session) + assert project.codegen_command is None + + +@pytest.mark.asyncio +async def test_codegen_command_column_round_trip(db_session: AsyncSession) -> None: + project = await _seed_project(db_session) + project.codegen_command = "make codegen" + await db_session.flush() + row = ( + await db_session.execute( + select(ProjectTable).where(ProjectTable.id == project.id) + ) + ).scalar_one() + assert row.codegen_command == "make codegen" diff --git a/tests/unit/services/test_git.py b/tests/unit/services/test_git.py index 0f0a58c4..08aaefbf 100644 --- a/tests/unit/services/test_git.py +++ b/tests/unit/services/test_git.py @@ -19,6 +19,7 @@ from roboco.config import settings from roboco.exceptions import GitCommandError, GitError, MergeConflictError from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError from roboco.services.forge import RepoRef +from roboco.services.gateway.quality_gate import GateResult from roboco.services.git import GitService if TYPE_CHECKING: @@ -163,6 +164,25 @@ async def test_push_task_branch_pushes_task_branch_by_name() -> None: assert_branch.assert_not_awaited() +@pytest.mark.asyncio +async def test_push_task_branch_runs_codegen_before_push() -> None: + """push_task_branch regenerates + commits codegen drift before pushing.""" + task = MagicMock(branch_name="feature/backend/abc") + project = MagicMock(slug="roboco") + svc = _service() + _bind(svc, "_assert_task_owned_with_branch", AsyncMock(return_value=task)) + _bind(svc, "_project_for_task", AsyncMock(return_value=project)) + _bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws"))) + codegen_mock = AsyncMock() + _bind(svc, "_run_codegen_and_commit", codegen_mock) + push_mock = AsyncMock(return_value=("feature/backend/abc", _PUSHED_COMMIT_COUNT)) + _bind(svc, "push", push_mock) + + await svc.push_task_branch(uuid4(), uuid4()) + + codegen_mock.assert_awaited_once_with("feature/backend/abc", Path("/tmp/ws")) + + @pytest.mark.asyncio async def test_push_branch_pushes_named_branch_not_current_checkout() -> None: """push_branch (open_pr's push side effect) pushes the NAMED branch. @@ -192,6 +212,23 @@ async def test_push_branch_pushes_named_branch_not_current_checkout() -> None: push_mock.assert_awaited_once_with(Path("/tmp/ws"), branch=branch_name) +@pytest.mark.asyncio +async def test_push_branch_runs_codegen_before_push() -> None: + """push_branch (open_pr's FIRST push) regenerates + commits codegen drift + before pushing — the initial PR head must never carry stale artifacts.""" + branch_name = "feature/backend/abc" + svc = _service() + _bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws"))) + codegen_mock = AsyncMock() + _bind(svc, "_run_codegen_and_commit", codegen_mock) + push_mock = AsyncMock(return_value=(branch_name, _PUSHED_COMMIT_COUNT)) + _bind(svc, "push", push_mock) + + await svc.push_branch(branch_name) + + codegen_mock.assert_awaited_once_with(branch_name, Path("/tmp/ws")) + + @pytest.mark.asyncio async def test_push_targets_explicit_branch_not_current_checkout() -> None: """push(branch=X) pushes X by ref even when the workspace is on Y.""" @@ -367,6 +404,150 @@ async def test_push_task_branch_noop_for_project_less_task() -> None: push_mock.assert_not_awaited() +# --------------------------------------------------------------------------- +# _run_codegen_and_commit: regenerate + commit codegen drift before push +# --------------------------------------------------------------------------- + + +def test_codegen_command_for_returns_none_when_unset() -> None: + project = MagicMock(codegen_command=None) + assert GitService._codegen_command_for(project) is None + + +def test_codegen_command_for_ignores_unspecced_mock_attribute() -> None: + """A bare MagicMock auto-vivifies codegen_command as a truthy MagicMock — + the isinstance(str) guard must treat that the same as unset, or every + loosely-specced GitService test would spuriously trip the codegen path.""" + assert GitService._codegen_command_for(MagicMock()) is None + + +def test_codegen_command_for_returns_configured_command() -> None: + project = MagicMock(codegen_command="make codegen") + assert GitService._codegen_command_for(project) == "make codegen" + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_noop_when_command_unset() -> None: + """Null codegen_command (most projects) never runs a subprocess.""" + task = MagicMock(id=uuid4(), branch_name="feature/backend/abc") + project = MagicMock(codegen_command=None) + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(return_value=task)) + _bind(svc, "_project_for_task", AsyncMock(return_value=project)) + run_mock = AsyncMock() + with patch("roboco.services.git.run_quality_commands", run_mock): + await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws")) + run_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_noop_when_no_task() -> None: + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(return_value=None)) + run_mock = AsyncMock() + with patch("roboco.services.git.run_quality_commands", run_mock): + await svc._run_codegen_and_commit("feature/backend/missing", Path("/tmp/ws")) + run_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_commits_drift() -> None: + """Codegen runs clean but produces drift -> add -A + a task-prefixed commit.""" + task_id = uuid4() + task = MagicMock(id=task_id, branch_name="feature/backend/abc") + project = MagicMock(codegen_command="make codegen", slug="roboco") + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(return_value=task)) + _bind(svc, "_project_for_task", AsyncMock(return_value=project)) + _bind(svc, "_ensure_worktree_for_commit", AsyncMock()) + calls: list[list[str]] = [] + + async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock: + calls.append(args) + res = MagicMock() + res.returncode = 0 + res.stdout = " M docs/rag/lifecycle/foo.md\n" if args[0] == "status" else "" + return res + + _bind(svc, "_run_git", AsyncMock(side_effect=_run_git)) + gate_result = GateResult(passed=True, output="ok") + with patch( + "roboco.services.git.run_quality_commands", + AsyncMock(return_value=gate_result), + ): + await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws")) + + assert ["add", "-A"] in calls + commit_call = next(c for c in calls if c[0] == "commit") + assert commit_call == [ + "commit", + "-m", + f"[{str(task_id)[:8]}] regenerate generated artifacts", + ] + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_noop_when_codegen_clean() -> None: + """Codegen runs clean with NO drift -> git status is clean, no commit.""" + task = MagicMock(id=uuid4(), branch_name="feature/backend/abc") + project = MagicMock(codegen_command="make codegen", slug="roboco") + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(return_value=task)) + _bind(svc, "_project_for_task", AsyncMock(return_value=project)) + _bind(svc, "_ensure_worktree_for_commit", AsyncMock()) + calls: list[list[str]] = [] + + async def _run_git(_ws: object, args: list[str], **_kw: object) -> MagicMock: + calls.append(args) + res = MagicMock() + res.returncode = 0 + res.stdout = "" + return res + + _bind(svc, "_run_git", AsyncMock(side_effect=_run_git)) + gate_result = GateResult(passed=True, output="ok") + with patch( + "roboco.services.git.run_quality_commands", + AsyncMock(return_value=gate_result), + ): + await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws")) + + assert not any(c[0] in ("add", "commit") for c in calls) + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_fail_open_on_command_failure() -> None: + """A non-zero codegen exit logs a warning and skips the commit (fail-open) — + push must proceed rather than a broken codegen command blocking delivery.""" + task = MagicMock(id=uuid4(), branch_name="feature/backend/abc") + project = MagicMock(codegen_command="make codegen", slug="roboco") + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(return_value=task)) + _bind(svc, "_project_for_task", AsyncMock(return_value=project)) + _bind(svc, "_ensure_worktree_for_commit", AsyncMock()) + run_git_mock = AsyncMock() + _bind(svc, "_run_git", run_git_mock) + gate_result = GateResult(passed=False, failures=("codegen",), output="boom") + with patch( + "roboco.services.git.run_quality_commands", + AsyncMock(return_value=gate_result), + ): + await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws")) + + # Never even checks git status — a failed codegen command skips straight + # through without touching git. + run_git_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_codegen_and_commit_fail_open_on_exception() -> None: + """Any unexpected exception (worktree resolution, git failure, ...) must + never raise out of this helper and break the caller's push.""" + svc = _service() + _bind(svc, "_task_for_branch", AsyncMock(side_effect=RuntimeError("boom"))) + await svc._run_codegen_and_commit("feature/backend/abc", Path("/tmp/ws")) + + # --------------------------------------------------------------------------- # diff: derives parent + invokes git diff # ---------------------------------------------------------------------------