From 8d3c68923a4148f06e311d77d25b2a5b463d79ca Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 26 Jul 2026 02:05:06 +0200 Subject: [PATCH 1/3] The bundled extension is ship, and it lives under druks.contrib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build was the platform's only package that was really an app: druks/build sat beside druks/durable and druks/events as if it were platform. It moves to druks/contrib/ship, so the import path says which layer a module belongs to, and the extension takes the name the product uses. The Build workflow keeps its name inside the app — it is still a build — but its durable kind is now ship.build, and one revision rewrites the kinds and settings keys already in Postgres. --- AGENTS.md | 6 +-- README.md | 10 ++-- backend/druks/agents.py | 2 +- backend/druks/api/app.py | 2 +- backend/druks/{build => contrib}/__init__.py | 0 backend/druks/contrib/ship/__init__.py | 0 .../{build => contrib/ship}/constants.py | 0 .../{build => contrib/ship}/contracts.py | 6 +-- .../druks/{build => contrib/ship}/enums.py | 0 .../{build => contrib/ship}/extension.py | 30 +++++------ .../druks/{build => contrib/ship}/journal.py | 2 +- .../druks/{build => contrib/ship}/models.py | 24 ++++----- .../druks/{build => contrib/ship}/policy.py | 6 +-- .../{build => contrib/ship}/prompt_context.py | 6 +-- .../druks/{build => contrib/ship}/routes.py | 10 ++-- .../druks/{build => contrib/ship}/schemas.py | 4 +- .../{build => contrib/ship}/subscribers.py | 28 +++++------ .../{build => contrib/ship}/workflows.py | 32 ++++++------ .../{build => contrib/ship}/workspace.py | 0 backend/druks/durable/schemas.py | 4 +- backend/druks/sandbox/datastructures.py | 2 +- backend/druks/sandbox/host.py | 2 +- backend/migrations/env.py | 2 +- ...7d6e5f4a_rename_build_extension_to_ship.py | 43 ++++++++++++++++ .../build}/_contract.md | 0 .../build}/_github_review.md | 0 .../build_workflow => ship/build}/_header.md | 0 .../build}/_related_repos.md | 0 .../build_workflow => ship/build}/_skills.md | 0 .../build}/evaluate_implementation.md | 10 ++-- .../build}/generate_plan.md | 8 +-- .../build}/implement.md | 8 +-- .../build}/review_code.md | 6 +-- .../build}/review_plan.md | 8 +-- .../build}/revise_contract.md | 8 +-- .../build}/triage_human_feedback.md | 8 +-- .../{build => ship}/profile/repo_profiler.md | 0 .../{build => ship}/verification_block.md | 0 backend/tests/conftest.py | 12 ++--- backend/tests/test_agent_routes.py | 2 +- backend/tests/test_api_runs.py | 20 ++++---- backend/tests/test_api_settings.py | 36 +++++++------ backend/tests/test_api_work_items.py | 34 ++++++------- backend/tests/test_build_board_membership.py | 14 +++--- backend/tests/test_build_dispatch.py | 16 +++--- backend/tests/test_build_durable.py | 16 +++--- backend/tests/test_build_journal.py | 6 +-- backend/tests/test_build_plan_phase.py | 25 +++++----- backend/tests/test_build_prompts.py | 38 +++++++------- backend/tests/test_build_workspace.py | 16 +++--- backend/tests/test_contracts.py | 4 +- backend/tests/test_durable_schemas.py | 34 ++++++------- backend/tests/test_events_feed.py | 8 +-- backend/tests/test_extension_config.py | 50 +++++++++---------- backend/tests/test_extension_loader.py | 10 ++-- backend/tests/test_extensions.py | 8 +-- backend/tests/test_lane_reactions.py | 24 ++++----- backend/tests/test_manifest.py | 2 +- backend/tests/test_mcp_servers.py | 8 +-- backend/tests/test_profiling.py | 22 ++++---- backend/tests/test_project_repo_routes.py | 16 +++--- backend/tests/test_prompt_override_sandbox.py | 4 +- backend/tests/test_repo_routing.py | 2 +- backend/tests/test_run_state.py | 2 +- backend/tests/test_settings_overrides.py | 14 +++--- backend/tests/test_signals.py | 2 +- backend/tests/test_subject_lifecycle.py | 20 ++++---- backend/tests/test_ticketing.py | 4 +- backend/tests/test_webhooks_jira.py | 16 +++--- backend/tests/test_webhooks_pull_request.py | 20 ++++---- backend/tests/test_webhooks_push.py | 16 +++--- backend/tests/test_workflow_identity.py | 8 +-- docs/concepts.md | 2 +- docs/configuration.md | 8 +-- docs/development.md | 2 +- docs/full-local.md | 6 +-- frontend/src/api/types.ts | 2 +- frontend/src/extensions/index.ts | 2 +- frontend/src/extensions/registry.tsx | 2 +- .../{build => ship}/AgentCallPage.tsx | 0 .../{build => ship}/HistoryPage.tsx | 0 .../extensions/{build => ship}/NotFound.tsx | 0 .../extensions/{build => ship}/StatusTag.tsx | 0 .../{build => ship}/WorkItemPage.tsx | 2 +- .../{build => ship}/WorkItemsPage.tsx | 0 .../src/extensions/{build => ship}/api.ts | 20 ++++---- .../{build => ship}/projects/ProjectsPage.tsx | 0 .../{build => ship}/projects/api.ts | 6 +-- .../{build => ship}/projects/types.ts | 0 .../src/extensions/{build => ship}/slug.ts | 0 .../{build => ship}/statusLine.test.ts | 6 +-- .../extensions/{build => ship}/statusLine.ts | 0 .../src/extensions/{build => ship}/ui.tsx | 24 ++++----- frontend/src/lib/extensionColors.ts | 2 +- frontend/src/pages/UsagePage.tsx | 2 +- frontend/src/styles.css | 2 +- pyproject.toml | 2 +- scripts/deploy.sh | 2 +- 98 files changed, 470 insertions(+), 428 deletions(-) rename backend/druks/{build => contrib}/__init__.py (100%) create mode 100644 backend/druks/contrib/ship/__init__.py rename backend/druks/{build => contrib/ship}/constants.py (100%) rename backend/druks/{build => contrib/ship}/contracts.py (98%) rename backend/druks/{build => contrib/ship}/enums.py (100%) rename backend/druks/{build => contrib/ship}/extension.py (89%) rename backend/druks/{build => contrib/ship}/journal.py (97%) rename backend/druks/{build => contrib/ship}/models.py (95%) rename backend/druks/{build => contrib/ship}/policy.py (93%) rename backend/druks/{build => contrib/ship}/prompt_context.py (80%) rename backend/druks/{build => contrib/ship}/routes.py (96%) rename backend/druks/{build => contrib/ship}/schemas.py (97%) rename backend/druks/{build => contrib/ship}/subscribers.py (82%) rename backend/druks/{build => contrib/ship}/workflows.py (96%) rename backend/druks/{build => contrib/ship}/workspace.py (100%) create mode 100644 backend/migrations/versions/9b8c7d6e5f4a_rename_build_extension_to_ship.py rename backend/templates/prompts/{build/build_workflow => ship/build}/_contract.md (100%) rename backend/templates/prompts/{build/build_workflow => ship/build}/_github_review.md (100%) rename backend/templates/prompts/{build/build_workflow => ship/build}/_header.md (100%) rename backend/templates/prompts/{build/build_workflow => ship/build}/_related_repos.md (100%) rename backend/templates/prompts/{build/build_workflow => ship/build}/_skills.md (100%) rename backend/templates/prompts/{build/build_workflow => ship/build}/evaluate_implementation.md (98%) rename backend/templates/prompts/{build/build_workflow => ship/build}/generate_plan.md (97%) rename backend/templates/prompts/{build/build_workflow => ship/build}/implement.md (97%) rename backend/templates/prompts/{build/build_workflow => ship/build}/review_code.md (97%) rename backend/templates/prompts/{build/build_workflow => ship/build}/review_plan.md (97%) rename backend/templates/prompts/{build/build_workflow => ship/build}/revise_contract.md (92%) rename backend/templates/prompts/{build/build_workflow => ship/build}/triage_human_feedback.md (93%) rename backend/templates/prompts/{build => ship}/profile/repo_profiler.md (100%) rename backend/templates/prompts/{build => ship}/verification_block.md (100%) rename frontend/src/extensions/{build => ship}/AgentCallPage.tsx (100%) rename frontend/src/extensions/{build => ship}/HistoryPage.tsx (100%) rename frontend/src/extensions/{build => ship}/NotFound.tsx (100%) rename frontend/src/extensions/{build => ship}/StatusTag.tsx (100%) rename frontend/src/extensions/{build => ship}/WorkItemPage.tsx (99%) rename frontend/src/extensions/{build => ship}/WorkItemsPage.tsx (100%) rename frontend/src/extensions/{build => ship}/api.ts (79%) rename frontend/src/extensions/{build => ship}/projects/ProjectsPage.tsx (100%) rename frontend/src/extensions/{build => ship}/projects/api.ts (90%) rename frontend/src/extensions/{build => ship}/projects/types.ts (100%) rename frontend/src/extensions/{build => ship}/slug.ts (100%) rename frontend/src/extensions/{build => ship}/statusLine.test.ts (91%) rename frontend/src/extensions/{build => ship}/statusLine.ts (100%) rename frontend/src/extensions/{build => ship}/ui.tsx (63%) diff --git a/AGENTS.md b/AGENTS.md index be2475c..54015fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Druks runs durable agent applications on DBOS and Postgres. It owns workflow execution, persisted state and events, gates, webhooks, sandbox access, and the shared dashboard. Apps are **extensions**: standalone Python packages -that self-register through the `druks.extensions` entry point. `build` is the +that self-register through the `druks.extensions` entry point. `ship` is the bundled reference extension for coordinating coding agents through GitHub PRs. ## Read map @@ -26,7 +26,7 @@ For extension-surface changes, inspect the proof extension at ## Architectural boundaries - Keep platform and extension ownership explicit. GitHub issue, branch, PR, and - coding-agent policy belongs to `build`, not to Druks core. + coding-agent policy belongs to `ship`, not to Druks core. - Describe durability precisely: completed durable checkpoints are reused when orchestration replays, but an interrupted operation may run again. Do not imply arbitrary-line resume or exactly-once external side effects. @@ -95,7 +95,7 @@ truth for CI, including the proof-extension install phase. routing, architectural boundaries, and contributor rules. - Link to one canonical explanation instead of copying it into multiple pages. - Verify behavioral claims against current source and focused tests. Distinguish - framework capabilities from `build` behavior and guarantees from policy. + framework capabilities from `ship` behavior and guarantees from policy. - Update this file only when contributor routing, repository structure, commands, or a load-bearing architectural invariant changes. diff --git a/README.md b/README.md index c954ffc..58a3274 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ > before 1.0; `main` and `latest` are edge builds, not stable releases. Druks is the self-hosted **home for durable agent apps**, running on the -Claude and Codex subscriptions you already pay for. Build ships out of the -box: autonomous software delivery from ticket to reviewed pull request. +Claude and Codex subscriptions you already pay for. Ship comes bundled: +autonomous software delivery from ticket to reviewed pull request. An ordinary agent script loses its place when the process dies. A Druks workflow records the result of each completed durable operation in Postgres. @@ -55,7 +55,7 @@ DRUKS_PROVIDER=docker bash <(curl -fsSL https://raw.githubusercontent.com/czpyth Then follow [full local setup](docs/full-local.md) to start Drukbox and connect the agent harnesses. A complete installation needs GitHub Apps because the -bundled `build` extension is installed; a standalone extension may have +bundled `ship` extension is installed; a standalone extension may have different integration requirements. ```text @@ -84,9 +84,9 @@ Python distribution registered through the `druks.extensions` entry-point group. Installing the distribution registers it; Druks does not need an extension-specific plugin list. -The bundled `build` extension is a concrete example. It coordinates coding +The bundled `ship` extension is a concrete example. It coordinates coding agents through tickets and GitHub pull requests, but GitHub PR orchestration is -`build` behavior—not the definition of Druks. +`ship` behavior—not the definition of Druks. ## Documentation diff --git a/backend/druks/agents.py b/backend/druks/agents.py index f333e33..d9e26e4 100644 --- a/backend/druks/agents.py +++ b/backend/druks/agents.py @@ -129,7 +129,7 @@ def get_timeout(self) -> int: return min(resolved, MAX_AGENT_TIMEOUT_SECONDS) async def __call__(self, **context: object) -> Any: - """Run the agent — ``await Build.implement(...)`` — as a durable step in the + """Run the agent — ``await Ship.implement(...)`` — as a durable step in the current workflow and return its parsed output. An agent run is always memoized — this picks which step does it: its own, or the @step it's already inside. workflow_id comes from the workflow context, not the caller; everything diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index 4aedbe4..455c813 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -212,7 +212,7 @@ async def _unhandled_exception_handler( # Platform-core routers, mounted by hand at their own prefixes. Extension routers -# (core, build, usage, …) are discovered and mounted under /api/ by load(). +# (core, ship, usage, …) are discovered and mounted under /api/ by load(). # /api sits behind the identity gate except the identity/connection surface and # the health probe; /_external routes carry their own authentication. The # boundary test pins the split. The auth and harness-connection routers mount diff --git a/backend/druks/build/__init__.py b/backend/druks/contrib/__init__.py similarity index 100% rename from backend/druks/build/__init__.py rename to backend/druks/contrib/__init__.py diff --git a/backend/druks/contrib/ship/__init__.py b/backend/druks/contrib/ship/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/druks/build/constants.py b/backend/druks/contrib/ship/constants.py similarity index 100% rename from backend/druks/build/constants.py rename to backend/druks/contrib/ship/constants.py diff --git a/backend/druks/build/contracts.py b/backend/druks/contrib/ship/contracts.py similarity index 98% rename from backend/druks/build/contracts.py rename to backend/druks/contrib/ship/contracts.py index 17cab63..49d2bab 100644 --- a/backend/druks/build/contracts.py +++ b/backend/druks/contrib/ship/contracts.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, model_validator from druks.agents import AgentOutput -from druks.build.enums import ( +from druks.contrib.ship.enums import ( EvaluationVerdict, HumanFeedbackAction, ReviewDecision, @@ -11,7 +11,7 @@ from druks.workflows import Gate, Workflow if TYPE_CHECKING: - from druks.build.workflows import BuildWorkflow + from druks.contrib.ship.workflows import Build class ReviewWork(Gate): @@ -25,7 +25,7 @@ class ReviewWork(Gate): @classmethod async def on_wait(cls, workflow: Workflow) -> None: - build = cast("BuildWorkflow", workflow) + build = cast("Build", workflow) await build.set_pr_draft(draft=False) await build.request_assignee_review() diff --git a/backend/druks/build/enums.py b/backend/druks/contrib/ship/enums.py similarity index 100% rename from backend/druks/build/enums.py rename to backend/druks/contrib/ship/enums.py diff --git a/backend/druks/build/extension.py b/backend/druks/contrib/ship/extension.py similarity index 89% rename from backend/druks/build/extension.py rename to backend/druks/contrib/ship/extension.py index 407344d..aeb2e0c 100644 --- a/backend/druks/build/extension.py +++ b/backend/druks/contrib/ship/extension.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field from druks.agents import Agent -from druks.build.contracts import ( +from druks.contrib.ship.contracts import ( CodeReviewOutput, ContractRevisionOutput, EvaluationOutput, @@ -11,8 +11,8 @@ ReviewOutput, TriageOutput, ) -from druks.build.models import WorkItem -from druks.build.schemas import WorkItemSummary +from druks.contrib.ship.models import WorkItem +from druks.contrib.ship.schemas import WorkItemSummary from druks.db import db_session from druks.events import Event, FeedItem from druks.extensions import Extension @@ -24,10 +24,10 @@ } -class Build(Extension): - name = "build" +class Ship(Extension): + name = "ship" subject = WorkItem - # build's tables (projects, work_items, ...) are already unprefixed in core's + # These tables (projects, work_items, ...) are already unprefixed in core's # migration history, so they must stay that way. prefix_tables = False icon = "hammer" @@ -78,50 +78,50 @@ def resting_status(cls, source: str) -> str: # them. The attribute name is each agent's id (its durable settings/timeline key). generate_plan = Agent( description="ticket → implementation plan", - prompt="build/build_workflow/generate_plan.md", + prompt="ship/build/generate_plan.md", contract=PlanOutput, model="codex", ) review_plan = Agent( description="critiques the plan before any work starts", - prompt="build/build_workflow/review_plan.md", + prompt="ship/build/review_plan.md", contract=ReviewOutput, model="claude", ) revise_contract = Agent( description="revises the plan contract on feedback", - prompt="build/build_workflow/revise_contract.md", + prompt="ship/build/revise_contract.md", contract=ContractRevisionOutput, model="codex", ) implement = Agent( description="plan → diff, in a drukbox", - prompt="build/build_workflow/implement.md", + prompt="ship/build/implement.md", contract=ImplementationOutput, model="claude", ) evaluate_implementation = Agent( description="adversarial review of the diff", - prompt="build/build_workflow/evaluate_implementation.md", + prompt="ship/build/evaluate_implementation.md", contract=EvaluationOutput, model="codex", effort="medium", ) review_code = Agent( description="line-level code review on the PR", - prompt="build/build_workflow/review_code.md", + prompt="ship/build/review_code.md", contract=CodeReviewOutput, model="claude", ) triage_human_feedback = Agent( description="routes a human's PR feedback back into the workflow", - prompt="build/build_workflow/triage_human_feedback.md", + prompt="ship/build/triage_human_feedback.md", contract=TriageOutput, model="codex", ) repo_profiler = Agent( description="reads a repo once and reports its stack, verification commands, and skills", - prompt="build/profile/repo_profiler.md", + prompt="ship/profile/repo_profiler.md", contract=RepoProfilerOutput, model="codex", ) @@ -157,7 +157,7 @@ def format_event(cls, event: Event) -> FeedItem: id=f"event:{event.id}", at=event.created_at, kind=kind, - source=run_kind or "build", + source=run_kind or "ship", summary=summary, link_path=f"/work-items/{wid}" if wid else None, meta={"ticketRef": ticket_ref} if ticket_ref else {}, diff --git a/backend/druks/build/journal.py b/backend/druks/contrib/ship/journal.py similarity index 97% rename from backend/druks/build/journal.py rename to backend/druks/contrib/ship/journal.py index 9ab6296..9791fec 100644 --- a/backend/druks/build/journal.py +++ b/backend/druks/contrib/ship/journal.py @@ -1,6 +1,6 @@ from contextlib import suppress -from druks.build.contracts import ( +from druks.contrib.ship.contracts import ( EvaluationOutput, ImplementationOutput, PlanData, diff --git a/backend/druks/build/models.py b/backend/druks/contrib/ship/models.py similarity index 95% rename from backend/druks/build/models.py rename to backend/druks/contrib/ship/models.py index 0340bb2..60a43a4 100644 --- a/backend/druks/build/models.py +++ b/backend/druks/contrib/ship/models.py @@ -6,8 +6,8 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship -from druks.build.enums import HandoffStatus -from druks.build.policy import RepoPolicy +from druks.contrib.ship.enums import HandoffStatus +from druks.contrib.ship.policy import RepoPolicy from druks.core.apis.github import get_github_client from druks.db import Base, Subject, db_session from druks.settings import load_settings @@ -284,22 +284,22 @@ def get_for_branch(cls, *, repo: str, branch: str) -> "WorkItem | None": async def ship(self) -> None: # A build parked on the operator's review is stranded by their merge; a running # one converges on its own, its merge step finding the PR already closed. - from druks.build.workflows import BuildWorkflow + from druks.contrib.ship.workflows import Build self.set_status(HandoffStatus.SHIPPED) - build = get_subject_status(self.subject_type, str(self.id), kind=BuildWorkflow.kind) + build = get_subject_status(self.subject_type, str(self.id), kind=Build.kind) if build.is_parked: - await BuildWorkflow.cancel(self, failure="pr merged while parked") + await Build.cancel(self, failure="pr merged while parked") await self.set_remote_status(TicketStatus.DONE) async def close_external(self) -> None: # The attempt was abandoned, not the ticket, so the ticket returns to the # provider's resting pool. Branch cleanup is best-effort: a fetch failure # must not strand it there. - from druks.build.workflows import BuildWorkflow + from druks.contrib.ship.workflows import Build self.set_status(HandoffStatus.CANCELLED, event_payload={"external": True}) - await BuildWorkflow.cancel(self, failure="pr closed without merge") + await Build.cancel(self, failure="pr closed without merge") db_session().flush() try: if (await RepoPolicy.resolve(self.repo)).delete_branch: @@ -343,9 +343,9 @@ def set_status( call-site convention. None clears the lane on (re)dispatch: no event.""" if status: # cycle: the extension imports this module at file scope. - import druks.build.extension as build_extension + import druks.contrib.ship.extension as ship_extension - build_extension.Build.record_event(type=status, subject=self, payload=event_payload) + ship_extension.Ship.record_event(type=status, subject=self, payload=event_payload) self.status = status self.updated_at = Base.utc_now() db_session().flush() @@ -354,13 +354,13 @@ async def set_remote_status(self, status: TicketStatus) -> None: # No-op for sources without a configured tracker (github, absent creds). if not is_tracker_source(self.source): return - # Lazy: the Build extension imports this module, so it can't be imported at top. - import druks.build.extension as build_extension + # Lazy: the Ship extension imports this module, so it can't be imported at top. + import druks.contrib.ship.extension as ship_extension try: tracker = get_tracker( self.source, - ready_for_agent_status=build_extension.Build.resting_status(self.source), + ready_for_agent_status=ship_extension.Ship.resting_status(self.source), ) except TrackerNotConfigured: return diff --git a/backend/druks/build/policy.py b/backend/druks/contrib/ship/policy.py similarity index 93% rename from backend/druks/build/policy.py rename to backend/druks/contrib/ship/policy.py index c3e442c..2afdeea 100644 --- a/backend/druks/build/policy.py +++ b/backend/druks/contrib/ship/policy.py @@ -28,7 +28,7 @@ class VerificationProfile(BaseModel): class RepoPolicy(BaseModel): - """The operator's ``.druks/build/config.yml``, validated whole so a typo'd + """The operator's ``.druks/ship/config.yml``, validated whole so a typo'd key fails loud at resolution.""" model_config = {"frozen": True, "extra": "forbid"} @@ -43,7 +43,7 @@ class RepoPolicy(BaseModel): @classmethod async def resolve(cls, repo: str | None) -> "RepoPolicy": - return await resolve_extension_config("build", repo=repo, model=cls) + return await resolve_extension_config("ship", repo=repo, model=cls) def plan_approval_gate(self, auto_dispatch: bool) -> GateValue: return self.gates.plan_approval or ("none" if auto_dispatch else "human") @@ -63,7 +63,7 @@ async def verification_block(self, *, profile: dict[str, Any], repo: str | None) {"label": "Tests", "commands": verification.get("test_commands", [])}, ] body = await render_prompt( - "build/verification_block.md", + "ship/verification_block.md", repo=repo, sections=sections, has_commands=any(section["commands"] for section in sections), diff --git a/backend/druks/build/prompt_context.py b/backend/druks/contrib/ship/prompt_context.py similarity index 80% rename from backend/druks/build/prompt_context.py rename to backend/druks/contrib/ship/prompt_context.py index 4e7c2fc..7bcffc6 100644 --- a/backend/druks/build/prompt_context.py +++ b/backend/druks/contrib/ship/prompt_context.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from druks.build.journal import BuildJournal -from druks.build.models import ProjectRepo +from druks.contrib.ship.journal import BuildJournal +from druks.contrib.ship.models import ProjectRepo @dataclass(frozen=True) class BuildPromptContext: """What build's prompt templates render against — the run's identity facts - plus its journal, assembled per agent call by ``BuildWorkflow.get_prompt_context``. + plus its journal, assembled per agent call by ``Build.get_prompt_context``. The templates read ``build.`` and nothing else off the run, so this is the whole contract between the workflow and its prompts; the workflow itself stays free of template accessors. diff --git a/backend/druks/build/routes.py b/backend/druks/contrib/ship/routes.py similarity index 96% rename from backend/druks/build/routes.py rename to backend/druks/contrib/ship/routes.py index 329fa02..a4e70cb 100644 --- a/backend/druks/build/routes.py +++ b/backend/druks/contrib/ship/routes.py @@ -3,8 +3,8 @@ from fastapi import APIRouter, Body, HTTPException, Query, Response, status from sqlalchemy import func, select, update -from druks.build.models import Project, ProjectRepo, WorkItem -from druks.build.schemas import ( +from druks.contrib.ship.models import Project, ProjectRepo, WorkItem +from druks.contrib.ship.schemas import ( AddProjectRepoRequest, CreateProjectRequest, DashboardItem, @@ -15,7 +15,7 @@ ProjectSummary, WorkItemsHistoryResponse, ) -from druks.build.workflows import Profile +from druks.contrib.ship.workflows import Profile from druks.core.apis.github import get_github_client from druks.db import db_session from druks.settings import load_settings @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -# /api/build/projects Project / ProjectRepo +# /api/ship/projects Project / ProjectRepo projects_router = APIRouter(prefix="/projects", tags=["projects"]) @@ -228,7 +228,7 @@ async def delete_project_repo(project_id: int, repo_id: int) -> None: session.flush() -# /api/build/work-items WorkItem CRUD +# /api/ship/work-items WorkItem CRUD work_items_router = APIRouter(prefix="/work-items", tags=["work-items"]) diff --git a/backend/druks/build/schemas.py b/backend/druks/contrib/ship/schemas.py similarity index 97% rename from backend/druks/build/schemas.py rename to backend/druks/contrib/ship/schemas.py index cf84a1f..652a9c5 100644 --- a/backend/druks/build/schemas.py +++ b/backend/druks/contrib/ship/schemas.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -from druks.build.models import Project, ProjectRepo, WorkItem +from druks.contrib.ship.models import Project, ProjectRepo, WorkItem from druks.schemas import BaseResponse from druks.workflows import RunState, SubjectSummary @@ -98,7 +98,7 @@ def from_work_item(cls, item: WorkItem) -> "Links": class WorkItemSummary(SubjectSummary): - # The work item's domain header — what only build knows. Status (where it is + # The work item's domain header — what only Ship knows. Status (where it is # in its lifecycle) and the timeline come from the platform's subject read-side, # which composes this with them; ``id`` is the platform subject key (str). source: Literal["linear", "github", "jira"] diff --git a/backend/druks/build/subscribers.py b/backend/druks/contrib/ship/subscribers.py similarity index 82% rename from backend/druks/build/subscribers.py rename to backend/druks/contrib/ship/subscribers.py index 8135033..4d92af2 100644 --- a/backend/druks/build/subscribers.py +++ b/backend/druks/contrib/ship/subscribers.py @@ -1,8 +1,8 @@ -from druks.build.contracts import ReviewWork -from druks.build.enums import HandoffStatus -from druks.build.extension import Build -from druks.build.models import ProjectRepo, WorkItem -from druks.build.workflows import BuildWorkflow, Profile +from druks.contrib.ship.contracts import ReviewWork +from druks.contrib.ship.enums import HandoffStatus +from druks.contrib.ship.extension import Ship +from druks.contrib.ship.models import ProjectRepo, WorkItem +from druks.contrib.ship.workflows import Build, Profile from druks.signals import subscribe from druks.ticketing.enums import TicketStatus from druks.workflows import get_subject_status @@ -17,7 +17,7 @@ async def run_start_returns_item_to_board(*, subject: WorkItem, **_: object) -> subject.set_status(None) -@subscribe("run.state", kind=BuildWorkflow.kind, subject=WorkItem) +@subscribe("run.state", kind=Build.kind, subject=WorkItem) async def provision_mirrors_onto_item( *, subject: WorkItem, pr_number: int, branch: str, **_: object ) -> None: @@ -26,20 +26,20 @@ async def provision_mirrors_onto_item( subject.update(pr_number=pr_number, branch=branch) -@subscribe("run.running", kind=BuildWorkflow.kind, subject=WorkItem) +@subscribe("run.running", kind=Build.kind, subject=WorkItem) async def build_start_marks_ticket_in_progress(*, subject: WorkItem, **_: object) -> None: # Every (re)start and gate-resume of a build means the ticket is in progress — # including the return from a rework loop that had parked it In Review. await subject.set_remote_status(TicketStatus.IN_PROGRESS) -@subscribe("run.pending_input", kind=BuildWorkflow.kind, gate=ReviewWork, subject=WorkItem) +@subscribe("run.pending_input", kind=Build.kind, gate=ReviewWork, subject=WorkItem) async def review_park_marks_ticket_in_review(*, subject: WorkItem, **_: object) -> None: await subject.set_remote_status(TicketStatus.IN_REVIEW) -@subscribe("run.failed", kind=BuildWorkflow.kind, subject=WorkItem) -@subscribe("run.cancelled", kind=BuildWorkflow.kind, subject=WorkItem) +@subscribe("run.failed", kind=Build.kind, subject=WorkItem) +@subscribe("run.cancelled", kind=Build.kind, subject=WorkItem) async def build_end_settles_the_item(*, subject: WorkItem, **_: object) -> None: # Nothing merged, so the attempt was abandoned — unless the PR already spoke: # ship() cancels the run it just shipped, and that cancel arrives here. @@ -51,7 +51,7 @@ async def build_end_settles_the_item(*, subject: WorkItem, **_: object) -> None: async def policy_push_reprofiles_the_repo(*, repo: str, paths: list, **_: object) -> None: # The operator edited the repo's build policy — re-apply it over the # profiled baseline. - if ".druks/build/config.yml" in paths: + if ".druks/ship/config.yml" in paths: project_repo = ProjectRepo.get_for_repo(repo) if project_repo: @@ -66,7 +66,7 @@ async def pr_review_answers_the_gate(*, repo: str, pr_number: int, payload: dict item = WorkItem.get_for_pr(repo=repo, pr_number=pr_number, branch=payload["branch"]) if not item: return - status = get_subject_status(item.subject_type, str(item.id), kind=BuildWorkflow.kind) + status = get_subject_status(item.subject_type, str(item.id), kind=Build.kind) if status.is_parked and status.gate == ReviewWork.name: await ReviewWork.answer( item, @@ -94,7 +94,7 @@ async def pr_close_settles_the_item(*, repo: str, pr_number: int, payload: dict) async def ticket_transition_drives_the_funnel(*, payload: dict) -> None: """Dispatch a build when a tracker ticket enters its provider's trigger status.""" source, status = payload["source"], payload["status"] - settings = Build.settings() + settings = Ship.settings() trigger = settings.linear_trigger_status if source == "linear" else settings.jira_trigger_status if trigger and status == trigger: - await BuildWorkflow.dispatch(ticket=payload) + await Build.dispatch(ticket=payload) diff --git a/backend/druks/build/workflows.py b/backend/druks/contrib/ship/workflows.py similarity index 96% rename from backend/druks/build/workflows.py rename to backend/druks/contrib/ship/workflows.py index e6d028f..e16cfe0 100644 --- a/backend/druks/build/workflows.py +++ b/backend/druks/contrib/ship/workflows.py @@ -5,13 +5,13 @@ from pydantic import BaseModel, Field from druks.accounts.models import Account -from druks.build.contracts import ImplementationOutput, ReviewWork -from druks.build.enums import ( +from druks.contrib.ship.contracts import ImplementationOutput, ReviewWork +from druks.contrib.ship.enums import ( EvaluationVerdict, HumanFeedbackAction, ReviewDecision, ) -from druks.build.models import ProjectRepo, WorkItem +from druks.contrib.ship.models import ProjectRepo, WorkItem from druks.core.apis.github import get_github_client, get_reviewer_github_client from druks.sandbox import repo as _repo from druks.sandbox.datastructures import RequiredMcpServer @@ -26,7 +26,7 @@ from druks.workflows import FatalError, Workflow, step from .constants import GITHUB_MCP_NAME, GITHUB_MCP_URL, PLAN_DRAFTS_PER_ROUND -from .extension import Build +from .extension import Ship from .journal import BuildJournal from .policy import RepoPolicy from .prompt_context import BuildPromptContext @@ -65,7 +65,7 @@ def get_agent_run_kwargs(self, **kwargs: Any) -> dict[str, Any]: return kwargs -class BuildWorkflow(Workflow): +class Build(Workflow): steps_reuse_sandbox = True workspace_class = BuildWorkspace journal_class = BuildJournal @@ -242,7 +242,7 @@ async def _load_policy_and_profile(self) -> dict[str, Any]: } @step - async def _load_settings(self) -> "BuildWorkflow.Settings": + async def _load_settings(self) -> "Build.Settings": # A step so replay reuses the values the run started with, not later edits. return self.settings() @@ -258,14 +258,14 @@ async def _plan_phase(self) -> bool: for _ in range(PLAN_DRAFTS_PER_ROUND): draft_guidance = unresolved_critique unresolved_critique = "" - plan = await Build.generate_plan( + plan = await Ship.generate_plan( answered_questions=answered_questions, operator_note=operator_note, reviewer_notes=draft_guidance, ) if human_approval_required or plan.questions: break - machine_review = await Build.review_plan() + machine_review = await Ship.review_plan() if machine_review.decision == ReviewDecision.APPROVE: return True unresolved_critique = machine_review.body @@ -285,10 +285,10 @@ async def _plan_phase(self) -> bool: async def _implement_phase(self) -> None: while True: await self.implement() - evaluation = await Build.evaluate_implementation() + evaluation = await Ship.evaluate_implementation() if evaluation.verdict == EvaluationVerdict.PASS: if self._settings.review_code: - await Build.review_code() + await Ship.review_code() if await self._work_gate(): return continue @@ -313,7 +313,7 @@ async def _work_gate(self) -> bool: if decision.action == "request_changes": return await self._triage() if decision.action == "revise_contract": - await Build.revise_contract() + await Ship.revise_contract() return False async def _approved_work(self) -> bool: @@ -325,11 +325,11 @@ async def _approved_work(self) -> bool: return True async def _triage(self) -> bool: - feedback = await Build.triage_human_feedback() + feedback = await Ship.triage_human_feedback() if feedback.action == HumanFeedbackAction.CHANGE_REQUIRED: return False # loop → implement if feedback.action == HumanFeedbackAction.CONTRACT_CHANGE_REQUIRED: - await Build.revise_contract() + await Ship.revise_contract() return False if feedback.action == HumanFeedbackAction.CLOSE: raise FatalError("closed at human triage") @@ -339,7 +339,7 @@ async def _triage(self) -> bool: # Body code, never @step: the agent calls inside memoize themselves and land # on the record, and set_state must re-run on replay — a @step would skip them. async def implement(self) -> ImplementationOutput: - delivery = await Build.implement() + delivery = await Ship.implement() # A bail is a stop, not a result: the implementer hit a contradiction in the # binding requirements and couldn't deliver. Fail the run with its own reason, # read off the dashboard instead of dug out of the transcript. @@ -415,7 +415,7 @@ class Profile(Workflow): reads the checkout and reports stack, verification commands, and recommended skills onto ProjectRepo.profile. ``refresh_only`` skips the agent — it re-applies the operator's pinned verification over the stored baseline, for - the reaction to a .druks/build/config.yml push.""" + the reaction to a .druks/ship/config.yml push.""" workspace_class = RepoWorkspace @@ -435,7 +435,7 @@ async def run(self, repo_id: int, refresh_only: bool = False) -> None: if refresh_only: baseline = project_repo.profile.get("baseline") or {} else: - baseline = await Build.repo_profiler(repo=project_repo.full_name) + baseline = await Ship.repo_profiler(repo=project_repo.full_name) # The agent picks from the catalog it was handed, but a skill can be # disabled between prompt render and result — read the ground truth again. enabled = {skill.name for skill in Skill.list_enabled()} diff --git a/backend/druks/build/workspace.py b/backend/druks/contrib/ship/workspace.py similarity index 100% rename from backend/druks/build/workspace.py rename to backend/druks/contrib/ship/workspace.py diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 648e26b..c027500 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -26,7 +26,7 @@ def _token_usage(cost_metadata: dict | None) -> TokenUsage | None: def get_display_label(kind: str) -> str: - # "build.build_workflow" → "Build workflow"; "implement" → "Implement". + # "ship.build" → "Build"; "implement" → "Implement". return kind.rsplit(".", 1)[-1].replace("_", " ").capitalize() @@ -124,7 +124,7 @@ def named(path: Path) -> ArtifactFile | None: class RunResponse(BaseResponse): id: str - # The durable kind ("build.build_workflow"); ``label`` is its display name ("Build workflow"). + # The durable kind ("ship.build"); ``label`` is its display name ("Build"). kind: str label: str state: Literal["scheduled", "running", "pending_input", "finished", "failed", "cancelled"] diff --git a/backend/druks/sandbox/datastructures.py b/backend/druks/sandbox/datastructures.py index 0fd94bf..b8956fb 100644 --- a/backend/druks/sandbox/datastructures.py +++ b/backend/druks/sandbox/datastructures.py @@ -109,7 +109,7 @@ class McpServer: @dataclass(frozen=True) class RequiredMcpServer: """An MCP server a workspace requires for its runs and credentials itself — - a run-scoped token the operator registry can't hold (build's per-repo + a run-scoped token the operator registry can't hold (Ship's per-repo reviewer token). It owns its name: a same-named registry entry is not delivered.""" diff --git a/backend/druks/sandbox/host.py b/backend/druks/sandbox/host.py index 5c778cc..fd6affa 100644 --- a/backend/druks/sandbox/host.py +++ b/backend/druks/sandbox/host.py @@ -207,7 +207,7 @@ async def run_agent( A failure is captured on the result (``status=FAILED``), not raised. The repo is a *precondition*, not an input: callers that need one clone - it into the VM first (see build's workspace). ``include_plugins=False`` (Claude only) + it into the VM first (see Ship's workspace). ``include_plugins=False`` (Claude only) skips uploading the operator's plugin state — for prompts that hit no MCP server; a no-op for codex. """ diff --git a/backend/migrations/env.py b/backend/migrations/env.py index db2c22e..41c89e4 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,7 +1,7 @@ from logging.config import fileConfig import druks.accounts.models # noqa: F401 -import druks.build.models # noqa: F401 +import druks.contrib.ship.models # noqa: F401 import druks.durable.models # noqa: F401 import druks.harnesses.models # noqa: F401 import druks.mcp.models # noqa: F401 diff --git a/backend/migrations/versions/9b8c7d6e5f4a_rename_build_extension_to_ship.py b/backend/migrations/versions/9b8c7d6e5f4a_rename_build_extension_to_ship.py new file mode 100644 index 0000000..b9debd5 --- /dev/null +++ b/backend/migrations/versions/9b8c7d6e5f4a_rename_build_extension_to_ship.py @@ -0,0 +1,43 @@ +"""rename build extension to ship + +Revision ID: 9b8c7d6e5f4a +Revises: e7d32c9b418f +Create Date: 2026-07-26 12:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9b8c7d6e5f4a" +down_revision: str | Sequence[str] | None = "e7d32c9b418f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "UPDATE settings_overrides " + "SET key = 'extension:ship:' || substring(key from 17) " + "WHERE key LIKE 'extension:build:%'" + ) + op.execute( + "UPDATE durable_runs " + "SET kind = 'ship.' || substring(kind from 7) " + "WHERE kind LIKE 'build.%'" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE settings_overrides " + "SET key = 'extension:build:' || substring(key from 16) " + "WHERE key LIKE 'extension:ship:%'" + ) + op.execute( + "UPDATE durable_runs " + "SET kind = 'build.' || substring(kind from 6) " + "WHERE kind LIKE 'ship.%'" + ) diff --git a/backend/templates/prompts/build/build_workflow/_contract.md b/backend/templates/prompts/ship/build/_contract.md similarity index 100% rename from backend/templates/prompts/build/build_workflow/_contract.md rename to backend/templates/prompts/ship/build/_contract.md diff --git a/backend/templates/prompts/build/build_workflow/_github_review.md b/backend/templates/prompts/ship/build/_github_review.md similarity index 100% rename from backend/templates/prompts/build/build_workflow/_github_review.md rename to backend/templates/prompts/ship/build/_github_review.md diff --git a/backend/templates/prompts/build/build_workflow/_header.md b/backend/templates/prompts/ship/build/_header.md similarity index 100% rename from backend/templates/prompts/build/build_workflow/_header.md rename to backend/templates/prompts/ship/build/_header.md diff --git a/backend/templates/prompts/build/build_workflow/_related_repos.md b/backend/templates/prompts/ship/build/_related_repos.md similarity index 100% rename from backend/templates/prompts/build/build_workflow/_related_repos.md rename to backend/templates/prompts/ship/build/_related_repos.md diff --git a/backend/templates/prompts/build/build_workflow/_skills.md b/backend/templates/prompts/ship/build/_skills.md similarity index 100% rename from backend/templates/prompts/build/build_workflow/_skills.md rename to backend/templates/prompts/ship/build/_skills.md diff --git a/backend/templates/prompts/build/build_workflow/evaluate_implementation.md b/backend/templates/prompts/ship/build/evaluate_implementation.md similarity index 98% rename from backend/templates/prompts/build/build_workflow/evaluate_implementation.md rename to backend/templates/prompts/ship/build/evaluate_implementation.md index 1e89ba1..0917eab 100644 --- a/backend/templates/prompts/build/build_workflow/evaluate_implementation.md +++ b/backend/templates/prompts/ship/build/evaluate_implementation.md @@ -30,10 +30,10 @@ plan should have asked for. You verify what it did ask for, exhaustively, in a s the most recent revision and unaddressed prior blockers only. See the ROUND-COUNTER AWARENESS rule below. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} Evaluate the implementation against the **Current plan** above, the issue, and the current PR diff. When `base_sha` and `head_sha` are listed in the **Workflow context** section above, use them as the authoritative diff range and evaluate `head_sha` against `base_sha`. If branch names disagree with those SHAs, trust the SHAs and mention metadata drift only when it affects the PR. Return blocked if an authoritative SHA is unavailable locally after fetching. Evaluate every acceptance criterion from the PR state and report one result per criterion. Inspection commands such as git diff/show, rg, and sed are allowed for review. Verification commands are different: run only the configured verification profile commands when feasible and report their results in checks. Do not invent repo-specific smoke tests or package install commands. Return exactly one final result object. Return pass only when the work is ready for a human final PR review. Return fail for actionable implementation changes. EXHAUSTIVE ENUMERATION — this is the single most important rule. Subsequent rounds will not retry, and findings you omit now cost an entire extra implementation loop to surface next round. Walk through these sweeps and list every blocker you find in a single response: @@ -80,4 +80,4 @@ GITHUB CHECKS — the PR's CI is part of your evidence; consult it yourself (`gh - Checks still running when you finish everything else: wait a few minutes for them; if they have not settled, report them as not_run and do not block on them. - If local verification cannot run because repo dependencies, private indexes, or credentials are unavailable, report those checks as not_run. Green GitHub checks covering the same ground make that gap non-blocking: return pass, not blocked, and name in `body` which GitHub checks stood in. -{% include "build/build_workflow/_github_review.md" %} +{% include "ship/build/_github_review.md" %} diff --git a/backend/templates/prompts/build/build_workflow/generate_plan.md b/backend/templates/prompts/ship/build/generate_plan.md similarity index 97% rename from backend/templates/prompts/build/build_workflow/generate_plan.md rename to backend/templates/prompts/ship/build/generate_plan.md index 1ccdcfa..48f9562 100644 --- a/backend/templates/prompts/build/build_workflow/generate_plan.md +++ b/backend/templates/prompts/ship/build/generate_plan.md @@ -47,10 +47,10 @@ your acceptance criteria. Wrong shape here compounds into every step that follow migration has a column, or a test covers a new branch. - Preserve the source's explicit out-of-scope statements near-verbatim. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} {% if answered_questions %} ## Answered questions diff --git a/backend/templates/prompts/build/build_workflow/implement.md b/backend/templates/prompts/ship/build/implement.md similarity index 97% rename from backend/templates/prompts/build/build_workflow/implement.md rename to backend/templates/prompts/ship/build/implement.md index b626d47..68c703c 100644 --- a/backend/templates/prompts/build/build_workflow/implement.md +++ b/backend/templates/prompts/ship/build/implement.md @@ -31,10 +31,10 @@ acceptance criterion — nothing more, nothing less. - Do not return `status="success"` when you have left a requirement unmet. The evaluator will catch it and the loop cost is the same — except the operator never sees your explanation. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} Implement the approved plan (rendered above as **Current plan**) on the work branch (see "Delivering your work" below). If the **Human feedback** section above carries an entry with implementation instructions, apply those instructions as the current revision request. Do not run ad hoc install, lint, test, build, or smoke commands during implementation unless the plan explicitly requires changing those commands or generated outputs. The evaluator runs and adjudicates the verification profile — you do not run or judge it; report every check as structured evidence (`not_run` or an observed baseline failure). Never leave dependency lockfile, generated, or cache changes unless they are part of the requested implementation. Return structured evidence for every acceptance criterion, every check you ran or intentionally did not run, changed files, and known risks. If a check was not run, include status not_run and a reason. ## Delivering your work diff --git a/backend/templates/prompts/build/build_workflow/review_code.md b/backend/templates/prompts/ship/build/review_code.md similarity index 97% rename from backend/templates/prompts/build/build_workflow/review_code.md rename to backend/templates/prompts/ship/build/review_code.md index 0287e1a..b359246 100644 --- a/backend/templates/prompts/build/build_workflow/review_code.md +++ b/backend/templates/prompts/ship/build/review_code.md @@ -26,12 +26,12 @@ write it? - Low-severity findings alone do not justify a ticket. Only file a follow-up sub-issue when there is at least one medium or high finding. -{% include "build/build_workflow/_header.md" %} +{% include "ship/build/_header.md" %} **MANDATORY FIRST ACTION — read the diff. This is not a suggestion.** Your very first tool call MUST be `git diff ..` using the SHAs rendered above. Then read every changed file END TO END — the whole file, not the changed hunks — before writing any finding. You are reviewing code, not a plan: you have no plan and no acceptance criteria here by design, because correctness against the contract was already adjudicated by the evaluator. Fetch the ticket when you need its scope to judge a finding, and to file the follow-up sub-issue below. -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} Review the implementation as a code reviewer, AFTER the evaluator has already passed on correctness against the acceptance criteria. Your job is the holistic quality pass: would you be happy maintaining this code in 6 months? You are advisory only. You CANNOT block this PR. The operator will merge whatever you say. You own both outputs of this review — a single PR comment, and a follow-up sub-issue on the ticket's tracker when the findings warrant one — and you write them yourself; neither loops the implementer. diff --git a/backend/templates/prompts/build/build_workflow/review_plan.md b/backend/templates/prompts/ship/build/review_plan.md similarity index 97% rename from backend/templates/prompts/build/build_workflow/review_plan.md rename to backend/templates/prompts/ship/build/review_plan.md index ba83c4b..396e6db 100644 --- a/backend/templates/prompts/build/build_workflow/review_plan.md +++ b/backend/templates/prompts/ship/build/review_plan.md @@ -30,10 +30,10 @@ implementation — your verdict is the gate. output, and the planner folds it. - Do not require changes beyond what the issue and the existing codebase support. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} Review the current plan in one complete pass. Batch every blocking issue and every required clarification into a single response — there is no second automatic round. SCOPE & APPROACH REVIEW — do this BEFORE evaluating contract details. These are the holistic checks that, if missed, cost the most downstream: a wrong shape at the plan stage burns implementation + evaluation rounds that no amount of polishing recovers. diff --git a/backend/templates/prompts/build/build_workflow/revise_contract.md b/backend/templates/prompts/ship/build/revise_contract.md similarity index 92% rename from backend/templates/prompts/build/build_workflow/revise_contract.md rename to backend/templates/prompts/ship/build/revise_contract.md index 523a1e9..0e59a4e 100644 --- a/backend/templates/prompts/build/build_workflow/revise_contract.md +++ b/backend/templates/prompts/ship/build/revise_contract.md @@ -21,10 +21,10 @@ everything the original plan got right. existing one. - Do not add scope beyond what the human feedback introduced. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} The human reviewer's feedback contradicts the current acceptance criteria. Revise the plan and acceptance criteria to incorporate the feedback while preserving the original issue intent. The latest triaged human feedback is in the **Human feedback** section above; the current acceptance criteria are in the **Acceptance criteria** section above. Return the full revised plan markdown, the complete updated acceptance criteria list, and concise implementation instructions describing what changed so the implementer knows what to redo. # Update the PR description diff --git a/backend/templates/prompts/build/build_workflow/triage_human_feedback.md b/backend/templates/prompts/ship/build/triage_human_feedback.md similarity index 93% rename from backend/templates/prompts/build/build_workflow/triage_human_feedback.md rename to backend/templates/prompts/ship/build/triage_human_feedback.md index 50a6ab8..cf436d4 100644 --- a/backend/templates/prompts/build/build_workflow/triage_human_feedback.md +++ b/backend/templates/prompts/ship/build/triage_human_feedback.md @@ -18,10 +18,10 @@ without ambiguity. - Do not edit files. -{% include "build/build_workflow/_header.md" %} -{% include "build/build_workflow/_contract.md" %} -{% include "build/build_workflow/_related_repos.md" %} -{% include "build/build_workflow/_skills.md" %} +{% include "ship/build/_header.md" %} +{% include "ship/build/_contract.md" %} +{% include "ship/build/_related_repos.md" %} +{% include "ship/build/_skills.md" %} Triage the latest pending entry in the **Human feedback** section above. Decide whether the feedback requires code changes, is incorrect or already addressed, needs a follow-up question, or means the PR should be closed/cancelled. Do not edit files. Use action `no_change` when no code change is needed and explain why in body. Use `change_required` only for actionable implementation work and put precise instructions for the implementer in implementation_instructions. Use `contract_change_required` when the feedback contradicts or invalidates one or more acceptance criteria — for example the human rejects a design choice that the criteria explicitly required. In that case put revised instructions in implementation_instructions explaining what changed. Use `question` when human input is needed before acting and put the exact question in question. Use `close` only when the correct action is to stop the PR. ## Post your triage outcome on the PR — REQUIRED (GitHub MCP) diff --git a/backend/templates/prompts/build/profile/repo_profiler.md b/backend/templates/prompts/ship/profile/repo_profiler.md similarity index 100% rename from backend/templates/prompts/build/profile/repo_profiler.md rename to backend/templates/prompts/ship/profile/repo_profiler.md diff --git a/backend/templates/prompts/build/verification_block.md b/backend/templates/prompts/ship/verification_block.md similarity index 100% rename from backend/templates/prompts/build/verification_block.md rename to backend/templates/prompts/ship/verification_block.md diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9c158a4..331b589 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -95,7 +95,7 @@ async def _dbos_cancel(workflow_id: str) -> None: with ( mock.patch.object(Workflow, "start", classmethod(_noop)), mock.patch("druks.agents.set_run_phase", _phase_noop), - mock.patch("druks.build.extension.get_subject_phase", _phase_noop), + mock.patch("druks.contrib.ship.extension.get_subject_phase", _phase_noop), mock.patch("dbos.DBOS.cancel_workflow_async", _dbos_cancel), ): yield @@ -413,7 +413,7 @@ def seed_build_run( ``item.build_run_id``. Returns the Run. Attach agent calls with ``seed_call(session, run, agent)``; the run + its calls are the item's timeline.""" - from druks.build.models import WorkItem + from druks.contrib.ship.models import WorkItem from druks.durable import Run from uuid_utils import uuid7 @@ -421,7 +421,7 @@ def seed_build_run( input_gate = "review" # a parked run always has a gate; derivation needs it run = Run( id=str(uuid7()), - kind="build.build_workflow", + kind="ship.build", input_gate=input_gate, input_request=input_request, failure=failure, @@ -471,7 +471,7 @@ def seed_agent_run( When ``work_item_id`` is given the run binds to it (so the call surfaces on that item's detail timeline); otherwise a fresh work item is created. Pass ``workflow_id`` to attach to an existing run instead.""" - from druks.build.models import Project, ProjectRepo, WorkItem + from druks.contrib.ship.models import Project, ProjectRepo, WorkItem from druks.database import db_session from druks.durable import AgentCall, Run @@ -499,7 +499,7 @@ def seed_agent_run( return call -def seed_run(session, run_id, *, kind="build.build_workflow", account_id="system", input_gate=None): +def seed_run(session, run_id, *, kind="ship.build", account_id="system", input_gate=None): # A bare durable_runs row so an AgentCall / Artifact FK to it resolves. from druks.durable import Run @@ -546,7 +546,7 @@ def make_test_work_item(*, repo: str, **kwargs): """Create a WorkItem with the required Project / ProjectRepo binding for tests. Looks up an existing Project by repo name; otherwise creates the chain. Extra kwargs flow into ``WorkItem.create``.""" - from druks.build.models import Project, ProjectRepo, WorkItem + from druks.contrib.ship.models import Project, ProjectRepo, WorkItem project = Project.get_for_repo(repo) if project is None: diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 80b9277..80b007b 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -199,7 +199,7 @@ def test_transcript_route_matches_the_read_machinery(client: TestClient, db_sess (call_dir / "stdout.jsonl").write_bytes(b"hello " + "é".encode() + b" transcript") response = client.get( - f"/api/build/transcripts/{call.id}", params={"stream": "stdout", "limit": 7} + f"/api/ship/transcripts/{call.id}", params={"stream": "stdout", "limit": 7} ) assert response.status_code == 200 chunk = read_transcript_chunk(call, "stdout", offset=0, limit=7) diff --git a/backend/tests/test_api_runs.py b/backend/tests/test_api_runs.py index 3f310a4..cb7b008 100644 --- a/backend/tests/test_api_runs.py +++ b/backend/tests/test_api_runs.py @@ -46,7 +46,7 @@ def test_list_files_inventories_call_artifacts( ): run_id = _seed_run(tmp_path=tmp_path) - response = client.get(f"/api/build/transcripts/{run_id}/files") + response = client.get(f"/api/ship/transcripts/{run_id}/files") assert response.status_code == 200 files = response.json() @@ -66,7 +66,7 @@ def test_transcript_range_fetch_paginates( run_id = _seed_run(tmp_path=tmp_path) first = client.get( - f"/api/build/transcripts/{run_id}", + f"/api/ship/transcripts/{run_id}", params={"stream": "stdout", "limit": 5}, ) assert first.status_code == 200 @@ -78,7 +78,7 @@ def test_transcript_range_fetch_paginates( assert data["text"] == "hello" second = client.get( - f"/api/build/transcripts/{run_id}", + f"/api/ship/transcripts/{run_id}", params={"stream": "stdout", "offset": 5, "limit": 1024}, ) assert second.status_code == 200 @@ -132,7 +132,7 @@ def test_transcript_missing_file_returns_eof( run_id = run.id response = client.get( - f"/api/build/transcripts/{run_id}", + f"/api/ship/transcripts/{run_id}", params={"stream": "stdout"}, ) @@ -152,7 +152,7 @@ def test_transcript_stream_emits_chunk_then_finishes( run_id = _seed_run(tmp_path=tmp_path) response = client.get( - f"/api/build/transcripts/{run_id}/stream", + f"/api/ship/transcripts/{run_id}/stream", params={"stream": "stdout"}, ) @@ -169,7 +169,7 @@ def test_transcript_stream_unknown_call_closes( ): # No such call: the stream ends instead of keepaliving forever. response = client.get( - "/api/build/transcripts/no-such-call/stream", + "/api/ship/transcripts/no-such-call/stream", params={"stream": "stdout"}, ) @@ -184,12 +184,12 @@ def test_get_file_serves_inventory_paths( ): run_id = _seed_run(tmp_path=tmp_path) - files = client.get(f"/api/build/transcripts/{run_id}/files").json() + files = client.get(f"/api/ship/transcripts/{run_id}/files").json() # Compose the download URL the way the client does: the listing's own route # plus the file's name — the wire carries names only. name = files["response"]["name"] - response = client.get(f"/api/build/transcripts/{run_id}/files/{name}") + response = client.get(f"/api/ship/transcripts/{run_id}/files/{name}") assert response.status_code == 200 assert response.json() == {"ok": True} @@ -202,7 +202,7 @@ def test_get_file_rejects_path_traversal( run_id = _seed_run(tmp_path=tmp_path) response = client.get( - f"/api/build/transcripts/{run_id}/files/..%2F..%2Fetc%2Fpasswd", + f"/api/ship/transcripts/{run_id}/files/..%2F..%2Fetc%2Fpasswd", ) assert response.status_code == 404 @@ -216,7 +216,7 @@ def test_get_file_missing_returns_404( run_id = _seed_run(tmp_path=tmp_path) response = client.get( - f"/api/build/transcripts/{run_id}/files/nope.json", + f"/api/ship/transcripts/{run_id}/files/nope.json", ) assert response.status_code == 404 diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py index 847d6a4..28ab0dc 100644 --- a/backend/tests/test_api_settings.py +++ b/backend/tests/test_api_settings.py @@ -170,18 +170,18 @@ def test_patch_unknown_harness_is_404(tmp_path: Path): assert response.status_code == 404 -def _build_extension(client: TestClient) -> dict: +def _ship_extension(client: TestClient) -> dict: body = client.get("/api/settings/extensions").json() - return next(m for m in body["extensions"] if m["name"] == "build") + return next(m for m in body["extensions"] if m["name"] == "ship") def test_extensions_surface_build_agents(tmp_path: Path): - """The build pipeline's agents all tune under the build extension.""" + """The build pipeline's agents all tune under the Ship extension.""" with _build_client(tmp_path) as client: body = client.get("/api/settings/extensions").json() extensions = {m["name"]: m for m in body["extensions"]} - build_agents = {a["name"]: a for a in extensions["build"]["agents"]} + build_agents = {a["name"]: a for a in extensions["ship"]["agents"]} # The build pipeline's plan stage stays; the standalone Plan-tab agent is gone. assert "generate_plan" in build_agents assert "planning" not in build_agents @@ -189,7 +189,7 @@ def test_extensions_surface_build_agents(tmp_path: Path): def test_extensions_surface_build_agents_and_workflow_defaults(tmp_path: Path): with _build_client(tmp_path) as client: - build = _build_extension(client) + build = _ship_extension(client) agents = {a["name"]: a for a in build["agents"]} # An agent's family-token default resolves to the family's model; effort @@ -224,7 +224,7 @@ def test_extensions_override_agent_model_persists(tmp_path: Path): json={"agentModels": {"implement": "gpt-5.5"}}, ) assert patch.status_code == 200 - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} assert agents["implement"]["model"] == "gpt-5.5" assert agents["implement"]["source"] == "agent" @@ -232,7 +232,7 @@ def test_extensions_override_agent_model_persists(tmp_path: Path): def test_extensions_harness_effort_and_per_agent_effort_override(tmp_path: Path): with _build_client(tmp_path) as client: - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} # generate_plan runs on codex and inherits the codex harness effort. assert agents["generate_plan"]["effort"] == "high" assert agents["generate_plan"]["effortSource"] == "harness" @@ -240,7 +240,7 @@ def test_extensions_harness_effort_and_per_agent_effort_override(tmp_path: Path) # Retune the codex harness effort + override one agent. client.patch("/api/settings/harnesses/codex", json={"effort": "low"}) client.patch("/api/settings/extensions", json={"agentEfforts": {"generate_plan": "high"}}) - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} # generate_plan overridden; revise_contract (also codex) inherits "low". assert agents["generate_plan"]["effort"] == "high" assert agents["generate_plan"]["effortSource"] == "agent" @@ -260,7 +260,7 @@ def test_extensions_reject_unknown_effort(tmp_path: Path): def test_extensions_harness_timeout_and_per_agent_timeout_override(tmp_path: Path): with _build_client(tmp_path) as client: - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} # implement runs on claude and inherits the claude harness timeout. assert agents["implement"]["timeout"] == 1800 assert agents["implement"]["timeoutSource"] == "harness" @@ -268,7 +268,7 @@ def test_extensions_harness_timeout_and_per_agent_timeout_override(tmp_path: Pat # Retune the claude harness timeout + override one agent. client.patch("/api/settings/harnesses/claude", json={"timeout": 1200}) client.patch("/api/settings/extensions", json={"agentTimeouts": {"implement": 3600}}) - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} # implement overridden; review_plan (also claude) inherits 1200. assert agents["implement"]["timeout"] == 3600 assert agents["implement"]["timeoutSource"] == "agent" @@ -288,7 +288,7 @@ def test_extensions_reject_non_positive_timeout(tmp_path: Path): def test_build_review_code_is_a_workflow_setting(tmp_path: Path): """Gating the code reviewer is a build-workflow boolean, not an agent flag.""" with _build_client(tmp_path) as client: - workflow = _build_extension(client)["workflows"][0] + workflow = _ship_extension(client)["workflows"][0] fields = {f["name"]: f for f in workflow["fields"]} assert fields["review_code"]["value"] is True assert fields["review_code"]["overridden"] is False @@ -298,7 +298,7 @@ def test_build_review_code_is_a_workflow_setting(tmp_path: Path): json={"workflowSettings": {workflow["kind"]: {"review_code": False}}}, ) assert patch.status_code == 200 - fields = {f["name"]: f for f in _build_extension(client)["workflows"][0]["fields"]} + fields = {f["name"]: f for f in _ship_extension(client)["workflows"][0]["fields"]} assert fields["review_code"]["value"] is False assert fields["review_code"]["overridden"] is True @@ -367,13 +367,13 @@ def test_extensions_clearing_an_override_reverts_to_the_family_default(tmp_path: client.patch( "/api/settings/extensions", json={"agentModels": {"generate_plan": "claude-opus-4-7"}} ) - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} assert agents["generate_plan"]["model"] == "claude-opus-4-7" assert agents["generate_plan"]["source"] == "agent" # Null clears the override; the agent falls back to its family default. client.patch("/api/settings/extensions", json={"agentModels": {"generate_plan": None}}) - agents = {a["name"]: a for a in _build_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_extension(client)["agents"]} assert agents["generate_plan"]["model"] == "gpt-5.5" assert agents["generate_plan"]["source"] == "default" @@ -393,12 +393,10 @@ def test_extensions_override_workflow_setting_persists(tmp_path: Path): with _build_client(tmp_path) as client: patch = client.patch( "/api/settings/extensions", - json={ - "workflowSettings": {"build.build_workflow": {"max_implementation_revisions": 8}} - }, + json={"workflowSettings": {"ship.build": {"max_implementation_revisions": 8}}}, ) assert patch.status_code == 200 - fields = {f["name"]: f for f in _build_extension(client)["workflows"][0]["fields"]} + fields = {f["name"]: f for f in _ship_extension(client)["workflows"][0]["fields"]} assert fields["max_implementation_revisions"]["value"] == 8 assert fields["max_implementation_revisions"]["overridden"] is True @@ -408,6 +406,6 @@ def test_extensions_reject_out_of_range_workflow_setting(tmp_path: Path): with _build_client(tmp_path) as client: response = client.patch( "/api/settings/extensions", - json={"workflowSettings": {"build_workflow": {"max_implementation_revisions": 99}}}, + json={"workflowSettings": {"ship.build": {"max_implementation_revisions": 99}}}, ) assert response.status_code == 422 diff --git a/backend/tests/test_api_work_items.py b/backend/tests/test_api_work_items.py index 190d451..b03c56e 100644 --- a/backend/tests/test_api_work_items.py +++ b/backend/tests/test_api_work_items.py @@ -2,7 +2,7 @@ import pytest from conftest import make_test_work_item, seed_build_run, seed_call -from druks.build.models import WorkItem +from druks.contrib.ship.models import WorkItem from fastapi.testclient import TestClient _RUN_STATE = { @@ -59,8 +59,8 @@ def _ship(repo, pr_number): item.set_status("shipped") -# The generic subject read-side — Build declares subject=WorkItem, so the -# platform mounts /api/build/work_item (list) and /{id} (detail). Build supplies +# The generic subject read-side — Ship declares subject=WorkItem, so the +# platform mounts /api/ship/work_item (list) and /{id} (detail). Ship supplies # only the domain summary; status (RunState-aggregated) and the timeline are the # platform's. See test_generic_subjects.py for the platform-side contract. @@ -75,7 +75,7 @@ def test_subject_list_shows_active_and_excludes_handed_off(client: TestClient, d _seed_op(db_session, done, state="finished") _ship(repo, 1) - rows = {r["summary"]["title"]: r for r in client.get("/api/build/work_item").json()["rows"]} + rows = {r["summary"]["title"]: r for r in client.get("/api/ship/work_item").json()["rows"]} assert "building" in rows assert "shipped one" not in rows assert rows["building"]["status"]["state"] == "running" @@ -95,7 +95,7 @@ def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, ) seed_call(db_session, run, "generate_plan") - detail = client.get(f"/api/build/work_item/{item.id}").json() + detail = client.get(f"/api/ship/work_item/{item.id}").json() assert detail["summary"]["id"] == str(item.id) assert detail["summary"]["remoteKey"] == "ACME-5" assert detail["summary"]["links"]["pr"] == "https://github.com/ClawHaven/acme-app/pull/8" @@ -112,7 +112,7 @@ def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, def test_subject_detail_unknown_is_404(client: TestClient): - assert client.get("/api/build/work_item/9999").status_code == 404 + assert client.get("/api/ship/work_item/9999").status_code == 404 def test_pending_gate_surfaces_input_request_on_the_run(db_session): @@ -165,7 +165,7 @@ def test_history_returns_only_done_work_items(client: TestClient, db_session): WorkItem.get(failed_id).update(pr_number=2) _seed_op(db_session, failed_id, state="failed") - items = client.get("/api/build/work-items/history").json()["items"] + items = client.get("/api/ship/work-items/history").json()["items"] titles = [it["title"] for it in items] assert "shipped one" in titles assert "still running" not in titles @@ -180,7 +180,7 @@ def test_pr_closed_without_merge_is_cancelled_in_history(client: TestClient, db_ _seed_op(db_session, wid, state="finished") WorkItem.get(wid).set_status("cancelled") - items = client.get("/api/build/work-items/history").json()["items"] + items = client.get("/api/ship/work-items/history").json()["items"] row = next(it for it in items if it["title"] == "abandoned") assert row["status"] == "cancelled" @@ -193,13 +193,13 @@ def test_history_clamps_limit(client: TestClient, db_session): _ship("ClawHaven/acme-app", i + 1) # limit > cap → clamps down, doesn't 400. - response = client.get("/api/build/work-items/history?limit=10000") + response = client.get("/api/ship/work-items/history?limit=10000") assert response.status_code == 200 items = response.json()["items"] assert len(items) == 3 # all three shipped; cap doesn't truncate here # limit < 1 → clamps up to 1. - response = client.get("/api/build/work-items/history?limit=0") + response = client.get("/api/ship/work-items/history?limit=0") assert response.status_code == 200 items = response.json()["items"] assert len(items) == 1 @@ -214,14 +214,14 @@ def test_repeated_runs_on_one_subject_each_surface_separately(db_session): seed_build_run(db_session, work_item_id=item.id, state="finished") entries = list_subject_timeline("work_item", str(item.id)) - assert [entry.kind for entry in entries] == ["build.build_workflow"] * 3 + assert [entry.kind for entry in entries] == ["ship.build"] * 3 assert len({entry.id for entry in entries}) == 3 def test_update_stamps_build_run_id(db_session): # build intake stamps the owning run via update(build_run_id=...); the kwarg # was missing, so every "Ready for Agent" transition threw a TypeError. - from druks.build.models import WorkItem + from druks.contrib.ship.models import WorkItem item = make_test_work_item(repo="ClawHaven/acme-app", title="x") run = seed_build_run(db_session, work_item_id=item.id, state="running") @@ -249,7 +249,7 @@ def test_timeline_shows_every_build_attempt(db_session): async def test_subject_activity_surfaces_running_phase(db_session, monkeypatch): # A running build run pushes a transient phase; the detail view's live activity # surfaces it ("Building sandbox VM…") — finer than the lifecycle status. - from druks.build import extension as build_extension + from druks.contrib.ship import extension as ship_extension item = make_test_work_item(repo="ClawHaven/acme-app", title="x") seed_build_run(db_session, work_item_id=item.id, state="running") @@ -257,8 +257,8 @@ async def test_subject_activity_surfaces_running_phase(db_session, monkeypatch): async def phase(_subject_type, _subject_id): return "provisioning_vm" - monkeypatch.setattr(build_extension, "get_subject_phase", phase) - activity = await build_extension.Build.subject_activity(item) + monkeypatch.setattr(ship_extension, "get_subject_phase", phase) + activity = await ship_extension.Ship.subject_activity(item) assert activity is not None assert activity.label == "Building sandbox VM…" assert activity.kind == "infra" @@ -266,11 +266,11 @@ async def phase(_subject_type, _subject_id): async def test_subject_activity_none_when_not_running(db_session): # A run parked on a gate isn't working — no live sub-phase. - from druks.build import extension as build_extension + from druks.contrib.ship import extension as ship_extension item = make_test_work_item(repo="ClawHaven/acme-app", title="x") seed_build_run( db_session, work_item_id=item.id, state="pending_input", input_gate="review_plan" ) - assert await build_extension.Build.subject_activity(item) is None + assert await ship_extension.Ship.subject_activity(item) is None diff --git a/backend/tests/test_build_board_membership.py b/backend/tests/test_build_board_membership.py index 49c424c..7e67f4b 100644 --- a/backend/tests/test_build_board_membership.py +++ b/backend/tests/test_build_board_membership.py @@ -1,14 +1,14 @@ -import druks.build.workflows # noqa: F401 # registers build.build_workflow, the seeded kind +import druks.contrib.ship.workflows # noqa: F401 # registers ship.build, the seeded kind import pytest from conftest import make_test_work_item, seed_build_run -from druks.build.enums import HandoffStatus -from druks.build.extension import Build -from druks.build.models import WorkItem +from druks.contrib.ship.enums import HandoffStatus +from druks.contrib.ship.extension import Ship +from druks.contrib.ship.models import WorkItem def _board_ids(db_session): db_session.expire_all() - return {row.id for row in Build.list_subjects()} + return {row.id for row in Ship.list_subjects()} @pytest.mark.parametrize("state", ["scheduled", "running", "pending_input", "failed"]) @@ -45,7 +45,7 @@ def test_an_item_without_runs_stays_off_the_board(db_session): async def test_a_cancelled_build_settles_as_cancelled(db_session): # Nothing merged, so History records the abandonment. - from druks.build.subscribers import build_end_settles_the_item + from druks.contrib.ship.subscribers import build_end_settles_the_item item = make_test_work_item(repo="ClawHaven/acme-app", title="cancelled build") await build_end_settles_the_item(subject=item) @@ -55,7 +55,7 @@ async def test_a_cancelled_build_settles_as_cancelled(db_session): async def test_a_shipped_item_keeps_its_outcome(db_session): # ship() lands first and owns the verdict; the run's own cancel must not # overwrite it on the way out. - from druks.build.subscribers import build_end_settles_the_item + from druks.contrib.ship.subscribers import build_end_settles_the_item item = make_test_work_item(repo="ClawHaven/acme-app", title="merged build") item.set_status(HandoffStatus.SHIPPED) diff --git a/backend/tests/test_build_dispatch.py b/backend/tests/test_build_dispatch.py index 118c0bd..f84de03 100644 --- a/backend/tests/test_build_dispatch.py +++ b/backend/tests/test_build_dispatch.py @@ -1,6 +1,6 @@ from conftest import make_test_work_item, seed_run -from druks.build.enums import HandoffStatus -from druks.build.workflows import BuildWorkflow +from druks.contrib.ship.enums import HandoffStatus +from druks.contrib.ship.workflows import Build async def test_dispatch_pulls_cancelled_item_back_onto_the_board(db_session, monkeypatch) -> None: @@ -14,8 +14,8 @@ async def test_dispatch_pulls_cancelled_item_back_onto_the_board(db_session, mon async def fake_start(cls, **kwargs): return "run-1" - monkeypatch.setattr(BuildWorkflow, "start", classmethod(fake_start)) - run_id = await BuildWorkflow.dispatch( + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + run_id = await Build.dispatch( ticket={ "source": item.source, "identifier": item.remote_key, @@ -48,8 +48,8 @@ async def test_redispatch_to_a_new_run_clears_prior_attempt_branch_and_pr( async def fake_start(cls, **kwargs): return "run-new" - monkeypatch.setattr(BuildWorkflow, "start", classmethod(fake_start)) - await BuildWorkflow.dispatch( + monkeypatch.setattr(Build, "start", classmethod(fake_start)) + await Build.dispatch( ticket={ "source": item.source, "identifier": item.remote_key, @@ -78,8 +78,8 @@ async def test_duplicate_dispatch_keeps_the_live_attempt_routing(db_session, mon async def dedup_start(cls, **kwargs): return "run-live" - monkeypatch.setattr(BuildWorkflow, "start", classmethod(dedup_start)) - await BuildWorkflow.dispatch( + monkeypatch.setattr(Build, "start", classmethod(dedup_start)) + await Build.dispatch( ticket={ "source": item.source, "identifier": item.remote_key, diff --git a/backend/tests/test_build_durable.py b/backend/tests/test_build_durable.py index 36a8976..6a878c9 100644 --- a/backend/tests/test_build_durable.py +++ b/backend/tests/test_build_durable.py @@ -36,7 +36,7 @@ def rt(): from fastapi import FastAPI # Load every extension first so the snapshot is the complete production registry; - # the durable workflow then overwrites build's same-named agents, and the + # the durable workflow then overwrites Ship's same-named agents, and the # wholesale restore puts the full set back. (Coexistence-only — the # collision is gone once the cutover deletes the old modules.) load(FastAPI()) @@ -53,13 +53,13 @@ def rt(): configure_engine(engine) configure_session(engine) - from druks.build.workflows import BuildWorkflow # registers on import + from druks.contrib.ship.workflows import Build # registers on import os.environ["DRUKS_DATABASE_URL"] = URL init_dbos() launch() try: - yield SimpleNamespace(engine=engine, flow=BuildWorkflow) + yield SimpleNamespace(engine=engine, flow=Build) finally: shutdown() engine.dispose() @@ -75,7 +75,7 @@ def _seed_work_item(engine, *, repo: str): # now fails the lifecycle step), so the item must exist, not just its id. from uuid import uuid4 - from druks.build.models import Project, WorkItem + from druks.contrib.ship.models import Project, WorkItem from sqlalchemy.orm import Session with Session(engine) as session: @@ -106,16 +106,16 @@ async def _wait(engine, wfid, predicate, timeout=20.0): def _stub(monkeypatch, rt, *, plan_approval="human", auto_dispatch=False): - import druks.build.workflows as m - from druks.build.contracts import ( + import druks.contrib.ship.workflows as m + from druks.contrib.ship.contracts import ( CodeReviewOutput, EvaluationOutput, ImplementationOutput, PlanData, ReviewOutput, ) - from druks.build.enums import EvaluationVerdict, ReviewDecision - from druks.build.policy import Gates, RepoPolicy + from druks.contrib.ship.enums import EvaluationVerdict, ReviewDecision + from druks.contrib.ship.policy import Gates, RepoPolicy flow = rt.flow diff --git a/backend/tests/test_build_journal.py b/backend/tests/test_build_journal.py index a172fe3..9831967 100644 --- a/backend/tests/test_build_journal.py +++ b/backend/tests/test_build_journal.py @@ -1,6 +1,6 @@ -from druks.build.contracts import ImplementationOutput, PlanData, ReviewWork, TriageOutput -from druks.build.enums import HumanFeedbackAction -from druks.build.journal import BuildJournal +from druks.contrib.ship.contracts import ImplementationOutput, PlanData, ReviewWork, TriageOutput +from druks.contrib.ship.enums import HumanFeedbackAction +from druks.contrib.ship.journal import BuildJournal def _journal(*entries) -> BuildJournal: diff --git a/backend/tests/test_build_plan_phase.py b/backend/tests/test_build_plan_phase.py index aea41e3..a17c390 100644 --- a/backend/tests/test_build_plan_phase.py +++ b/backend/tests/test_build_plan_phase.py @@ -1,24 +1,25 @@ from types import SimpleNamespace import pytest -from druks.build.contracts import ( +from druks.contrib.ship.contracts import ( PlanData, QuestionOptionOutput, QuestionOutput, ReviewOutput, ReviewWork, ) -from druks.build.enums import ReviewDecision -from druks.build.policy import RepoPolicy -from druks.build.workflows import Build, BuildWorkflow +from druks.contrib.ship.enums import ReviewDecision +from druks.contrib.ship.extension import Ship +from druks.contrib.ship.policy import RepoPolicy +from druks.contrib.ship.workflows import Build from druks.workflows import FatalError, OperatorReply, Run -def _flow(*, auto_dispatch: bool = False) -> BuildWorkflow: - flow = BuildWorkflow() +def _flow(*, auto_dispatch: bool = False) -> Build: + flow = Build() # plan_approval is undeclared: the gate resolves to "none" iff auto_dispatch. flow._policy = RepoPolicy() - flow._settings = BuildWorkflow.Settings(auto_dispatch_on_plan_approval=auto_dispatch) + flow._settings = Build.Settings(auto_dispatch_on_plan_approval=auto_dispatch) return flow @@ -30,7 +31,7 @@ async def fake_plan_agent(**kwargs): passes.append(kwargs) return next(supply) - monkeypatch.setattr(Build, "generate_plan", fake_plan_agent) + monkeypatch.setattr(Ship, "generate_plan", fake_plan_agent) return passes @@ -40,14 +41,14 @@ def _fake_grades(monkeypatch, *grades: ReviewOutput) -> None: async def fake_review_agent(): return next(supply) - monkeypatch.setattr(Build, "review_plan", fake_review_agent) + monkeypatch.setattr(Ship, "review_plan", fake_review_agent) def _no_review_agent(monkeypatch) -> None: async def fail_review_agent(): raise AssertionError("review_plan must not run here") - monkeypatch.setattr(Build, "review_plan", fail_review_agent) + monkeypatch.setattr(Ship, "review_plan", fail_review_agent) async def test_gate_mode_parks_every_plan_and_never_calls_the_reviewer(monkeypatch): @@ -344,7 +345,7 @@ async def fake_approved_work(): async def test_needs_clarification_delivery_stops_the_run(monkeypatch): """The implementer bailing (needs_clarification) fails the run with its own reason — the stop is a workflow decision now, not a contract side effect.""" - flow = BuildWorkflow() + flow = Build() async def bailed(): return SimpleNamespace( @@ -352,6 +353,6 @@ async def bailed(): summary="AC-3 requires a pure function that performs I/O", ) - monkeypatch.setattr(Build, "implement", bailed) + monkeypatch.setattr(Ship, "implement", bailed) with pytest.raises(FatalError, match="pure function"): await flow.implement() diff --git a/backend/tests/test_build_prompts.py b/backend/tests/test_build_prompts.py index dcd6303..29ceb35 100644 --- a/backend/tests/test_build_prompts.py +++ b/backend/tests/test_build_prompts.py @@ -1,14 +1,13 @@ import re -from pathlib import Path from types import SimpleNamespace import pytest -from druks.build import workflows as build_workflows -from druks.build.contracts import PlanData -from druks.build.journal import BuildJournal -from druks.build.models import Project, ProjectRepo -from druks.build.prompt_context import BuildPromptContext +from druks.contrib.ship.contracts import PlanData +from druks.contrib.ship.journal import BuildJournal +from druks.contrib.ship.models import Project, ProjectRepo +from druks.contrib.ship.prompt_context import BuildPromptContext from druks.prompts import render_prompt +from druks.prompts.resolver import PROMPTS_DIR from druks.workflows import FatalError _OP_TEMPLATES = [ @@ -60,7 +59,7 @@ def _workspace() -> SimpleNamespace: @pytest.mark.parametrize("template", _OP_TEMPLATES) async def test_build_operation_prompt_renders(template): output = await render_prompt( - f"build/build_workflow/{template}", + f"ship/build/{template}", build=_build(), verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -80,7 +79,7 @@ async def test_implement_prompt_provisions_when_no_pr_exists(): build.branch = None build.pr_number = None output = await render_prompt( - "build/build_workflow/implement.md", + "ship/build/implement.md", build=build, verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -95,7 +94,7 @@ async def test_generate_plan_prompt_quotes_operator_content(): """Free-text answers and the operator's note render block-quoted line by line — operator words stay answer content in the prompt, never instruction text.""" output = await render_prompt( - "build/build_workflow/generate_plan.md", + "ship/build/generate_plan.md", build=_build(), verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -109,7 +108,7 @@ async def test_generate_plan_prompt_quotes_operator_content(): async def test_generate_plan_prompt_quotes_the_reviewer_critique(): output = await render_prompt( - "build/build_workflow/generate_plan.md", + "ship/build/generate_plan.md", build=_build(), verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -132,7 +131,7 @@ async def test_ruled_out_approaches_cross_the_plan_review_park(): ) ) output = await render_prompt( - "build/build_workflow/implement.md", + "ship/build/implement.md", build=build, verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -144,14 +143,14 @@ async def test_ruled_out_approaches_cross_the_plan_review_park(): async def test_the_planner_resolves_the_assignee_not_the_reviewer(): # Only the planner always runs, so only its prompt owns the resolution. planner = await render_prompt( - "build/build_workflow/generate_plan.md", + "ship/build/generate_plan.md", build=_build(), verification="VERIFICATION-BLOCK", workspace=_workspace(), **_CALL_KWARGS["generate_plan.md"], ) reviewer = await render_prompt( - "build/build_workflow/review_plan.md", + "ship/build/review_plan.md", build=_build(), verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -169,7 +168,7 @@ async def test_build_prompt_orders_the_ticket_fetch(template): build = _build() build.source = "linear" output = await render_prompt( - f"build/build_workflow/{template}", + f"ship/build/{template}", build=build, verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -188,7 +187,7 @@ async def test_review_code_prompt_owns_its_followup_subissue(): build = _build() build.source = "linear" output = await render_prompt( - "build/build_workflow/review_code.md", + "ship/build/review_code.md", build=build, verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -243,7 +242,7 @@ async def test_contract_context_is_omitted_only_from_code_review(): ) for template in _OP_TEMPLATES: output = await render_prompt( - f"build/build_workflow/{template}", + f"ship/build/{template}", build=build, verification="VERIFICATION-BLOCK", workspace=_workspace(), @@ -261,10 +260,11 @@ async def test_contract_context_is_omitted_only_from_code_review(): def test_build_prompt_context_covers_template_attrs(): # Every build prompt reads build.; assert BuildPromptContext carries them # all, so a template ref can never outrun the context contract. - prompts_root = Path(build_workflows.__file__).resolve().parents[2] - prompts_dir = prompts_root / "templates/prompts/build/build_workflow" + prompts_dir = PROMPTS_DIR / "ship/build" + templates = sorted(prompts_dir.glob("*.md")) + assert templates, f"no build prompts under {prompts_dir}" attrs: set[str] = set() - for template in prompts_dir.glob("*.md"): + for template in templates: attrs |= set(re.findall(r"\bbuild\.([a-z_]+)", template.read_text())) fields = set(BuildPromptContext.__dataclass_fields__) missing = sorted(a for a in attrs if a not in fields) diff --git a/backend/tests/test_build_workspace.py b/backend/tests/test_build_workspace.py index c163cf6..3e80c3e 100644 --- a/backend/tests/test_build_workspace.py +++ b/backend/tests/test_build_workspace.py @@ -2,8 +2,8 @@ from typing import Any import pytest -from druks.build.constants import GITHUB_MCP_NAME, GITHUB_MCP_URL -from druks.build.workflows import BuildWorkflow, BuildWorkspace +from druks.contrib.ship.constants import GITHUB_MCP_NAME, GITHUB_MCP_URL +from druks.contrib.ship.workflows import Build, BuildWorkspace from druks.mcp.helpers import get_bearer_token_env_var from druks.sandbox import host as host_mod from druks.sandbox.layout import get_related_root @@ -74,10 +74,10 @@ async def fake_exec(self: Any, argv: list[str], **_kw: Any) -> Any: monkeypatch.setattr(host_mod.Sandbox, "write_secret", _noop) monkeypatch.setattr(host_mod.Sandbox, "exec", fake_exec) monkeypatch.setattr( - "druks.build.workflows.get_github_client", + "druks.contrib.ship.workflows.get_github_client", lambda _s: SimpleNamespace(token_for_repo=_token), ) - monkeypatch.setattr("druks.build.workflows.get_reviewer_github_client", reviewer) + monkeypatch.setattr("druks.contrib.ship.workflows.get_reviewer_github_client", reviewer) monkeypatch.setattr("druks.sandbox.repo.ensure", fake_ensure) return ensured, execs @@ -95,8 +95,8 @@ async def _reviewer_token(_repo: str) -> str: ) sandbox = host_mod.Sandbox(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type] - workflow = BuildWorkflow() - workflow.input = BuildWorkflow._run_input_model(repo="o/extension") + workflow = Build() + workflow.input = Build._run_input_model(repo="o/extension") kwargs = await workflow.get_workspace_kwargs(sandbox) assert ensured == ["https://github.com/o/extension"] @@ -117,8 +117,8 @@ def _no_reviewer(_s: Any) -> Any: _workspace_kwargs_stubs(monkeypatch, reviewer=_no_reviewer) sandbox = host_mod.Sandbox(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type] - workflow = BuildWorkflow() - workflow.input = BuildWorkflow._run_input_model(repo="o/extension") + workflow = Build() + workflow.input = Build._run_input_model(repo="o/extension") with pytest.raises(FatalError, match="reviewer GitHub App"): await workflow.get_workspace_kwargs(sandbox) diff --git a/backend/tests/test_contracts.py b/backend/tests/test_contracts.py index 93911e7..25852fa 100644 --- a/backend/tests/test_contracts.py +++ b/backend/tests/test_contracts.py @@ -3,8 +3,8 @@ # or the Codex harness 400s at runtime. The fake-harness tests never send the # real schema, so this guards it directly. import pytest -from druks.build import contracts as O -from druks.build.enums import ReviewDecision +from druks.contrib.ship import contracts as O +from druks.contrib.ship.enums import ReviewDecision from pydantic import ValidationError MODELS = [ diff --git a/backend/tests/test_durable_schemas.py b/backend/tests/test_durable_schemas.py index 2396db5..9470088 100644 --- a/backend/tests/test_durable_schemas.py +++ b/backend/tests/test_durable_schemas.py @@ -1,4 +1,4 @@ -from druks.build.workflows import BuildWorkflow +from druks.contrib.ship.workflows import Build from druks.durable.enums import RunState from druks.durable.exceptions import GateTimeout from druks.durable.models import AgentCall, Run @@ -30,8 +30,8 @@ def _status_of(runs, calls=None): def test_subject_state_takes_the_newest_run(): runs = [ - _run("new", "build.build_workflow", RunState.RUNNING), - _run("old", "build.build_workflow", RunState.PENDING_INPUT), + _run("new", "ship.build", RunState.RUNNING), + _run("old", "ship.build", RunState.PENDING_INPUT), ] assert _status_of(runs).state == RunState.RUNNING @@ -39,40 +39,40 @@ def test_subject_state_takes_the_newest_run(): def test_subject_state_takes_a_newer_parked_run_over_an_older_running_one(): # Recency decides, not a hardcoded state preference. runs = [ - _run("new", "build.build_workflow", RunState.PENDING_INPUT), - _run("old", "build.build_workflow", RunState.RUNNING), + _run("new", "ship.build", RunState.PENDING_INPUT), + _run("old", "ship.build", RunState.RUNNING), ] assert _status_of(runs).state == RunState.PENDING_INPUT def test_subject_state_uses_the_latest_outcome_once_every_run_is_terminal(): runs = [ - _run("new", "build.build_workflow", RunState.FINISHED), - _run("old", "build.build_workflow", RunState.FAILED), + _run("new", "ship.build", RunState.FINISHED), + _run("old", "ship.build", RunState.FAILED), ] assert _status_of(runs).state == RunState.FINISHED def test_status_surfaces_the_newest_active_runs_gate(): runs = [ - _run("new", "build.build_workflow", RunState.PENDING_INPUT, "review"), - _run("old", "build.build_workflow", RunState.PENDING_INPUT, "review_work"), + _run("new", "ship.build", RunState.PENDING_INPUT, "review"), + _run("old", "ship.build", RunState.PENDING_INPUT, "review_work"), ] assert _status_of(runs).gate == "review" def test_status_carries_the_running_runs_kind_and_no_stale_gate(): runs = [ - _run("new", "build.build_workflow", RunState.RUNNING), - _run("old", "build.build_workflow", RunState.PENDING_INPUT, "review"), + _run("new", "ship.build", RunState.RUNNING), + _run("old", "ship.build", RunState.PENDING_INPUT, "review"), ] status = _status_of(runs) - assert status.kind == "build.build_workflow" + assert status.kind == "ship.build" assert not status.gate def test_status_carries_the_latest_agent_call_agent(): - runs = [_run("new", "build.build_workflow", RunState.RUNNING)] + runs = [_run("new", "ship.build", RunState.RUNNING)] calls = [AgentCall(agent="generate_plan"), AgentCall(agent="implement")] assert _status_of(runs, calls).agent == "implement" @@ -81,7 +81,7 @@ def test_parked_status_carries_no_agent_even_when_calls_are_handed_in(): # The detail read passes the parked run's calls; the fact stays consistent # with the board, where a parked row never queries them. runs = [ - _run("new", "build.build_workflow", RunState.PENDING_INPUT, "review"), + _run("new", "ship.build", RunState.PENDING_INPUT, "review"), ] calls = [AgentCall(agent="implement")] status = _status_of(runs, calls) @@ -93,16 +93,16 @@ def test_status_carries_the_gate_timeout_reason(): # The gate timeout's stamped failure_code rides the status as ``reason`` — # the board renders the re-trigger hint from it instead of a bare "failed". runs = [ - _run("new", BuildWorkflow.kind, RunState.FAILED, failure_code=GateTimeout.code), + _run("new", Build.kind, RunState.FAILED, failure_code=GateTimeout.code), ] status = _status_of(runs) assert status.reason == GateTimeout.code - assert status.kind == BuildWorkflow.kind + assert status.kind == Build.kind def test_status_carries_failure_but_no_reason_when_the_run_crashed(): runs = [ - _run("new", BuildWorkflow.kind, RunState.FAILED, failure="boom"), + _run("new", Build.kind, RunState.FAILED, failure="boom"), ] status = _status_of(runs) assert not status.reason diff --git a/backend/tests/test_events_feed.py b/backend/tests/test_events_feed.py index b2417ae..52da6c0 100644 --- a/backend/tests/test_events_feed.py +++ b/backend/tests/test_events_feed.py @@ -9,10 +9,10 @@ def test_feed_reads_run_and_milestone_events(db_session): Event.emit( type="run.running", subject=item.identity, - extension="build", - payload={"kind": "build.build_workflow", "run": "wf1"}, + extension="ship", + payload={"kind": "ship.build", "run": "wf1"}, ) - Event.emit(type="shipped", subject=item.identity, extension="build") + Event.emit(type="shipped", subject=item.identity, extension="ship") db_session.flush() page, _ = build_feed() @@ -21,7 +21,7 @@ def test_feed_reads_run_and_milestone_events(db_session): shipped = by_kind["milestone.shipped"] assert shipped.link_path == f"/work-items/{item.id}" assert "ACME-9" in shipped.summary - assert "build_workflow started" in by_kind["run.running"].summary + assert "build started" in by_kind["run.running"].summary def test_feed_paginates_same_second_events_without_loss_or_repeat(db_session): diff --git a/backend/tests/test_extension_config.py b/backend/tests/test_extension_config.py index da84a94..33c2004 100644 --- a/backend/tests/test_extension_config.py +++ b/backend/tests/test_extension_config.py @@ -1,6 +1,6 @@ import pytest from conftest import make_settings -from druks.build.policy import RepoPolicy +from druks.contrib.ship.policy import RepoPolicy from druks.extensions.config import resolve_extension_config from druks.extensions.exceptions import ExtensionConfigError @@ -26,16 +26,16 @@ async def test_only_the_repo_tier_is_fetched(self, monkeypatch): """No org-wide .druks tier — one repo, one file, one fetch.""" calls = _fake_fetch( monkeypatch, - {(REPO, ".druks/build/config.yml"): "sandbox:\n image: repo-image\n"}, + {(REPO, ".druks/ship/config.yml"): "sandbox:\n image: repo-image\n"}, ) policy = await RepoPolicy.resolve(REPO) assert policy.sandbox.image == "repo-image" - assert calls == [(REPO, ".druks/build/config.yml")] + assert calls == [(REPO, ".druks/ship/config.yml")] async def test_unknown_key_fails_loudly(self, monkeypatch): """A typo'd key is a ExtensionConfigError at resolution, not a silent no-op.""" - _fake_fetch(monkeypatch, {(REPO, ".druks/build/config.yml"): "verficiation: {}\n"}) - with pytest.raises(ExtensionConfigError, match="invalid build config"): + _fake_fetch(monkeypatch, {(REPO, ".druks/ship/config.yml"): "verficiation: {}\n"}) + with pytest.raises(ExtensionConfigError, match="invalid ship config"): await RepoPolicy.resolve(REPO) async def test_no_file_yields_defaults(self, monkeypatch): @@ -45,7 +45,7 @@ async def test_no_file_yields_defaults(self, monkeypatch): async def test_repo_none_skips_fetching_entirely(self, monkeypatch): calls = _fake_fetch(monkeypatch, {}) - await resolve_extension_config("build", repo=None, model=RepoPolicy) + await resolve_extension_config("ship", repo=None, model=RepoPolicy) assert calls == [] @@ -71,8 +71,8 @@ async def aclose(self): pass self._wire(monkeypatch, tmp_path, _GitHub()) - assert await fetch_file(repo=REPO, path=".druks/build/config.yml") is None - assert await fetch_file(repo=REPO, path=".druks/build/config.yml") is None + assert await fetch_file(repo=REPO, path=".druks/ship/config.yml") is None + assert await fetch_file(repo=REPO, path=".druks/ship/config.yml") is None assert len(fetches) == 1 async def test_fetch_error_propagates(self, monkeypatch, tmp_path): @@ -87,15 +87,15 @@ async def aclose(self): self._wire(monkeypatch, tmp_path, _GitHub()) with pytest.raises(RuntimeError, match="github down"): - await fetch_file(repo=REPO, path=".druks/build/config.yml") + await fetch_file(repo=REPO, path=".druks/ship/config.yml") class TestExtensionPromptOverridePaths: - """Unlike build's config.yml, prompt overrides keep the org-then-repo tiering.""" + """Unlike Ship's config.yml, prompt overrides keep the org-then-repo tiering.""" async def test_repo_extension_dir_checked_before_org(self, monkeypatch): - """``build/build_workflow/*`` resolves via the repo's - ``.druks/build/prompts/...`` first, then the org's extension dir.""" + """``ship/build/*`` resolves via the repo's + ``.druks/ship/prompts/...`` first, then the org's extension dir.""" from druks.prompts.resolver import _resolve_override calls: list[tuple[str, str]] = [] @@ -105,23 +105,23 @@ async def fetch(*, repo: str, path: str) -> str | None: return None monkeypatch.setattr("druks.prompts.resolver.fetch_file", fetch) - body = await _resolve_override("build/build_workflow/implement.md", repo=REPO) + body = await _resolve_override("ship/build/implement.md", repo=REPO) assert body is None assert calls == [ - (REPO, ".druks/build/prompts/build_workflow/implement.md"), - (ORG_DRUKS, "build/prompts/build_workflow/implement.md"), + (REPO, ".druks/ship/prompts/build/implement.md"), + (ORG_DRUKS, "ship/prompts/build/implement.md"), ] async def test_repo_extension_dir_wins(self, monkeypatch): from druks.prompts.resolver import _resolve_override async def fetch(*, repo: str, path: str) -> str | None: - if (repo, path) == (REPO, ".druks/build/prompts/build_workflow/implement.md"): + if (repo, path) == (REPO, ".druks/ship/prompts/build/implement.md"): return "tuned" return None monkeypatch.setattr("druks.prompts.resolver.fetch_file", fetch) - assert await _resolve_override("build/build_workflow/implement.md", repo=REPO) == "tuned" + assert await _resolve_override("ship/build/implement.md", repo=REPO) == "tuned" class TestLoadPolicyAndProfile: @@ -144,14 +144,14 @@ async def _run_step(_options, fn): configure_engine(None) def _flow(self, *, repo): - from druks.build.workflows import BuildWorkflow + from druks.contrib.ship.workflows import Build - flow = BuildWorkflow() - flow.input = BuildWorkflow._run_input_model(repo=repo, pr_number=1, branch="agent/x") + flow = Build() + flow.input = Build._run_input_model(repo=repo, pr_number=1, branch="agent/x") return flow async def test_resolves_live(self, db_session, monkeypatch): - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo async def _live(cls, repo): return RepoPolicy.model_validate({"sandbox": {"image": "live"}}) @@ -174,7 +174,7 @@ async def test_full_policy_yaml_round_trips(self, monkeypatch): _fake_fetch( monkeypatch, { - (REPO, ".druks/build/config.yml"): ( + (REPO, ".druks/ship/config.yml"): ( "gates:\n" " plan_approval: none\n" " implementation_approval: human\n" @@ -193,16 +193,16 @@ async def test_full_policy_yaml_round_trips(self, monkeypatch): assert policy.verification.test_commands == ("make test",) async def test_absent_verification_key_means_no_pin(self, monkeypatch): - _fake_fetch(monkeypatch, {(REPO, ".druks/build/config.yml"): "on_approval: none\n"}) + _fake_fetch(monkeypatch, {(REPO, ".druks/ship/config.yml"): "on_approval: none\n"}) policy = await RepoPolicy.resolve(REPO) assert policy.verification is None async def test_unknown_gate_value_fails_loudly(self, monkeypatch): _fake_fetch( monkeypatch, - {(REPO, ".druks/build/config.yml"): "gates: {implementation_approval: agent}\n"}, + {(REPO, ".druks/ship/config.yml"): "gates: {implementation_approval: agent}\n"}, ) - with pytest.raises(ExtensionConfigError, match="invalid build config"): + with pytest.raises(ExtensionConfigError, match="invalid ship config"): await RepoPolicy.resolve(REPO) async def test_gates_default_to_inherit(self, monkeypatch): diff --git a/backend/tests/test_extension_loader.py b/backend/tests/test_extension_loader.py index eba454a..6d53d67 100644 --- a/backend/tests/test_extension_loader.py +++ b/backend/tests/test_extension_loader.py @@ -3,15 +3,15 @@ _PLATFORM_ROOT = Path(__file__).resolve().parent.parent / "druks" -def test_import_extension_models_registers_build_via_generic_discovery(): - # build's tables are unprefixed (they live in core's schema), so it flows through the +def test_import_extension_models_registers_ship_via_generic_discovery(): + # Ship's tables are unprefixed (they live in core's schema), so it flows through the # same iter_extensions() path as any extension — exempt via prefix_tables=False, not a # hardcoded platform import. - from druks.build.extension import Build + from druks.contrib.ship.extension import Ship from druks.extensions.loader import import_extension_models from druks.models import Base - assert Build.prefix_tables is False + assert Ship.prefix_tables is False import_extension_models() # idempotent; raises if the unprefixed tables aren't exempt assert {"projects", "work_items", "project_repos"} <= set(Base.metadata.tables) @@ -22,5 +22,5 @@ def test_platform_does_not_import_an_extension_package(): # unbundling one can't break init_db. for module in ("extensions/loader.py", "database.py"): source = (_PLATFORM_ROOT / module).read_text() - assert "import druks.build" not in source, f"{module} imports the build extension" + assert "import druks.contrib" not in source, f"{module} imports the contrib namespace" assert "import druks.usage" not in source, f"{module} imports the usage extension" diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py index 8eb6f94..0e3d254 100644 --- a/backend/tests/test_extensions.py +++ b/backend/tests/test_extensions.py @@ -9,12 +9,12 @@ def test_iter_extensions_discovers_the_in_tree_extensions(): """The in-tree extensions resolve from the ``druks.extensions`` entry points.""" - assert {extension.name for extension in iter_extensions()} == {"build", "core", "usage"} + assert {extension.name for extension in iter_extensions()} == {"core", "ship", "usage"} -def test_build_app_derives_its_package_from_the_defining_module(): - build = next(extension for extension in iter_extensions() if extension.name == "build") - assert build.package == "druks.build" # Build lives in druks.build.extension +def test_ship_app_derives_its_package_from_the_defining_module(): + ship = next(extension for extension in iter_extensions() if extension.name == "ship") + assert ship.package == "druks.contrib.ship" def test_extension_without_a_name_is_rejected(): diff --git a/backend/tests/test_lane_reactions.py b/backend/tests/test_lane_reactions.py index 35cac56..02759a5 100644 --- a/backend/tests/test_lane_reactions.py +++ b/backend/tests/test_lane_reactions.py @@ -1,10 +1,10 @@ -import druks.build.subscribers # noqa: F401 — registers the lane reactions +import druks.contrib.ship.subscribers # noqa: F401 — registers the lane reactions import pytest from conftest import make_test_work_item, seed_dbos_status -from druks.build.contracts import ReviewWork -from druks.build.enums import HandoffStatus -from druks.build.models import WorkItem -from druks.build.workflows import BuildWorkflow +from druks.contrib.ship.contracts import ReviewWork +from druks.contrib.ship.enums import HandoffStatus +from druks.contrib.ship.models import WorkItem +from druks.contrib.ship.workflows import Build from druks.durable import Run from druks.signals import publish from druks.ticketing.enums import TicketStatus @@ -17,7 +17,7 @@ async def test_run_running_puts_item_back_on_board(db_session): item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-1") item.set_status(HandoffStatus.CANCELLED) - await publish("run.running", subject=item.identity, kind=BuildWorkflow.kind) + await publish("run.running", subject=item.identity, kind=Build.kind) assert WorkItem.get(item.id).status is None @@ -32,11 +32,11 @@ async def _push(self, status): item = make_test_work_item(repo="acme/widget", title="t", source="linear", remote_key="ACME-7") subject = item.identity - await publish("run.running", subject=subject, kind=BuildWorkflow.kind) - await publish("run.pending_input", subject=subject, kind=BuildWorkflow.kind, gate="review_work") + await publish("run.running", subject=subject, kind=Build.kind) + await publish("run.pending_input", subject=subject, kind=Build.kind, gate="review_work") # Other gates and other kinds don't push. - await publish("run.pending_input", subject=subject, kind=BuildWorkflow.kind, gate="review_plan") - await publish("run.running", subject=subject, kind="build.profile") + await publish("run.pending_input", subject=subject, kind=Build.kind, gate="review_plan") + await publish("run.running", subject=subject, kind="ship.profile") assert pushed == [TicketStatus.IN_PROGRESS, TicketStatus.IN_REVIEW] @@ -46,7 +46,7 @@ async def test_pr_review_answers_through_the_review_gate(db_session, monkeypatch item.update(pr_number=12, branch="agent/acme-9") run = Run( id=str(uuid7()), - kind=BuildWorkflow.kind, + kind=Build.kind, input_gate=ReviewWork.name, input_request={"presentation": "external", "label": "Review implementation"}, ) @@ -96,7 +96,7 @@ async def test_provision_state_reaches_the_work_item(db_session): await publish( "run.state", subject=item.identity, - kind=BuildWorkflow.kind, + kind=Build.kind, pr_number=12, branch="agent/eng-8", ticket_ref="ACME-8", diff --git a/backend/tests/test_manifest.py b/backend/tests/test_manifest.py index e3a6e33..8d984d3 100644 --- a/backend/tests/test_manifest.py +++ b/backend/tests/test_manifest.py @@ -58,7 +58,7 @@ def test_manifest_records_the_delivered_capability_set(db_session): url="https://api.githubcopilot.com/mcp/", bearer_token_env_var=get_bearer_token_env_var("github"), ) - # Both servers delivered with their token — github is build's own + # Both servers delivered with their token — github is Ship's own # requirement (get_required_mcp_servers), so it reads delivered but not declared. manifest = _build( mcp_servers=(linear, github), diff --git a/backend/tests/test_mcp_servers.py b/backend/tests/test_mcp_servers.py index 1786f85..7e05394 100644 --- a/backend/tests/test_mcp_servers.py +++ b/backend/tests/test_mcp_servers.py @@ -47,7 +47,7 @@ async def _delivery(**kwargs) -> dict: def _requiring_workspace(*servers: RequiredMcpServer) -> Workspace: # A workspace declaring the servers it requires and credentials itself, - # as build does. + # as Ship does. class _Requiring(Workspace): def get_required_mcp_servers(self) -> tuple[RequiredMcpServer, ...]: return servers @@ -120,7 +120,7 @@ async def test_delivery_carries_static_token_in_env(db_session): async def test_required_server_delivers_beside_the_registry(db_session): # A workspace declares a server with a run-scoped token it minted itself - # (build's per-repo reviewer token): wire shape + env var ride the same + # (Ship's per-repo reviewer token): wire shape + env var ride the same # seam as every registry server. McpServer.create(name="linear", url=_LINEAR_URL, token=_TOKEN) workspace = _requiring_workspace( @@ -456,8 +456,8 @@ def _static_entry(url): def test_packaged_catalog_ships_linear_disabled(registry_state, db_session): # The one packaged default: Linear's hosted MCP, shipped dark — an oauth # entry has no grant until an operator connects it, and an enabled - # unconnected one would fail every run's delivery. build's github MCP is - # build's own requirement (get_required_mcp_servers), never a catalog entry. + # unconnected one would fail every run's delivery. Ship's github MCP is + # Ship's own requirement (get_required_mcp_servers), never a catalog entry. load_mcp_catalog(PACKAGED_MCP_CATALOG) assert "github" not in mcp_servers diff --git a/backend/tests/test_profiling.py b/backend/tests/test_profiling.py index 4ebac23..17fb305 100644 --- a/backend/tests/test_profiling.py +++ b/backend/tests/test_profiling.py @@ -1,9 +1,9 @@ import pytest -from druks.build.contracts import RepoProfilerOutput -from druks.build.extension import Build -from druks.build.models import Project, ProjectRepo -from druks.build.policy import RepoPolicy, VerificationProfile -from druks.build.workflows import Profile +from druks.contrib.ship.contracts import RepoProfilerOutput +from druks.contrib.ship.extension import Ship +from druks.contrib.ship.models import Project, ProjectRepo +from druks.contrib.ship.policy import RepoPolicy, VerificationProfile +from druks.contrib.ship.workflows import Profile from druks.skills.datastructures import InstalledSkill from druks.skills.models import SkillCollection @@ -103,7 +103,7 @@ async def test_persists_baseline_and_effective(self, db_session, monkeypatch): async def _profiler(*, repo: str): return _profiled() - monkeypatch.setattr(Build, "repo_profiler", _profiler) + monkeypatch.setattr(Ship, "repo_profiler", _profiler) monkeypatch.setattr(RepoPolicy, "resolve", staticmethod(_no_policy)) await Profile().run(repo_id=repo.id) @@ -124,7 +124,7 @@ async def _profiler(*, repo: str): recommended_skills=["django-patterns", "retired-skill", "made-up-skill"] ) - monkeypatch.setattr(Build, "repo_profiler", _profiler) + monkeypatch.setattr(Ship, "repo_profiler", _profiler) monkeypatch.setattr(RepoPolicy, "resolve", staticmethod(_no_policy)) await Profile().run(repo_id=repo.id) @@ -141,7 +141,7 @@ async def _profiler(*, repo: str): async def _pinning_policy(repo): return RepoPolicy(verification=VerificationProfile(test_commands=("make test",))) - monkeypatch.setattr(Build, "repo_profiler", _profiler) + monkeypatch.setattr(Ship, "repo_profiler", _profiler) monkeypatch.setattr(RepoPolicy, "resolve", staticmethod(_pinning_policy)) await Profile().run(repo_id=repo.id) @@ -166,7 +166,7 @@ async def _boom(*, repo: str): async def _pinning_policy(repo): return RepoPolicy(verification=VerificationProfile(test_commands=("make test",))) - monkeypatch.setattr(Build, "repo_profiler", _boom) + monkeypatch.setattr(Ship, "repo_profiler", _boom) monkeypatch.setattr(RepoPolicy, "resolve", staticmethod(_pinning_policy)) await Profile().run(repo_id=repo.id, refresh_only=True) @@ -183,7 +183,7 @@ class TestProfileStatus: failed, so there's no separate 'ready but stale' state.""" def _summary(self, repo): - from druks.build.schemas import ProjectRepoSummary + from druks.contrib.ship.schemas import ProjectRepoSummary return ProjectRepoSummary.from_repo(repo) @@ -192,7 +192,7 @@ def _seed_run(self, db_session, repo, *, state, failure=None): from druks.durable import Run from uuid_utils import uuid7 - run = Run(id=str(uuid7()), kind="build.profile", failure=failure) + run = Run(id=str(uuid7()), kind="ship.profile", failure=failure) db_session.add(run) db_session.flush() seed_dbos_status(db_session, run.id, state, subject={"type": "project_repo", "id": repo.id}) diff --git a/backend/tests/test_project_repo_routes.py b/backend/tests/test_project_repo_routes.py index 4d0cda5..ef4d1fe 100644 --- a/backend/tests/test_project_repo_routes.py +++ b/backend/tests/test_project_repo_routes.py @@ -17,7 +17,7 @@ def client(tmp_path: Path, db_session, monkeypatch): def _stub_profile_dispatch(monkeypatch): """Profile.dispatch hits DBOS's queue — route tests only prove they hand it the registered repo.""" - from druks.build.workflows import Profile + from druks.contrib.ship.workflows import Profile calls: list[dict] = [] @@ -32,9 +32,9 @@ async def _dispatch(cls, repo, *, refresh_only=False): def test_adding_a_repo_dispatches_a_profile_run(client: TestClient, monkeypatch): calls = _stub_profile_dispatch(monkeypatch) - project = client.post("/api/build/projects", json={"name": "Acme"}).json() + project = client.post("/api/ship/projects", json={"name": "Acme"}).json() repo = client.post( - f"/api/build/projects/{project['id']}/repos", + f"/api/ship/projects/{project['id']}/repos", json={"fullName": "acme/widget"}, ).json() @@ -50,13 +50,13 @@ def test_adding_a_repo_dispatches_a_profile_run(client: TestClient, monkeypatch) def test_profile_endpoint_dispatches(client: TestClient, monkeypatch): # Concurrency is the Profile workflow's subject-unique lock, not the route's # job — the route always dispatches and start() dedups against a live run. - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo calls = _stub_profile_dispatch(monkeypatch) project = Project.create(name="Acme") repo = ProjectRepo.create(project_id=project.id, full_name="acme/widget") - response = client.post(f"/api/build/projects/{project.id}/repos/{repo.id}/profile") + response = client.post(f"/api/ship/projects/{project.id}/repos/{repo.id}/profile") assert response.status_code == 200 assert calls == [ @@ -70,14 +70,14 @@ def test_profile_endpoint_dispatches(client: TestClient, monkeypatch): def test_nested_repo_routes_are_scoped_to_their_project(client: TestClient, monkeypatch): """PATCH / profile / DELETE reached through the wrong project's URL are 404 and side-effect-free — the routes scope by (project_id, repo_id), not repo_id alone.""" - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo profile_calls = _stub_profile_dispatch(monkeypatch) owner = Project.create(name="Owner") other = Project.create(name="Other") repo_id = ProjectRepo.create(project_id=owner.id, full_name="acme/widget").id - wrong = f"/api/build/projects/{other.id}/repos/{repo_id}" + wrong = f"/api/ship/projects/{other.id}/repos/{repo_id}" assert client.patch(wrong, json={"purpose": "infra"}).status_code == 404 assert client.post(f"{wrong}/profile").status_code == 404 assert client.delete(wrong).status_code == 404 @@ -86,7 +86,7 @@ def test_nested_repo_routes_are_scoped_to_their_project(client: TestClient, monk assert profile_calls == [] # Through its own project the repo mutates and deletes as normal. - right = f"/api/build/projects/{owner.id}/repos/{repo_id}" + right = f"/api/ship/projects/{owner.id}/repos/{repo_id}" patched = client.patch(right, json={"purpose": "infra"}) assert patched.status_code == 200 assert patched.json()["purpose"] == "infra" diff --git a/backend/tests/test_prompt_override_sandbox.py b/backend/tests/test_prompt_override_sandbox.py index fd609a5..fe0aa91 100644 --- a/backend/tests/test_prompt_override_sandbox.py +++ b/backend/tests/test_prompt_override_sandbox.py @@ -17,11 +17,11 @@ async def test_override_cannot_reach_globals(monkeypatch): "{{ self.__init__.__globals__['os'].system('touch /tmp/pwned') }}", monkeypatch ) with pytest.raises(SecurityError): - await render_prompt("build/build_workflow/setup.md", repo="owner/repo") + await render_prompt("ship/build/setup.md", repo="owner/repo") async def test_override_cannot_mutate_context(monkeypatch): # Immutable sandbox: an override can't mutate a live object from context either. await _serve_override("{{ items.append(1) }}", monkeypatch) with pytest.raises(SecurityError): - await render_prompt("build/build_workflow/setup.md", repo="owner/repo", items=[]) + await render_prompt("ship/build/setup.md", repo="owner/repo", items=[]) diff --git a/backend/tests/test_repo_routing.py b/backend/tests/test_repo_routing.py index 63e16c0..437744a 100644 --- a/backend/tests/test_repo_routing.py +++ b/backend/tests/test_repo_routing.py @@ -1,4 +1,4 @@ -from druks.build.models import Project, ProjectRepo +from druks.contrib.ship.models import Project, ProjectRepo def _register(db_session, *full_names): diff --git a/backend/tests/test_run_state.py b/backend/tests/test_run_state.py index 71c8f87..7ce97b8 100644 --- a/backend/tests/test_run_state.py +++ b/backend/tests/test_run_state.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime, timedelta from unittest import mock -import druks.build.workflows # noqa: F401 # registers build.build_workflow, the seeded kind +import druks.contrib.ship.workflows # noqa: F401 # registers ship.build, the seeded kind import pytest from conftest import make_test_work_item, seed_build_run, seed_run from dbos._error import DBOSWorkflowCancelledError diff --git a/backend/tests/test_settings_overrides.py b/backend/tests/test_settings_overrides.py index a22b80b..8d77be2 100644 --- a/backend/tests/test_settings_overrides.py +++ b/backend/tests/test_settings_overrides.py @@ -7,17 +7,17 @@ def test_extension_setting_override_then_default(db_session): # No override → the declared default passed by the caller. - assert SettingsOverride.extension_setting("build", "auto_merge", True) is True + assert SettingsOverride.extension_setting("ship", "auto_merge", True) is True # An override wins — including turning it off. - SettingsOverride.set_extension_setting("build", "auto_merge", False) - assert SettingsOverride.extension_setting("build", "auto_merge", True) is False + SettingsOverride.set_extension_setting("ship", "auto_merge", False) + assert SettingsOverride.extension_setting("ship", "auto_merge", True) is False # Clearing reverts to the caller's default. - SettingsOverride.set_extension_setting("build", "auto_merge", None) - assert SettingsOverride.extension_setting("build", "auto_merge", True) is True + SettingsOverride.set_extension_setting("ship", "auto_merge", None) + assert SettingsOverride.extension_setting("ship", "auto_merge", True) is True def test_workflow_setting_namespaced_by_kind(db_session): - SettingsOverride.set_workflow_setting("build_workflow", "shared", "a") + SettingsOverride.set_workflow_setting("ship.build", "shared", "a") SettingsOverride.set_workflow_setting("other_workflow", "shared", "b") - assert SettingsOverride.workflow_setting("build_workflow", "shared", None) == "a" + assert SettingsOverride.workflow_setting("ship.build", "shared", None) == "a" assert SettingsOverride.workflow_setting("other_workflow", "shared", None) == "b" diff --git a/backend/tests/test_signals.py b/backend/tests/test_signals.py index 5b7250e..499f18c 100644 --- a/backend/tests/test_signals.py +++ b/backend/tests/test_signals.py @@ -1,6 +1,6 @@ import pytest from conftest import make_test_work_item -from druks.build.models import ProjectRepo, WorkItem +from druks.contrib.ship.models import ProjectRepo, WorkItem from druks.signals import publish, subscribe from druks.workflows import Workflow from sqlalchemy.orm import object_session diff --git a/backend/tests/test_subject_lifecycle.py b/backend/tests/test_subject_lifecycle.py index 999f700..d4f2c9e 100644 --- a/backend/tests/test_subject_lifecycle.py +++ b/backend/tests/test_subject_lifecycle.py @@ -2,9 +2,9 @@ import pytest from conftest import make_test_work_item, seed_dbos_status -from druks.build.contracts import ReviewWork -from druks.build.models import WorkItem -from druks.build.workflows import BuildWorkflow, Profile +from druks.contrib.ship.contracts import ReviewWork +from druks.contrib.ship.models import WorkItem +from druks.contrib.ship.workflows import Build, Profile from druks.durable.models import Run from druks.durable.reads import get_subject_phase from druks.models import Base @@ -47,7 +47,7 @@ async def test_gate_answer_resumes_only_a_run_parked_on_its_gate(db_session, mon parked = _subject_run( db_session, subject=subject, - kind=BuildWorkflow.kind, + kind=Build.kind, state="pending_input", gate=OperatorReply.name, ) @@ -66,7 +66,7 @@ async def resume(self, **reply): _subject_run( db_session, subject=timed_out, - kind=BuildWorkflow.kind, + kind=Build.kind, state="failed", gate=ReviewWork.name, ) @@ -81,7 +81,7 @@ async def test_workflow_cancel_takes_its_own_kind_and_passes_over_idle_subjects( # Webhooks redeliver, and a PR can close long after its build ended: cancelling what # is already gone is the no-op the caller expects, not an error. subject = _work_item(remote_key="ENG-748-C") - build = _subject_run(db_session, subject=subject, kind=BuildWorkflow.kind, state="running") + build = _subject_run(db_session, subject=subject, kind=Build.kind, state="running") _subject_run(db_session, subject=subject, kind=Profile.kind, state="running", order=1) cancelled = [] @@ -90,12 +90,12 @@ async def cancel(self, *, failure=None): monkeypatch.setattr(Run, "cancel", cancel) - await BuildWorkflow.cancel(subject) + await Build.cancel(subject) assert cancelled == [build.id] idle = _work_item(remote_key="ENG-748-D") - _subject_run(db_session, subject=idle, kind=BuildWorkflow.kind, state="finished") - await BuildWorkflow.cancel(idle) + _subject_run(db_session, subject=idle, kind=Build.kind, state="finished") + await Build.cancel(idle) assert cancelled == [build.id] @@ -104,7 +104,7 @@ async def test_subject_phase_reads_the_driving_running_workflow(db_session, monk _subject_run( db_session, subject=subject, - kind=BuildWorkflow.kind, + kind=Build.kind, state="pending_input", gate=OperatorReply.name, ) diff --git a/backend/tests/test_ticketing.py b/backend/tests/test_ticketing.py index 0402fe6..62df581 100644 --- a/backend/tests/test_ticketing.py +++ b/backend/tests/test_ticketing.py @@ -188,7 +188,7 @@ async def aclose(self): @pytest.mark.asyncio async def test_remote_state_pushes_status(db_session, monkeypatch): from conftest import make_test_work_item - from druks.build import models + from druks.contrib.ship import models item = make_test_work_item(repo="acme/widget", source="linear", remote_key="ACME-1", title="t") fake = _FakeTracker() @@ -211,7 +211,7 @@ async def test_remote_state_skips_non_tracker_source(db_session): @pytest.mark.asyncio async def test_remote_state_closes_on_failure(db_session, monkeypatch): from conftest import make_test_work_item - from druks.build import models + from druks.contrib.ship import models from druks.core.apis.linear import LinearAPIError item = make_test_work_item(repo="acme/widget", source="linear", remote_key="ACME-2", title="t") diff --git a/backend/tests/test_webhooks_jira.py b/backend/tests/test_webhooks_jira.py index a780b83..5ea7239 100644 --- a/backend/tests/test_webhooks_jira.py +++ b/backend/tests/test_webhooks_jira.py @@ -1,7 +1,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock -import druks.build.subscribers as subs +import druks.contrib.ship.subscribers as subs import druks.core.webhooks.jira as jira_mod import pytest from conftest import make_settings, seed_run @@ -146,15 +146,15 @@ async def _emit(event_type, **kwargs): def _pin_settings(monkeypatch, **over): - settings = subs.Build.Settings(**over) - monkeypatch.setattr(subs.Build, "settings", classmethod(lambda cls: settings)) + settings = subs.Ship.Settings(**over) + monkeypatch.setattr(subs.Ship, "settings", classmethod(lambda cls: settings)) async def test_trigger_status_dispatches_build_with_the_webhook_payload(tmp_path, monkeypatch): """The build funnel receives the normalized ticket payload without a refetch.""" _pin_settings(monkeypatch, jira_trigger_status="Ready") build = AsyncMock() - monkeypatch.setattr(subs.BuildWorkflow, "dispatch", build) + monkeypatch.setattr(subs.Build, "dispatch", build) payload = _jira_payload(status="Ready") await subs.ticket_transition_drives_the_funnel(payload=payload) @@ -164,7 +164,7 @@ async def test_trigger_status_dispatches_build_with_the_webhook_payload(tmp_path async def test_trigger_status_routes_a_new_ticket_by_label(tmp_path, db_session, monkeypatch): """No work item yet: the label names the repo, the registry routes it.""" - from druks.build.models import Project, ProjectRepo, WorkItem + from druks.contrib.ship.models import Project, ProjectRepo, WorkItem project = Project.create(name="octo/alfred") ProjectRepo.create(project_id=project.id, full_name="octo/alfred") @@ -175,7 +175,7 @@ async def test_trigger_status_routes_a_new_ticket_by_label(tmp_path, db_session, async def fake_start(cls, **kwargs): return "run-new" - monkeypatch.setattr(subs.BuildWorkflow, "start", classmethod(fake_start)) + monkeypatch.setattr(subs.Build, "start", classmethod(fake_start)) await subs.ticket_transition_drives_the_funnel( payload=_jira_payload(key="SHRP-1", status="Ready", project="Octo", labels=["Alfred"]), @@ -191,7 +191,7 @@ async def test_trigger_status_ignores_an_unroutable_ticket(tmp_path, db_session, """No signal matches a registered repo → no build.""" _pin_settings(monkeypatch, jira_trigger_status="Ready") start = AsyncMock() - monkeypatch.setattr(subs.BuildWorkflow, "start", start) + monkeypatch.setattr(subs.Build, "start", start) await subs.ticket_transition_drives_the_funnel( payload=_jira_payload(key="SHRP-2", status="Ready", project="Octo"), @@ -203,7 +203,7 @@ async def test_trigger_status_ignores_an_unroutable_ticket(tmp_path, db_session, async def test_refinement_candidate_status_no_longer_dispatches(tmp_path, monkeypatch): _pin_settings(monkeypatch, jira_trigger_status="Ready") build = AsyncMock() - monkeypatch.setattr(subs.BuildWorkflow, "dispatch", build) + monkeypatch.setattr(subs.Build, "dispatch", build) await subs.ticket_transition_drives_the_funnel(payload=_jira_payload(status="Backlog")) diff --git a/backend/tests/test_webhooks_pull_request.py b/backend/tests/test_webhooks_pull_request.py index f481aee..3b167ce 100644 --- a/backend/tests/test_webhooks_pull_request.py +++ b/backend/tests/test_webhooks_pull_request.py @@ -1,9 +1,9 @@ from types import SimpleNamespace -import druks.build.subscribers # noqa: F401 — registers the pr.closed subscriber +import druks.contrib.ship.subscribers # noqa: F401 — registers the pr.closed subscriber import pytest from conftest import make_settings, make_test_work_item, seed_build_run -from druks.build.models import WorkItem +from druks.contrib.ship.models import WorkItem from druks.core.webhooks.github import GitHubEvents from druks.durable import Run from druks.events.models import Event @@ -12,7 +12,7 @@ @pytest.fixture(autouse=True) def _stub_config_fetch(monkeypatch): - # The external-close path resolves the repo's live .druks/build/config.yml; + # The external-close path resolves the repo's live .druks/ship/config.yml; # default to "no file" (default policy) so tests don't reach GitHub. async def _fetch(*, repo, path): return None @@ -200,7 +200,7 @@ async def test_external_close_returns_ticket_to_resting_pool(db_session, tmp_pat """Closing the PR abandons the attempt, not the ticket: druks pushes the provider's resting status (Linear → Backlog, Jira → Open) so the ticket doesn't strand in In Progress/Review.""" - from druks.build.models import WorkItem + from druks.contrib.ship.models import WorkItem from druks.ticketing.enums import TicketStatus pushed = [] @@ -223,7 +223,7 @@ async def _record(self, status): @pytest.mark.asyncio async def test_external_merge_pushes_done(db_session, tmp_path, monkeypatch): """An externally-merged PR mirrors druks's own merge op: ticket → Done.""" - from druks.build.models import WorkItem + from druks.contrib.ship.models import WorkItem from druks.ticketing.enums import TicketStatus pushed = [] @@ -245,9 +245,9 @@ async def _record(self, status): @pytest.mark.asyncio async def test_external_close_honors_delete_branch_policy(db_session, tmp_path, monkeypatch): - """delete_branch: false in the repo's live .druks/build/config.yml keeps the + """delete_branch: false in the repo's live .druks/ship/config.yml keeps the head branch on an external close.""" - from druks.build import models as build_models + from druks.contrib.ship import models as build_models async def _fetch(*, repo, path): return "delete_branch: false\n" @@ -275,7 +275,7 @@ async def _record(repo, branch): @pytest.mark.asyncio async def test_external_close_deletes_branch_by_default(db_session, tmp_path, monkeypatch): - from druks.build import models as build_models + from druks.contrib.ship import models as build_models deleted = [] @@ -300,8 +300,8 @@ async def _record(repo, branch): async def test_external_close_survives_policy_resolution_failure(db_session, tmp_path, monkeypatch): """Branch cleanup is best-effort: a policy-resolution failure must not strand the ticket — the cancel and resting-pool reset still happen.""" - from druks.build import models as build_models - from druks.build.policy import RepoPolicy + from druks.contrib.ship import models as build_models + from druks.contrib.ship.policy import RepoPolicy from druks.ticketing.enums import TicketStatus async def _boom(cls, repo): diff --git a/backend/tests/test_webhooks_push.py b/backend/tests/test_webhooks_push.py index 39c3d11..aed0e58 100644 --- a/backend/tests/test_webhooks_push.py +++ b/backend/tests/test_webhooks_push.py @@ -1,6 +1,6 @@ from types import SimpleNamespace -import druks.build.subscribers # noqa: F401 — registers the repo.pushed subscriber +import druks.contrib.ship.subscribers # noqa: F401 — registers the repo.pushed subscriber from conftest import make_settings from druks.core.webhooks.github import GitHubEvents @@ -22,7 +22,7 @@ async def _fire_push(*, payload, tmp_path): def _stub_profile_dispatch(monkeypatch): - from druks.build.workflows import Profile + from druks.contrib.ship.workflows import Profile calls: list[dict] = [] @@ -35,7 +35,7 @@ async def _dispatch(cls, repo, *, refresh_only=False): async def test_policy_push_on_default_branch_reprofiles(tmp_path, db_session, monkeypatch): - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo project = Project.create(name="Acme") repo = ProjectRepo.create(project_id=project.id, full_name="acme/widget") @@ -46,7 +46,7 @@ async def test_policy_push_on_default_branch_reprofiles(tmp_path, db_session, mo full_name="acme/widget", default_branch="main", ref="refs/heads/main", - changed_paths=(".druks/build/config.yml",), + changed_paths=(".druks/ship/config.yml",), ), tmp_path=tmp_path, ) @@ -60,7 +60,7 @@ async def test_policy_push_on_default_branch_reprofiles(tmp_path, db_session, mo async def test_non_default_branch_push_is_ignored(tmp_path, db_session, monkeypatch): - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo project = Project.create(name="Acme") ProjectRepo.create(project_id=project.id, full_name="acme/widget") @@ -71,7 +71,7 @@ async def test_non_default_branch_push_is_ignored(tmp_path, db_session, monkeypa full_name="acme/widget", default_branch="main", ref="refs/heads/feature-x", - changed_paths=(".druks/build/config.yml",), + changed_paths=(".druks/ship/config.yml",), ), tmp_path=tmp_path, ) @@ -80,7 +80,7 @@ async def test_non_default_branch_push_is_ignored(tmp_path, db_session, monkeypa async def test_unrelated_path_push_is_ignored(tmp_path, db_session, monkeypatch): - from druks.build.models import Project, ProjectRepo + from druks.contrib.ship.models import Project, ProjectRepo project = Project.create(name="Acme") ProjectRepo.create(project_id=project.id, full_name="acme/widget") @@ -107,7 +107,7 @@ async def test_policy_push_for_an_unknown_repo_is_a_noop(tmp_path, db_session, m full_name="acme/not-registered", default_branch="main", ref="refs/heads/main", - changed_paths=(".druks/build/config.yml",), + changed_paths=(".druks/ship/config.yml",), ), tmp_path=tmp_path, ) diff --git a/backend/tests/test_workflow_identity.py b/backend/tests/test_workflow_identity.py index 8e00ae6..a62e948 100644 --- a/backend/tests/test_workflow_identity.py +++ b/backend/tests/test_workflow_identity.py @@ -1,5 +1,5 @@ import pytest -from druks.build.workflows import BuildWorkflow, Profile +from druks.contrib.ship.workflows import Build, Profile from druks.core.workflows import RefreshTokens from druks.durable.enums import RunState from druks.durable.exceptions import WorkflowError @@ -100,10 +100,10 @@ def test_none_owned_package_keeps_bare_kinds(): def test_in_tree_identities_are_stable(): # These kinds are durable identities (DBOS workflow names, settings keys, # dedup prefixes, step-name prefixes) — byte-for-byte pins. - assert BuildWorkflow.kind == "build.build_workflow" - assert Profile.kind == "build.profile" + assert Build.kind == "ship.build" + assert Profile.kind == "ship.profile" assert RefreshTokens.kind == "core.refresh_tokens" - assert (BuildWorkflow.extension, RefreshTokens.extension) == ("build", "core") + assert (Build.extension, RefreshTokens.extension) == ("ship", "core") def test_steps_capture_the_namespaced_kind(): diff --git a/docs/concepts.md b/docs/concepts.md index 0e8e01b..5b52784 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -49,7 +49,7 @@ name must match `Extension.name`. The same name scopes: - any provider credentials or prerequisites specific to that application - optional static frontend assets shipped in the extension package -The bundled `build` extension owns projects, work items, ticket intake, GitHub +The bundled `ship` extension owns projects, work items, ticket intake, GitHub branches and pull requests, coding-agent policy, and its dashboard pages. Those are useful examples, not platform guarantees. diff --git a/docs/configuration.md b/docs/configuration.md index f3467bc..8d04393 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,7 +117,7 @@ second. Agents consume the API through the MCP endpoint; see ## GitHub Apps -The bundled `build` extension requires two GitHub Apps. These are application +The bundled `ship` extension requires two GitHub Apps. These are application requirements, not requirements of the Druks extension mechanism itself. - **Operator app:** receives webhooks and performs application-owned writes @@ -125,7 +125,7 @@ requirements, not requirements of the Druks extension mechanism itself. - **Reviewer app:** submits reviews through a distinct GitHub identity. Personal access tokens are not a supported substitute. Install both Apps on -the same repositories; that installation set is where `build` may act. +the same repositories; that installation set is where `ship` may act. The fast path is: @@ -176,7 +176,7 @@ at another compatible GitHub API endpoint. ## Ticketing integrations The bundled integrations support Linear and Jira. Configure only one ticketing -source for `build` intake. +source for `ship` intake. Linear: @@ -198,7 +198,7 @@ JIRA_WEBHOOK_SECRET= credential fields are present, its webhook secret is required. Webhook URLs use `/_external/linear/events/` and `/_external/jira/events/`. -The statuses that trigger or move `build` work are settings declared by the +The statuses that trigger or move `ship` work are settings declared by the extension and edited in the dashboard, not environment variables. ## Harnesses diff --git a/docs/development.md b/docs/development.md index 8afe7cc..67e8aad 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,7 +60,7 @@ contains the built SPA and serves it from FastAPI. | `backend/druks/sandbox/` | Drukbox lifecycle, SSH execution, workspace delivery | | `backend/druks/api/` | FastAPI composition and platform routes | | `backend/druks/{mcp,skills,notifications,user_settings}/` | Shared operator services | -| `backend/druks/build/` | Bundled reference extension, not framework core | +| `backend/druks/contrib/ship/` | Bundled reference extension, not framework core | | `frontend/src/` | Shared dashboard shell and bundled extension UI | | `backend/migrations/` | Core/bundled schema history | | `deploy/`, `scripts/` | Images, Compose, Caddy, setup, and deployment | diff --git a/docs/full-local.md b/docs/full-local.md index ffdc161..69c7ca9 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -18,7 +18,7 @@ in isolated containers rather than in the Druks process. - Git - enough local Docker capacity for Postgres, Redis, Druks, Drukbox, and short-lived sandbox containers -- two GitHub Apps if you intend to use the bundled `build` extension +- two GitHub Apps if you intend to use the bundled `ship` extension No Tailscale account or remote VM provider is needed. The Druks application and sandbox images are published for both `linux/amd64` @@ -37,7 +37,7 @@ The first run: - points Druks at Drukbox on `127.0.0.1:8000` - prints blank required fields and exits without booting if setup is incomplete -For the bundled `build` extension, provision its GitHub Apps: +For the bundled `ship` extension, provision its GitHub Apps: ```bash bash <(curl -fsSL https://raw.githubusercontent.com/czpython/druks/main/scripts/install.sh) --apps @@ -119,7 +119,7 @@ This creates and deletes a real sandbox container. ## 5. Exercise an application Druks does not invent a generic domain job: an installed extension supplies the -workflow and its trigger. In the bundled distribution, `build` is the reference +workflow and its trigger. In the bundled distribution, `ship` is the reference application. Register a project in its dashboard and use its configured ticket or GitHub trigger. Watch the run appear in the subject page and Events feed; agent-call pages stream transcript and artifact data. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 99c0f50..9b1cba0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -109,7 +109,7 @@ export interface ArtifactContent { // One run on the subject's timeline, with its agent calls in execution order. export interface RunSummary { id: string - // The durable kind ("build.build_workflow"); label is its backend display name ("Build workflow"). + // The durable kind ("ship.build"); label is its backend display name ("Build"). kind: string label: string state: RunState diff --git a/frontend/src/extensions/index.ts b/frontend/src/extensions/index.ts index 662bca9..114df08 100644 --- a/frontend/src/extensions/index.ts +++ b/frontend/src/extensions/index.ts @@ -2,4 +2,4 @@ // extension contributes frontend by calling ``registerExtensionUI`` at import time; // listing it here is what pulls it into the bundle. Adding an extension's UI is one // line here — the shell (App, ExtensionDropdown, api/client) never learns its name. -import './build/ui' +import './ship/ui' diff --git a/frontend/src/extensions/registry.tsx b/frontend/src/extensions/registry.tsx index 473c214..9da313f 100644 --- a/frontend/src/extensions/registry.tsx +++ b/frontend/src/extensions/registry.tsx @@ -8,7 +8,7 @@ import type { ReactNode } from 'react' // contributions. // One route an extension mounts. ``path`` is a wouter pattern under the router base -// (e.g. ``/build`` or ``/work-items/:slug``); ``render`` receives the matched params. +// (e.g. ``/ship`` or ``/work-items/:slug``); ``render`` receives the matched params. export interface ExtensionRoute { path: string render: (params: Record) => ReactNode diff --git a/frontend/src/extensions/build/AgentCallPage.tsx b/frontend/src/extensions/ship/AgentCallPage.tsx similarity index 100% rename from frontend/src/extensions/build/AgentCallPage.tsx rename to frontend/src/extensions/ship/AgentCallPage.tsx diff --git a/frontend/src/extensions/build/HistoryPage.tsx b/frontend/src/extensions/ship/HistoryPage.tsx similarity index 100% rename from frontend/src/extensions/build/HistoryPage.tsx rename to frontend/src/extensions/ship/HistoryPage.tsx diff --git a/frontend/src/extensions/build/NotFound.tsx b/frontend/src/extensions/ship/NotFound.tsx similarity index 100% rename from frontend/src/extensions/build/NotFound.tsx rename to frontend/src/extensions/ship/NotFound.tsx diff --git a/frontend/src/extensions/build/StatusTag.tsx b/frontend/src/extensions/ship/StatusTag.tsx similarity index 100% rename from frontend/src/extensions/build/StatusTag.tsx rename to frontend/src/extensions/ship/StatusTag.tsx diff --git a/frontend/src/extensions/build/WorkItemPage.tsx b/frontend/src/extensions/ship/WorkItemPage.tsx similarity index 99% rename from frontend/src/extensions/build/WorkItemPage.tsx rename to frontend/src/extensions/ship/WorkItemPage.tsx index ef927b7..f228b15 100644 --- a/frontend/src/extensions/build/WorkItemPage.tsx +++ b/frontend/src/extensions/ship/WorkItemPage.tsx @@ -191,7 +191,7 @@ function WorkItemView({ data }: { data: WorkItemDetail }) { const crumb = (
- + ← work items
diff --git a/frontend/src/extensions/build/WorkItemsPage.tsx b/frontend/src/extensions/ship/WorkItemsPage.tsx similarity index 100% rename from frontend/src/extensions/build/WorkItemsPage.tsx rename to frontend/src/extensions/ship/WorkItemsPage.tsx diff --git a/frontend/src/extensions/build/api.ts b/frontend/src/extensions/ship/api.ts similarity index 79% rename from frontend/src/extensions/build/api.ts rename to frontend/src/extensions/ship/api.ts index 6b4b405..292b90a 100644 --- a/frontend/src/extensions/build/api.ts +++ b/frontend/src/extensions/ship/api.ts @@ -1,5 +1,5 @@ /** - * Build's frontend module: its API paths, response shapes, and vocabulary. The + * Ship's frontend module: its API paths, response shapes, and vocabulary. The * platform types it composes with (RunState, SubjectResponse, RunSummary, * AgentCallSummary, ArtifactFile, AgentCallFiles, TranscriptChunk) live in the * shared ``api/types``; this file holds only build's own vocabulary. @@ -8,23 +8,23 @@ import { getJSON, subjectApi } from '../../api/client' import type { SubjectResponse, SubjectRow, SubjectSummary } from '../../api/types' -// build's identity on the platform: the name that keys its ``/api/build`` namespace +// Ship's identity on the platform: the name that keys its ``/api/ship`` namespace // and the subject type its runs are about. The only place these literals live — the // generic shell reads the extension name off the registry, never hardcodes it. -export const BUILD = 'build' +export const SHIP = 'ship' export const WORK_ITEM = 'work_item' // build's read-side, specialised from the platform's generic subject endpoints. export const buildApi = { - workItem: (id: number) => subjectApi.read(BUILD, WORK_ITEM, id), - boardStreamUrl: () => subjectApi.boardStream(BUILD, WORK_ITEM), - subjectStreamUrl: (id: number) => subjectApi.stream(BUILD, WORK_ITEM, id), - transcriptBase: (callId: string) => subjectApi.transcriptBase(BUILD, callId), - transcriptFiles: (callId: string) => subjectApi.transcriptFiles(BUILD, callId), - transcriptFile: (callId: string, name: string) => subjectApi.transcriptFile(BUILD, callId, name), + workItem: (id: number) => subjectApi.read(SHIP, WORK_ITEM, id), + boardStreamUrl: () => subjectApi.boardStream(SHIP, WORK_ITEM), + subjectStreamUrl: (id: number) => subjectApi.stream(SHIP, WORK_ITEM, id), + transcriptBase: (callId: string) => subjectApi.transcriptBase(SHIP, callId), + transcriptFiles: (callId: string) => subjectApi.transcriptFiles(SHIP, callId), + transcriptFile: (callId: string, name: string) => subjectApi.transcriptFile(SHIP, callId, name), history: (limit?: number) => { const qs = limit !== undefined ? `?limit=${limit}` : '' - return getJSON(`/api/${BUILD}/work-items/history${qs}`) + return getJSON(`/api/${SHIP}/work-items/history${qs}`) }, } diff --git a/frontend/src/extensions/build/projects/ProjectsPage.tsx b/frontend/src/extensions/ship/projects/ProjectsPage.tsx similarity index 100% rename from frontend/src/extensions/build/projects/ProjectsPage.tsx rename to frontend/src/extensions/ship/projects/ProjectsPage.tsx diff --git a/frontend/src/extensions/build/projects/api.ts b/frontend/src/extensions/ship/projects/api.ts similarity index 90% rename from frontend/src/extensions/build/projects/api.ts rename to frontend/src/extensions/ship/projects/api.ts index dadead5..8edb849 100644 --- a/frontend/src/extensions/build/projects/api.ts +++ b/frontend/src/extensions/ship/projects/api.ts @@ -14,10 +14,10 @@ import type { UpdateProjectRepoRequest, UpdateProjectRequest, } from './types' -import { BUILD } from '../api' +import { SHIP } from '../api' -// Projects are build-owned, under its own ``/api/build/projects`` namespace. -const root = `/api/${BUILD}/projects` +// Projects are Ship-owned, under its own ``/api/ship/projects`` namespace. +const root = `/api/${SHIP}/projects` export const projectsApi = { list: () => getJSON(root), diff --git a/frontend/src/extensions/build/projects/types.ts b/frontend/src/extensions/ship/projects/types.ts similarity index 100% rename from frontend/src/extensions/build/projects/types.ts rename to frontend/src/extensions/ship/projects/types.ts diff --git a/frontend/src/extensions/build/slug.ts b/frontend/src/extensions/ship/slug.ts similarity index 100% rename from frontend/src/extensions/build/slug.ts rename to frontend/src/extensions/ship/slug.ts diff --git a/frontend/src/extensions/build/statusLine.test.ts b/frontend/src/extensions/ship/statusLine.test.ts similarity index 91% rename from frontend/src/extensions/build/statusLine.test.ts rename to frontend/src/extensions/ship/statusLine.test.ts index 9287079..181c135 100644 --- a/frontend/src/extensions/build/statusLine.test.ts +++ b/frontend/src/extensions/ship/statusLine.test.ts @@ -6,7 +6,7 @@ import { statusLine } from './statusLine' function status(overrides: Partial): SubjectStatus { return { state: 'running', - kind: 'build.build_workflow', + kind: 'ship.build', agent: null, gate: null, failure: null, @@ -38,12 +38,12 @@ describe('statusLine', () => { }) it('running before any call shows the kind', () => { - expect(statusLine(status({}))).toBe('Build workflow') + expect(statusLine(status({}))).toBe('Build') }) it('a timed-out gate renders the re-trigger hint', () => { expect(statusLine(status({ state: 'failed', reason: 'gate_timeout' }))).toBe( - 'Build workflow timed out — re-trigger to retry', + 'Build timed out — re-trigger to retry', ) }) diff --git a/frontend/src/extensions/build/statusLine.ts b/frontend/src/extensions/ship/statusLine.ts similarity index 100% rename from frontend/src/extensions/build/statusLine.ts rename to frontend/src/extensions/ship/statusLine.ts diff --git a/frontend/src/extensions/build/ui.tsx b/frontend/src/extensions/ship/ui.tsx similarity index 63% rename from frontend/src/extensions/build/ui.tsx rename to frontend/src/extensions/ship/ui.tsx index 3eb4876..163a619 100644 --- a/frontend/src/extensions/build/ui.tsx +++ b/frontend/src/extensions/ship/ui.tsx @@ -1,5 +1,5 @@ import { registerExtensionUI } from '../registry' -import { BUILD } from './api' +import { SHIP } from './api' import { parseLeadingId } from './slug' import { AgentCallPage } from './AgentCallPage' import { HistoryPage } from './HistoryPage' @@ -8,22 +8,22 @@ import { ProjectsPage } from './projects/ProjectsPage' import { WorkItemPage } from './WorkItemPage' import { WorkItemsPage } from './WorkItemsPage' -// build contributes its UI through the same registry any extension uses — its pages +// Ship contributes its UI through the same registry any extension uses — its pages // are not the app's spine, they're one extension's routes. The shell mounts these and -// derives build's subnav from ``nav``; feed, settings, and usage it gets for free. +// derives Ship's subnav from ``nav``; feed, settings, and usage it gets for free. registerExtensionUI({ - name: BUILD, - home: `/${BUILD}`, + name: SHIP, + home: `/${SHIP}`, systemStrip: true, nav: [ - { href: `/${BUILD}`, label: 'active', match: (loc) => loc === `/${BUILD}` || isWorkItem(loc) }, - { href: `/${BUILD}/history`, label: 'history' }, - { href: `/${BUILD}/projects`, label: 'projects' }, + { href: `/${SHIP}`, label: 'active', match: (loc) => loc === `/${SHIP}` || isWorkItem(loc) }, + { href: `/${SHIP}/history`, label: 'history' }, + { href: `/${SHIP}/projects`, label: 'projects' }, ], routes: [ - { path: `/${BUILD}`, render: () => }, - { path: `/${BUILD}/history`, render: () => }, - { path: `/${BUILD}/projects`, render: () => }, + { path: `/${SHIP}`, render: () => }, + { path: `/${SHIP}/history`, render: () => }, + { path: `/${SHIP}/projects`, render: () => }, { path: '/work-items/:slug/agent-calls/:callId', render: ({ slug, callId }) => { @@ -43,7 +43,7 @@ registerExtensionUI({ ], }) -// A work-item detail URL (item page or its agent-call child). build's detail pages +// A work-item detail URL (item page or its agent-call child). Ship's detail pages // live off ``/work-items``, so its "active" tab lights on them too. function isWorkItem(location: string): boolean { return location.startsWith('/work-items/') diff --git a/frontend/src/lib/extensionColors.ts b/frontend/src/lib/extensionColors.ts index 8566c02..e4ea0a4 100644 --- a/frontend/src/lib/extensionColors.ts +++ b/frontend/src/lib/extensionColors.ts @@ -2,7 +2,7 @@ // newly-installed extension gets the next accent with no per-name CSS. This is // decoration (the dropdown trigger, the active subnav underline), so it draws from // its own palette — never the harness ``--bucket-*`` tokens, which mean "which coding -// agent". The first entry keeps build's existing cyan so nothing shifts for it. +// agent". The first entry keeps Ship's existing cyan so nothing shifts for it. const EXTENSION_PALETTE = ['#4dd4f4', '#5eead4', '#a78bfa', '#f472b6', '#a3e635', '#fbbf24', '#60a5fa', '#f5a85c'] export const extensionAccent = (names: string[]): Record => diff --git a/frontend/src/pages/UsagePage.tsx b/frontend/src/pages/UsagePage.tsx index c1e4eef..e907c0d 100644 --- a/frontend/src/pages/UsagePage.tsx +++ b/frontend/src/pages/UsagePage.tsx @@ -4,7 +4,7 @@ import { UsagePanel } from '../components/UsagePanel' /** * Dedicated route for the usage detail view (formerly a panel on - * /build). The compact pill stays in the appbar; clicking it lands + * /ship). The compact pill stays in the appbar; clicking it lands * here. * * Thin shell — the actual layout, data fetching, refresh button, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f119768..4c10f02 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1788,7 +1788,7 @@ input.set-select { background-image: none; padding-right: 12px; } } /* Color buckets reuse the dashboard palette so colors carry the - same meaning here as on /build. Cyan = in-flight runs, teal = + same meaning here as on /ship. Cyan = in-flight runs, teal = planning/scope, amber = inbound webhook, magenta = human signal, neutral = audit/jobs. */ .event-kind-agent { diff --git a/pyproject.toml b/pyproject.toml index 04f871a..b89bea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,8 +63,8 @@ druks = "druks.cli:main" # walks each one's package for capabilities. Adding an extension is one entry, not # edits across the platform wiring. [project.entry-points."druks.extensions"] -build = "druks.build.extension:Build" core = "druks.core.extension:Core" +ship = "druks.contrib.ship.extension:Ship" usage = "druks.usage.extension:Usage" [build-system] diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 20d3808..22acf40 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -30,7 +30,7 @@ for _ in {1..30}; do sleep 2 done curl -fsS http://127.0.0.1:8001/health -curl -fsS http://127.0.0.1:8001/api/build/work-items/history?limit=1 >/dev/null +curl -fsS http://127.0.0.1:8001/api/ship/work-items/history?limit=1 >/dev/null echo "[deploy] done" "${COMPOSE[@]}" ps From d88431d4da9080e3b30a0cf7f410d6545e9fdf15 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Sun, 26 Jul 2026 16:44:32 +0700 Subject: [PATCH 2/3] Ship's templates travel with its package (#90) The prompts sat in a platform-level backend/templates/prompts/ that only ship used, kept in the wheel by a force-include. They move to druks/contrib/ship/templates/, the root every extension gets: the resolver walks each installed extension's package for one and mounts it under the extension's name, so ship/build/implement.md is build/implement.md inside ship's own tree. Names and the .druks/ship/prompts/ override path are unchanged, and an extension shipped on its own now carries its templates with it. That emptied backend/templates/, so the directory, the force-include, and the loader entry pointing at it go with it. --- .../ship/templates}/build/_contract.md | 0 .../ship/templates}/build/_github_review.md | 0 .../contrib/ship/templates}/build/_header.md | 0 .../ship/templates}/build/_related_repos.md | 0 .../contrib/ship/templates}/build/_skills.md | 0 .../build/evaluate_implementation.md | 0 .../ship/templates}/build/generate_plan.md | 0 .../ship/templates}/build/implement.md | 0 .../ship/templates}/build/review_code.md | 0 .../ship/templates}/build/review_plan.md | 0 .../ship/templates}/build/revise_contract.md | 0 .../templates}/build/triage_human_feedback.md | 0 .../ship/templates}/profile/repo_profiler.md | 0 .../ship/templates}/verification_block.md | 0 backend/druks/prompts/resolver.py | 26 +++++++++---------- backend/tests/test_build_prompts.py | 5 ++-- pyproject.toml | 7 ----- 17 files changed, 15 insertions(+), 23 deletions(-) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/_contract.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/_github_review.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/_header.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/_related_repos.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/_skills.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/evaluate_implementation.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/generate_plan.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/implement.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/review_code.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/review_plan.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/revise_contract.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/build/triage_human_feedback.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/profile/repo_profiler.md (100%) rename backend/{templates/prompts/ship => druks/contrib/ship/templates}/verification_block.md (100%) diff --git a/backend/templates/prompts/ship/build/_contract.md b/backend/druks/contrib/ship/templates/build/_contract.md similarity index 100% rename from backend/templates/prompts/ship/build/_contract.md rename to backend/druks/contrib/ship/templates/build/_contract.md diff --git a/backend/templates/prompts/ship/build/_github_review.md b/backend/druks/contrib/ship/templates/build/_github_review.md similarity index 100% rename from backend/templates/prompts/ship/build/_github_review.md rename to backend/druks/contrib/ship/templates/build/_github_review.md diff --git a/backend/templates/prompts/ship/build/_header.md b/backend/druks/contrib/ship/templates/build/_header.md similarity index 100% rename from backend/templates/prompts/ship/build/_header.md rename to backend/druks/contrib/ship/templates/build/_header.md diff --git a/backend/templates/prompts/ship/build/_related_repos.md b/backend/druks/contrib/ship/templates/build/_related_repos.md similarity index 100% rename from backend/templates/prompts/ship/build/_related_repos.md rename to backend/druks/contrib/ship/templates/build/_related_repos.md diff --git a/backend/templates/prompts/ship/build/_skills.md b/backend/druks/contrib/ship/templates/build/_skills.md similarity index 100% rename from backend/templates/prompts/ship/build/_skills.md rename to backend/druks/contrib/ship/templates/build/_skills.md diff --git a/backend/templates/prompts/ship/build/evaluate_implementation.md b/backend/druks/contrib/ship/templates/build/evaluate_implementation.md similarity index 100% rename from backend/templates/prompts/ship/build/evaluate_implementation.md rename to backend/druks/contrib/ship/templates/build/evaluate_implementation.md diff --git a/backend/templates/prompts/ship/build/generate_plan.md b/backend/druks/contrib/ship/templates/build/generate_plan.md similarity index 100% rename from backend/templates/prompts/ship/build/generate_plan.md rename to backend/druks/contrib/ship/templates/build/generate_plan.md diff --git a/backend/templates/prompts/ship/build/implement.md b/backend/druks/contrib/ship/templates/build/implement.md similarity index 100% rename from backend/templates/prompts/ship/build/implement.md rename to backend/druks/contrib/ship/templates/build/implement.md diff --git a/backend/templates/prompts/ship/build/review_code.md b/backend/druks/contrib/ship/templates/build/review_code.md similarity index 100% rename from backend/templates/prompts/ship/build/review_code.md rename to backend/druks/contrib/ship/templates/build/review_code.md diff --git a/backend/templates/prompts/ship/build/review_plan.md b/backend/druks/contrib/ship/templates/build/review_plan.md similarity index 100% rename from backend/templates/prompts/ship/build/review_plan.md rename to backend/druks/contrib/ship/templates/build/review_plan.md diff --git a/backend/templates/prompts/ship/build/revise_contract.md b/backend/druks/contrib/ship/templates/build/revise_contract.md similarity index 100% rename from backend/templates/prompts/ship/build/revise_contract.md rename to backend/druks/contrib/ship/templates/build/revise_contract.md diff --git a/backend/templates/prompts/ship/build/triage_human_feedback.md b/backend/druks/contrib/ship/templates/build/triage_human_feedback.md similarity index 100% rename from backend/templates/prompts/ship/build/triage_human_feedback.md rename to backend/druks/contrib/ship/templates/build/triage_human_feedback.md diff --git a/backend/templates/prompts/ship/profile/repo_profiler.md b/backend/druks/contrib/ship/templates/profile/repo_profiler.md similarity index 100% rename from backend/templates/prompts/ship/profile/repo_profiler.md rename to backend/druks/contrib/ship/templates/profile/repo_profiler.md diff --git a/backend/templates/prompts/ship/verification_block.md b/backend/druks/contrib/ship/templates/verification_block.md similarity index 100% rename from backend/templates/prompts/ship/verification_block.md rename to backend/druks/contrib/ship/templates/verification_block.md diff --git a/backend/druks/prompts/resolver.py b/backend/druks/prompts/resolver.py index 5eb9bfe..85959a4 100644 --- a/backend/druks/prompts/resolver.py +++ b/backend/druks/prompts/resolver.py @@ -2,21 +2,20 @@ import importlib.util from pathlib import Path -from jinja2 import Environment, FileSystemLoader, StrictUndefined +from jinja2 import Environment, FileSystemLoader, PrefixLoader, StrictUndefined from jinja2.sandbox import ImmutableSandboxedEnvironment from druks.extensions.fetcher import fetch_file from druks.extensions.loader import iter_extensions -PROMPTS_DIR = Path(__file__).resolve().parents[2] / "templates" / "prompts" - @functools.cache def _environment() -> Environment: - # One Jinja environment over the bundled core templates plus each installed - # extension's own ``templates/prompts`` root, so a separately-shipped extension carries - # its prompts in its package. Overrides resolved as strings via - # ``from_string`` still see the loader for ``{% include %}`` against partials. + # One Jinja environment over every installed extension's own ``templates`` root, + # each mounted under the extension's name: ``ship/build/implement.md`` is + # ``build/implement.md`` inside ship's package, so nothing repeats the extension in + # its own tree. Overrides resolved as strings via ``from_string`` still see the + # loader for ``{% include %}`` against partials. # # Sandboxed because a ``.druks//prompts/*`` override is authored by anyone with # push access to a monitored repo: the sandbox blocks the ``__globals__`` walk to @@ -24,7 +23,7 @@ def _environment() -> Environment: # ``workspace`` objects in context. Bundled templates only read public attributes, # so the sandbox is invisible to them. return ImmutableSandboxedEnvironment( - loader=FileSystemLoader([PROMPTS_DIR, *_extension_prompt_roots()]), + loader=PrefixLoader(_extension_template_roots()), autoescape=False, undefined=StrictUndefined, keep_trailing_newline=True, @@ -33,15 +32,15 @@ def _environment() -> Environment: ) -def _extension_prompt_roots() -> list[Path]: - roots: list[Path] = [] +def _extension_template_roots() -> dict[str, FileSystemLoader]: + roots: dict[str, FileSystemLoader] = {} for extension in iter_extensions(): spec = importlib.util.find_spec(extension.package) if not spec or not spec.submodule_search_locations: continue - root = Path(spec.submodule_search_locations[0]) / "templates" / "prompts" + root = Path(spec.submodule_search_locations[0]) / "templates" if root.is_dir(): - roots.append(root) + roots[extension.name] = FileSystemLoader(root) return roots @@ -58,8 +57,7 @@ async def render_prompt( 1. ``/.druks//prompts/`` — repo-specific tuning 2. ``/.druks`` repo ``/prompts/`` — org-wide tuning - 3. bundled ``backend/templates/prompts/`` and each installed extension's - own ``/templates/prompts`` root — built-in baseline + 3. ```` under the extension's own ``/templates`` root — built-in baseline A 404 at a tier silently falls through to the next. Auth or network failures propagate — those are real misconfigurations and the diff --git a/backend/tests/test_build_prompts.py b/backend/tests/test_build_prompts.py index 29ceb35..d60089f 100644 --- a/backend/tests/test_build_prompts.py +++ b/backend/tests/test_build_prompts.py @@ -1,13 +1,14 @@ import re +from pathlib import Path from types import SimpleNamespace import pytest +from druks.contrib import ship from druks.contrib.ship.contracts import PlanData from druks.contrib.ship.journal import BuildJournal from druks.contrib.ship.models import Project, ProjectRepo from druks.contrib.ship.prompt_context import BuildPromptContext from druks.prompts import render_prompt -from druks.prompts.resolver import PROMPTS_DIR from druks.workflows import FatalError _OP_TEMPLATES = [ @@ -260,7 +261,7 @@ async def test_contract_context_is_omitted_only_from_code_review(): def test_build_prompt_context_covers_template_attrs(): # Every build prompt reads build.; assert BuildPromptContext carries them # all, so a template ref can never outrun the context contract. - prompts_dir = PROMPTS_DIR / "ship/build" + prompts_dir = Path(ship.__file__).parent / "templates/build" templates = sorted(prompts_dir.glob("*.md")) assert templates, f"no build prompts under {prompts_dir}" attrs: set[str] = set() diff --git a/pyproject.toml b/pyproject.toml index b89bea9..5f78082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,18 +77,11 @@ packages = ["backend/druks"] [tool.hatch.build.targets.sdist] include = [ "/backend/druks", - "/backend/templates", "/LICENSE", "/pyproject.toml", "/README.md", ] -# Ship the prompt templates dir alongside the wheel. ``druks.core.prompts`` -# resolves the path relative to ``backend/`` so the directory has to land -# at the same height as the ``druks`` package in the installed tree. -[tool.hatch.build.targets.wheel.force-include] -"backend/templates" = "templates" - [tool.ruff] line-length = 100 target-version = "py311" From 974f62614117e59920d1d0f9d0ae6c2200134eaf Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 26 Jul 2026 11:50:30 +0200 Subject: [PATCH 3/3] The transcript and scaffolding tests name ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both arrived after this branch forked: the transcript tests hit /api/build, and the scaffolding test proves a name collision against an installed extension — which build no longer is. --- backend/tests/test_api_runs.py | 4 ++-- backend/tests/test_scaffolding.py | 2 +- frontend/src/components/RunTranscript.test.tsx | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/tests/test_api_runs.py b/backend/tests/test_api_runs.py index cb7b008..ff9894c 100644 --- a/backend/tests/test_api_runs.py +++ b/backend/tests/test_api_runs.py @@ -95,7 +95,7 @@ def test_transcript_of_a_running_call_is_never_cached( call_id = _seed_run(tmp_path=tmp_path, finished=False) response = client.get( - f"/api/build/transcripts/{call_id}", + f"/api/ship/transcripts/{call_id}", params={"stream": "stdout", "limit": 5}, ) @@ -113,7 +113,7 @@ def test_transcript_of_an_abandoned_call_is_cached_immutably( call_id = _seed_run(tmp_path=tmp_path, finished=False, run_state="failed") response = client.get( - f"/api/build/transcripts/{call_id}", + f"/api/ship/transcripts/{call_id}", params={"stream": "stdout", "limit": 5}, ) diff --git a/backend/tests/test_scaffolding.py b/backend/tests/test_scaffolding.py index 0cc732e..257a009 100644 --- a/backend/tests/test_scaffolding.py +++ b/backend/tests/test_scaffolding.py @@ -70,7 +70,7 @@ def test_create_extension_rejects_bad_and_taken_names(tmp_path): with pytest.raises(ValueError, match="must match"): create_extension("Night-Watch", tmp_path) with pytest.raises(ValueError, match="already installed"): - create_extension("build", tmp_path) + create_extension("ship", tmp_path) create_extension("night_watch", tmp_path) with pytest.raises(ValueError, match="already exists"): create_extension("night_watch", tmp_path) diff --git a/frontend/src/components/RunTranscript.test.tsx b/frontend/src/components/RunTranscript.test.tsx index 71bbe95..c02cbab 100644 --- a/frontend/src/components/RunTranscript.test.tsx +++ b/frontend/src/components/RunTranscript.test.tsx @@ -37,7 +37,7 @@ describe('RunTranscript', () => { .mockReturnValueOnce(secondResponse.promise) vi.stubGlobal('fetch', fetchMock) - render() + render() expect(await screen.findByText('first row')).toBeTruthy() expect(fetchMock).toHaveBeenCalledTimes(2) @@ -57,16 +57,16 @@ describe('RunTranscript', () => { ) vi.stubGlobal('fetch', fetchMock) - render() + render() expect(await screen.findByText('initial row')).toBeTruthy() expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - '/api/build/transcripts/call-2?stream=stdout&offset=0&limit=262144', + '/api/ship/transcripts/call-2?stream=stdout&offset=0&limit=262144', { headers: { Accept: 'application/json' } }, ) expect(useSSEMock).toHaveBeenLastCalledWith( - '/api/build/transcripts/call-2/stream?stream=stdout&offset=12', + '/api/ship/transcripts/call-2/stream?stream=stdout&offset=12', expect.objectContaining({ enabled: true }), ) @@ -81,7 +81,7 @@ describe('RunTranscript', () => { handlers?.['agent_call.finished']?.({}) }) expect(useSSEMock).toHaveBeenLastCalledWith( - '/api/build/transcripts/call-2/stream?stream=stdout&offset=12', + '/api/ship/transcripts/call-2/stream?stream=stdout&offset=12', expect.objectContaining({ enabled: false }), ) })