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
+150
View File
@@ -0,0 +1,150 @@
"""Every stage move publishes ``task.step`` once, on the task and the org channel.
What these tests defend:
* a legal move publishes exactly one ``task.step``: the stage it left, the stage
it reached, the pipeline and the coarse status, on ``task:<id>`` and ``org:<id>``
* a refused move (an illegal transition) publishes nothing, so a client is never
told that a task moved when it did not
* a broken realtime bus cannot undo a move that already happened
* the payload names stages and statuses only, never a model or an employee
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from app.application.engine.state import advance
from app.application.ports.realtime import (
RealtimeEvent,
get_publisher,
org_channel,
set_publisher,
task_channel,
)
from app.core.errors import ConflictError
from app.domain.base import new_id
from app.domain.workforce.models import TaskStatus
from app.domain.workforce.pipeline import Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
class _Recorder:
"""Stands in for the realtime bus and keeps every event it is given."""
def __init__(self) -> None:
self.events: list[RealtimeEvent] = []
async def publish(self, event: RealtimeEvent) -> None:
self.events.append(event)
class _BrokenBus:
async def publish(self, event: RealtimeEvent) -> None:
raise RuntimeError("bus is down")
@pytest_asyncio.fixture
async def recorder():
"""Install a recording publisher for one test and put the previous one back."""
previous = get_publisher()
bus = _Recorder()
set_publisher(bus)
try:
yield bus
finally:
set_publisher(previous)
@pytest_asyncio.fixture
async def one_task(session_factory):
"""One task at ``pending_manager_review`` in a fresh organization."""
async with session_factory() as session:
user = UserRow(email=f"step-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان گام", slug=f"step-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
task = TaskRow(organization_id=org.id, title="کار گام")
session.add(task)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=task.id,
organization_id=org.id,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
await session.commit()
return session_factory, org.id, task.id
def _steps(bus: _Recorder) -> list[RealtimeEvent]:
return [event for event in bus.events if event.type == "task.step"]
async def test_a_legal_move_publishes_one_task_step_on_both_channels(one_task, recorder):
session_factory, org_id, task_id = one_task
async with session_factory() as session:
moved = await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.PENDING_ANALYSIS,
)
await session.commit()
assert moved.stage is Stage.PENDING_ANALYSIS
steps = _steps(recorder)
assert len(steps) == 1
step = steps[0]
assert step.channels == (task_channel(task_id), org_channel(org_id))
assert step.organization_id == org_id
assert step.data == {
"task_id": task_id,
"from": str(Stage.PENDING_MANAGER_REVIEW),
"to": str(Stage.PENDING_ANALYSIS),
"pipeline": str(moved.pipeline),
"status": TaskStatus.RUNNING.value,
}
async def test_a_refused_move_publishes_nothing(one_task, recorder):
session_factory, _org_id, task_id = one_task
async with session_factory() as session:
with pytest.raises(ConflictError):
await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.COMPLETED,
)
await session.rollback()
assert _steps(recorder) == []
async def test_a_broken_bus_does_not_undo_the_move(one_task):
session_factory, _org_id, task_id = one_task
previous = get_publisher()
set_publisher(_BrokenBus())
try:
async with session_factory() as session:
moved = await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.PENDING_ANALYSIS,
)
await session.commit()
finally:
set_publisher(previous)
assert moved.stage is Stage.PENDING_ANALYSIS
async with session_factory() as session:
stored = await PipelineStateRepository(session).get_or_raise(task_id)
assert stored.stage is Stage.PENDING_ANALYSIS