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:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
+408
View File
@@ -0,0 +1,408 @@
"""The engine's read API: status, task board, task detail and recent turns.
What these tests defend:
* any member, including a viewer, reads every route; a foreign member gets 404 on every
route, and a task id of another organization is a 404 under the caller's own organization
* ``status`` reports the wallet balance (``credit_ok`` only above zero), the owner's cap, the
headcount and the stage counts of the organization
* the board lists engine and legacy tasks, and ``stage`` narrows it to one stage
* the task detail carries the pipeline stages, the turns, the gate verdicts, the runs, the
last diff and the merged sha; a turn excerpt is capped at 1,000 characters
* no response names a model, a provider or an ``ai_model_id`` (every seeded model and
provider name is scanned in every body)
"""
from __future__ import annotations
from dataclasses import dataclass
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.application.container_org import build_model_grants
from app.domain.base import utcnow
from app.domain.identity.models import Role
from app.domain.workforce.engine import EmployeeProfile, EngineTurn, GateName, GateResult
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus
from app.domain.workforce.pipeline import (
Mode,
PipelineKey,
Stage,
TaskKind,
TaskPipelineState,
pipeline_stages,
)
from app.infrastructure.db.models import AiModelRow, AiProviderRow, MembershipRow, TaskRow
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import EmployeeRepository, RunRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
DIFF = "diff --git a/app/login.py b/app/login.py\n+def login(): return True\n"
SHA = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"
TITLE = "صفحهٔ ورود"
EMPLOYEE = "برنامه‌نویس یک"
def _base(org_id: str) -> str:
return f"/api/organizations/{org_id}/engine"
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",
}
@dataclass
class _Org:
id: str
project_id: str
owner_id: str
owner_token: str
@dataclass
class _Data:
org: _Org
model_id: str
done_task_id: str
testing_task_id: str
legacy_task_id: str
async def _make_org(client: AsyncClient, slug: str) -> _Org:
owner = await signup(client, f"{slug}-owner@example.com")
response = await client.post(
"/api/setup/complete",
json=_payload(f"سازمان {slug}", slug),
headers=auth_header(owner["access_token"]),
)
assert response.status_code == 201, response.text
body = response.json()
return _Org(
id=body["organization"]["id"],
project_id=body["project"]["id"],
owner_id=owner["user"]["id"],
owner_token=owner["access_token"],
)
async def _join(
client: AsyncClient,
session_factory: async_sessionmaker,
organization_id: str,
role: Role,
email: str,
) -> str:
"""Add a fresh account to an organization with ``role``; returns its token."""
user = await signup(client, email)
async with session_factory() as session:
session.add(
MembershipRow(
organization_id=organization_id, user_id=user["user"]["id"], role=role.value
)
)
await session.commit()
return user["access_token"]
async def _engine_task(session, org: _Org, *, title: str, stage: Stage, **fields) -> TaskRow:
employee_id = fields.pop("employee_id", None)
row = TaskRow(
organization_id=org.id,
title=title,
description="وصف کار",
project_id=org.project_id,
created_by_user_id=org.owner_id,
assigned_employee_id=employee_id,
)
session.add(row)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=row.id,
organization_id=org.id,
project_id=org.project_id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=stage,
**fields,
)
)
return row
async def _seed(session_factory: async_sessionmaker, org: _Org) -> _Data:
"""One finished engine task (turn, gate, run, diff, sha), one at a test stage, one legacy."""
async with session_factory() as session:
model = (await session.execute(select(AiModelRow).limit(1))).scalars().first()
assert model is not None
developer = await EmployeeRepository(session).create(
Employee(organization_id=org.id, name=EMPLOYEE, role_key=RoleKey.DEVELOPER)
)
await EmployeeProfileRepository(session).upsert(
EmployeeProfile(
employee_id=developer.id,
ai_model_id=model.id,
roles=[RoleKey.DEVELOPER.value],
)
)
done = await _engine_task(
session,
org,
title=TITLE,
stage=Stage.COMPLETED,
employee_id=developer.id,
last_diff=DIFF,
merged_sha=SHA,
)
testing = await _engine_task(session, org, title="جدول کاربران", stage=Stage.PENDING_TEST)
legacy = TaskRow(
organization_id=org.id,
title="کار قدیمی",
project_id=org.project_id,
created_by_user_id=org.owner_id,
)
session.add(legacy)
await session.flush()
run = await RunRepository(session).create(
Run(task_id=done.id, employee_id=developer.id, status=RunStatus.COMPLETED)
)
await EngineTurnRepository(session).create(
EngineTurn(
task_id=done.id,
run_id=run.id,
employee_id=developer.id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt="پاسخ " * 400,
ok=True,
finished_at=utcnow(),
)
)
await GateResultRepository(session).create(
GateResult(task_id=done.id, gate=GateName.GREEN, ok=True, detail="آزمون‌ها سبز شد.")
)
await session.commit()
return _Data(
org=org,
model_id=model.id,
done_task_id=done.id,
testing_task_id=testing.id,
legacy_task_id=legacy.id,
)
async def _get(client: AsyncClient, token: str, url: str):
return await client.get(url, headers=auth_header(token))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
async def test_status_reports_credit_policy_team_and_stage_counts(
client, session_factory, seeded
):
org = await _make_org(client, "eng-status")
await fund_org(session_factory, org.id, 40.0)
await _seed(session_factory, org)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
headcount = await EmployeeRepository(session).count_for_org(org.id)
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
body = response.json()
assert body["credit_ok"] is True
assert body["balance"] == 40.0
assert body["currency"] == "USD"
assert body["max_employees"] == cap
assert body["team_size"] == headcount
assert body["stage_counts"] == {"completed": 1, "pending_test": 1}
async def test_status_is_not_credit_ok_without_credit(client, session_factory, seeded):
org = await _make_org(client, "eng-zero")
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
assert response.json()["credit_ok"] is False
assert response.json()["balance"] == 0.0
# ---------------------------------------------------------------------------
# board
# ---------------------------------------------------------------------------
async def test_the_board_lists_engine_and_legacy_tasks_and_filters_by_stage(
client, session_factory, seeded
):
org = await _make_org(client, "eng-board")
data = await _seed(session_factory, org)
board = f"{_base(org.id)}/tasks"
everything = await _get(client, org.owner_token, board)
assert everything.status_code == 200, everything.text
rows = {row["id"]: row for row in everything.json()}
assert set(rows) == {data.done_task_id, data.testing_task_id, data.legacy_task_id}
assert rows[data.legacy_task_id]["stage"] is None
assert rows[data.legacy_task_id]["pipeline"] is None
assert rows[data.legacy_task_id]["cycle"] == 0
assert rows[data.testing_task_id]["stage"] == "pending_test"
assert rows[data.done_task_id]["assignee_name"] == EMPLOYEE
testing = await client.get(
board, params={"stage": "pending_test"}, headers=auth_header(org.owner_token)
)
assert [row["id"] for row in testing.json()] == [data.testing_task_id]
limited = await client.get(board, params={"limit": 1}, headers=auth_header(org.owner_token))
assert len(limited.json()) == 1
unknown = await client.get(
board, params={"stage": "nope"}, headers=auth_header(org.owner_token)
)
assert unknown.status_code == 422
# ---------------------------------------------------------------------------
# detail and turns
# ---------------------------------------------------------------------------
async def test_task_detail_returns_stages_turns_gates_runs_diff_and_sha(
client, session_factory, seeded
):
org = await _make_org(client, "eng-detail")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/tasks/{data.done_task_id}")
assert response.status_code == 200, response.text
body = response.json()
assert body["stage"] == "completed"
assert body["description"] == "وصف کار"
assert body["stages"] == [str(s) for s in pipeline_stages(PipelineKey.PLAN_CODE_TEST)]
assert body["merged_sha"] == SHA
assert body["diff"] == DIFF
assert body["assignee_name"] == EMPLOYEE
[turn] = body["turns"]
assert turn["employee_name"] == EMPLOYEE
assert turn["role"] == "developer"
assert turn["mode"] == str(Mode.WORK)
assert len(turn["reply_excerpt"]) == 1000
[gate] = body["gates"]
assert gate["gate"] == "green"
assert gate["ok"] is True
[run] = body["runs"]
assert run["status"] == "completed"
async def test_recent_turns_carry_the_task_title_and_the_employee_name(
client, session_factory, seeded
):
org = await _make_org(client, "eng-turns")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/turns")
assert response.status_code == 200, response.text
[turn] = response.json()
assert turn["task_id"] == data.done_task_id
assert turn["task_title"] == TITLE
assert turn["employee_name"] == EMPLOYEE
too_many = await _get(client, org.owner_token, f"{_base(org.id)}/turns?limit=0")
assert too_many.status_code == 422
# ---------------------------------------------------------------------------
# who may read
# ---------------------------------------------------------------------------
async def test_a_viewer_reads_every_route(client, session_factory, seeded):
org = await _make_org(client, "eng-viewer")
data = await _seed(session_factory, org)
token = await _join(
client, session_factory, org.id, Role.VIEWER, "eng-viewer-user@example.com"
)
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, token, f"{_base(org.id)}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
async def test_a_foreign_member_gets_404_on_every_route(client, session_factory, seeded):
org_a = await _make_org(client, "eng-a")
data = await _seed(session_factory, org_a)
org_b = await _make_org(client, "eng-b")
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, org_b.owner_token, f"{_base(org_a.id)}{path}")
assert response.status_code == 404, f"{path}: {response.text}"
# A's task id under B's own organization is not found, and B's board stays empty.
inside_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks/{data.done_task_id}")
assert inside_b.status_code == 404
board_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks")
assert board_b.json() == []
turns_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/turns")
assert turns_b.json() == []
# ---------------------------------------------------------------------------
# no model identity
# ---------------------------------------------------------------------------
async def test_no_response_names_a_model_a_provider_or_an_ai_model_id(
client, session_factory, seeded
):
org = await _make_org(client, "eng-names")
data = await _seed(session_factory, org)
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
forbidden = {data.model_id}
for model in models:
forbidden.update({model.internal_name, model.display_name_for_admin})
forbidden.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
forbidden = {value for value in forbidden if len(value) >= 3}
assert forbidden
base = _base(org.id)
paths = (
"/status",
"/tasks",
f"/tasks/{data.done_task_id}",
f"/tasks/{data.testing_task_id}",
"/turns",
)
for path in paths:
response = await _get(client, org.owner_token, f"{base}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
for value in forbidden:
assert value not in response.text, f"{path} names {value!r}"