Engine honours org policy and monthly budget, legacy runner skips engine tasks, sandbox session provisioned on demand, project fallback for threads, reviewer fallback, tenant checks on project ids, memory reflection respects grants, expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
186 lines
8.2 KiB
Python
186 lines
8.2 KiB
Python
"""End-to-end smoke for the v2 seams: wizard, team, manager order, engine task, push, run.
|
|
|
|
Only the model is fake: ``tests.engine_fakes.ScriptedModel`` answers every call, reached
|
|
through ``app.application.container.get_model_provider``. Git is the default fake
|
|
workspace (``GIT_WORKSPACE=fake``, the ``container_delivery._fake_workspace`` singleton),
|
|
and the sandbox and domain router are the mock defaults. Everything else is the real
|
|
code path: the owner grants the default models over HTTP, the manager completes the
|
|
wizard over HTTP (which runs the real bootstrap and delivery hooks), the manager opens a
|
|
thread over HTTP without a project as the web UI does, the order goes through the real
|
|
``ManagerTurns.chat``, and every pass is one real ``AiRuntimeWorker.drain()``.
|
|
|
|
What it proves is that the seams line up. A fresh organization gets its team, its demo
|
|
host and its granted models. A thread made without a project still yields a task in the
|
|
organization's project, with a pipeline state. The engine starts the project's sandbox
|
|
session on its own. The task walks to ``COMPLETED``, and the merge queues a ``dev``
|
|
pipeline run that names the pushed commit. No network, docker or real git.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.application.container_delivery import _fake_workspace
|
|
from app.application.container_engine import build_scheduler
|
|
from app.application.ports.repo_workspace import DiffResult
|
|
from app.core.config import settings
|
|
from app.domain.workforce.models import RoleKey, TaskStatus
|
|
from app.domain.workforce.pipeline import Stage
|
|
from app.infrastructure.db.models import (
|
|
AiModelRow,
|
|
OrganizationRow,
|
|
OrgModelGrantRow,
|
|
SandboxSessionRow,
|
|
WorkspaceRow,
|
|
)
|
|
from app.infrastructure.db.models_delivery import AppRuntimeRow
|
|
from app.infrastructure.repositories.delivery import PipelineRunRepository
|
|
from app.infrastructure.repositories.engine import PipelineStateRepository
|
|
from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository
|
|
from app.workers.ai_runtime import AiRuntimeWorker
|
|
from tests.conftest import auth_header
|
|
from tests.engine_fakes import ScriptedModel, ScriptedReply
|
|
from tests.test_org_wizard import _complete, _manager
|
|
|
|
DEFAULTS = "/api/admin/default-model-grants"
|
|
|
|
#: The manager's chat reply: one task tag, so the order becomes one task.
|
|
ORDER_REPLY = (
|
|
"باشه، یک کار میسازم.\n"
|
|
'[[CREATE_TASK title="صفحهٔ ورود" kind="feature"]]'
|
|
"ساخت صفحهٔ ورود با اعتبارسنجی[[/CREATE_TASK]]"
|
|
)
|
|
|
|
#: The replies that drive one task from triage to merge, as in test_engine_flow. The
|
|
#: pipeline is ``plan_code_test``: the default for a feature, and the one the ASSIGN reply
|
|
#: names. It has no review stage, so no review replies are needed.
|
|
PIPELINE_REPLIES = [
|
|
'[[ASSIGN employee="" pipeline="plan_code_test"/]]',
|
|
"[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]",
|
|
"[[WORK_REPORT]]پیادهسازی شد[[/WORK_REPORT]]",
|
|
"[[TEST_PASS]]سبز است[[/TEST_PASS]]",
|
|
"[[COMPLETE/]]",
|
|
]
|
|
|
|
|
|
async def _sandboxes_of(session_factory, organization_id: str, project_id: str) -> list:
|
|
"""The sandbox sessions of one organization's project."""
|
|
async with session_factory() as session:
|
|
stmt = (
|
|
select(SandboxSessionRow)
|
|
.join(WorkspaceRow, SandboxSessionRow.workspace_id == WorkspaceRow.id)
|
|
.where(
|
|
WorkspaceRow.organization_id == organization_id,
|
|
WorkspaceRow.project_id == project_id,
|
|
)
|
|
)
|
|
return list((await session.execute(stmt)).scalars().all())
|
|
|
|
|
|
async def test_a_manager_order_ships_through_the_engine(
|
|
client, session_factory, seeded, platform_owner_token, monkeypatch, redirect_error_log
|
|
):
|
|
scripted = ScriptedModel(
|
|
[ScriptedReply(content=ORDER_REPLY), *(ScriptedReply(content=r) for r in PIPELINE_REPLIES)]
|
|
)
|
|
monkeypatch.setattr("app.application.container.get_model_provider", lambda: scripted)
|
|
|
|
# The platform owner grants one reasoning model and one cheap model to the default set.
|
|
async with session_factory() as session:
|
|
models = list(
|
|
(await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name)))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
strong = next(m for m in models if "reasoning" in m.capabilities)
|
|
cheap = next(m for m in models if "reasoning" not in m.capabilities)
|
|
owner = auth_header(platform_owner_token)
|
|
for model in (strong, cheap):
|
|
granted = await client.put(
|
|
f"{DEFAULTS}/{model.id}", json={"is_active": True}, headers=owner
|
|
)
|
|
assert granted.status_code == 200, granted.text
|
|
|
|
# The manager completes the wizard over HTTP: org, project, team, runtime, grants.
|
|
_, headers = await _manager(client)
|
|
body = await _complete(client, headers)
|
|
org_id = body["organization"]["id"]
|
|
slug = body["organization"]["slug"]
|
|
project_id = body["project"]["id"]
|
|
|
|
async with session_factory() as session:
|
|
roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)}
|
|
runtime = await session.get(AppRuntimeRow, project_id)
|
|
grants = (
|
|
await session.execute(
|
|
select(OrgModelGrantRow).where(OrgModelGrantRow.organization_id == org_id)
|
|
)
|
|
).scalars().all()
|
|
owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id
|
|
|
|
assert {RoleKey.MANAGER, RoleKey.PLANNER, RoleKey.DEVELOPER, RoleKey.TESTER} <= roles
|
|
assert runtime is not None
|
|
assert runtime.demo_host == f"{slug}.{settings.preview_root_domain}"
|
|
assert grants
|
|
|
|
# The manager opens a thread the way the web UI does: no project_id.
|
|
created = await client.post(
|
|
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
|
|
)
|
|
assert created.status_code == 201, created.text
|
|
assert created.json()["project_id"] is None
|
|
thread_id = created.json()["id"]
|
|
|
|
# A manager order in that thread becomes one task with a pipeline state. The order goes to
|
|
# the engine's manager directly: the HTTP message route builds its manager without pipeline
|
|
# states (see test_integration_thread_project.py).
|
|
async with session_factory() as session:
|
|
await build_scheduler(session).manager.chat(
|
|
organization_id=org_id,
|
|
thread_id=thread_id,
|
|
content="یک صفحهٔ ورود بساز.",
|
|
user_id=owner_user_id,
|
|
)
|
|
await session.commit()
|
|
tasks = await TaskRepository(session).list_for_org(org_id)
|
|
states = await PipelineStateRepository(session).list_for_org(org_id)
|
|
|
|
assert len(tasks) == 1
|
|
assert len(states) == 1
|
|
assert tasks[0].project_id == project_id
|
|
assert states[0].project_id == project_id
|
|
task_id = tasks[0].id
|
|
|
|
# Nothing has started the project's sandbox yet; the engine must do it.
|
|
assert await _sandboxes_of(session_factory, org_id, project_id) == []
|
|
|
|
# The engine walks the task through its stages. Each pass is one real worker drain (the
|
|
# legacy drain, then one Scheduler.tick()); the merge pushes to dev.
|
|
_fake_workspace.diffs[task_id] = DiffResult(
|
|
changed_files=["app/main.py"], text="--- a\n+++ b\n@@\n+x\n"
|
|
)
|
|
state = None
|
|
for _ in range(10):
|
|
await AiRuntimeWorker().drain()
|
|
async with session_factory() as session:
|
|
state = await PipelineStateRepository(session).get(task_id)
|
|
if state is not None and state.stage is Stage.COMPLETED:
|
|
break
|
|
|
|
async with session_factory() as session:
|
|
task = await TaskRepository(session).get(task_id)
|
|
|
|
assert state is not None and state.stage is Stage.COMPLETED
|
|
assert task is not None and task.status is TaskStatus.DONE
|
|
# The engine started the project's sandbox session on its own; no API call did.
|
|
assert await _sandboxes_of(session_factory, org_id, project_id)
|
|
|
|
pushed_sha = _fake_workspace.heads[(project_id, "dev")]
|
|
async with session_factory() as session:
|
|
runs = await PipelineRunRepository(session).list_for_project(
|
|
organization_id=org_id, project_id=project_id
|
|
)
|
|
assert any(
|
|
run.trigger == "engine" and run.branch == "dev" and run.sha == pushed_sha for run in runs
|
|
)
|