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
+263
View File
@@ -0,0 +1,263 @@
"""The person's controls on an engine task: pause, resume, cancel and answer.
``Scheduler.user_action`` is the one place these moves are made for a person.
What it defends:
* pause moves a running task to ``paused``; resume brings a paused task back to
triage, and is refused while the wallet is empty (the task stays paused)
* cancel ends the task and releases its worktree
* an answer is accepted only from ``pending_user_decision``; it reaches the task
description and the thread, and the task goes back to triage
* a task of another organization is a 404, never a conflict (tenant isolation)
"""
# ruff: noqa: F811 - the `world` fixture is imported from test_engine_flow; the test parameters
# named `world` are that fixture, which ruff would otherwise read as a redefinition.
from __future__ import annotations
import pytest
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import FakeRepoWorkspace
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.workforce.models import MessageRole, TaskStatus, Thread
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import OrganizationRow, TaskRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import (
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel
from tests.test_engine_flow import (
_calls,
_FundedWallet,
_scheduler,
_task,
_Wallets,
_ZeroWallet,
world, # noqa: F401 - pytest injects this fixture into the tests below
)
def _scheduler_for(session, org, *, workspace=None, wallets=None):
return _scheduler(
session,
org,
scripted=ScriptedModel([]),
sandbox=FakeSandbox(),
workspace=workspace if workspace is not None else FakeRepoWorkspace(),
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=wallets,
)
async def test_pause_moves_a_running_task_to_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
async def test_resume_brings_a_paused_task_back_to_triage_when_funded(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_resume_with_a_zero_wallet_is_refused_and_the_task_stays_paused(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_ZeroWallet()))
with pytest.raises(ConflictError) as refused:
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert refused.value.message == BUDGET_PAUSED_MESSAGE
assert state is not None and state.stage is Stage.PAUSED
async def test_resume_is_refused_for_a_task_that_is_not_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
with pytest.raises(ConflictError, match="فقط کار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
async def test_cancel_ends_the_task_and_releases_its_worktree(world, session_factory):
workspace = FakeRepoWorkspace()
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, workspace=workspace)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="cancel",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.CANCELLED
assert task is not None and task.status is TaskStatus.CANCELLED
assert _calls(workspace, "remove_worktree")
async def test_an_answer_reaches_the_description_and_the_thread_then_triage(
world, session_factory
):
async with session_factory() as session:
thread = await ThreadRepository(session).create(
Thread(
organization_id=world.organization_id,
title="گفتگو",
project_id=world.project_id,
)
)
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
row.thread_id = thread.id
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" نسخهٔ دوم را انجام بده ",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
messages = await MessageRepository(session).list_for_thread(thread.id)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
assert task is not None
assert task.description.endswith("پاسخ کاربر: نسخهٔ دوم را انجام بده")
assert [(m.role, m.content) for m in messages] == [
(MessageRole.USER, "نسخهٔ دوم را انجام بده")
]
async def test_an_answer_is_refused_unless_the_task_waits_for_a_decision(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
assert task is not None and "پاسخ کاربر" not in task.description
async def test_a_blank_answer_is_refused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ValidationError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_USER_DECISION
async def test_a_legacy_task_without_an_engine_state_is_a_conflict(world, session_factory):
async with session_factory() as session:
row = TaskRow(
organization_id=world.organization_id,
title="کار قدیمی",
project_id=world.project_id,
created_by_user_id=world.user_id,
)
session.add(row)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError, match="موتور خودکار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
async def test_another_organization_gets_not_found_for_every_control(world, session_factory):
async with session_factory() as session:
other = OrganizationRow(
name="سازمان دیگر",
slug=f"other-{new_id()[:6]}",
owner_user_id=world.user_id,
)
session.add(other)
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
for action in ("pause", "resume", "cancel", "answer"):
with pytest.raises(NotFoundError):
await scheduler.user_action(
organization_id=other.id,
task_id=row.id,
action=action,
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION