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
@@ -0,0 +1,82 @@
"""prepaid credit ledger, task diff/merged sha, default employee cap 7
Revision ID: c4d5e6f7a8b9
Revises: b9c0d1e2f3a4
Create Date: 2026-10-14 12:00:00.000000
Owned by the v2 prepaid-credit workstream (todo-v2-final.md T1, decisions D-A and
D-B). ``credit_transactions`` is the wallet history; ``usage_event_id`` is unique,
so one usage row debits the wallet at most once. ``last_diff`` and ``merged_sha``
back the task page. The default employee cap moves from 5 to 7.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c4d5e6f7a8b9"
down_revision: str | None = "b9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"credit_transactions",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("balance_after", sa.Float(), nullable=False),
sa.Column("reason", sa.String(length=500), nullable=False, server_default=""),
sa.Column("actor_user_id", sa.String(length=32), nullable=True),
sa.Column("usage_event_id", sa.String(length=32), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("usage_event_id", name="uq_credit_transactions_usage_event_id"),
)
op.create_index(
"ix_credit_transactions_organization_id", "credit_transactions", ["organization_id"]
)
op.create_index(
"ix_credit_tx_org_created", "credit_transactions", ["organization_id", "created_at"]
)
with op.batch_alter_table("task_pipeline_state") as batch:
batch.add_column(sa.Column("last_diff", sa.Text(), nullable=False, server_default=""))
batch.add_column(
sa.Column("merged_sha", sa.String(length=64), nullable=False, server_default="")
)
with op.batch_alter_table("org_policies") as batch:
batch.alter_column(
"max_employees",
existing_type=sa.Integer(),
existing_nullable=False,
server_default="7",
)
def downgrade() -> None:
with op.batch_alter_table("org_policies") as batch:
batch.alter_column(
"max_employees",
existing_type=sa.Integer(),
existing_nullable=False,
server_default="5",
)
with op.batch_alter_table("task_pipeline_state") as batch:
batch.drop_column("merged_sha")
batch.drop_column("last_diff")
op.drop_index("ix_credit_tx_org_created", table_name="credit_transactions")
op.drop_index("ix_credit_transactions_organization_id", table_name="credit_transactions")
op.drop_table("credit_transactions")
@@ -209,6 +209,7 @@ def _build_manager(
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -235,6 +236,7 @@ def _build_manager(
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=runner,
wallets=CreditWalletRepository(session),
)
+31 -1
View File
@@ -28,6 +28,7 @@ from app.application.ports.realtime import (
task_channel,
)
from app.core.config import settings
from app.domain.workforce.pipeline import PipelineKey, Stage, coarse_status
@dataclass(slots=True, kw_only=True)
@@ -204,4 +205,33 @@ async def emit_team_changed(
)
__all__ = ["RunFeed", "emit_team_changed"]
async def emit_task_step(
*,
task_id: str,
organization_id: str,
from_stage: Stage,
to_stage: Stage,
pipeline: PipelineKey,
) -> None:
"""A task moved one stage: the task page's stepper follows it live.
The payload carries stage names and the coarse status only, never a model
or an employee name.
"""
await publish(
RealtimeEvent(
type="task.step",
channels=(task_channel(task_id), org_channel(organization_id)),
organization_id=organization_id,
data={
"task_id": task_id,
"from": str(from_stage),
"to": str(to_stage),
"pipeline": str(pipeline),
"status": coarse_status(to_stage).value,
},
)
)
__all__ = ["RunFeed", "emit_task_step", "emit_team_changed"]
@@ -88,6 +88,7 @@ from app.infrastructure.repositories.ai_control import (
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -109,6 +110,11 @@ CHAT_MAX_ATTEMPTS = 2
#: The message a new organization sees when the owner has granted nothing yet.
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه"
#: The reply a zero-credit organization gets instead of a model call (prepaid, D-B).
CHAT_NO_CREDIT_MESSAGE = (
"اعتبار سازمان صفر است؛ مدیر پس از افزودن اعتبار توسط مالک سامانه پاسخ می‌دهد."
)
#: Bootstrap tiers per role, in the plan's order (strongest / balanced / fast).
BOOTSTRAP_TIERS: dict[RoleKey, str] = {
RoleKey.MANAGER: "strong",
@@ -223,6 +229,7 @@ class ManagerTurns:
states: PipelineStateRepository | None = None,
runner: TurnRunner | None = None,
feed=None,
wallets: CreditWalletRepository | None = None,
) -> None:
self.provider = provider
self.employees = employees
@@ -239,6 +246,7 @@ class ManagerTurns:
self.states = states
self.runner = runner
self.feed = feed
self.wallets = wallets
# ------------------------------------------------------------------ #
# 3.6 bootstrap #
@@ -404,6 +412,17 @@ class ManagerTurns:
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
# Prepaid: a reply costs a model call, so an org with no credit gets the
# note instead. No model is called and no tag is executed.
if await self._out_of_credit(organization_id):
note = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=CHAT_NO_CREDIT_MESSAGE,
)
)
return user_message, note
employee = await self._manager_of(organization_id, employee_id)
extra = "\n\n".join(
part
@@ -492,6 +511,13 @@ class ManagerTurns:
)
return user_message, assistant_message
async def _out_of_credit(self, organization_id: str) -> bool:
"""True when a wired wallet shows no credit left (a missing wallet counts as none)."""
if self.wallets is None:
return False
wallet = await self.wallets.get_for_org(organization_id)
return wallet is None or wallet.balance <= 0
def _chat_context(
self,
*,
@@ -1083,6 +1109,7 @@ class ManagerTurns:
__all__ = [
"BOOTSTRAP_TIERS",
"CHAT_NO_CREDIT_MESSAGE",
"CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE",
+100 -12
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Literal
from app.application.engine.gates import GateOutcome, GateRunner
from app.application.engine.manager import ManagerOutcome, ManagerTurns, _pick_granted_model
@@ -32,10 +33,11 @@ from app.application.engine.state import advance, park_dependents
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView
from app.application.ports.realtime import emit_task_status
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings
from app.core.errors import ConflictError
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import utcnow
from app.domain.workforce.engine import (
MAX_TRANSIENT_RETRIES,
@@ -43,8 +45,18 @@ from app.domain.workforce.engine import (
GateName,
is_transient,
)
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus, Task, TaskStatus
from app.domain.workforce.models import (
Employee,
Message,
MessageRole,
RoleKey,
Run,
RunStatus,
Task,
TaskStatus,
)
from app.domain.workforce.pipeline import (
TERMINAL_STAGES,
Outcome,
Stage,
TaskPipelineState,
@@ -85,8 +97,10 @@ MERGE_STAGES: tuple[Stage, ...] = (Stage.MERGE_REQUEST,)
#: address; the *name* is the employee's, which is what a reviewer reads.
ENGINE_COMMIT_EMAIL = "platform@smart-org.local"
#: Persian reason shown when work pauses because the money ran out.
BUDGET_PAUSED_MESSAGE = "اعتبار سازمان برای ادامهٔ کار کافی نیست؛ کار موقتاً متوقف شد."
#: Persian reason shown when work pauses because the credit ran out (prepaid, D-B).
BUDGET_PAUSED_MESSAGE = (
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد."
)
#: The stage a turn has to be at for each manager mode.
_MANAGER_STAGES: frozenset[Stage] = frozenset(
@@ -426,7 +440,14 @@ class Scheduler:
same_count=state.same_diff_count,
diff_text=diff.text,
)
await self._patch(state, last_diff_hash=new_hash, same_diff_count=same_count)
# One write for the hash, the counter and the diff the task page shows
# (capped at 20,000 chars). Only a diff that passed the guard is stored.
await self._patch(
state,
last_diff_hash=new_hash,
same_diff_count=same_count,
last_diff=diff.text[:20000],
)
if not progress.ok:
await self._send_back(state, task, progress.detail)
report.failed += 1
@@ -530,6 +551,7 @@ class Scheduler:
return
final_sha = pushed.sha or merged.sha or sha
await self._patch(state, merged_sha=final_sha)
await self._move(state, task, Stage.COMPLETED)
await self.repo_workspace.remove_worktree(worktree=worktree)
if task.project_id:
@@ -622,16 +644,14 @@ class Scheduler:
async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may.
Usage is post-paid: the ledger feeds invoices and nothing debits a wallet
from usage. A zero balance is therefore the normal starting state of every
org, not "out of money", so only an overdrawn wallet (below zero) pauses
work. The owner's spend lever is the org's monthly credit budget.
Billing is prepaid: the platform owner credits the wallet, and every usage
row debits it. Zero means "no credit left", so a zero, negative or missing
wallet pauses work before a turn is spent. The per-task cap and the org's
monthly budget still apply on top of that.
"""
if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be
# frozen out of its first task. A zero balance is not a pause either.
if wallet is not None and wallet.balance < 0:
if wallet is None or wallet.balance <= 0:
return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id)
@@ -648,6 +668,74 @@ class Scheduler:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None
# ------------------------------------------------------------------ #
# the person's controls #
# ------------------------------------------------------------------ #
async def user_action(
self,
*,
organization_id: str,
task_id: str,
action: Literal["pause", "resume", "cancel", "answer"],
user_id: str,
answer: str = "",
) -> TaskPipelineState:
"""Apply one control a person pressed on a task, and return its new state.
The task is looked up inside the caller's organization, so a foreign id is
a 404. A task outside the engine, or one already closed, is a conflict.
Resume is refused while the budget is spent, so a task paused for credit
stays paused until credit is added and resume is pressed again. ``user_id``
names the person who pressed the control; the engine keeps no actor column,
so the route writes the audit row.
"""
task = await self.tasks.get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await self.states.get(task_id)
if state is None:
raise ConflictError("این کار در موتور خودکار نیست.")
if state.stage in TERMINAL_STAGES:
raise ConflictError("این کار بسته شده است.")
if action == "pause":
await self._move(state, task, Stage.PAUSED)
elif action == "resume":
if state.stage is not Stage.PAUSED:
raise ConflictError("فقط کار متوقف‌شده را می‌توان ادامه داد.")
reason = await self._budget_reason(task)
if reason is not None:
raise ConflictError(reason)
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
elif action == "cancel":
await self._move(state, task, Stage.CANCELLED)
else:
await self._answer(state, task, answer)
updated = await self.states.get(task_id)
if updated is None: # pragma: no cover - the row was read above
raise NotFoundError("این کار وضعیت موتور ندارد.")
await emit_task_status(
task_id=task_id,
organization_id=organization_id,
status=coarse_status(updated.stage).value,
)
return updated
async def _answer(self, state: TaskPipelineState, task: Task, answer: str) -> None:
"""A person answers the decision a task waits on; triage reads it next."""
content = answer.strip()
if not 1 <= len(content) <= 4000:
raise ValidationError("پاسخ باید بین ۱ تا ۴۰۰۰ نویسه باشد.")
if state.stage is not Stage.PENDING_USER_DECISION:
raise ConflictError("این کار منتظر پاسخ شما نیست.")
# The move comes first, so a lost race raises before anything is written.
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
await self.tasks.append_description(task.id, "\n\nپاسخ کاربر: " + content)
if task.thread_id:
await self.manager.messages.create(
Message(thread_id=task.thread_id, role=MessageRole.USER, content=content)
)
async def _retry_or_fail(
self, state: TaskPipelineState, task: Task, message: str, kind: str
) -> None:
+12 -2
View File
@@ -19,6 +19,7 @@ truth, the status column is the summary existing APIs keep reading.
from __future__ import annotations
from app.application.engine.events import emit_task_step
from app.core.errors import ConflictError, NotFoundError
from app.domain.workforce.pipeline import (
SIDE_STAGES,
@@ -46,14 +47,14 @@ async def advance(
Raises ``ConflictError`` when the transition is illegal or when another
worker moved the task first, and ``NotFoundError`` when the task has no
engine state row at all.
engine state row at all. A move that lands publishes one ``task.step``.
"""
if not can_transition_stage(expected_from, to):
raise ConflictError(
f"گذار نابجا از {expected_from.value} به {to.value}."
)
try:
return await states.advance(task_id=task_id, expected_from=expected_from, to=to)
moved = await states.advance(task_id=task_id, expected_from=expected_from, to=to)
except UnknownTaskError as exc:
raise NotFoundError("این کار وضعیت موتور ندارد.") from exc
except InvalidTransitionError as exc:
@@ -62,6 +63,15 @@ async def advance(
raise ConflictError(
"این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید."
) from exc
# The publisher never raises, so a broken bus cannot undo the move.
await emit_task_step(
task_id=moved.task_id,
organization_id=moved.organization_id,
from_stage=expected_from,
to_stage=to,
pipeline=moved.pipeline,
)
return moved
async def park_dependents(
@@ -17,6 +17,7 @@ from app.application.ports import PaymentProviderPort
from app.application.ports.realtime import emit_wallet_changed
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.domain.billing.models import (
CreditKind,
CreditWallet,
Invoice,
Payment,
@@ -31,6 +32,7 @@ from app.infrastructure.repositories.billing import (
PlanRepository,
SubscriptionRepository,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
#: A tenant may not buy an arbitrary amount of credit in one action.
MIN_TOPUP = 1.0
@@ -56,6 +58,8 @@ class BillingService:
self.subscriptions = subscriptions
self.ledger = ledger
self.payment_provider = payment_provider
# Settled payments are credited through the credit ledger, in the wallets' session.
self.credit_ledger = CreditLedgerRepository(wallets.session)
# -- reads ---------------------------------------------------------------
@@ -107,6 +111,8 @@ class BillingService:
) -> CreditWallet:
"""Grant (or, for a platform owner, claw back) credit.
Superseded by the owner-only ``CreditService``, which writes the credit ledger.
``allow_negative`` is reserved for the platform owner console; an org
admin may only ever add credit.
"""
@@ -171,7 +177,9 @@ class BillingService:
This is the only path that turns money into credit, so it is idempotent
with respect to status: a payment that is already final is returned
untouched rather than credited twice.
untouched rather than credited twice. The credit is one ``adjustment`` row in
the credit ledger, written in this session, so it shows in the owner's credit
history and updates ``last_credit_at``.
"""
payment = next(
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
@@ -183,12 +191,15 @@ class BillingService:
return payment
if succeeded:
wallet = await self.wallets.adjust(
organization_id=organization_id, delta=payment.amount
credit = await self.credit_ledger.apply(
organization_id=organization_id,
amount=payment.amount,
kind=CreditKind.ADJUSTMENT,
reason="billing/settle-payment",
)
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
await emit_wallet_changed(
organization_id=organization_id, balance=wallet.balance
organization_id=organization_id, balance=credit.balance_after
)
else:
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
@@ -0,0 +1,136 @@
"""Prepaid credit: the platform owner's top-ups, the history and the balances.
An organization's wallet is only ever moved through ``CreditLedgerRepository``, so every
change has a history row. This service is the owner's side of that ledger: it credits one
organization by hand, reads its manual history, lists every organization's balance for the
owner's credit page, and grants the optional starting credit to a new organization. Usage
debits are not written here; ``UsageLedgerRepository.record`` writes them.
Only the platform owner may call the write methods and the cross-organization ``balances``.
The routers enforce that; this service does not look at who is calling.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.billing.models import CreditKind, CreditTransaction
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.identity import OrganizationRepository
#: The most one owner action may credit at once. A bigger top-up is made in steps.
MAX_OWNER_CREDIT = 100_000.0
#: The reason is required and stored with the history row (at most 500 characters).
_REASON_MIN = 3
_REASON_MAX = 500
#: The entries a history page shows: what someone did to the wallet, not usage debits.
_MANUAL_KINDS = (CreditKind.OWNER_CREDIT, CreditKind.STARTING_CREDIT, CreditKind.ADJUSTMENT)
@dataclass(slots=True, kw_only=True)
class OrgBalance:
"""One organization's row on the platform owner's credit page."""
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None
class CreditService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ledger = CreditLedgerRepository(session)
self.wallets = CreditWalletRepository(session)
self.orgs = OrganizationRepository(session)
self.states = PipelineStateRepository(session)
async def owner_credit(
self,
*,
organization_id: str,
amount: float,
reason: str,
actor_user_id: str,
) -> CreditTransaction:
"""Credit one organization's wallet on the platform owner's word."""
if await self.orgs.get(organization_id) is None:
raise NotFoundError("سازمان یافت نشد.")
if not 0 < amount <= MAX_OWNER_CREDIT:
raise ValidationError(
f"مقدار اعتبار باید بیشتر از صفر و حداکثر {MAX_OWNER_CREDIT:g} باشد."
)
clean_reason = reason.strip()
if not _REASON_MIN <= len(clean_reason) <= _REASON_MAX:
raise ValidationError("دلیل افزودن اعتبار لازم است.")
transaction = await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.OWNER_CREDIT,
reason=clean_reason,
actor_user_id=actor_user_id,
)
# Live push: the sidebar credit badge follows the wallet as it moves.
await emit_wallet_changed(
organization_id=organization_id, balance=transaction.balance_after
)
return transaction
async def history(self, organization_id: str, *, limit: int = 50) -> list[CreditTransaction]:
"""One organization's manual credit history, newest first. Usage debits are left out."""
return await self.ledger.history(organization_id, kinds=_MANUAL_KINDS, limit=limit)
async def balances(self) -> list[OrgBalance]:
"""Every organization's balance, paused task count and last credit date.
Cross-organization on purpose: only the platform owner's credit page may call it.
A missing wallet is shown as zero.
"""
paused = await self.states.count_at_by_org(Stage.PAUSED)
last_credit = await self.ledger.last_credit_at_by_org()
rows: list[OrgBalance] = []
for org in await self.orgs.list_all():
wallet = await self.wallets.get_for_org(org.id)
rows.append(
OrgBalance(
organization_id=org.id,
name=org.name,
slug=org.slug,
balance=wallet.balance if wallet else 0.0,
currency=wallet.currency if wallet else "USD",
paused_tasks=paused.get(org.id, 0),
last_credit_at=last_credit.get(org.id),
)
)
return rows
async def grant_starting_credit(self, organization_id: str) -> CreditTransaction | None:
"""Give a new organization the configured starting credit, if one is set.
``BILLING_STARTING_CREDIT`` defaults to 0, so by default nothing is granted and the
owner credits new organizations by hand.
"""
amount = float(getattr(settings, "billing_starting_credit", 0.0))
if amount <= 0:
return None
return await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.STARTING_CREDIT,
reason="اعتبار آغازین",
)
@@ -45,8 +45,9 @@ TIER_LABELS: dict[str, str] = {
#: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first.
DEFAULT_MAX_EMPLOYEES = 5
#: without writing a row first. D-A: the default cap is 7 employees per
#: organization; ``settings.engine_max_employees`` stays the upper bound.
DEFAULT_MAX_EMPLOYEES = 7
@dataclass(slots=True)
@@ -221,6 +221,11 @@ class OrgSetupService:
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await self.session.flush()
from app.application.services.credit_service import CreditService
await CreditService(self.session).grant_starting_credit(org.id)
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
@@ -32,6 +32,7 @@ from app.application.ports.realtime import (
emit_task_created,
emit_task_status,
emit_usage_recorded,
emit_wallet_changed,
)
from app.core.errors import (
ConflictError,
@@ -61,6 +62,13 @@ from app.domain.workforce.models import (
ToolCallStatus,
can_transition_run,
)
from app.domain.workforce.pipeline import (
Risk,
Stage,
TaskKind,
TaskPipelineState,
resolve_pipeline,
)
from app.domain.workforce.tools import (
TOOL_CATALOG,
ToolSpec,
@@ -74,6 +82,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
@@ -94,6 +103,12 @@ from app.infrastructure.repositories.workforce import (
#: non-zero placeholder and mark it in the usage metadata.
_UNPRICED = 0.0000001
#: Said when the legacy "run" button is pressed on a task the engine owns.
_ENGINE_OWNS_TASK = (
"این کار را موتور خودکار اجرا می‌کند؛ "
"برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید."
)
_SYSTEM_PROMPT = (
"تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. "
"هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن."
@@ -126,7 +141,7 @@ async def record_model_usage(
card = await rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await ledger.record(
balance = await ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
@@ -141,6 +156,9 @@ async def record_model_usage(
metadata_json={"priced": card is not None},
)
)
if balance is not None:
# The debit moved the wallet: the sidebar badge follows it live.
await emit_wallet_changed(organization_id=organization_id, balance=balance)
# Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load.
await emit_usage_recorded(
@@ -406,6 +424,7 @@ class WorkforceService:
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -426,6 +445,7 @@ class WorkforceService:
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
wallets=CreditWalletRepository(session),
)
# ------------------------------------------------------------------ #
@@ -443,6 +463,13 @@ class WorkforceService:
thread_id: str | None = None,
assigned_employee_id: str | None = None,
) -> Task:
"""A user order to the manager: the task enters the engine at manager review.
The task gets a pipeline state at ``pending_manager_review``, so the scheduler's
triage turn takes it from there and picks the real pipeline and assignee (D-D).
Without a state the legacy one-shot drain would run it outside the engine. MCP
``tasks_create`` calls this method, so its tasks enter the engine too.
"""
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
@@ -451,6 +478,7 @@ class WorkforceService:
await self.get_employee_or_404(
organization_id=organization_id, employee_id=assigned_employee_id
)
project_id = project_id or await self._project_id_for(organization_id)
task = await self.tasks.create(
Task(
organization_id=organization_id,
@@ -462,6 +490,21 @@ class WorkforceService:
created_by_user_id=created_by_user_id,
)
)
await PipelineStateRepository(self.tasks.session).create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=None,
task_kind=TaskKind.FEATURE,
has_active_reviewer=False,
),
kind=TaskKind.FEATURE,
risk=Risk.MEDIUM,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
# Live push: the tasks list and the manager-chat sidebar update the
# moment the task exists, whoever created it and from where.
await emit_task_created(
@@ -472,6 +515,16 @@ class WorkforceService:
)
return task
async def _project_id_for(self, organization_id: str) -> str | None:
"""The project a project-less order lands in: the one its employees work in.
Mirrors ``ManagerTurns._project_id_for``, so a task made here and a task the
manager makes from a chat get the same project.
"""
for employee in await self.employees.list_for_org(organization_id, limit=1):
return employee.project_id
return None
async def list_tasks(
self,
organization_id: str,
@@ -506,6 +559,9 @@ class WorkforceService:
employee_id: str | None = None,
) -> Run:
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
if await PipelineStateRepository(self.tasks.session).get(task.id) is not None:
# The engine owns this task; its own buttons (pause, resume, cancel, answer) drive it.
raise ConflictError(_ENGINE_OWNS_TASK)
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
raise ConflictError("این کار قبلاً بسته شده است.")
+6
View File
@@ -194,6 +194,12 @@ class Settings(BaseSettings):
git_allow_file_protocol: bool = False
# <<< agent-3
# >>> integration (billing): add settings ONLY between these markers
#: Credit given to every new organization at creation (prepaid). 0 means the
#: platform owner credits new organizations by hand.
billing_starting_credit: float = 0.0
# <<< integration
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
+3
View File
@@ -63,6 +63,9 @@ class AuditAction:
PROJECT_CREATE = "project.create"
PROJECT_DELETE = "project.delete"
TASK_CREATE = "task.create"
#: A person paused, resumed, cancelled or answered an engine task. The metadata
#: names the action only; an answer's text is never written to the audit trail.
TASK_CONTROL = "task.control"
RUN_CANCEL = "run.cancel"
#: A platform owner triaged an error in the console. Auditable so "who
#: decided this bug was fixed?" has an answer.
+28
View File
@@ -83,6 +83,34 @@ class CreditWallet:
self.updated_at = utcnow()
class CreditKind(StrEnum):
"""Why a wallet's balance moved."""
OWNER_CREDIT = "owner_credit"
STARTING_CREDIT = "starting_credit"
USAGE = "usage"
ADJUSTMENT = "adjustment"
@dataclass(slots=True, kw_only=True)
class CreditTransaction:
"""One change to a wallet's balance, with the balance it left behind.
``amount`` is signed: a credit is positive, a debit is negative.
``usage_event_id`` is set on usage debits only, so one usage row debits once.
"""
organization_id: str
kind: CreditKind
amount: float
balance_after: float
reason: str = ""
actor_user_id: str | None = None
usage_event_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class UsageEvent:
"""One billable unit of work. Append-only."""
@@ -441,6 +441,10 @@ class TaskPipelineState:
same_diff_count: int = 0
retry_count: int = 0
waiver_reason: str = ""
#: The latest accepted diff (the writer caps it); the task page shows it.
last_diff: str = ""
#: The sha the merge landed on, once the task is COMPLETED.
merged_sha: str = ""
parent_task_id: str | None = None
depends_on: list[str] = field(default_factory=list)
version: int = 0
+1
View File
@@ -1023,6 +1023,7 @@ class QualityMetricRow(Base):
# parallel agents never edit the same file). Imported last so every table is
# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate.
# ---------------------------------------------------------------------------
from app.infrastructure.db import models_billing as _models_billing # noqa: E402,F401
from app.infrastructure.db import models_delivery as _models_delivery # noqa: E402,F401
from app.infrastructure.db import models_engine as _models_engine # noqa: E402,F401
from app.infrastructure.db import models_org as _models_org # noqa: E402,F401
@@ -0,0 +1,50 @@
"""ORM tables of the prepaid credit ledger (v2).
Use ``from app.core.database import Base`` and the helpers in
``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
Foreign keys to existing tables are written as plain strings, e.g.
``ForeignKey("organizations.id", ondelete="CASCADE")``.
``credit_transactions`` is the history of an organization's prepaid wallet
(``credit_wallets.balance``). Every change to the balance writes one row here, in
the same transaction, with the balance it left behind:
* ``owner_credit``, ``starting_credit`` and ``adjustment`` move the balance by a
signed amount (credit positive, debit negative);
* ``usage`` is the debit for one usage row. ``usage_event_id`` is unique, so one
usage row debits the wallet at most once, even when it is written twice.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.infrastructure.db.columns import created, pk
class CreditTransactionRow(Base):
__tablename__ = "credit_transactions"
__table_args__ = (
Index("ix_credit_tx_org_created", "organization_id", "created_at"),
UniqueConstraint("usage_event_id", name="uq_credit_transactions_usage_event_id"),
)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
#: owner_credit | starting_credit | usage | adjustment (see ``CreditKind``).
kind: Mapped[str] = mapped_column(String(16), nullable=False)
#: Signed: a credit is positive, a debit is negative.
amount: Mapped[float] = mapped_column(Float, nullable=False)
balance_after: Mapped[float] = mapped_column(Float, nullable=False)
reason: Mapped[str] = mapped_column(String(500), default="", nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
#: The usage row this debit paid for. Unique: the idempotency of a usage debit.
usage_event_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = created()
@@ -82,6 +82,11 @@ class TaskPipelineStateRow(Base):
#: A waived test pass must always be explainable: the reason is stored,
#: never inferred (PLAN-v2 §3, green gate).
waiver_reason: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The latest accepted diff (the writer caps it). The worktree is gone after the
#: merge, so the task page reads the diff from here.
last_diff: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The sha the merge landed on, once the state is COMPLETED.
merged_sha: Mapped[str] = mapped_column(String(64), default="", nullable=False)
parent_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
+1 -1
View File
@@ -143,7 +143,7 @@ class OrgPolicyRow(Base):
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
)
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
max_employees: Mapped[int] = mapped_column(Integer, default=7, nullable=False)
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
from app.domain.base import new_id
from app.infrastructure.db.models import (
AuditEventRow,
ErrorEventRow,
@@ -18,6 +19,7 @@ from app.infrastructure.db.models import (
TraceRow,
UsageEventRow,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
@@ -107,14 +109,26 @@ class AuditRepository:
class UsageLedgerRepository:
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth.
Prepaid: every usage row also debits its organization's wallet, once, in the
same transaction (the debit is keyed on the usage row's id). The ledger stays
the source of truth; the wallet is the balance it leaves behind.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(self, *, usage: UsageRecord) -> None:
async def record(self, *, usage: UsageRecord) -> float | None:
"""Append one usage row, debit the wallet for it, return the new balance.
Both writes share the caller's transaction, so a rolled-back request leaves
neither behind. Returns ``None`` when the row cost nothing and nothing was
debited.
"""
total = usage.total_price or (usage.quantity * usage.unit_price)
row = UsageEventRow(
id=new_id(),
organization_id=usage.organization_id,
project_id=usage.project_id,
user_id=usage.user_id,
@@ -129,6 +143,12 @@ class UsageLedgerRepository:
)
self.session.add(row)
await self.session.flush()
debit = await CreditLedgerRepository(self.session).debit_for_usage(
organization_id=usage.organization_id,
usage_event_id=row.id,
amount=total,
)
return debit.balance_after if debit is not None else None
async def breakdown(
self,
@@ -0,0 +1,148 @@
"""The prepaid credit ledger: every balance change is one atomic update and one history row.
``credit_wallets.balance`` is the number the engine reads; ``credit_transactions``
is the history that explains it. Both change in the caller's transaction, so a
rolled-back request leaves neither behind.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.db.models import CreditWalletRow
from app.infrastructure.db.models_billing import CreditTransactionRow
from app.infrastructure.repositories.billing import CreditWalletRepository
#: Longest history page one call may return; the UI never needs more at once.
_MAX_HISTORY = 200
def _transaction(row: CreditTransactionRow) -> CreditTransaction:
return CreditTransaction(
id=row.id,
organization_id=row.organization_id,
kind=CreditKind(row.kind),
amount=float(row.amount),
balance_after=float(row.balance_after),
reason=row.reason,
actor_user_id=row.actor_user_id,
usage_event_id=row.usage_event_id,
created_at=row.created_at,
)
class CreditLedgerRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def apply(
self,
*,
organization_id: str,
amount: float,
kind: CreditKind,
reason: str = "",
actor_user_id: str | None = None,
usage_event_id: str | None = None,
) -> CreditTransaction:
"""Move the wallet by a signed ``amount`` and record the move.
The balance changes in one SQL ``UPDATE`` (``balance = balance + amount``),
never as a Python read-modify-write, so two writers cannot lose each
other's change. The history row records the balance that update left.
"""
await CreditWalletRepository(self.session).ensure(organization_id)
await self.session.execute(
update(CreditWalletRow)
.where(CreditWalletRow.organization_id == organization_id)
.values(balance=CreditWalletRow.balance + amount, updated_at=utcnow())
.execution_options(synchronize_session="fetch")
)
balance_after = (
await self.session.execute(
select(CreditWalletRow.balance).where(
CreditWalletRow.organization_id == organization_id
)
)
).scalar_one()
row = CreditTransactionRow(
organization_id=organization_id,
kind=str(kind),
amount=amount,
balance_after=float(balance_after),
reason=reason[:500],
actor_user_id=actor_user_id,
usage_event_id=usage_event_id,
)
self.session.add(row)
await self.session.flush()
return _transaction(row)
async def debit_for_usage(
self, *, organization_id: str, usage_event_id: str, amount: float
) -> CreditTransaction | None:
"""Debit the wallet for one usage row, at most once.
Returns ``None`` when the usage costs nothing or was already debited. The
debit runs in a savepoint: when two writers race on the same usage row, the
loser's insert is rolled back and it returns ``None`` instead of poisoning
the caller's transaction. Any other integrity failure is re-raised.
"""
if amount <= 0 or await self._debited(organization_id, usage_event_id):
return None
try:
async with self.session.begin_nested():
return await self.apply(
organization_id=organization_id,
amount=-amount,
kind=CreditKind.USAGE,
usage_event_id=usage_event_id,
)
except IntegrityError:
if await self._debited(organization_id, usage_event_id):
return None
raise
async def history(
self,
organization_id: str,
*,
kinds: tuple[CreditKind, ...] | None = None,
limit: int = 50,
) -> list[CreditTransaction]:
"""One organization's history, newest first, optionally one kind of entry."""
stmt = select(CreditTransactionRow).where(
CreditTransactionRow.organization_id == organization_id
)
if kinds:
stmt = stmt.where(CreditTransactionRow.kind.in_([str(k) for k in kinds]))
stmt = stmt.order_by(CreditTransactionRow.created_at.desc()).limit(
min(limit, _MAX_HISTORY)
)
return [_transaction(r) for r in (await self.session.execute(stmt)).scalars()]
async def last_credit_at_by_org(self) -> dict[str, datetime]:
"""When each organization last received credit (usage debits do not count).
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(CreditTransactionRow.organization_id, func.max(CreditTransactionRow.created_at))
.where(CreditTransactionRow.kind != str(CreditKind.USAGE))
.group_by(CreditTransactionRow.organization_id)
)
result = await self.session.execute(stmt)
return dict(result.tuples().all())
async def _debited(self, organization_id: str, usage_event_id: str) -> bool:
stmt = select(CreditTransactionRow.id).where(
CreditTransactionRow.organization_id == organization_id,
CreditTransactionRow.usage_event_id == usage_event_id,
)
return (await self.session.execute(stmt)).first() is not None
@@ -36,7 +36,7 @@ from app.domain.workforce.pipeline import (
TaskKind,
TaskPipelineState,
)
from app.infrastructure.db.models import RunRow, UsageEventRow
from app.infrastructure.db.models import RunRow, TaskRow, UsageEventRow
from app.infrastructure.db.models_engine import (
EmployeeProfileRow,
EngineTurnRow,
@@ -75,6 +75,8 @@ _PATCHABLE = frozenset(
"same_diff_count",
"retry_count",
"waiver_reason",
"last_diff",
"merged_sha",
"parent_task_id",
"depends_on",
}
@@ -97,6 +99,8 @@ def _state(row: TaskPipelineStateRow) -> TaskPipelineState:
same_diff_count=row.same_diff_count,
retry_count=row.retry_count,
waiver_reason=row.waiver_reason,
last_diff=row.last_diff,
merged_sha=row.merged_sha,
parent_task_id=row.parent_task_id,
depends_on=list(row.depends_on or []),
version=row.version,
@@ -185,6 +189,8 @@ class PipelineStateRepository:
same_diff_count=state.same_diff_count,
retry_count=state.retry_count,
waiver_reason=state.waiver_reason,
last_diff=state.last_diff,
merged_sha=state.merged_sha,
parent_task_id=state.parent_task_id,
depends_on=list(state.depends_on),
version=state.version,
@@ -268,6 +274,27 @@ class PipelineStateRepository:
)
return int((await self.session.execute(stmt)).scalar_one())
async def count_at_by_org(self, stage: Stage) -> dict[str, int]:
"""How many tasks sit at one stage, per organization.
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(TaskPipelineStateRow.organization_id, func.count())
.where(TaskPipelineStateRow.stage == str(stage))
.group_by(TaskPipelineStateRow.organization_id)
)
return {org: int(n) for org, n in (await self.session.execute(stmt)).all()}
async def stage_counts(self, organization_id: str) -> dict[str, int]:
"""How many of one organization's tasks sit at each stage."""
stmt = (
select(TaskPipelineStateRow.stage, func.count())
.where(TaskPipelineStateRow.organization_id == organization_id)
.group_by(TaskPipelineStateRow.stage)
)
return {stage: int(n) for stage, n in (await self.session.execute(stmt)).all()}
# ---------------------------------------------------------------------------
# employee_profiles
@@ -410,6 +437,21 @@ class EngineTurnRepository:
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_org(self, organization_id: str, *, limit: int = 50) -> list[EngineTurn]:
"""The newest turns across one organization's tasks.
Turns carry no organization column, so the tenant filter joins through
the task: another organization's turn can never match.
"""
stmt = (
select(EngineTurnRow)
.join(TaskRow, EngineTurnRow.task_id == TaskRow.id)
.where(TaskRow.organization_id == organization_id)
.order_by(EngineTurnRow.started_at.desc())
.limit(min(limit, 200))
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def finish(
self,
turn_id: str,
@@ -39,7 +39,7 @@ class DefaultGrantRecord:
@dataclass(slots=True, kw_only=True)
class OrgPolicyRecord:
organization_id: str
max_employees: int = 5
max_employees: int = 7
monthly_credit_budget: float | None = None
automation_enabled: bool = True
mcp_enabled: bool = True
@@ -510,6 +510,15 @@ class TaskRepository:
await self.session.flush()
return _task(row)
async def append_description(self, task_id: str, text: str) -> Task | None:
"""Add text to the end of a task's description (a person's answer, for triage)."""
row = await self.session.get(TaskRow, task_id)
if row is None:
return None
row.description = f"{row.description or ''}{text}"
await self.session.flush()
return _task(row)
class RunRepository:
def __init__(self, session: AsyncSession) -> None:
@@ -0,0 +1,144 @@
"""Owner credit console: ``/api/admin/credits`` and ``/api/admin/orgs/{org}/credit``.
Prepaid billing (D-B): the platform owner is the only one who puts credit into an
organization's wallet. Each credit writes one history row (``CreditService``), one audit row
and one live ``billing.wallet`` push, so the organization's credit badge follows the balance.
Owner-only at the router level, like ``admin_grants.py``. Nothing here names a model or a
provider, and no secret is returned. Organization members read their own balance through
``/api/organizations/{org}/billing``.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.credit_service import CreditService, OrgBalance
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditTransaction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import OrganizationRepository
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-credits"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OrgBalanceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None = None
class CreditRequest(BaseModel):
#: Checked by ``CreditService`` (a zero amount or a blank reason gets a Persian message).
amount: float
reason: str
class CreditTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
amount: float
balance_after: float
reason: str
created_at: datetime
def _balance_out(view: OrgBalance) -> OrgBalanceOut:
return OrgBalanceOut(**asdict(view))
def _transaction_out(transaction: CreditTransaction) -> CreditTransactionOut:
return CreditTransactionOut(
id=transaction.id,
kind=str(transaction.kind),
amount=transaction.amount,
balance_after=transaction.balance_after,
reason=transaction.reason,
created_at=transaction.created_at,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/credits", response_model=list[OrgBalanceOut])
async def list_balances(session: AsyncSession = Depends(get_session)) -> list[OrgBalanceOut]:
"""Every organization's balance, paused task count and last credit date."""
return [_balance_out(view) for view in await CreditService(session).balances()]
@router.post("/orgs/{org_id}/credit", response_model=CreditTransactionOut)
async def credit_org(
org_id: str,
payload: CreditRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CreditTransactionOut:
"""Add credit to one organization's wallet; the history and audit rows commit together."""
transaction = await CreditService(session).owner_credit(
organization_id=org_id,
amount=payload.amount,
reason=payload.reason,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.BILLING_CREDIT,
organization_id=org_id,
actor_user_id=owner.id,
target_type="credit_wallet",
# One wallet per organization, so the organization id names the wallet.
target_id=org_id,
metadata={
"amount": payload.amount,
"reason": transaction.reason,
"balance": transaction.balance_after,
},
ip=ip,
user_agent=ua,
)
return _transaction_out(transaction)
@router.get("/orgs/{org_id}/credit-history", response_model=list[CreditTransactionOut])
async def credit_history(
org_id: str,
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[CreditTransactionOut]:
"""One organization's manual credit history, newest first (usage debits are not listed)."""
if await OrganizationRepository(session).get(org_id) is None:
raise NotFoundError("سازمان یافت نشد.")
history = await CreditService(session).history(org_id, limit=limit)
return [_transaction_out(transaction) for transaction in history]
+45 -20
View File
@@ -1,8 +1,10 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
"""Billing endpoints: summary, usage ledger, invoices, payments and checkout.
Reading billing requires ``billing.view``; changing it requires
``billing.manage``. A tenant can therefore never see — or move — another
organization's money.
Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the
platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing
requires ``billing.view``; ``checkout``, which only creates a pending payment, requires
``billing.manage``. A tenant can therefore never see or move another organization's money,
and an organization admin cannot give its own wallet credit.
"""
from __future__ import annotations
@@ -13,11 +15,16 @@ from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.application.services.audit_service import record_audit
from app.application.services.billing_service import BillingService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditKind
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.interfaces.http.deps import (
OrgContext,
client_meta,
@@ -27,6 +34,9 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
#: Said to anyone who is not the platform owner and tries to move an organization's credit.
OWNER_ONLY_CREDIT = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
# ---------------------------------------------------------------------------
# Schemas
@@ -88,7 +98,9 @@ class PaymentOut(BaseModel):
class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
amount: float = Field(
description="Platform owner only. Positive tops up; negative claws back."
)
class WalletOut(BaseModel):
@@ -210,35 +222,47 @@ async def add_credit(
payload: AddCreditRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> WalletOut:
# A negative delta is a platform-owner-only clawback.
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
"""Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
else:
context.require(CAN_MANAGE_BILLING)
The change is one history row of kind ``adjustment``, so it shows in the owner's
credit history for this organization.
"""
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
if payload.amount == 0:
raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.")
wallet = await service.add_credit(
transaction = await CreditLedgerRepository(session).apply(
organization_id=context.org_id,
amount=payload.amount,
allow_negative=context.user.is_platform_owner,
kind=CreditKind.ADJUSTMENT,
reason="billing/add-credit",
actor_user_id=context.user.id,
)
if payload.amount < 0 and transaction.balance_after < 0:
# Refuse to leave the wallet in debt. The request's session rolls back on this
# error, so the balance change and its history row are both undone.
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
wallet = await CreditWalletRepository(session).ensure(context.org_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance},
metadata={"amount": payload.amount, "balance": transaction.balance_after},
ip=ip, user_agent=ua,
)
# Live push: the sidebar credit badge follows the wallet, whoever moved it.
await emit_wallet_changed(
organization_id=context.org_id, balance=transaction.balance_after
)
return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
balance=transaction.balance_after,
currency=wallet.currency,
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
)
@@ -269,12 +293,13 @@ async def settle_payment(
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> PaymentOut:
"""Finalise a payment.
"""Finalise a payment. Only the platform owner may: a succeeded payment credits the wallet.
In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit.
"""
context.require(CAN_MANAGE_BILLING)
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
)
@@ -0,0 +1,670 @@
"""The AI workforce engine's API: status, task board, task detail and recent turns.
Every route resolves its tenant through ``get_org_context``. The reads are open to
every member of the organization, so the credit notice and the board work for
every role. The four actions on a task (pause, resume, cancel, answer) need the
``tasks.run`` capability and each writes one audit row. The team (view, hire by role
and tier, fire) is in this router too; hire and fire need ``employees.manage``.
Three things are load-bearing here:
* **Model identity never leaves.** No schema in this module has a field that can
hold a model id or name, a provider, an account, a container or a port. A turn
shows the employee's *name*, the role, the mode and the stage, never the model
that ran it. A future refactor cannot leak one without adding such a field.
* **Every query is tenant-scoped.** Tasks, states, turns, gates and runs are read
through repositories that filter by organization, and a task id that belongs to
another organization is a 404 (the same answer a missing id gets).
* **Bounded reads.** The board scans at most the newest 500 tasks of the
organization when it filters by stage; a turn excerpt is capped at 1,000 characters.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grants
from app.application.ports.model_grants import GrantedModel
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey, Run, Task
from app.domain.workforce.pipeline import Stage, TaskPipelineState, pipeline_stages
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
TaskRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context
router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"])
#: The most tasks a board reads, newest first (a stage filter looks only among these).
_BOARD_SCAN_LIMIT = 500
#: The most pipeline states read in one listing; the rest are looked up one by one.
_STATE_SCAN_LIMIT = 1000
#: The longest reply excerpt a turn carries. The full reply is not shown here.
_EXCERPT_CHARS = 1000
#: The most turns one task detail carries, and the most gate verdicts.
_DETAIL_LIMIT = 200
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class EngineStatusOut(BaseModel):
"""Whether the organization's engine can work, and how much of the team is in use."""
balance: float
currency: str
credit_ok: bool
automation_enabled: bool
max_employees: int
team_size: int
stage_counts: dict[str, int]
class EngineTaskOut(BaseModel):
"""One task on the board.
``stage``, ``pipeline`` and ``kind`` are null for a task the engine does not
own (a legacy task). ``cycle`` and ``max_cycles`` are 0 for those tasks too.
"""
id: str
title: str
status: str
stage: str | None
pipeline: str | None
kind: str | None
cycle: int
max_cycles: int
thread_id: str | None
assigned_employee_id: str | None
assignee_name: str | None
created_at: datetime
updated_at: datetime
class EngineTurnOut(BaseModel):
"""One employee turn: who ran, in which role and stage, and how it ended."""
id: str
run_id: str
role: str
mode: str
stage: str
employee_name: str | None
ok: bool
error_kind: str
tool_calls: int
reply_excerpt: str
started_at: datetime
finished_at: datetime | None
class EngineOrgTurnOut(EngineTurnOut):
"""A turn in the organization's recent list, with the task it belongs to."""
task_id: str
task_title: str
class EngineGateOut(BaseModel):
id: str
gate: str
ok: bool
detail: str
created_at: datetime
class EngineRunOut(BaseModel):
id: str
status: str
started_at: datetime | None
finished_at: datetime | None
class EngineTaskDetailOut(EngineTaskOut):
"""One task with its pipeline, its turns, its gate verdicts and its last diff."""
description: str
#: The stages of the task's pipeline in order; empty for a legacy task.
stages: list[str]
waiver_reason: str
branch: str
depends_on: list[str]
#: The sha the merge landed on, once the task is completed.
merged_sha: str
#: The last accepted diff (capped by the writer at 20,000 characters).
diff: str
turns: list[EngineTurnOut]
gates: list[EngineGateOut]
runs: list[EngineRunOut]
class EngineAnswerRequest(BaseModel):
"""A person's answer to the question a task is waiting on."""
content: str = Field(min_length=1, max_length=4000)
class TierOut(BaseModel):
tier: str
label: str
class TeamMemberOut(BaseModel):
"""One team member. ``tier`` is neutral: no model, provider or model id is shown."""
id: str
name: str
role_key: str
status: str
#: ``fast``, ``balanced``, ``strong``, or ``unknown`` when the model is not granted.
tier: str
busy_task_id: str | None
busy_task_title: str | None
hired_by: str
class TeamOut(BaseModel):
employees: list[TeamMemberOut]
#: The tiers the organization has granted models for, cheapest first.
tiers: list[TierOut]
#: The owner's cap on the headcount for this organization.
max_employees: int
count: int
class HireRequest(BaseModel):
"""A hire asks for a role and a tier. The model is chosen by the platform."""
role: RoleKey
tier: str = Field(min_length=1, max_length=20)
name: str = Field(default="", max_length=120)
# ---------------------------------------------------------------------------
# Mapping
# ---------------------------------------------------------------------------
#: Neutral tier labels, cheapest first: the order the team view lists them in.
_TIER_LABELS: dict[str, str] = {"fast": "سریع", "balanced": "متعادل", "strong": "قوی"}
def _task_out(
task: Task, state: TaskPipelineState | None, names: dict[str, str]
) -> EngineTaskOut:
return EngineTaskOut(
id=task.id,
title=task.title,
status=str(task.status),
stage=str(state.stage) if state else None,
pipeline=str(state.pipeline) if state else None,
kind=str(state.kind) if state else None,
cycle=state.cycle if state else 0,
max_cycles=state.max_cycles if state else 0,
thread_id=task.thread_id,
assigned_employee_id=task.assigned_employee_id,
assignee_name=names.get(task.assigned_employee_id or ""),
created_at=task.created_at,
updated_at=task.updated_at,
)
def _turn_out(turn: EngineTurn, names: dict[str, str]) -> EngineTurnOut:
return EngineTurnOut(
id=turn.id,
run_id=turn.run_id,
role=str(turn.role),
mode=str(turn.mode),
stage=str(turn.stage),
employee_name=names.get(turn.employee_id or ""),
ok=turn.ok,
error_kind=turn.error_kind,
tool_calls=turn.tool_calls,
reply_excerpt=turn.reply_excerpt[:_EXCERPT_CHARS],
started_at=turn.started_at,
finished_at=turn.finished_at,
)
async def _employee_names(
session: AsyncSession, organization_id: str, employee_ids: list[str | None]
) -> dict[str, str]:
"""Names of the employees a list refers to, read inside the organization only.
One listing covers the team in the common case; an id outside that listing is
looked up on its own, still scoped to the organization.
"""
wanted = {eid for eid in employee_ids if eid}
repo = EmployeeRepository(session)
employees: list[Employee] = await repo.list_for_org(organization_id, limit=500)
names = {e.id: e.name for e in employees if e.id in wanted}
for employee_id in wanted:
if employee_id not in names:
employee = await repo.get_for_org(organization_id, employee_id)
if employee is not None:
names[employee_id] = employee.name
return names
async def _task_titles(
session: AsyncSession, organization_id: str, task_ids: list[str]
) -> dict[str, str]:
"""Titles of the tasks a list refers to, read inside the organization only."""
wanted = set(task_ids)
repo = TaskRepository(session)
tasks: list[Task] = await repo.list_for_org(organization_id, limit=_BOARD_SCAN_LIMIT)
titles = {t.id: t.title for t in tasks if t.id in wanted}
for task_id in wanted:
if task_id not in titles:
task = await repo.get_for_org(organization_id, task_id)
if task is not None:
titles[task_id] = task.title
return titles
async def _granted_tiers(
session: AsyncSession, organization_id: str
) -> tuple[list[GrantedModel], dict[str, str]]:
"""The organization's granted models, and each one's neutral tier by model id."""
granted = await build_model_grants(session).granted_models(organization_id=organization_id)
return granted, {model.ai_model_id: model.tier for model in granted}
def _tier_list(granted: list[GrantedModel]) -> list[TierOut]:
present = {model.tier for model in granted}
return [
TierOut(tier=tier, label=label) for tier, label in _TIER_LABELS.items() if tier in present
]
async def _member_out(
session: AsyncSession,
organization_id: str,
employee: Employee,
tiers: dict[str, str],
) -> TeamMemberOut:
profile = await EmployeeProfileRepository(session).get(employee.id)
tier = "unknown"
busy_task_id: str | None = None
busy_task_title: str | None = None
hired_by = ""
if profile is not None:
tier = tiers.get(profile.ai_model_id or "", "unknown")
busy_task_id = profile.busy_task_id
hired_by = profile.hired_by
if busy_task_id:
busy = await TaskRepository(session).get_for_org(organization_id, busy_task_id)
busy_task_title = busy.title if busy is not None else None
return TeamMemberOut(
id=employee.id,
name=employee.name,
role_key=str(employee.role_key),
status=str(employee.status),
tier=tier,
busy_task_id=busy_task_id,
busy_task_title=busy_task_title,
hired_by=hired_by,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/status", response_model=EngineStatusOut)
async def engine_status(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineStatusOut:
"""The credit balance, the owner's policy, the team size and the stage counts.
``credit_ok`` is false for a missing wallet as well as for a zero balance,
because prepaid work needs credit to run (decision D-B).
"""
organization_id = context.org_id
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
return EngineStatusOut(
balance=wallet.balance if wallet is not None else 0.0,
currency=wallet.currency if wallet is not None else "USD",
credit_ok=wallet is not None and wallet.balance > 0,
automation_enabled=policy.automation_enabled,
max_employees=policy.max_employees,
team_size=await EmployeeRepository(session).count_for_org(organization_id),
stage_counts=await PipelineStateRepository(session).stage_counts(organization_id),
)
@router.get("/tasks", response_model=list[EngineTaskOut])
async def list_engine_tasks(
stage: Stage | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineTaskOut]:
"""The organization's tasks, newest first, with their stage when the engine owns them.
With ``stage`` only the tasks at that stage are returned, found among the newest
500 tasks of the organization.
"""
organization_id = context.org_id
states_repo = PipelineStateRepository(session)
states = {
s.task_id: s
for s in await states_repo.list_for_org(organization_id, limit=_STATE_SCAN_LIMIT)
}
rows: list[tuple[Task, TaskPipelineState | None]] = []
for task in await TaskRepository(session).list_for_org(
organization_id, limit=_BOARD_SCAN_LIMIT
):
state = states.get(task.id)
if state is None:
# A legacy task, or an engine task older than the newest states listed above.
state = await states_repo.get(task.id)
if stage is not None and (state is None or state.stage is not stage):
continue
rows.append((task, state))
if len(rows) >= limit:
break
names = await _employee_names(
session, organization_id, [task.assigned_employee_id for task, _ in rows]
)
return [_task_out(task, state, names) for task, state in rows]
@router.get("/tasks/{task_id}", response_model=EngineTaskDetailOut)
async def get_engine_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskDetailOut:
"""One task with its stages, turns, gate verdicts, runs, diff and merged sha."""
organization_id = context.org_id
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
turns = await EngineTurnRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
gates = await GateResultRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
runs: list[Run] = await RunRepository(session).list_for_task(task.id)
names = await _employee_names(
session,
organization_id,
[task.assigned_employee_id, *(turn.employee_id for turn in turns)],
)
base = _task_out(task, state, names)
return EngineTaskDetailOut(
**base.model_dump(),
description=task.description,
stages=[str(stage) for stage in pipeline_stages(state.pipeline)] if state else [],
waiver_reason=state.waiver_reason if state else "",
branch=state.branch if state else "",
depends_on=list(state.depends_on) if state else [],
merged_sha=state.merged_sha if state else "",
diff=state.last_diff if state else "",
turns=[_turn_out(turn, names) for turn in turns],
gates=[
EngineGateOut(
id=gate.id,
gate=str(gate.gate),
ok=gate.ok,
detail=gate.detail,
created_at=gate.created_at,
)
for gate in gates
],
runs=[
EngineRunOut(
id=run.id,
status=str(run.status),
started_at=run.started_at,
finished_at=run.finished_at,
)
for run in runs
],
)
@router.get("/turns", response_model=list[EngineOrgTurnOut])
async def list_recent_turns(
limit: int = Query(default=50, ge=1, le=200),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineOrgTurnOut]:
"""The organization's newest turns across all its tasks, with each task's title."""
organization_id = context.org_id
turns = await EngineTurnRepository(session).list_for_org(organization_id, limit=limit)
names = await _employee_names(session, organization_id, [turn.employee_id for turn in turns])
titles = await _task_titles(session, organization_id, [turn.task_id for turn in turns])
return [
EngineOrgTurnOut(
**_turn_out(turn, names).model_dump(),
task_id=turn.task_id,
task_title=titles.get(turn.task_id, ""),
)
for turn in turns
]
# ---------------------------------------------------------------------------
# Actions on one task
# ---------------------------------------------------------------------------
async def _task_view(session: AsyncSession, organization_id: str, task_id: str) -> EngineTaskOut:
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
names = await _employee_names(session, organization_id, [task.assigned_employee_id])
return _task_out(task, state, names)
async def _control(
*,
action: Literal["pause", "resume", "cancel", "answer"],
task_id: str,
request: Request,
context: OrgContext,
session: AsyncSession,
answer: str = "",
) -> EngineTaskOut:
context.require(CAN_RUN_TASKS)
# Imported inside the handler: the container wires the whole engine, and the read
# routes above do not need it.
from app.application.container import build_scheduler
await build_scheduler(session).user_action(
organization_id=context.org_id,
task_id=task_id,
action=action,
user_id=context.user.id,
answer=answer,
)
ip, ua = client_meta(request)
# Only the action is written: an answer's text stays out of the audit trail.
await record_audit(
session,
action=AuditAction.TASK_CONTROL,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="task",
target_id=task_id,
metadata={"action": action},
ip=ip,
user_agent=ua,
)
return await _task_view(session, context.org_id, task_id)
@router.post("/tasks/{task_id}/pause", response_model=EngineTaskOut)
async def pause_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Stop a task before its next turn. Its stage is kept, so resume picks it up."""
return await _control(
action="pause", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/resume", response_model=EngineTaskOut)
async def resume_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Send a paused task back to triage. Refused while the organization has no credit."""
return await _control(
action="resume", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/cancel", response_model=EngineTaskOut)
async def cancel_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""End a task. Its worktree is released and tasks waiting on it are parked."""
return await _control(
action="cancel", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/answer", response_model=EngineTaskOut)
async def answer_task(
task_id: str,
body: EngineAnswerRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Answer the question a task waits on. Only a task at a decision accepts one."""
return await _control(
action="answer",
task_id=task_id,
request=request,
context=context,
session=session,
answer=body.content,
)
# ---------------------------------------------------------------------------
# Team: the view, hire by role and tier, fire
# ---------------------------------------------------------------------------
@router.get("/team", response_model=TeamOut)
async def get_team(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamOut:
"""The team, without the archived members, with each member's neutral tier."""
organization_id = context.org_id
granted, tiers = await _granted_tiers(session, organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
employees = [
employee
for employee in await EmployeeRepository(session).list_for_org(organization_id, limit=500)
if employee.status is not EmployeeStatus.ARCHIVED
]
members = [await _member_out(session, organization_id, e, tiers) for e in employees]
return TeamOut(
employees=members,
tiers=_tier_list(granted),
max_employees=policy.max_employees,
count=len(members),
)
@router.post("/team/hire", response_model=TeamMemberOut, status_code=201)
async def hire_member(
body: HireRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Hire one employee for a role on a granted model of the requested tier."""
context.require(CAN_MANAGE_EMPLOYEES)
# Imported inside the handler, as for the task actions above.
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.hire(
organization_id=context.org_id,
role=body.role,
tier=body.tier,
name=body.name,
project_id=None,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"role_key": str(body.role), "tier": body.tier},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@router.post("/team/{employee_id}/fire", response_model=TeamMemberOut)
async def fire_member(
employee_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Archive one employee. The last active manager cannot be fired."""
context.require(CAN_MANAGE_EMPLOYEES)
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.fire(
organization_id=context.org_id, employee_id=employee_id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_UPDATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"action": "fire"},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@@ -145,6 +145,11 @@ async def create_organization(
)
# Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await session.flush()
from app.application.services.credit_service import CreditService
await CreditService(session).grant_starting_credit(org.id)
ip, ua = client_meta(request)
await record_audit(
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import (
admin,
admin_credits,
admin_domains,
admin_grants,
admin_users,
@@ -31,6 +32,7 @@ from app.interfaces.http.routers import (
auth,
billing,
delivery,
engine,
health,
invites,
knowledge,
@@ -82,6 +84,8 @@ def build_rpc_app() -> FastAPI:
application.include_router(memory.router, prefix="/api")
application.include_router(mcp_tokens.router, prefix="/api")
application.include_router(admin_grants.router, prefix="/api")
application.include_router(admin_credits.router, prefix="/api")
application.include_router(engine.router, prefix="/api")
application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.router, prefix="/api")
application.include_router(sandbox.admin_router, prefix="/api")
+29
View File
@@ -0,0 +1,29 @@
"""Test helpers for the prepaid credit ledger.
Prepaid billing means an organization needs credit before engine work runs, so
tests that exercise that work fund the organization first. ``fund_org`` writes
the credit through the same repository the platform owner's endpoints use, so
the wallet and its history are written the real way.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.repositories.credit import CreditLedgerRepository
async def fund_org(
session_factory: async_sessionmaker, organization_id: str, amount: float = 100.0
) -> CreditTransaction:
"""Credit an organization's wallet by ``amount`` as a labelled adjustment."""
async with session_factory() as session:
transaction = await CreditLedgerRepository(session).apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.ADJUSTMENT,
reason="test funding",
)
await session.commit()
return transaction
+36
View File
@@ -0,0 +1,36 @@
"""A pre-v2 task: a plain ``tasks`` row with no ``task_pipeline_state``.
Every task made since the engine owns direct tasks gets a pipeline state, so HTTP
``POST /tasks`` and MCP ``tasks_create`` no longer create one of these (decision D-D).
Tasks created before that have no state and still run through the legacy one-shot path
(``WorkforceService.start_run``). Tests of that path create their task with this helper.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.infrastructure.db.models import TaskRow
async def legacy_task(
session_factory: async_sessionmaker,
organization_id: str,
created_by_user_id: str,
*,
title: str = "کار قدیمی",
project_id: str | None = None,
assigned_employee_id: str | None = None,
) -> str:
"""Insert one task with no pipeline state and return its id."""
async with session_factory() as session:
row = TaskRow(
organization_id=organization_id,
project_id=project_id,
title=title,
assigned_employee_id=assigned_employee_id,
created_by_user_id=created_by_user_id,
)
session.add(row)
await session.commit()
return row.id
+205
View File
@@ -0,0 +1,205 @@
"""The owner's credit console: top-ups, an organization's history and the balances page.
What these tests defend:
* a credit from the platform owner moves the wallet, writes one history row, one audit row
that names the reason, and one live ``billing.wallet`` event with the new balance
* the history and the balances page show the credit under the right organization
* only the platform owner reaches the three endpoints: an organization owner and a plain user
get 403 and change nothing, an anonymous caller gets 401
* an unknown organization is a 404, and a zero or negative amount or a blank reason is refused
with a Persian message
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import select
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.domain.audit.models import AuditAction
from app.domain.base import new_id
from app.infrastructure.db.models import AuditEventRow, UserRow
from tests.conftest import auth_header
CREDITS = "/api/admin/credits"
ORGS = "/api/admin/orgs"
OWNER_EMAIL = "platform-owner@example.com"
REASON = "شارژ اولیهٔ سازمان"
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)
@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)
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
"""An organization the manager created through the wizard, as a customer would."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def test_the_owner_credit_moves_the_wallet_and_is_audited_and_pushed(
client, manager_token, platform_owner_token, seeded, session_factory, recorder
):
org_id = await _create_org(client, manager_token, "سازمان الف", "credit-alpha")
owner = auth_header(platform_owner_token)
credited = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": 25.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 200, credited.text
body = credited.json()
assert body["kind"] == "owner_credit"
assert body["amount"] == 25.0
assert body["balance_after"] == 25.0
assert body["reason"] == REASON
# The response carries only the history fields: no model, provider or secret.
assert set(body) == {"id", "kind", "amount", "balance_after", "reason", "created_at"}
balances = await client.get(CREDITS, headers=owner)
assert balances.status_code == 200, balances.text
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 25.0
assert row["currency"] == "USD"
assert row["paused_tasks"] == 0
assert row["last_credit_at"] is not None
history = await client.get(f"{ORGS}/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [h["reason"] for h in history.json()] == [REASON]
async with session_factory() as session:
owner_id = (
await session.execute(select(UserRow.id).where(UserRow.email == OWNER_EMAIL))
).scalar_one()
audits = (
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == AuditAction.BILLING_CREDIT,
AuditEventRow.organization_id == org_id,
)
)
).scalars().all()
assert len(audits) == 1
assert audits[0].actor_user_id == owner_id
assert audits[0].metadata_json["reason"] == REASON
assert audits[0].metadata_json["balance"] == 25.0
pushed = [e for e in recorder.events if e.type == "billing.wallet"]
assert pushed, "the owner's credit must push the new balance to the organization"
assert pushed[-1].data == {"organization_id": org_id, "balance": 25.0}
async def test_only_the_platform_owner_reaches_the_credit_console(
client, manager_token, plain_user_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "credit-beta")
calls = [
("get", CREDITS, None),
("post", f"{ORGS}/{org_id}/credit", {"amount": 5.0, "reason": REASON}),
("get", f"{ORGS}/{org_id}/credit-history", None),
]
# The manager is the organization's owner; the plain user is a customer with no seat.
for token in (manager_token, plain_user_token):
for method, path, body in calls:
kwargs: dict = {"headers": auth_header(token)}
if body is not None:
kwargs["json"] = body
response = await getattr(client, method)(path, **kwargs)
assert response.status_code == 403, response.text
anonymous = await client.get(CREDITS)
assert anonymous.status_code == 401
# The refused credits changed nothing.
balances = await client.get(CREDITS, headers=auth_header(platform_owner_token))
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 0.0
assert row["last_credit_at"] is None
async def test_an_unknown_organization_is_not_found(client, platform_owner_token, seeded):
owner = auth_header(platform_owner_token)
missing = new_id()
credited = await client.post(
f"{ORGS}/{missing}/credit", json={"amount": 5.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 404, credited.text
history = await client.get(f"{ORGS}/{missing}/credit-history", headers=owner)
assert history.status_code == 404, history.text
async def test_the_history_limit_is_checked_and_the_list_is_scoped(
client, manager_token, platform_owner_token, seeded
):
org_a = await _create_org(client, manager_token, "سازمان الف", "credit-hist-a")
owner = auth_header(platform_owner_token)
for reason in ("شارژ نخست", "شارژ دوم"):
response = await client.post(
f"{ORGS}/{org_a}/credit", json={"amount": 10.0, "reason": reason}, headers=owner
)
assert response.status_code == 200, response.text
capped = await client.get(
f"{ORGS}/{org_a}/credit-history?limit=1", headers=owner
)
assert capped.status_code == 200, capped.text
assert [h["reason"] for h in capped.json()] == ["شارژ دوم"]
too_many = await client.get(f"{ORGS}/{org_a}/credit-history?limit=500", headers=owner)
assert too_many.status_code == 422, too_many.text
async def test_a_refused_credit_is_a_4xx_with_a_persian_message(
client, manager_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان پ", "credit-gamma")
owner = auth_header(platform_owner_token)
for amount, reason, message in (
(0.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(-3.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(10.0, "", "دلیل افزودن اعتبار لازم است."),
):
response = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": amount, "reason": reason}, headers=owner
)
assert response.status_code == 422, response.text
assert message in response.json()["error"]["message"]
+161
View File
@@ -0,0 +1,161 @@
"""Prepaid billing (D-B): only the platform owner moves an organization's credit.
What these tests defend:
* an organization owner and an organization admin (who holds ``billing.manage``) get 403 from
``add-credit`` and from ``settle``, and the wallet does not move
* a member keeps reading the billing summary (``billing.view``) and gets 403 on ``add-credit``
* the platform owner's ``add-credit`` moves the wallet and writes one ``adjustment`` history row
with the reason ``billing/add-credit``; a clawback that would leave the wallet below zero is
refused and changes nothing
* the platform owner can settle a pending payment, and a succeeded payment credits the wallet
"""
from __future__ import annotations
from httpx import AsyncClient
from app.domain.identity.models import Role
from app.infrastructure.db.models import MembershipRow
from tests.conftest import auth_header, signup
OWNER_ONLY = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _join(session_factory, organization_id: str, user_id: str, role: Role) -> None:
async with session_factory() as session:
session.add(
MembershipRow(organization_id=organization_id, user_id=user_id, role=role.value)
)
await session.commit()
async def _pending_payment(client: AsyncClient, token: str, organization_id: str) -> str:
response = await client.post(
f"/api/organizations/{organization_id}/billing/checkout",
json={"amount": 30.0},
headers=auth_header(token),
)
assert response.status_code == 201, response.text
return response.json()["payment_id"]
async def test_an_organization_owner_and_admin_cannot_add_credit_or_settle(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان الف", "bill-owner")
admin = await signup(client, "billing-admin@example.com")
await _join(session_factory, org_id, admin["user"]["id"], Role.ADMIN)
payment_id = await _pending_payment(client, manager_token, org_id)
billing = f"/api/organizations/{org_id}/billing"
for token in (manager_token, admin["access_token"]):
headers = auth_header(token)
credit = await client.post(f"{billing}/add-credit", json={"amount": 50.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
settle = await client.post(
f"{billing}/payments/{payment_id}/settle",
json={"succeeded": True},
headers=headers,
)
assert settle.status_code == 403, settle.text
assert settle.json()["error"]["message"] == OWNER_ONLY
summary = await client.get(f"{billing}/summary", headers=auth_header(manager_token))
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
async def test_a_member_reads_the_summary_but_cannot_add_credit(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "bill-member")
member = await signup(client, "billing-member@example.com")
await _join(session_factory, org_id, member["user"]["id"], Role.MEMBER)
billing = f"/api/organizations/{org_id}/billing"
headers = auth_header(member["access_token"])
summary = await client.get(f"{billing}/summary", headers=headers)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
credit = await client.post(f"{billing}/add-credit", json={"amount": 5.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
async def test_the_platform_owner_adds_and_claws_back_credit_as_adjustments(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان پ", "bill-platform")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
credit = await client.post(f"{billing}/add-credit", json={"amount": 25.0}, headers=owner)
assert credit.status_code == 200, credit.text
assert credit.json()["balance"] == 25.0
assert credit.json()["low_balance"] is False
clawback = await client.post(f"{billing}/add-credit", json={"amount": -10.0}, headers=owner)
assert clawback.status_code == 200, clawback.text
assert clawback.json()["balance"] == 15.0
refused = await client.post(f"{billing}/add-credit", json={"amount": -50.0}, headers=owner)
assert refused.status_code == 422, refused.text
assert refused.json()["error"]["message"] == "اعتبار کافی برای این کسر وجود ندارد."
zero = await client.post(f"{billing}/add-credit", json={"amount": 0.0}, headers=owner)
assert zero.status_code == 422, zero.text
# The refused clawback rolled back: the balance is still 15.
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 15.0
history = await client.get(f"/api/admin/orgs/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [(h["kind"], h["amount"], h["reason"]) for h in history.json()] == [
("adjustment", -10.0, "billing/add-credit"),
("adjustment", 25.0, "billing/add-credit"),
]
async def test_the_platform_owner_settles_a_payment_and_the_wallet_is_credited(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان ت", "bill-settle")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
payment_id = await _pending_payment(client, platform_owner_token, org_id)
settled = await client.post(
f"{billing}/payments/{payment_id}/settle", json={"succeeded": True}, headers=owner
)
assert settled.status_code == 200, settled.text
assert settled.json()["status"] == "succeeded"
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 30.0
@@ -0,0 +1,112 @@
"""A settled payment is credited through the credit ledger, so the owner's history shows it.
What these tests defend:
* settling a pending payment as succeeded writes exactly one ``adjustment`` history row with the
reason ``billing/settle-payment`` and the payment's amount, the wallet goes up by that amount
once, and the organization's ``last_credit_at`` is set
* settling the same payment again changes nothing: the payment is already final, so neither the
wallet nor the history moves
* a failed payment credits nothing and writes no history row
"""
from __future__ import annotations
from app.application.container import build_billing_service
from app.domain.base import new_id
from app.domain.billing.models import CreditKind, Payment, PaymentStatus
from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository, PaymentRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
SETTLE_REASON = "billing/settle-payment"
async def _org_with_pending_payment(
session_factory, slug: str, amount: float
) -> tuple[str, str]:
"""Create an organization and one pending payment; return (organization id, payment id)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name="t",
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
payment = await PaymentRepository(session).create(
Payment(organization_id=org.id, amount=amount)
)
await session.commit()
return org.id, payment.id
async def _settle(
session_factory, organization_id: str, payment_id: str, *, succeeded: bool
) -> Payment:
"""Settle through the service the owner's endpoint calls, in its own transaction."""
async with session_factory() as session:
payment = await build_billing_service(session).settle_payment(
organization_id=organization_id, payment_id=payment_id, succeeded=succeeded
)
await session.commit()
return payment
async def _balance_and_history(session_factory, organization_id: str):
"""The wallet balance and every credit history row of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
history = await CreditLedgerRepository(session).history(organization_id)
return (wallet.balance if wallet else 0.0), history
async def test_a_settled_payment_writes_one_adjustment_row_and_credits_once(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-once", 30.0
)
payment = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert payment.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert [(t.kind, t.amount, t.balance_after, t.reason) for t in history] == [
(CreditKind.ADJUSTMENT, 30.0, 30.0, SETTLE_REASON)
]
async with session_factory() as session:
last_credit_at = await CreditLedgerRepository(session).last_credit_at_by_org()
assert organization_id in last_credit_at
async def test_settling_the_same_payment_twice_does_not_credit_twice(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-twice", 30.0
)
await _settle(session_factory, organization_id, payment_id, succeeded=True)
again = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert again.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert len(history) == 1
async def test_a_failed_payment_credits_nothing(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-failed", 30.0
)
failed = await _settle(session_factory, organization_id, payment_id, succeeded=False)
assert failed.status == PaymentStatus.FAILED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 0.0
assert history == []
@@ -0,0 +1,96 @@
"""The optional starting credit for new organizations (``BILLING_STARTING_CREDIT``, D-B).
What these tests defend:
* with the setting at 0, an organization created over HTTP or by the wizard starts with an
empty wallet and no ``starting_credit`` history row, so the platform owner credits it by hand
* with a positive setting, both paths give the new organization that credit and exactly one
``starting_credit`` history row, and no other organization is credited
"""
from __future__ import annotations
from httpx import AsyncClient
from app.core.config import settings
from app.domain.billing.models import CreditKind
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.conftest import auth_header, signup
STARTING_REASON = "اعتبار آغازین"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _http_org(client: AsyncClient, token: str, name: str) -> str:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["id"]
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _credit_state(session_factory, organization_id: str):
"""The wallet balance and the starting-credit history of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
starting = await CreditLedgerRepository(session).history(
organization_id, kinds=(CreditKind.STARTING_CREDIT,)
)
return (wallet.balance if wallet else 0.0), starting
async def test_with_the_default_setting_a_new_organization_starts_empty(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 0.0)
http_manager = await signup(client, "start-http@example.com")
wizard_manager = await signup(client, "start-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان HTTP")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ویزارد", "start-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 0.0
assert starting == []
async def test_a_positive_setting_credits_both_paths_once(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 3.0)
http_manager = await signup(client, "credit-http@example.com")
wizard_manager = await signup(client, "credit-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان الف")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ب", "credit-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 3.0
assert len(starting) == 1
assert starting[0].amount == 3.0
assert starting[0].balance_after == 3.0
assert starting[0].reason == STARTING_REASON
+171
View File
@@ -0,0 +1,171 @@
"""The manager chat does not call a model for an organization with no credit.
What these tests defend:
* a zero-credit organization gets the note as the manager's reply: the model is
never called and the tags in the order are not executed (prepaid, decision D-B)
* a missing wallet is no credit either
* once the owner credits the organization, the same chat reaches the model
* a manager built without a wallet (the direct construction the other manager
tests use) keeps its old behaviour
The organization fixture and the manager's model profile come from
``tests.test_engine_manager``.
"""
from __future__ import annotations
from app.application.container_org import build_memory
from app.application.engine.manager import CHAT_NO_CREDIT_MESSAGE, ManagerTurns
from app.domain.workforce.models import MessageRole, RoleKey
from app.infrastructure.db.models import CreditWalletRow, ThreadRow
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.billing_helpers import fund_org
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
from tests.test_engine_manager import _with_model_profile
from tests.test_engine_manager import world as manager_world
#: The engine manager tests' organization fixture. Binding it under its own name
#: registers it for this module's tests (a bare import would be flagged as unused).
world = manager_world
_ORDER = '[[CREATE_TASK title="ورود" kind="feature"]]ساخت فرم ورود[[/CREATE_TASK]]'
_PLAIN_REPLY = "آماده‌ام."
async def _thread_with_manager(world, session) -> str:
"""A manager with a strong model profile, and a thread to talk in."""
manager = await world.employee(session, "مدیر", RoleKey.MANAGER)
await _with_model_profile(world, session, manager.id, world.strong_id)
thread = await ThreadRepository(session).create(
ThreadRow(organization_id=world.organization_id, title="گفتگو")
)
await session.commit()
return thread.id
async def _manager(session, *, scripted, grants, wired: bool) -> ManagerTurns:
"""The engine manager on real repositories; ``wired`` adds the prepaid gate."""
return ManagerTurns(
provider=scripted,
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=None,
wallets=CreditWalletRepository(session) if wired else None,
)
async def test_a_zero_credit_org_gets_the_note_and_no_model_call(world, session_factory):
async with session_factory() as session:
session.add(
CreditWalletRow(organization_id=world.organization_id, balance=0.0, currency="USD")
)
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_ORDER)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
user_message, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="یک فرم ورود بساز.",
user_id=world.user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(world.organization_id)
assert user_message.content == "یک فرم ورود بساز."
assert assistant.role is MessageRole.ASSISTANT
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
# The order's CREATE_TASK tag was not executed.
assert tasks == []
async def test_a_missing_wallet_counts_as_no_credit(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
async def test_after_the_owner_credits_the_org_the_model_answers(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
await fund_org(session_factory, world.organization_id, 50.0)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
async def test_a_manager_built_without_a_wallet_keeps_the_old_behaviour(world, session_factory):
"""No ``wallets`` means no gate: the direct constructions in other tests rely on it."""
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=False)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
+218
View File
@@ -0,0 +1,218 @@
"""Every usage row debits its organization's prepaid wallet once, in the same transaction.
What these tests defend:
* a model call debits its price: the balance falls by that price and exactly one
``usage`` history row names the usage row it paid for
* a zero-priced usage row is still recorded, but debits nothing
* a sandbox usage debits the wallet too (every unit goes through the one ledger)
* another organization's wallet is never touched
* a rolled-back session leaves neither the usage row nor the debit
* the live wallet event carries the new balance, so the sidebar badge follows it
"""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy import select
from app.application.ports.audit import UsageRecord
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.application.services.workforce_service import record_model_usage
from app.domain.ai_control.models import RateCard, RateUnit
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UsageEventRow, UserRow
from app.infrastructure.repositories.ai_control import RateCardRepository
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
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)
@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 two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"debit-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"debit-{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 _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def _usage_ids(session_factory, organization_id: str) -> list[str]:
async with session_factory() as session:
result = await session.execute(
select(UsageEventRow.id).where(UsageEventRow.organization_id == organization_id)
)
return list(result.scalars())
async def test_a_model_call_debits_its_price_and_names_its_usage_row(two_orgs, recorder):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await RateCardRepository(session).upsert(
RateCard(
name="توکن ورودی",
unit=RateUnit.MODEL_INPUT_TOKENS,
customer_price=2.5,
internal_cost_estimate=1.0,
)
)
await session.commit()
async with session_factory() as session:
await record_model_usage(
ledger=UsageLedgerRepository(session),
rate_cards=RateCardRepository(session),
organization_id=org_a,
unit=RateUnit.MODEL_INPUT_TOKENS,
quantity=4.0,
)
await session.commit()
# Four units at 2.5 each: the wallet falls by 10.
assert await _balance(session_factory, org_a) == 90.0
usage_ids = await _usage_ids(session_factory, org_a)
assert len(usage_ids) == 1
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(debits) == 1
assert debits[0].amount == -10.0
assert debits[0].balance_after == 90.0
assert debits[0].usage_event_id == usage_ids[0]
wallet_events = [e for e in recorder.events if e.type == "billing.wallet"]
assert len(wallet_events) == 1
assert wallet_events[0].data["balance"] == 90.0
async def test_a_zero_priced_usage_row_is_recorded_but_debits_nothing(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=0.0,
)
)
await session.commit()
assert balance_after is None
assert len(await _usage_ids(session_factory, org_a)) == 1
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
async def test_a_sandbox_usage_debits_the_wallet_too(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 20.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.SANDBOX_CPU_SECOND,
quantity=10.0,
unit_price=0.1,
total_price=1.0,
)
)
await session.commit()
assert balance_after == 19.0
assert await _balance(session_factory, org_a) == 19.0
async def test_another_organizations_wallet_is_untouched(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=5.0,
)
)
await session.commit()
assert await _balance(session_factory, org_a) == 45.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_debits = await CreditLedgerRepository(session).history(
org_b, kinds=(CreditKind.USAGE,)
)
assert b_debits == []
async def test_a_rolled_back_session_leaves_neither_the_usage_row_nor_the_debit(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=7.0,
)
)
await session.rollback()
assert await _usage_ids(session_factory, org_a) == []
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
+163
View File
@@ -0,0 +1,163 @@
"""The prepaid credit ledger: each balance change is one atomic update and one history row.
What these tests defend:
* a credit then a debit leave the balance their history rows record
* the same usage row debits a wallet once, however many times it is written
* debiting one organization never moves another organization's wallet
* a missing wallet is created at zero, then credited
* the history is newest first and always scoped to one organization
"""
from __future__ import annotations
import pytest_asyncio
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"ledger-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"ledger-{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 _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def test_a_credit_then_a_debit_leave_the_balance_their_history_records(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
debit = await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=30.0
)
await session.commit()
assert debit is not None
assert debit.kind is CreditKind.USAGE
assert debit.amount == -30.0
assert debit.balance_after == 70.0
assert await _balance(session_factory, org_a) == 70.0
async with session_factory() as session:
history = await CreditLedgerRepository(session).history(org_a)
assert [t.kind for t in history] == [CreditKind.USAGE, CreditKind.ADJUSTMENT]
assert [t.balance_after for t in history] == [70.0, 100.0]
async def test_the_same_usage_row_debits_the_wallet_once(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
usage_id = new_id()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
first = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
second = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
free = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=0.0
)
await session.commit()
assert first is not None and first.balance_after == 75.0
assert second is None
assert free is None
assert await _balance(session_factory, org_a) == 75.0
async with session_factory() as session:
usage_rows = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(usage_rows) == 1
assert usage_rows[0].usage_event_id == usage_id
async def test_debiting_one_organization_never_moves_another(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=20.0
)
await session.commit()
assert await _balance(session_factory, org_a) == 30.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_history = await CreditLedgerRepository(session).history(org_b)
assert [t.kind for t in b_history] == [CreditKind.ADJUSTMENT]
async def test_a_missing_wallet_is_created_at_zero_then_credited(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
assert await CreditWalletRepository(session).get_for_org(org_a) is None
async with session_factory() as session:
credit = await CreditLedgerRepository(session).apply(
organization_id=org_a,
amount=25.0,
kind=CreditKind.OWNER_CREDIT,
reason="first credit",
)
await session.commit()
assert credit.balance_after == 25.0
assert await _balance(session_factory, org_a) == 25.0
async def test_history_is_newest_first_scoped_and_capped(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
repo = CreditLedgerRepository(session)
await repo.apply(
organization_id=org_a, amount=10.0, kind=CreditKind.STARTING_CREDIT, reason="start"
)
await repo.apply(
organization_id=org_a, amount=40.0, kind=CreditKind.OWNER_CREDIT, reason="owner"
)
# Org B never received credit: a usage debit alone is not a credit.
await repo.debit_for_usage(organization_id=org_b, usage_event_id=new_id(), amount=1.0)
await session.commit()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
everything = await repo.history(org_a)
credits_only = await repo.history(org_a, kinds=(CreditKind.OWNER_CREDIT,))
newest_one = await repo.history(org_a, limit=1)
org_b_history = await repo.history(org_b)
last_credit = await repo.last_credit_at_by_org()
assert [t.reason for t in everything] == ["owner", "start"]
assert [t.reason for t in credits_only] == ["owner"]
assert [t.reason for t in newest_one] == ["owner"]
assert [t.kind for t in org_b_history] == [CreditKind.USAGE]
assert set(last_credit) == {org_a}
assert last_credit[org_a] is not None
+236
View File
@@ -0,0 +1,236 @@
"""The platform owner's credit service: top-ups, the manual history and the balances page.
What these tests defend:
* an owner credit moves the wallet by the amount and writes one history row that names
the owner who made it and the reason
* a zero, negative or oversized amount, and a blank or too-short reason, are refused before
any wallet or history row is written
* an unknown organization is a 404, never a silently created wallet
* the history shows manual credit only (no usage debits) and only one organization's rows
* the balances page lists every organization with its own balance, paused task count and
last credit date; a usage debit is not a credit
* the starting credit is granted only when the setting is positive
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from app.application.services.credit_service import MAX_OWNER_CREDIT, CreditService
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.domain.workforce.pipeline import Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
VALID_REASON = "شارژ دستی مالک"
async def _new_org(session, label: str) -> str:
"""An organization with its own owner and no wallet yet."""
user = UserRow(email=f"credit-svc-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"credit-svc-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
return org.id
@pytest_asyncio.fixture
async def two_orgs(session_factory):
async with session_factory() as session:
org_a = await _new_org(session, "الف")
org_b = await _new_org(session, "ب")
await session.commit()
return session_factory, (org_a, org_b)
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
return wallet.balance if wallet else 0.0
async def _add_task_at(session_factory, organization_id: str, stage: Stage) -> None:
async with session_factory() as session:
task = TaskRow(organization_id=organization_id, title="کار آزمایشی")
session.add(task)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(task_id=task.id, organization_id=organization_id, stage=stage)
)
await session.commit()
async def test_an_owner_credit_moves_the_balance_and_writes_one_row(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
actor = new_id()
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a, amount=40.0, reason=VALID_REASON, actor_user_id=actor
)
await session.commit()
assert credit.kind is CreditKind.OWNER_CREDIT
assert credit.amount == 40.0
assert credit.balance_after == 40.0
assert await _balance(session_factory, org_a) == 40.0
async with session_factory() as session:
rows = await CreditLedgerRepository(session).history(org_a)
assert len(rows) == 1
assert rows[0].reason == VALID_REASON
assert rows[0].actor_user_id == actor
@pytest.mark.parametrize("amount", [0.0, -5.0, MAX_OWNER_CREDIT + 1])
async def test_a_zero_negative_or_oversized_amount_is_refused(two_orgs, amount):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError):
await CreditService(session).owner_credit(
organization_id=org_a, amount=amount, reason=VALID_REASON, actor_user_id=new_id()
)
async with session_factory() as session:
assert await CreditLedgerRepository(session).history(org_a) == []
assert await _balance(session_factory, org_a) == 0.0
async def test_exactly_the_maximum_amount_is_accepted(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a,
amount=MAX_OWNER_CREDIT,
reason=VALID_REASON,
actor_user_id=new_id(),
)
await session.commit()
assert credit.balance_after == MAX_OWNER_CREDIT
@pytest.mark.parametrize("reason", ["", " ", "ab", "x" * 501])
async def test_a_blank_short_or_long_reason_is_refused(two_orgs, reason):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError) as caught:
await CreditService(session).owner_credit(
organization_id=org_a, amount=10.0, reason=reason, actor_user_id=new_id()
)
assert caught.value.message == "دلیل افزودن اعتبار لازم است."
assert await _balance(session_factory, org_a) == 0.0
async def test_an_unknown_organization_is_not_found(session_factory):
async with session_factory() as session:
with pytest.raises(NotFoundError) as caught:
await CreditService(session).owner_credit(
organization_id=new_id(),
amount=10.0,
reason=VALID_REASON,
actor_user_id=new_id(),
)
assert caught.value.status_code == 404
async def test_the_history_lists_manual_credit_for_one_organization_only(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
async with session_factory() as session:
service = CreditService(session)
await service.owner_credit(
organization_id=org_a, amount=10.0, reason=VALID_REASON, actor_user_id=new_id()
)
await service.owner_credit(
organization_id=org_b, amount=7.0, reason="شارژ سازمان ب", actor_user_id=new_id()
)
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=4.0
)
await session.commit()
async with session_factory() as session:
history = await CreditService(session).history(org_a)
newest = await CreditService(session).history(org_a, limit=1)
# The usage debit is not in the history; the other organization's credit is not either.
assert [t.kind for t in history] == [CreditKind.OWNER_CREDIT, CreditKind.ADJUSTMENT]
assert [t.reason for t in history] == [VALID_REASON, "test funding"]
assert [t.kind for t in newest] == [CreditKind.OWNER_CREDIT]
async def test_the_balances_list_each_organization_with_its_own_numbers(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
org_c = await _new_org(session, "پ")
await session.commit()
async with session_factory() as session:
await CreditService(session).owner_credit(
organization_id=org_a, amount=30.0, reason=VALID_REASON, actor_user_id=new_id()
)
await session.commit()
async with session_factory() as session:
# A usage debit takes the wallet below zero; it is not a credit.
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_b, usage_event_id=new_id(), amount=2.0
)
await session.commit()
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_b, Stage.PENDING_IMPLEMENTATION)
async with session_factory() as session:
rows = {row.organization_id: row for row in await CreditService(session).balances()}
funded, debited, empty = rows[org_a], rows[org_b], rows[org_c]
assert funded.name == "سازمان الف"
assert funded.balance == 30.0
assert funded.paused_tasks == 2
assert funded.last_credit_at is not None
assert debited.name == "سازمان ب"
assert debited.balance == -2.0
assert debited.paused_tasks == 0
assert debited.last_credit_at is None
# An organization with no wallet is listed at zero, not left out.
assert empty.balance == 0.0
assert empty.currency == "USD"
assert empty.paused_tasks == 0
assert empty.last_credit_at is None
async def test_the_starting_credit_is_granted_only_when_the_setting_is_positive(
two_orgs, monkeypatch
):
session_factory, (org_a, org_b) = two_orgs
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=0.0),
)
async with session_factory() as session:
assert await CreditService(session).grant_starting_credit(org_a) is None
await session.commit()
assert await _balance(session_factory, org_a) == 0.0
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=3.0),
)
async with session_factory() as session:
granted = await CreditService(session).grant_starting_credit(org_b)
await session.commit()
assert granted is not None
assert granted.kind is CreditKind.STARTING_CREDIT
assert granted.balance_after == 3.0
+169
View File
@@ -0,0 +1,169 @@
"""Direct tasks enter the engine, and the legacy "run" button refuses them (decision D-D).
What these tests defend:
* a task made over HTTP ``POST /tasks`` gets a pipeline state at ``pending_manager_review``
in the organization's project, so the scheduler's manager takes it from there
* ``POST /tasks/{id}/runs`` on such a task is refused with 409: the engine owns it, and
nothing runs outside the engine
* a task made through the MCP gateway (``tasks_create``) gets the same state
* a pre-v2 task with no state still runs through the legacy path
"""
from __future__ import annotations
from httpx import AsyncClient
from app.application.services.mcp_service import McpService, TokenRateLimiter
from app.domain.base import new_id
from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
#: Part of the Persian refusal shown when the legacy run button is pressed on an engine task.
ENGINE_OWNS = "موتور خودکار"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> dict:
"""The wizard's answer: the organization, its project and its team."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def _state(session_factory, task_id: str):
async with session_factory() as session:
return await PipelineStateRepository(session).get(task_id)
async def _org_with_owner(session_factory, slug: str) -> tuple[str, str]:
"""An organization and its owner, made directly (the MCP gateway needs no wizard)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(MembershipRow(organization_id=org.id, user_id=user.id, role=Role.OWNER.value))
await session.commit()
return org.id, user.id
async def _mcp_token(session_factory, organization_id: str, user_id: str) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=organization_id,
name="agent",
scopes=["tasks:write", "tasks:read"],
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def test_a_task_made_over_http_enters_the_engine(client, session_factory, seeded):
manager = await signup(client, "direct-http@example.com")
body = await _wizard_org(client, manager["access_token"], "سازمان الف", "direct-http")
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "صفحهٔ ورود"},
headers=auth_header(manager["access_token"]),
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
state = await _state(session_factory, task_id)
assert state is not None, "a direct task must get a pipeline state"
assert state.organization_id == org_id
assert state.project_id == project_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async with session_factory() as session:
task = await TaskRepository(session).get(task_id)
assert task is not None and task.project_id == project_id
async def test_running_a_direct_task_is_refused_with_409(client, session_factory, seeded):
manager = await signup(client, "direct-run@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان ب", "direct-run")
org_id = body["organization"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks", json={"title": "کار مستقیم"}, headers=headers
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 409, run.text
assert ENGINE_OWNS in run.json()["error"]["message"]
# Nothing ran outside the engine: the task still has no run.
detail = await client.get(f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers)
assert detail.status_code == 200, detail.text
assert detail.json()["runs"] == []
async def test_an_mcp_task_enters_the_engine(session_factory, seeded):
org_id, user_id = await _org_with_owner(session_factory, "direct-mcp")
token = await _mcp_token(session_factory, org_id, user_id)
gateway = McpGateway(
session_factory=session_factory, rate_limiter=TokenRateLimiter(limit_per_minute=None)
)
created = await gateway.call(
tool="tasks_create", arguments={"title": "کار از راه MCP"}, token=token
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
state = await _state(session_factory, task_id)
assert state is not None, "an MCP task must get a pipeline state too"
assert state.organization_id == org_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_a_pre_v2_task_still_runs_through_the_legacy_path(client, session_factory, seeded):
manager = await signup(client, "direct-legacy@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان پ", "direct-legacy")
org_id = body["organization"]["id"]
task_id = await legacy_task(session_factory, org_id, manager["user"]["id"], title="کار قدیمی")
assert await _state(session_factory, task_id) is None
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 201, run.text
assert run.json()["status"] == "completed", run.text
+32 -2
View File
@@ -24,7 +24,11 @@ import pytest_asyncio
from httpx import AsyncClient
from app.domain.identity.models import PlatformRole
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -33,7 +37,9 @@ async def _platform_seed(seeded):
return seeded
async def test_end_to_end_smoke_path(client: AsyncClient):
async def test_end_to_end_smoke_path(client: AsyncClient, session_factory):
"""Decision D-D: the task made over HTTP enters the engine and refuses the legacy run, so
the run, its metering and its audit are walked on a pre-v2 task (see step 10)."""
# -- 1. signup -------------------------------------------------------- #
# The platform owner, not a plain manager: step 6 drives the sandbox
# directly, and v2 makes container/command access owner-only
@@ -184,6 +190,9 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
assert thread.status_code == 201, thread.text
thread_id = thread.json()["id"]
# prepaid (D-B): the HTTP org starts at zero credit, and the chat costs model calls.
await fund_org(session_factory, org_id)
message = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id},
@@ -212,10 +221,31 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
# Decision D-D: a task made over HTTP is the engine's from manager review, so it has a
# pipeline state and the legacy run button refuses it.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None and state.stage is Stage.PENDING_MANAGER_REVIEW
refused = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
assert refused.status_code == 409, refused.text
# -- 10. run task ------------------------------------------------------ #
# The legacy run path serves pre-v2 tasks, which have no pipeline state, so the run, its
# metering and its audit are checked on one of those (decision D-D).
run_task_id = await legacy_task(
session_factory,
org_id,
signup_body["user"]["id"],
title="افزودن تست برای ماژول پرداخت",
project_id=project_id,
assigned_employee_id=employee_id,
)
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
f"/api/organizations/{org_id}/tasks/{run_task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
+408
View File
@@ -0,0 +1,408 @@
"""The engine's read API: status, task board, task detail and recent turns.
What these tests defend:
* any member, including a viewer, reads every route; a foreign member gets 404 on every
route, and a task id of another organization is a 404 under the caller's own organization
* ``status`` reports the wallet balance (``credit_ok`` only above zero), the owner's cap, the
headcount and the stage counts of the organization
* the board lists engine and legacy tasks, and ``stage`` narrows it to one stage
* the task detail carries the pipeline stages, the turns, the gate verdicts, the runs, the
last diff and the merged sha; a turn excerpt is capped at 1,000 characters
* no response names a model, a provider or an ``ai_model_id`` (every seeded model and
provider name is scanned in every body)
"""
from __future__ import annotations
from dataclasses import dataclass
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.application.container_org import build_model_grants
from app.domain.base import utcnow
from app.domain.identity.models import Role
from app.domain.workforce.engine import EmployeeProfile, EngineTurn, GateName, GateResult
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus
from app.domain.workforce.pipeline import (
Mode,
PipelineKey,
Stage,
TaskKind,
TaskPipelineState,
pipeline_stages,
)
from app.infrastructure.db.models import AiModelRow, AiProviderRow, MembershipRow, TaskRow
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import EmployeeRepository, RunRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
DIFF = "diff --git a/app/login.py b/app/login.py\n+def login(): return True\n"
SHA = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"
TITLE = "صفحهٔ ورود"
EMPLOYEE = "برنامه‌نویس یک"
def _base(org_id: str) -> str:
return f"/api/organizations/{org_id}/engine"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
@dataclass
class _Org:
id: str
project_id: str
owner_id: str
owner_token: str
@dataclass
class _Data:
org: _Org
model_id: str
done_task_id: str
testing_task_id: str
legacy_task_id: str
async def _make_org(client: AsyncClient, slug: str) -> _Org:
owner = await signup(client, f"{slug}-owner@example.com")
response = await client.post(
"/api/setup/complete",
json=_payload(f"سازمان {slug}", slug),
headers=auth_header(owner["access_token"]),
)
assert response.status_code == 201, response.text
body = response.json()
return _Org(
id=body["organization"]["id"],
project_id=body["project"]["id"],
owner_id=owner["user"]["id"],
owner_token=owner["access_token"],
)
async def _join(
client: AsyncClient,
session_factory: async_sessionmaker,
organization_id: str,
role: Role,
email: str,
) -> str:
"""Add a fresh account to an organization with ``role``; returns its token."""
user = await signup(client, email)
async with session_factory() as session:
session.add(
MembershipRow(
organization_id=organization_id, user_id=user["user"]["id"], role=role.value
)
)
await session.commit()
return user["access_token"]
async def _engine_task(session, org: _Org, *, title: str, stage: Stage, **fields) -> TaskRow:
employee_id = fields.pop("employee_id", None)
row = TaskRow(
organization_id=org.id,
title=title,
description="وصف کار",
project_id=org.project_id,
created_by_user_id=org.owner_id,
assigned_employee_id=employee_id,
)
session.add(row)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=row.id,
organization_id=org.id,
project_id=org.project_id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=stage,
**fields,
)
)
return row
async def _seed(session_factory: async_sessionmaker, org: _Org) -> _Data:
"""One finished engine task (turn, gate, run, diff, sha), one at a test stage, one legacy."""
async with session_factory() as session:
model = (await session.execute(select(AiModelRow).limit(1))).scalars().first()
assert model is not None
developer = await EmployeeRepository(session).create(
Employee(organization_id=org.id, name=EMPLOYEE, role_key=RoleKey.DEVELOPER)
)
await EmployeeProfileRepository(session).upsert(
EmployeeProfile(
employee_id=developer.id,
ai_model_id=model.id,
roles=[RoleKey.DEVELOPER.value],
)
)
done = await _engine_task(
session,
org,
title=TITLE,
stage=Stage.COMPLETED,
employee_id=developer.id,
last_diff=DIFF,
merged_sha=SHA,
)
testing = await _engine_task(session, org, title="جدول کاربران", stage=Stage.PENDING_TEST)
legacy = TaskRow(
organization_id=org.id,
title="کار قدیمی",
project_id=org.project_id,
created_by_user_id=org.owner_id,
)
session.add(legacy)
await session.flush()
run = await RunRepository(session).create(
Run(task_id=done.id, employee_id=developer.id, status=RunStatus.COMPLETED)
)
await EngineTurnRepository(session).create(
EngineTurn(
task_id=done.id,
run_id=run.id,
employee_id=developer.id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt="پاسخ " * 400,
ok=True,
finished_at=utcnow(),
)
)
await GateResultRepository(session).create(
GateResult(task_id=done.id, gate=GateName.GREEN, ok=True, detail="آزمون‌ها سبز شد.")
)
await session.commit()
return _Data(
org=org,
model_id=model.id,
done_task_id=done.id,
testing_task_id=testing.id,
legacy_task_id=legacy.id,
)
async def _get(client: AsyncClient, token: str, url: str):
return await client.get(url, headers=auth_header(token))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
async def test_status_reports_credit_policy_team_and_stage_counts(
client, session_factory, seeded
):
org = await _make_org(client, "eng-status")
await fund_org(session_factory, org.id, 40.0)
await _seed(session_factory, org)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
headcount = await EmployeeRepository(session).count_for_org(org.id)
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
body = response.json()
assert body["credit_ok"] is True
assert body["balance"] == 40.0
assert body["currency"] == "USD"
assert body["max_employees"] == cap
assert body["team_size"] == headcount
assert body["stage_counts"] == {"completed": 1, "pending_test": 1}
async def test_status_is_not_credit_ok_without_credit(client, session_factory, seeded):
org = await _make_org(client, "eng-zero")
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
assert response.json()["credit_ok"] is False
assert response.json()["balance"] == 0.0
# ---------------------------------------------------------------------------
# board
# ---------------------------------------------------------------------------
async def test_the_board_lists_engine_and_legacy_tasks_and_filters_by_stage(
client, session_factory, seeded
):
org = await _make_org(client, "eng-board")
data = await _seed(session_factory, org)
board = f"{_base(org.id)}/tasks"
everything = await _get(client, org.owner_token, board)
assert everything.status_code == 200, everything.text
rows = {row["id"]: row for row in everything.json()}
assert set(rows) == {data.done_task_id, data.testing_task_id, data.legacy_task_id}
assert rows[data.legacy_task_id]["stage"] is None
assert rows[data.legacy_task_id]["pipeline"] is None
assert rows[data.legacy_task_id]["cycle"] == 0
assert rows[data.testing_task_id]["stage"] == "pending_test"
assert rows[data.done_task_id]["assignee_name"] == EMPLOYEE
testing = await client.get(
board, params={"stage": "pending_test"}, headers=auth_header(org.owner_token)
)
assert [row["id"] for row in testing.json()] == [data.testing_task_id]
limited = await client.get(board, params={"limit": 1}, headers=auth_header(org.owner_token))
assert len(limited.json()) == 1
unknown = await client.get(
board, params={"stage": "nope"}, headers=auth_header(org.owner_token)
)
assert unknown.status_code == 422
# ---------------------------------------------------------------------------
# detail and turns
# ---------------------------------------------------------------------------
async def test_task_detail_returns_stages_turns_gates_runs_diff_and_sha(
client, session_factory, seeded
):
org = await _make_org(client, "eng-detail")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/tasks/{data.done_task_id}")
assert response.status_code == 200, response.text
body = response.json()
assert body["stage"] == "completed"
assert body["description"] == "وصف کار"
assert body["stages"] == [str(s) for s in pipeline_stages(PipelineKey.PLAN_CODE_TEST)]
assert body["merged_sha"] == SHA
assert body["diff"] == DIFF
assert body["assignee_name"] == EMPLOYEE
[turn] = body["turns"]
assert turn["employee_name"] == EMPLOYEE
assert turn["role"] == "developer"
assert turn["mode"] == str(Mode.WORK)
assert len(turn["reply_excerpt"]) == 1000
[gate] = body["gates"]
assert gate["gate"] == "green"
assert gate["ok"] is True
[run] = body["runs"]
assert run["status"] == "completed"
async def test_recent_turns_carry_the_task_title_and_the_employee_name(
client, session_factory, seeded
):
org = await _make_org(client, "eng-turns")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/turns")
assert response.status_code == 200, response.text
[turn] = response.json()
assert turn["task_id"] == data.done_task_id
assert turn["task_title"] == TITLE
assert turn["employee_name"] == EMPLOYEE
too_many = await _get(client, org.owner_token, f"{_base(org.id)}/turns?limit=0")
assert too_many.status_code == 422
# ---------------------------------------------------------------------------
# who may read
# ---------------------------------------------------------------------------
async def test_a_viewer_reads_every_route(client, session_factory, seeded):
org = await _make_org(client, "eng-viewer")
data = await _seed(session_factory, org)
token = await _join(
client, session_factory, org.id, Role.VIEWER, "eng-viewer-user@example.com"
)
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, token, f"{_base(org.id)}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
async def test_a_foreign_member_gets_404_on_every_route(client, session_factory, seeded):
org_a = await _make_org(client, "eng-a")
data = await _seed(session_factory, org_a)
org_b = await _make_org(client, "eng-b")
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, org_b.owner_token, f"{_base(org_a.id)}{path}")
assert response.status_code == 404, f"{path}: {response.text}"
# A's task id under B's own organization is not found, and B's board stays empty.
inside_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks/{data.done_task_id}")
assert inside_b.status_code == 404
board_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks")
assert board_b.json() == []
turns_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/turns")
assert turns_b.json() == []
# ---------------------------------------------------------------------------
# no model identity
# ---------------------------------------------------------------------------
async def test_no_response_names_a_model_a_provider_or_an_ai_model_id(
client, session_factory, seeded
):
org = await _make_org(client, "eng-names")
data = await _seed(session_factory, org)
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
forbidden = {data.model_id}
for model in models:
forbidden.update({model.internal_name, model.display_name_for_admin})
forbidden.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
forbidden = {value for value in forbidden if len(value) >= 3}
assert forbidden
base = _base(org.id)
paths = (
"/status",
"/tasks",
f"/tasks/{data.done_task_id}",
f"/tasks/{data.testing_task_id}",
"/turns",
)
for path in paths:
response = await _get(client, org.owner_token, f"{base}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
for value in forbidden:
assert value not in response.text, f"{path} names {value!r}"
+186
View File
@@ -0,0 +1,186 @@
"""Pause, resume, cancel and answer on an engine task, through the HTTP routes.
What these tests defend:
* a member with ``tasks.run`` pauses and resumes a funded task; resume is refused with the
Persian budget reason while the organization has no credit, and the task stays paused
* cancel ends the task and writes one ``task.control`` audit row that names the action
* an answer is accepted only from ``pending_user_decision``; its text never reaches the audit
trail, and a blank answer is a 422
* a viewer gets 403, and a member of another organization gets 404, on every action
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
from app.domain.identity.models import Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import AuditEventRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.test_engine_api import _base, _engine_task, _join, _make_org
ANSWER = "نسخهٔ دوم را انجام بده"
async def _post(client, token: str, organization_id: str, task_id: str, action: str, json=None):
return await client.post(
f"{_base(organization_id)}/tasks/{task_id}/{action}",
json=json,
headers=auth_header(token),
)
async def _stage_of(session_factory, task_id: str) -> Stage | None:
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
return state.stage if state is not None else None
async def _task_at(session_factory, org, stage: Stage, title: str = "صفحهٔ ورود") -> str:
async with session_factory() as session:
row = await _engine_task(session, org, title=title, stage=stage)
await session.commit()
return row.id
async def test_a_member_pauses_then_resumes_a_funded_task(client, session_factory, seeded):
org = await _make_org(client, "act-pause")
await fund_org(session_factory, org.id, 40.0)
member = await _join(client, session_factory, org.id, Role.MEMBER, "act-member@example.com")
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
paused = await _post(client, member, org.id, task_id, "pause")
assert paused.status_code == 200, paused.text
assert paused.json()["stage"] == "paused"
assert paused.json()["status"] == "waiting_approval"
resumed = await _post(client, member, org.id, task_id, "resume")
assert resumed.status_code == 200, resumed.text
assert resumed.json()["stage"] == "pending_manager_review"
async def test_resume_is_refused_without_credit_and_the_task_stays_paused(
client, session_factory, seeded
):
org = await _make_org(client, "act-zero")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-zero-member@example.com"
)
task_id = await _task_at(session_factory, org, Stage.PAUSED)
refused = await _post(client, member, org.id, task_id, "resume")
assert refused.status_code == 409, refused.text
assert refused.json()["error"]["message"] == BUDGET_PAUSED_MESSAGE
assert await _stage_of(session_factory, task_id) is Stage.PAUSED
async def test_cancel_ends_the_task_and_writes_one_audit_row(client, session_factory, seeded):
org = await _make_org(client, "act-cancel")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-cancel-member@example.com"
)
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
cancelled = await _post(client, member, org.id, task_id, "cancel")
assert cancelled.status_code == 200, cancelled.text
assert cancelled.json()["stage"] == "cancelled"
assert cancelled.json()["status"] == "cancelled"
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == "task.control",
AuditEventRow.target_id == task_id,
)
)
)
.scalars()
.all()
)
assert len(events) == 1
assert events[0].organization_id == org.id
assert events[0].target_type == "task"
assert events[0].metadata_json == {"action": "cancel"}
async def test_an_answer_is_accepted_only_from_a_decision_and_stays_out_of_the_audit(
client, session_factory, seeded
):
org = await _make_org(client, "act-answer")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-answer-member@example.com"
)
working = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION, "کار یک")
deciding = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION, "کار دو")
refused = await _post(
client, member, org.id, working, "answer", json={"content": ANSWER}
)
assert refused.status_code == 409, refused.text
assert await _stage_of(session_factory, working) is Stage.PENDING_IMPLEMENTATION
blank = await _post(client, member, org.id, deciding, "answer", json={"content": ""})
assert blank.status_code == 422, blank.text
assert await _stage_of(session_factory, deciding) is Stage.PENDING_USER_DECISION
accepted = await _post(
client, member, org.id, deciding, "answer", json={"content": ANSWER}
)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["stage"] == "pending_manager_review"
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == "task.control",
AuditEventRow.target_id == deciding,
)
)
)
.scalars()
.all()
)
assert [event.metadata_json for event in events] == [{"action": "answer"}]
assert ANSWER not in repr(events[0].metadata_json)
async def test_a_viewer_is_refused_and_a_foreign_member_gets_not_found(
client, session_factory, seeded
):
org = await _make_org(client, "act-viewer")
viewer = await _join(
client, session_factory, org.id, Role.VIEWER, "act-viewer-user@example.com"
)
other = await _make_org(client, "act-other")
task_id = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION)
for action in ("pause", "resume", "cancel"):
response = await _post(client, viewer, org.id, task_id, action)
assert response.status_code == 403, f"{action}: {response.text}"
answer = await _post(client, viewer, org.id, task_id, "answer", json={"content": ANSWER})
assert answer.status_code == 403, answer.text
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
for action in ("pause", "resume", "cancel"):
response = await _post(client, other.owner_token, org.id, task_id, action)
assert response.status_code == 404, f"{action}: {response.text}"
answer = await _post(
client, other.owner_token, org.id, task_id, "answer", json={"content": ANSWER}
)
assert answer.status_code == 404, answer.text
# The other organization's own routes do not reach this task either.
inside = await _post(client, other.owner_token, other.id, task_id, "pause")
assert inside.status_code == 404, inside.text
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
+255
View File
@@ -0,0 +1,255 @@
"""The engine's team API: the team with tiers, hire by role and tier, and fire.
What these tests defend:
* ``GET /team`` shows each member's neutral tier, ``unknown`` while no granted model backs it;
the tiers the organization has; the owner's cap; and not the archived members
* hire takes a role and a tier, never a model. A tier with no granted model is a 409, and the
cap is the owner's policy: a hire at the cap is a 409
* firing archives the member; the last active manager cannot be fired (409)
* a member without ``employees.manage`` gets 403 on hire and fire; a foreign member gets 404
* no response names a model, a provider or an ``ai_model_id``
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_org import build_model_grants
from app.domain.audit.models import AuditAction
from app.domain.identity.models import Role
from app.infrastructure.db.models import AiModelRow, AiProviderRow, AuditEventRow
from tests.conftest import auth_header
from tests.test_engine_api import _base, _join, _make_org
REFUSED_NO_GRANT = "مدلی از این سطح"
REFUSED_CAP = "سقف تعداد کارمندان"
REFUSED_LAST_MANAGER = "آخرین مدیر"
async def _strong_model_id(session_factory) -> str:
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
return next(m.id for m in models if "reasoning" in (m.capabilities or []))
async def _grant_strong(client, owner_token: str, organization_id: str, session_factory) -> str:
"""Give the organization the strong model only, so its tiers are exactly ``strong``."""
model_id = await _strong_model_id(session_factory)
response = await client.put(
f"/api/admin/model-grants/{organization_id}/models/{model_id}",
json={},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
return model_id
async def _set_cap(client, owner_token: str, organization_id: str, cap: int) -> None:
response = await client.put(
f"/api/admin/org-policies/{organization_id}",
json={"max_employees": cap},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
async def _team(client, token: str, organization_id: str) -> dict:
response = await client.get(f"{_base(organization_id)}/team", headers=auth_header(token))
assert response.status_code == 200, response.text
return response.json()
async def _hire(client, token: str, organization_id: str, role: str, tier: str, name: str = ""):
return await client.post(
f"{_base(organization_id)}/team/hire",
json={"role": role, "tier": tier, "name": name},
headers=auth_header(token),
)
async def _forbidden_values(session_factory, model_id: str) -> set[str]:
"""Every model name, admin label, provider name and model id the seed knows, plus this one."""
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
values = {model_id}
for model in models:
values.update({model.internal_name, model.display_name_for_admin, model.id})
values.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
return {value for value in values if len(value) >= 3}
async def test_the_team_view_shows_neutral_tiers_and_the_owners_cap(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-view")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
body = await _team(client, org.owner_token, org.id)
assert body["max_employees"] == cap
assert body["tiers"] == [{"tier": "strong", "label": "قوی"}]
assert body["count"] == len(body["employees"])
roles = {member["role_key"]: member for member in body["employees"]}
# The team was bootstrapped before the grant, so nobody runs on a granted model yet.
assert roles["manager"]["tier"] == "unknown"
assert roles["manager"]["status"] == "paused"
for value in await _forbidden_values(session_factory, model_id):
assert value not in str(body), f"team names {value!r}"
async def test_hire_takes_a_role_and_a_tier_and_returns_the_member(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-hire")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = (await _team(client, org.owner_token, org.id))["count"]
hired = await _hire(
client, org.owner_token, org.id, "developer", "strong", "برنامه‌نویس دوم"
)
assert hired.status_code == 201, hired.text
body = hired.json()
assert body["tier"] == "strong"
assert body["role_key"] == "developer"
assert body["name"] == "برنامه‌نویس دوم"
assert body["status"] == "active"
for value in await _forbidden_values(session_factory, model_id):
assert value not in hired.text, f"hire names {value!r}"
team = await _team(client, org.owner_token, org.id)
assert team["count"] == before + 1
assert body["id"] in {member["id"] for member in team["employees"]}
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == body["id"])
)
)
.scalars()
.all()
)
assert [e.action for e in events] == [str(AuditAction.EMPLOYEE_CREATE)]
assert events[0].metadata_json == {"role_key": "developer", "tier": "strong"}
async def test_a_tier_with_no_granted_model_is_a_conflict(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-tier")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
for tier in ("fast", "balanced", "ultra"):
response = await _hire(client, org.owner_token, org.id, "tester", tier)
assert response.status_code == 409, f"{tier}: {response.text}"
assert REFUSED_NO_GRANT in response.json()["error"]["message"]
async def test_the_cap_is_the_owners_policy_and_a_hire_stops_at_it(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-cap")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
count = (await _team(client, org.owner_token, org.id))["count"]
await _set_cap(client, platform_owner_token, org.id, count + 1)
first = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert first.status_code == 201, first.text
second = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert second.status_code == 409, second.text
assert REFUSED_CAP in second.json()["error"]["message"]
team = await _team(client, org.owner_token, org.id)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
assert team["max_employees"] == cap == count + 1
assert team["count"] == count + 1
async def test_the_last_manager_cannot_be_fired_and_a_fired_member_is_archived(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-fire")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = await _team(client, org.owner_token, org.id)
manager = next(m for m in before["employees"] if m["role_key"] == "manager")
headers = auth_header(org.owner_token)
refused = await client.post(f"{_base(org.id)}/team/{manager['id']}/fire", headers=headers)
assert refused.status_code == 409, refused.text
assert REFUSED_LAST_MANAGER in refused.json()["error"]["message"]
hired = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert hired.status_code == 201, hired.text
fired = await client.post(
f"{_base(org.id)}/team/{hired.json()['id']}/fire", headers=headers
)
assert fired.status_code == 200, fired.text
assert fired.json()["status"] == "archived"
after = await _team(client, org.owner_token, org.id)
assert hired.json()["id"] not in {m["id"] for m in after["employees"]}
assert after["count"] == before["count"]
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == hired.json()["id"])
)
)
.scalars()
.all()
)
actions = [(e.action, e.metadata_json) for e in events]
assert (str(AuditAction.EMPLOYEE_UPDATE), {"action": "fire"}) in actions
async def test_a_member_without_the_capability_is_refused_on_hire_and_fire(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-member")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
member = await _join(
client, session_factory, org.id, Role.MEMBER, "team-member-user@example.com"
)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
hired = await _hire(client, member, org.id, "developer", "strong")
assert hired.status_code == 403, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=auth_header(member))
assert fired.status_code == 403, fired.text
# The member still reads the team.
readable = await client.get(f"{_base(org.id)}/team", headers=auth_header(member))
assert readable.status_code == 200, readable.text
async def test_another_organization_gets_not_found_on_the_team(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-a")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
other = await _make_org(client, "team-b")
headers = auth_header(other.owner_token)
assert (await client.get(f"{_base(org.id)}/team", headers=headers)).status_code == 404
hired = await client.post(
f"{_base(org.id)}/team/hire",
json={"role": "developer", "tier": "strong"},
headers=headers,
)
assert hired.status_code == 404, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=headers)
assert fired.status_code == 404, fired.text
+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
+67 -10
View File
@@ -14,7 +14,8 @@ git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
* red tests and a forbidden diff send the work *back* with cycle + 1 instead of
merging it
* ``analysis_only`` never creates a worktree
* an overdrawn wallet pauses the task before a single turn is spent
* an empty, overdrawn or missing wallet pauses the task before a single turn is
spent (prepaid billing, decision D-B)
* a transient provider failure is retried, then handed to the manager
"""
@@ -92,13 +93,19 @@ class _OverdrawnWallet:
class _ZeroWallet:
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org."""
"""A wallet at exactly zero: prepaid, so no credit is left."""
balance = 0.0
class _FundedWallet:
"""A wallet with credit left: the org may spend a turn."""
balance = 5.0
class _Wallets:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | None) -> None:
self._wallet = wallet
async def get_for_org(self, organization_id: str):
@@ -567,14 +574,12 @@ async def test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent(
assert BUDGET_PAUSED_MESSAGE
async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
"""A 0.0 balance is every org's starting state, so it must not pause the task.
async def test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent(world, session_factory):
"""A zero balance is no credit left: the task pauses before a turn is spent.
This is an intentional behaviour change. The scheduler used to pause on
``balance <= 0``, which froze every new org before its first turn: usage is
post-paid and nothing debits a wallet. Only an overdrawn wallet (below zero)
pauses now. The old test that encoded the previous rule was renamed and
updated, as PLAN-v2 §7.4 requires for a deliberate behaviour change.
Prepaid billing (decision D-B) reverses the post-paid rule of the earlier plan
(its T3), under which a zero balance was every new org's normal state and did
not pause. Now the platform owner credits an org before any work runs.
"""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
@@ -594,6 +599,58 @@ async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
assert scripted.calls == 0
async def test_a_missing_wallet_pauses_the_task(world, session_factory):
"""No wallet row at all counts as zero credit (prepaid): the task pauses."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(None),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert scripted.calls == 0
async def test_a_funded_wallet_does_not_pause(world, session_factory):
"""Credit left means the org may spend a turn: the task is not paused."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(_FundedWallet()),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is not Stage.PAUSED
assert scripted.calls == 1
+171
View File
@@ -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}
+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
+68
View File
@@ -0,0 +1,68 @@
"""The scheduler stores what the task page shows: the last diff and the merged sha.
What this test defends:
* the diff the diff guard accepted is kept on the state (``last_diff``), so the
task page can show it after the worktree is gone
* the sha the platform pushed to ``dev`` is kept on the state (``merged_sha``),
so the task page can link to it
The flow fixtures and the scripted replies come from ``tests.test_engine_flow``.
"""
from __future__ import annotations
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import DiffResult, FakeRepoWorkspace
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel, ScriptedReply
from tests.test_engine_flow import _run, _scheduler, _task, _team
from tests.test_engine_flow import world as flow_world
#: The flow tests' organization fixture. Binding it under its own name registers it
#: for this module's tests (a bare import would be flagged as unused and shadowed).
world = flow_world
async def test_the_last_diff_and_the_merged_sha_are_stored_on_the_state(world, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_review_test"/]]'),
ScriptedReply(content="[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]"),
ScriptedReply(content="[[WORK_REPORT]]پیاده‌سازی شد[[/WORK_REPORT]]"),
ScriptedReply(content="[[REVIEW_PASS]]قابل قبول است[[/REVIEW_PASS]]"),
ScriptedReply(content="[[TEST_PASS]]سبز است[[/TEST_PASS]]"),
ScriptedReply(content="[[COMPLETE/]]"),
]
)
sandbox = FakeSandbox()
workspace = FakeRepoWorkspace()
memory = RecordingMemory()
delivery = NullDeliveryHooks()
diff_text = "--- a/app/main.py\n+++ b/app/main.py\n@@\n+x = 1\n"
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
workspace.diffs[row.id] = DiffResult(changed_files=["app/main.py"], text=diff_text)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=sandbox,
workspace=workspace,
memory=memory,
delivery=delivery,
)
await _run(scheduler, session, 7)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.COMPLETED
# The fake workspace pushes the merged commit to ``dev``; that is the sha the
# platform reports, and it must be the one stored on the state.
pushed_sha = workspace.heads[(world.project_id, "dev")]
assert len(pushed_sha) == 40
assert state.last_diff == diff_text
assert state.merged_sha == pushed_sha
+16 -14
View File
@@ -429,25 +429,27 @@ async def test_a_failed_task_run_is_persisted_with_its_tenant(
seeded, client: AsyncClient, session_factory, monkeypatch
):
"""A task that cannot run is a tenant-visible problem, so the row must name
the organization — otherwise nobody can find it in the console."""
the organization — otherwise nobody can find it in the console.
Decision D-D: the legacy drain is the path under test, so the task is a pre-v2 row
(no pipeline state); a task made over HTTP is the engine's now.
"""
from app.application.services.workforce_service import WorkforceService
from app.workers.ai_runtime import AiRuntimeWorker
from tests.test_workers import _make_org_with_employee
from tests.legacy_tasks import legacy_task
from tests.test_workers import _make_org_with_employee, _owner_of
org_id, headers, project_id, employee_id = await _make_org_with_employee(
org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "errlog-worker@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کاری که شکست می‌خورد",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کاری که شکست می‌خورد",
project_id=project_id,
assigned_employee_id=employee_id,
)
async def _boom(*args, **kwargs):
raise RuntimeError("provider returned a malformed response")
@@ -22,6 +22,7 @@ from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import AiModelRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete
@@ -78,6 +79,9 @@ async def test_a_manager_order_over_http_gets_a_pipeline_state(
assert created.json()["project_id"] is None
thread_id = created.json()["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The order goes through the real message route.
posted = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
@@ -16,6 +16,7 @@ from app.application.container_engine import build_scheduler
from app.infrastructure.db.models import AiModelRow, OrganizationRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
@@ -58,6 +59,9 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The web UI's way: a thread with no project_id.
created = await client.post(
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
@@ -66,8 +70,10 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
assert created.json()["project_id"] is None
thread_id = created.json()["id"]
# The order goes to the engine's manager. The HTTP message route builds its manager
# without pipeline states (workforce_service._manager_turns), so it cannot show the state.
# The order goes to the engine's manager. The HTTP message route builds its manager with
# the pipeline states too (workforce_service._manager_turns, covered by
# test_integration_order_route.py); this test calls the manager directly only to read the
# reply and the states in the same session.
async with session_factory() as session:
owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id
_, assistant = await build_scheduler(session).manager.chat(
+9
View File
@@ -36,6 +36,7 @@ from app.core.config import settings
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
@@ -49,6 +50,7 @@ from app.infrastructure.db.models_delivery import (
ProductionDeployRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
@@ -348,6 +350,7 @@ async def test_pipeline_status_reports_the_delivery_state_and_no_host_details(
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded
):
"""The task is attributed to the minter and enters the engine at manager review (D-D)."""
org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory)
token = await _token(
@@ -374,6 +377,12 @@ async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
assert row is not None
assert row.created_by_user_id == user_id
# Decision D-D: a task created through the gateway enters the engine at manager review.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None
assert state.stage is Stage.PENDING_MANAGER_REVIEW
# …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other")
other_token = await _token(
+9 -2
View File
@@ -16,6 +16,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_model_grant_service, build_model_grants
from app.application.engine.manager import BOOTSTRAP_TIERS
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
@@ -323,13 +324,18 @@ async def test_policy_defaults_caps_and_audit(
session_factory,
seeded,
):
"""D-A: the default cap is 7 employees per organization (it was 5).
The starting team (``BOOTSTRAP_TIERS``, 4 employees) must fit under that default.
"""
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
owner = auth_header(platform_owner_token)
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
assert fresh.status_code == 200, fresh.text
body = fresh.json()
assert body["max_employees"] == 5
assert body["max_employees"] == 7
assert len(BOOTSTRAP_TIERS) <= body["max_employees"]
assert body["automation_enabled"] is True
assert body["mcp_enabled"] is True
assert body["monthly_credit_budget"] is None
@@ -383,6 +389,7 @@ async def test_policy_defaults_caps_and_audit(
async def test_policy_is_isolated_per_organization(
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
):
"""D-A: a cap written for one organization leaves the other at the default of 7."""
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
owner = auth_header(platform_owner_token)
@@ -392,4 +399,4 @@ async def test_policy_is_isolated_per_organization(
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
assert a.json()["max_employees"] == 2
assert b.json()["max_employees"] == 5
assert b.json()["max_employees"] == 7
+16 -13
View File
@@ -127,13 +127,18 @@ async def test_platform_owner_is_not_joined_to_any_organization(
async def test_seeded_install_can_use_the_product(
client: AsyncClient, seeded_install
client: AsyncClient, seeded_install, session_factory
):
"""The seed's platform rows are enough to create a project and run a task.
A seed that satisfies the login test but leaves the catalogue empty would
still fail the moment a developer tried anything real.
Decision D-D: the run is the legacy path's, so the task is a pre-v2 row (no pipeline
state). A task made over HTTP enters the engine and is refused by the run button.
"""
from tests.legacy_tasks import legacy_task
login = await client.post(
"/api/auth/login",
json={
@@ -142,7 +147,8 @@ async def test_seeded_install_can_use_the_product(
},
)
headers = auth_header(login.json()["access_token"])
org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"]
me = (await client.get("/api/auth/me", headers=headers)).json()
org_id = me["organizations"][0]["id"]
# The platform catalogue the seed installs must be visible to a tenant.
templates = await client.get(
@@ -177,17 +183,14 @@ async def test_seeded_install_can_use_the_product(
)
).json()["id"]
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار seed",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
me["user"]["id"],
title="کار seed",
project_id=project_id,
assigned_employee_id=employee_id,
)
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
+6 -2
View File
@@ -38,6 +38,7 @@ from app.infrastructure.repositories.delivery import PipelineRunRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository
from app.workers.ai_runtime import AiRuntimeWorker
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
@@ -107,6 +108,8 @@ async def test_a_manager_order_ships_through_the_engine(
org_id = body["organization"]["id"]
slug = body["organization"]["slug"]
project_id = body["project"]["id"]
# prepaid: the owner credits a new org before work
await fund_org(session_factory, org_id, 1000.0)
async with session_factory() as session:
roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)}
@@ -132,8 +135,9 @@ async def test_a_manager_order_ships_through_the_engine(
thread_id = created.json()["id"]
# A manager order in that thread becomes one task with a pipeline state. The order goes to
# the engine's manager directly: the HTTP message route builds its manager without pipeline
# states (see test_integration_thread_project.py).
# the engine's manager directly, only to read the reply and the states in this session: the
# HTTP message route builds its manager with the pipeline states too
# (test_integration_order_route.py).
async with session_factory() as session:
await build_scheduler(session).manager.chat(
organization_id=org_id,
+59 -44
View File
@@ -22,11 +22,13 @@ from httpx import AsyncClient
from app.domain.base import utcnow
from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow
from app.workers import base as worker_base
from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True)
@@ -89,25 +91,32 @@ async def _make_org_with_employee(
return org_id, headers, project_id, employee_id
async def _owner_of(session_factory, organization_id: str) -> str:
"""The user who created an organization: the accountable creator of its tasks."""
async with session_factory() as session:
return (await session.get(OrganizationRow, organization_id)).owner_user_id
# -- ai-runtime -------------------------------------------------------------- #
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
"""A task created without pressing "run" must still be picked up."""
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient, session_factory):
"""A task created without pressing "run" must still be picked up.
Decision D-D: the drain picks up pre-v2 tasks (no pipeline state), so the task is one
of those. A task made over HTTP is the engine's, and the scheduler takes it instead.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime@example.com"
)
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار بی‌صاحب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
task_id = await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار بی‌صاحب",
project_id=project_id,
assigned_employee_id=employee_id,
)
# Nothing has run it yet.
detail = await client.get(
@@ -130,19 +139,21 @@ async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
assert runs[0]["status"] == "completed"
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
"""A closed task must not be re-run — re-running would double-bill."""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient, session_factory):
"""A closed task must not be re-run — re-running would double-bill.
Decision D-D: a pre-v2 task (no pipeline state), because that is the drain's path.
"""
org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌باره",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار یک‌باره",
project_id=project_id,
assigned_employee_id=employee_id,
)
first = await AiRuntimeWorker().drain()
@@ -156,19 +167,21 @@ async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
# -- billing-worker ---------------------------------------------------------- #
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
"""Usage in the period must become exactly one draft invoice."""
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient, session_factory):
"""Usage in the period must become exactly one draft invoice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار قابل صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار قابل صورتحساب",
project_id=project_id,
assigned_employee_id=employee_id,
)
assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered"
@@ -190,19 +203,21 @@ async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
assert body[0]["amount"] > 0, "the invoice must carry the ledger's total"
async def test_billing_roll_up_is_idempotent(client: AsyncClient):
"""Two passes over the same period must not bill twice."""
async def test_billing_roll_up_is_idempotent(client: AsyncClient, session_factory):
"""Two passes over the same period must not bill twice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing-twice@example.com"
)
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار یک‌بار صورتحساب",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
await legacy_task(
session_factory,
org_id,
await _owner_of(session_factory, org_id),
title="کار یک‌بار صورتحساب",
project_id=project_id,
assigned_employee_id=employee_id,
)
await AiRuntimeWorker().drain()
+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",