feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- 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
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Direct tasks enter the engine, and the legacy "run" button refuses them (decision D-D).
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* a task made over HTTP ``POST /tasks`` gets a pipeline state at ``pending_manager_review``
|
||||
in the organization's project, so the scheduler's manager takes it from there
|
||||
* ``POST /tasks/{id}/runs`` on such a task is refused with 409: the engine owns it, and
|
||||
nothing runs outside the engine
|
||||
* a task made through the MCP gateway (``tasks_create``) gets the same state
|
||||
* a pre-v2 task with no state still runs through the legacy path
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.application.services.mcp_service import McpService, TokenRateLimiter
|
||||
from app.domain.base import new_id
|
||||
from app.domain.identity.models import PlatformRole, Role
|
||||
from app.domain.workforce.pipeline import Stage
|
||||
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
|
||||
from app.infrastructure.repositories.engine import PipelineStateRepository
|
||||
from app.infrastructure.repositories.workforce import TaskRepository
|
||||
from app.interfaces.mcp.gateway import McpGateway
|
||||
from tests.conftest import auth_header, signup
|
||||
from tests.legacy_tasks import legacy_task
|
||||
|
||||
#: Part of the Persian refusal shown when the legacy run button is pressed on an engine task.
|
||||
ENGINE_OWNS = "موتور خودکار"
|
||||
|
||||
|
||||
def _payload(name: str, slug: str) -> dict:
|
||||
return {
|
||||
"org_name": name,
|
||||
"slug": slug,
|
||||
"app_name": f"app-{slug}",
|
||||
"app_type": "web_app",
|
||||
"language": "node",
|
||||
"framework": "next",
|
||||
"git_source": "platform",
|
||||
}
|
||||
|
||||
|
||||
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> dict:
|
||||
"""The wizard's answer: the organization, its project and its team."""
|
||||
response = await client.post(
|
||||
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _state(session_factory, task_id: str):
|
||||
async with session_factory() as session:
|
||||
return await PipelineStateRepository(session).get(task_id)
|
||||
|
||||
|
||||
async def _org_with_owner(session_factory, slug: str) -> tuple[str, str]:
|
||||
"""An organization and its owner, made directly (the MCP gateway needs no wizard)."""
|
||||
async with session_factory() as session:
|
||||
user = UserRow(
|
||||
id=new_id(),
|
||||
email=f"{slug}@example.com",
|
||||
password_hash="x",
|
||||
name=slug,
|
||||
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()
|
||||
session.add(MembershipRow(organization_id=org.id, user_id=user.id, role=Role.OWNER.value))
|
||||
await session.commit()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
async def _mcp_token(session_factory, organization_id: str, user_id: str) -> str:
|
||||
async with session_factory() as session:
|
||||
issued = await McpService(session).issue(
|
||||
organization_id=organization_id,
|
||||
name="agent",
|
||||
scopes=["tasks:write", "tasks:read"],
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
return issued.token
|
||||
|
||||
|
||||
async def test_a_task_made_over_http_enters_the_engine(client, session_factory, seeded):
|
||||
manager = await signup(client, "direct-http@example.com")
|
||||
body = await _wizard_org(client, manager["access_token"], "سازمان الف", "direct-http")
|
||||
org_id = body["organization"]["id"]
|
||||
project_id = body["project"]["id"]
|
||||
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "صفحهٔ ورود"},
|
||||
headers=auth_header(manager["access_token"]),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
task_id = created.json()["id"]
|
||||
|
||||
state = await _state(session_factory, task_id)
|
||||
assert state is not None, "a direct task must get a pipeline state"
|
||||
assert state.organization_id == org_id
|
||||
assert state.project_id == project_id
|
||||
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
||||
|
||||
async with session_factory() as session:
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
assert task is not None and task.project_id == project_id
|
||||
|
||||
|
||||
async def test_running_a_direct_task_is_refused_with_409(client, session_factory, seeded):
|
||||
manager = await signup(client, "direct-run@example.com")
|
||||
headers = auth_header(manager["access_token"])
|
||||
body = await _wizard_org(client, manager["access_token"], "سازمان ب", "direct-run")
|
||||
org_id = body["organization"]["id"]
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks", json={"title": "کار مستقیم"}, headers=headers
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
task_id = created.json()["id"]
|
||||
|
||||
run = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
|
||||
)
|
||||
assert run.status_code == 409, run.text
|
||||
assert ENGINE_OWNS in run.json()["error"]["message"]
|
||||
|
||||
# Nothing ran outside the engine: the task still has no run.
|
||||
detail = await client.get(f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["runs"] == []
|
||||
|
||||
|
||||
async def test_an_mcp_task_enters_the_engine(session_factory, seeded):
|
||||
org_id, user_id = await _org_with_owner(session_factory, "direct-mcp")
|
||||
token = await _mcp_token(session_factory, org_id, user_id)
|
||||
gateway = McpGateway(
|
||||
session_factory=session_factory, rate_limiter=TokenRateLimiter(limit_per_minute=None)
|
||||
)
|
||||
|
||||
created = await gateway.call(
|
||||
tool="tasks_create", arguments={"title": "کار از راه MCP"}, token=token
|
||||
)
|
||||
assert created.ok is True, created.error
|
||||
task_id = created.payload["task"]["id"]
|
||||
|
||||
state = await _state(session_factory, task_id)
|
||||
assert state is not None, "an MCP task must get a pipeline state too"
|
||||
assert state.organization_id == org_id
|
||||
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
||||
|
||||
|
||||
async def test_a_pre_v2_task_still_runs_through_the_legacy_path(client, session_factory, seeded):
|
||||
manager = await signup(client, "direct-legacy@example.com")
|
||||
headers = auth_header(manager["access_token"])
|
||||
body = await _wizard_org(client, manager["access_token"], "سازمان پ", "direct-legacy")
|
||||
org_id = body["organization"]["id"]
|
||||
task_id = await legacy_task(session_factory, org_id, manager["user"]["id"], title="کار قدیمی")
|
||||
assert await _state(session_factory, task_id) is None
|
||||
|
||||
run = await client.post(
|
||||
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
|
||||
)
|
||||
assert run.status_code == 201, run.text
|
||||
assert run.json()["status"] == "completed", run.text
|
||||
Reference in New Issue
Block a user