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,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
|
||||
Reference in New Issue
Block a user