feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
@@ -0,0 +1,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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("این کار قبلاً بسته شده است.")
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user