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,171 @@
|
||||
"""The engine repositories behind the v2 UI: read and write paths.
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* ``last_diff`` and ``merged_sha`` are patched under the version lock and read
|
||||
back unchanged (the task page shows both)
|
||||
* the organization turn feed never returns another organization's turns, even
|
||||
when both organizations worked in the same window (turns carry no
|
||||
organization column, so the tenant filter goes through the task)
|
||||
* stage counts are per organization: one organization's board does not count
|
||||
another organization's tasks
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.workforce.engine import EngineTurn
|
||||
from app.domain.workforce.models import RoleKey
|
||||
from app.domain.workforce.pipeline import Mode, Stage, TaskPipelineState
|
||||
from app.infrastructure.db.models import OrganizationRow, RunRow, TaskRow, UserRow
|
||||
from app.infrastructure.repositories.engine import (
|
||||
EngineTurnRepository,
|
||||
PipelineStateRepository,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def two_orgs(session_factory):
|
||||
"""Two organizations, each with its own owner: the tenant boundary under test."""
|
||||
org_ids: list[str] = []
|
||||
async with session_factory() as session:
|
||||
for label in ("الف", "ب"):
|
||||
user = UserRow(email=f"repo-ui-{new_id()[:8]}@example.com", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(
|
||||
name=f"سازمان {label}", slug=f"repo-ui-{new_id()[:8]}", owner_user_id=user.id
|
||||
)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
org_ids.append(org.id)
|
||||
await session.commit()
|
||||
return session_factory, org_ids
|
||||
|
||||
|
||||
async def _add_task(session_factory, organization_id: str, title: str) -> str:
|
||||
async with session_factory() as session:
|
||||
task = TaskRow(organization_id=organization_id, title=title)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
return task.id
|
||||
|
||||
|
||||
async def _add_run(session_factory, task_id: str) -> str:
|
||||
async with session_factory() as session:
|
||||
run = RunRow(task_id=task_id)
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
return run.id
|
||||
|
||||
|
||||
async def _add_state(
|
||||
session_factory, organization_id: str, task_id: str, stage: Stage
|
||||
) -> TaskPipelineState:
|
||||
async with session_factory() as session:
|
||||
state = await PipelineStateRepository(session).create(
|
||||
TaskPipelineState(task_id=task_id, organization_id=organization_id, stage=stage)
|
||||
)
|
||||
await session.commit()
|
||||
return state
|
||||
|
||||
|
||||
async def _add_turn(
|
||||
session_factory, task_id: str, run_id: str, excerpt: str, started_at: datetime
|
||||
) -> None:
|
||||
async with session_factory() as session:
|
||||
await EngineTurnRepository(session).create(
|
||||
EngineTurn(
|
||||
task_id=task_id,
|
||||
run_id=run_id,
|
||||
role=RoleKey.DEVELOPER,
|
||||
mode=Mode.WORK,
|
||||
stage=Stage.PENDING_IMPLEMENTATION,
|
||||
reply_excerpt=excerpt,
|
||||
started_at=started_at,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_last_diff_and_merged_sha_are_patched_and_read_back(two_orgs):
|
||||
session_factory, (org_a, _org_b) = two_orgs
|
||||
task_id = await _add_task(session_factory, org_a, "کار دیف")
|
||||
state = await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
|
||||
assert state.last_diff == ""
|
||||
assert state.merged_sha == ""
|
||||
|
||||
diff_text = "diff --git a/app.py b/app.py\n+print('سلام')\n"
|
||||
sha = "ab12cd34" * 5
|
||||
async with session_factory() as session:
|
||||
patched = await PipelineStateRepository(session).patch(
|
||||
task_id=task_id,
|
||||
expected_version=state.version,
|
||||
last_diff=diff_text,
|
||||
merged_sha=sha,
|
||||
)
|
||||
await session.commit()
|
||||
assert patched.last_diff == diff_text
|
||||
assert patched.merged_sha == sha
|
||||
assert patched.version == state.version + 1
|
||||
|
||||
async with session_factory() as session:
|
||||
read_back = await PipelineStateRepository(session).get_or_raise(task_id)
|
||||
assert read_back.last_diff == diff_text
|
||||
assert read_back.merged_sha == sha
|
||||
|
||||
|
||||
async def test_the_org_turn_feed_never_returns_another_organizations_turns(two_orgs):
|
||||
session_factory, (org_a, org_b) = two_orgs
|
||||
base = utcnow()
|
||||
task_a = await _add_task(session_factory, org_a, "کار الف")
|
||||
task_b = await _add_task(session_factory, org_b, "کار ب")
|
||||
run_a = await _add_run(session_factory, task_a)
|
||||
run_b = await _add_run(session_factory, task_b)
|
||||
# Interleaved in time, and org B's turn is the newest of all.
|
||||
await _add_turn(session_factory, task_a, run_a, "الف-یک", base + timedelta(seconds=1))
|
||||
await _add_turn(session_factory, task_b, run_b, "ب-دو", base + timedelta(seconds=2))
|
||||
await _add_turn(session_factory, task_a, run_a, "الف-سه", base + timedelta(seconds=3))
|
||||
await _add_turn(session_factory, task_b, run_b, "ب-ده", base + timedelta(seconds=10))
|
||||
|
||||
async with session_factory() as session:
|
||||
repo = EngineTurnRepository(session)
|
||||
feed_a = await repo.list_for_org(org_a)
|
||||
feed_b = await repo.list_for_org(org_b)
|
||||
newest_a = await repo.list_for_org(org_a, limit=1)
|
||||
|
||||
assert [t.reply_excerpt for t in feed_a] == ["الف-سه", "الف-یک"]
|
||||
assert all(t.task_id == task_a for t in feed_a)
|
||||
assert [t.reply_excerpt for t in feed_b] == ["ب-ده", "ب-دو"]
|
||||
assert all(t.task_id == task_b for t in feed_b)
|
||||
assert [t.reply_excerpt for t in newest_a] == ["الف-سه"]
|
||||
|
||||
|
||||
async def test_stage_counts_are_per_organization(two_orgs):
|
||||
session_factory, (org_a, org_b) = two_orgs
|
||||
for title in ("الف یک", "الف دو"):
|
||||
task_id = await _add_task(session_factory, org_a, title)
|
||||
await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
|
||||
review_id = await _add_task(session_factory, org_a, "الف سه")
|
||||
await _add_state(session_factory, org_a, review_id, Stage.PENDING_MANAGER_REVIEW)
|
||||
b_task = await _add_task(session_factory, org_b, "ب یک")
|
||||
await _add_state(session_factory, org_b, b_task, Stage.PENDING_IMPLEMENTATION)
|
||||
|
||||
async with session_factory() as session:
|
||||
repo = PipelineStateRepository(session)
|
||||
counts_a = await repo.stage_counts(org_a)
|
||||
counts_b = await repo.stage_counts(org_b)
|
||||
at_implementation = await repo.count_at_by_org(Stage.PENDING_IMPLEMENTATION)
|
||||
at_review = await repo.count_at_by_org(Stage.PENDING_MANAGER_REVIEW)
|
||||
|
||||
assert counts_a == {
|
||||
str(Stage.PENDING_IMPLEMENTATION): 2,
|
||||
str(Stage.PENDING_MANAGER_REVIEW): 1,
|
||||
}
|
||||
assert counts_b == {str(Stage.PENDING_IMPLEMENTATION): 1}
|
||||
assert at_implementation == {org_a: 2, org_b: 1}
|
||||
assert at_review == {org_a: 1}
|
||||
Reference in New Issue
Block a user