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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user