- 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
409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""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}"
|