v2 foundation: shared contracts, app profile schema, wiring stubs
- projects: app profile columns + one app per org; project_external_git, deploy_targets, org_model_grants tables (migration a0f0e1d2c3b4) - ports + fakes: RepoWorkspace, DeliveryHooks, DeployTarget, Memory, ModelGrants, WorkforceBootstrap; AppProfile + AppProfileRepository - per-workstream wiring (container_org/engine/delivery), config blocks, empty routers, delivery worker, MCP/delivery service shells, web stubs - admin nav derives from ADMIN_ITEMS; plan docs updated for ext4 worktrees Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3a68b0fa07
commit
0cd9194b64
@@ -0,0 +1,226 @@
|
||||
"""v2 foundation: schema invariants, shared repository, contracts and wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
import app.application.container as container
|
||||
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
||||
from app.application.ports.deploy_target import (
|
||||
DeployTargetSpec,
|
||||
DeployTargetTester,
|
||||
FakeDeployTargetTester,
|
||||
)
|
||||
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
|
||||
from app.application.ports.model_grants import ModelGrantPort, tier_for
|
||||
from app.application.ports.repo_workspace import (
|
||||
DiffResult,
|
||||
FakeRepoWorkspace,
|
||||
MergeOutcome,
|
||||
RemoteCredentials,
|
||||
RepoWorkspacePort,
|
||||
)
|
||||
from app.application.ports.workforce_bootstrap import (
|
||||
NullWorkforceBootstrap,
|
||||
WorkforceBootstrapPort,
|
||||
)
|
||||
from app.domain.base import new_id
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from app.infrastructure.db.models import (
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
OrganizationRow,
|
||||
ProjectRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.repositories.app_profile import AppProfileRepository
|
||||
|
||||
|
||||
async def _org_with_user(session, slug: str) -> tuple[str, str]:
|
||||
user = UserRow(
|
||||
id=new_id(),
|
||||
email=f"{slug}@example.com",
|
||||
password_hash="x",
|
||||
name="t",
|
||||
platform_role=PlatformRole.MANAGER.value,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_project_per_organization(session_factory):
|
||||
async with session_factory() as session:
|
||||
org_id, _ = await _org_with_user(session, "solo")
|
||||
session.add(ProjectRow(id=new_id(), organization_id=org_id, name="a", slug="a"))
|
||||
await session.commit()
|
||||
session.add(ProjectRow(id=new_id(), organization_id=org_id, name="b", slug="b"))
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_profile_repository_is_tenant_scoped(session_factory):
|
||||
async with session_factory() as session:
|
||||
org_a, _ = await _org_with_user(session, "org-a")
|
||||
org_b, _ = await _org_with_user(session, "org-b")
|
||||
project = ProjectRow(id=new_id(), organization_id=org_a, name="a", slug="a")
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
|
||||
repo = AppProfileRepository(session)
|
||||
profile = await repo.get_for_org(org_a)
|
||||
assert profile is not None
|
||||
assert profile.healthcheck_path == "/"
|
||||
assert profile.git_source == "platform"
|
||||
assert profile.wizard_completed is False
|
||||
|
||||
# Another org can neither read nor update this project's profile.
|
||||
assert await repo.get_for_org(org_b) is None
|
||||
assert await repo.get(organization_id=org_b, project_id=project.id) is None
|
||||
assert (
|
||||
await repo.update_profile(
|
||||
organization_id=org_b, project_id=project.id, values={"language": "go"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
updated = await repo.update_profile(
|
||||
organization_id=org_a,
|
||||
project_id=project.id,
|
||||
values={"language": "python", "framework": "fastapi", "app_port": 8000},
|
||||
)
|
||||
assert updated is not None
|
||||
assert (updated.language, updated.framework, updated.app_port) == (
|
||||
"python",
|
||||
"fastapi",
|
||||
8000,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await repo.update_profile(
|
||||
organization_id=org_a, project_id=project.id, values={"organization_id": "x"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_model_grants_return_active_models_with_tiers(session_factory):
|
||||
async with session_factory() as session:
|
||||
provider = AiProviderRow(id=new_id(), name="agy-test")
|
||||
session.add(provider)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
AiModelRow(
|
||||
id="m-strong", provider_id=provider.id, internal_name="s",
|
||||
display_name_for_admin="S", capabilities=["text", "reasoning"],
|
||||
output_price_per_1m=10.0,
|
||||
),
|
||||
AiModelRow(
|
||||
id="m-fast", provider_id=provider.id, internal_name="f",
|
||||
display_name_for_admin="F", capabilities=["text"], output_price_per_1m=0.5,
|
||||
),
|
||||
AiModelRow(
|
||||
id="m-off", provider_id=provider.id, internal_name="o",
|
||||
display_name_for_admin="O", capabilities=["text"], is_active=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
grants = container.build_model_grants(session)
|
||||
assert isinstance(grants, ModelGrantPort)
|
||||
granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
|
||||
assert granted == {"m-strong": "strong", "m-fast": "fast"}
|
||||
assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
|
||||
assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
|
||||
|
||||
|
||||
def test_tier_for_never_uses_names():
|
||||
assert tier_for(capabilities=["reasoning"], output_price_per_1m=99) == "strong"
|
||||
assert tier_for(capabilities=["text"], output_price_per_1m=1) == "fast"
|
||||
assert tier_for(capabilities=["text"], output_price_per_1m=5) == "balanced"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contract_fakes_satisfy_their_protocols():
|
||||
assert isinstance(FakeRepoWorkspace(), RepoWorkspacePort)
|
||||
assert isinstance(NullDeliveryHooks(), DeliveryHooks)
|
||||
assert isinstance(FakeDeployTargetTester(), DeployTargetTester)
|
||||
assert isinstance(NullMemory(), MemoryPort)
|
||||
assert isinstance(NullWorkforceBootstrap(), WorkforceBootstrapPort)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_repo_workspace_full_flow():
|
||||
repo = FakeRepoWorkspace()
|
||||
ws = await repo.create_task_worktree(project_id="p", task_id="t1")
|
||||
assert ws.branch == "task/t1" and ws.sandbox_path.endswith("/t1")
|
||||
# An empty diff commits nothing.
|
||||
assert await repo.commit(worktree=ws, message="m", author="a") is None
|
||||
repo.diffs["t1"] = DiffResult(text="+x", changed_files=["a.py"])
|
||||
sha = await repo.commit(worktree=ws, message="m", author="a")
|
||||
assert sha
|
||||
merged = await repo.merge_into(project_id="p", branch=ws.branch)
|
||||
assert merged.ok and merged.sha == sha
|
||||
assert (await repo.push(project_id="p", branch="dev")).ok
|
||||
assert await repo.head(project_id="p", branch="dev") == sha
|
||||
repo.merge_outcomes.append(MergeOutcome(ok=False, conflict_files=["a.py"]))
|
||||
assert (await repo.merge_into(project_id="p", branch="x")).conflict_files == ["a.py"]
|
||||
check = await repo.test_remote(url="https://x/y.git", credentials=RemoteCredentials())
|
||||
assert check.ok
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_deploy_tester_and_hooks_and_memory_record_calls():
|
||||
tester = FakeDeployTargetTester()
|
||||
ok = await tester.test_connection(
|
||||
target=DeployTargetSpec(kind="ssh", host="h.example.com", port=22, username="u")
|
||||
)
|
||||
bad = await tester.test_connection(
|
||||
target=DeployTargetSpec(kind="ftp", host="fail.example.com", port=21, username="u")
|
||||
)
|
||||
assert ok.ok and ok.fingerprint and not bad.ok
|
||||
|
||||
hooks = NullDeliveryHooks()
|
||||
await hooks.on_app_created(organization_id="o", project_id="p")
|
||||
await hooks.on_branch_pushed(
|
||||
organization_id="o", project_id="p", branch="dev", sha="abc", source="engine"
|
||||
)
|
||||
assert len(hooks.created) == 1 and hooks.pushed[0]["sha"] == "abc"
|
||||
|
||||
memory = NullMemory()
|
||||
assert await memory.build_context(organization_id="o", role="developer", query="q") == ""
|
||||
await memory.on_task_finished(
|
||||
organization_id="o", task=TaskOutcome(task_id="t", title="x", status="completed")
|
||||
)
|
||||
assert memory.finished[0].task_id == "t"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_exposes_all_v2_builders(session_factory):
|
||||
async with session_factory() as session:
|
||||
assert isinstance(container.build_repo_workspace(session), RepoWorkspacePort)
|
||||
assert isinstance(container.build_delivery_hooks(session), DeliveryHooks)
|
||||
assert isinstance(container.build_deploy_target_tester(session), DeployTargetTester)
|
||||
assert isinstance(container.build_memory(session), MemoryPort)
|
||||
assert isinstance(container.build_workforce_bootstrap(session), WorkforceBootstrapPort)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_builds_with_v2_routers_and_delivery_worker_runs(client):
|
||||
# The full handler app imports every v2 router; /health proves it assembled.
|
||||
assert (await client.get("/health")).status_code == 200
|
||||
|
||||
from app.workers.__main__ import _build_parser, _pass_function
|
||||
from app.workers.delivery import DeliveryWorker
|
||||
|
||||
assert (await DeliveryWorker().run_pass()).failed == 0
|
||||
# ...and it is selectable from the CLI.
|
||||
one_pass = _pass_function(_build_parser().parse_args(["delivery"]))
|
||||
assert (await one_pass()).name == "delivery"
|
||||
Reference in New Issue
Block a user