- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
190 lines
8.4 KiB
Python
190 lines
8.4 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.billing_helpers import fund_org
|
|
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"]
|
|
# prepaid: the owner credits a new org before work
|
|
await fund_org(session_factory, org_id, 1000.0)
|
|
|
|
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, only to read the reply and the states in this session: the
|
|
# HTTP message route builds its manager with the pipeline states too
|
|
# (test_integration_order_route.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
|
|
)
|