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
+48 -35
View File
@@ -17,6 +17,7 @@ import pytest_asyncio
from httpx import AsyncClient
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -207,23 +208,24 @@ async def test_tools_catalog_requires_no_secrets(client: AsyncClient):
# ---------------------------------------------------------------------------
async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
"""Every run must be reconstructable from its steps."""
token = (await signup(client, "wf-run@example.com"))["access_token"]
async def test_starting_a_run_produces_a_timeline(client: AsyncClient, session_factory):
"""Every run must be reconstructable from its steps.
The task is a pre-v2 row with no pipeline state, so it runs through the legacy path.
A task made over HTTP now enters the engine and is refused here (decision D-D).
"""
signed = await signup(client, "wf-run@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "افزودن تست برای ماژول پرداخت",
"description": "پوشش تست واحد برای محاسبهٔ تخفیف",
"assigned_employee_id": employee["id"],
},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="افزودن تست برای ماژول پرداخت",
assigned_employee_id=employee["id"],
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -241,17 +243,20 @@ async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
)
async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
token = (await signup(client, "wf-runsecret@example.com"))["access_token"]
async def test_run_payload_never_leaks_model_identity(client: AsyncClient, session_factory):
"""The legacy run's payload names no model (a pre-v2 task, decision D-D)."""
signed = await signup(client, "wf-runsecret@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار برای بررسی نشت",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee["id"]},
@@ -263,17 +268,20 @@ async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
assert forbidden not in payload, f"{forbidden} leaked in run payload"
async def test_task_detail_lists_its_runs(client: AsyncClient):
token = (await signup(client, "wf-taskdetail@example.com"))["access_token"]
async def test_task_detail_lists_its_runs(client: AsyncClient, session_factory):
"""A pre-v2 task (no pipeline state, decision D-D) keeps its runs on its detail."""
signed = await signup(client, "wf-taskdetail@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار با چند اجرا",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -292,19 +300,24 @@ async def test_task_detail_lists_its_runs(client: AsyncClient):
assert runs[0]["task_id"] == task_id
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient):
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient, session_factory):
"""A successful run closes its task; re-running it must be refused, not
silently create a second run against a task that is already done."""
token = (await signup(client, "wf-closedtask@example.com"))["access_token"]
silently create a second run against a task that is already done.
The task is a pre-v2 row (no pipeline state, decision D-D), so this is the legacy path.
"""
signed = await signup(client, "wf-closedtask@example.com")
token = signed["access_token"]
org_id = await _org(client, token)
employee = await _employee(client, token, org_id)
task = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "کار بسته‌شده", "assigned_employee_id": employee["id"]},
headers=auth_header(token),
task_id = await legacy_task(
session_factory,
org_id,
signed["user"]["id"],
title="کار بسته‌شده",
assigned_employee_id=employee["id"],
)
task_id = task.json()["id"]
first = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",