feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final

- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
@@ -0,0 +1,144 @@
"""Owner credit console: ``/api/admin/credits`` and ``/api/admin/orgs/{org}/credit``.
Prepaid billing (D-B): the platform owner is the only one who puts credit into an
organization's wallet. Each credit writes one history row (``CreditService``), one audit row
and one live ``billing.wallet`` push, so the organization's credit badge follows the balance.
Owner-only at the router level, like ``admin_grants.py``. Nothing here names a model or a
provider, and no secret is returned. Organization members read their own balance through
``/api/organizations/{org}/billing``.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.credit_service import CreditService, OrgBalance
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditTransaction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import OrganizationRepository
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-credits"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OrgBalanceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None = None
class CreditRequest(BaseModel):
#: Checked by ``CreditService`` (a zero amount or a blank reason gets a Persian message).
amount: float
reason: str
class CreditTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
amount: float
balance_after: float
reason: str
created_at: datetime
def _balance_out(view: OrgBalance) -> OrgBalanceOut:
return OrgBalanceOut(**asdict(view))
def _transaction_out(transaction: CreditTransaction) -> CreditTransactionOut:
return CreditTransactionOut(
id=transaction.id,
kind=str(transaction.kind),
amount=transaction.amount,
balance_after=transaction.balance_after,
reason=transaction.reason,
created_at=transaction.created_at,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/credits", response_model=list[OrgBalanceOut])
async def list_balances(session: AsyncSession = Depends(get_session)) -> list[OrgBalanceOut]:
"""Every organization's balance, paused task count and last credit date."""
return [_balance_out(view) for view in await CreditService(session).balances()]
@router.post("/orgs/{org_id}/credit", response_model=CreditTransactionOut)
async def credit_org(
org_id: str,
payload: CreditRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CreditTransactionOut:
"""Add credit to one organization's wallet; the history and audit rows commit together."""
transaction = await CreditService(session).owner_credit(
organization_id=org_id,
amount=payload.amount,
reason=payload.reason,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.BILLING_CREDIT,
organization_id=org_id,
actor_user_id=owner.id,
target_type="credit_wallet",
# One wallet per organization, so the organization id names the wallet.
target_id=org_id,
metadata={
"amount": payload.amount,
"reason": transaction.reason,
"balance": transaction.balance_after,
},
ip=ip,
user_agent=ua,
)
return _transaction_out(transaction)
@router.get("/orgs/{org_id}/credit-history", response_model=list[CreditTransactionOut])
async def credit_history(
org_id: str,
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[CreditTransactionOut]:
"""One organization's manual credit history, newest first (usage debits are not listed)."""
if await OrganizationRepository(session).get(org_id) is None:
raise NotFoundError("سازمان یافت نشد.")
history = await CreditService(session).history(org_id, limit=limit)
return [_transaction_out(transaction) for transaction in history]
+45 -20
View File
@@ -1,8 +1,10 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
"""Billing endpoints: summary, usage ledger, invoices, payments and checkout.
Reading billing requires ``billing.view``; changing it requires
``billing.manage``. A tenant can therefore never see — or move — another
organization's money.
Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the
platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing
requires ``billing.view``; ``checkout``, which only creates a pending payment, requires
``billing.manage``. A tenant can therefore never see or move another organization's money,
and an organization admin cannot give its own wallet credit.
"""
from __future__ import annotations
@@ -13,11 +15,16 @@ from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.application.services.audit_service import record_audit
from app.application.services.billing_service import BillingService
from app.core.database import get_session
from app.core.errors import PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditKind
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.interfaces.http.deps import (
OrgContext,
client_meta,
@@ -27,6 +34,9 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
#: Said to anyone who is not the platform owner and tries to move an organization's credit.
OWNER_ONLY_CREDIT = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
# ---------------------------------------------------------------------------
# Schemas
@@ -88,7 +98,9 @@ class PaymentOut(BaseModel):
class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
amount: float = Field(
description="Platform owner only. Positive tops up; negative claws back."
)
class WalletOut(BaseModel):
@@ -210,35 +222,47 @@ async def add_credit(
payload: AddCreditRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> WalletOut:
# A negative delta is a platform-owner-only clawback.
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
"""Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
else:
context.require(CAN_MANAGE_BILLING)
The change is one history row of kind ``adjustment``, so it shows in the owner's
credit history for this organization.
"""
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
if payload.amount == 0:
raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.")
wallet = await service.add_credit(
transaction = await CreditLedgerRepository(session).apply(
organization_id=context.org_id,
amount=payload.amount,
allow_negative=context.user.is_platform_owner,
kind=CreditKind.ADJUSTMENT,
reason="billing/add-credit",
actor_user_id=context.user.id,
)
if payload.amount < 0 and transaction.balance_after < 0:
# Refuse to leave the wallet in debt. The request's session rolls back on this
# error, so the balance change and its history row are both undone.
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
wallet = await CreditWalletRepository(session).ensure(context.org_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance},
metadata={"amount": payload.amount, "balance": transaction.balance_after},
ip=ip, user_agent=ua,
)
# Live push: the sidebar credit badge follows the wallet, whoever moved it.
await emit_wallet_changed(
organization_id=context.org_id, balance=transaction.balance_after
)
return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
balance=transaction.balance_after,
currency=wallet.currency,
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
)
@@ -269,12 +293,13 @@ async def settle_payment(
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> PaymentOut:
"""Finalise a payment.
"""Finalise a payment. Only the platform owner may: a succeeded payment credits the wallet.
In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit.
"""
context.require(CAN_MANAGE_BILLING)
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
)
@@ -0,0 +1,670 @@
"""The AI workforce engine's API: status, task board, task detail and recent turns.
Every route resolves its tenant through ``get_org_context``. The reads are open to
every member of the organization, so the credit notice and the board work for
every role. The four actions on a task (pause, resume, cancel, answer) need the
``tasks.run`` capability and each writes one audit row. The team (view, hire by role
and tier, fire) is in this router too; hire and fire need ``employees.manage``.
Three things are load-bearing here:
* **Model identity never leaves.** No schema in this module has a field that can
hold a model id or name, a provider, an account, a container or a port. A turn
shows the employee's *name*, the role, the mode and the stage, never the model
that ran it. A future refactor cannot leak one without adding such a field.
* **Every query is tenant-scoped.** Tasks, states, turns, gates and runs are read
through repositories that filter by organization, and a task id that belongs to
another organization is a 404 (the same answer a missing id gets).
* **Bounded reads.** The board scans at most the newest 500 tasks of the
organization when it filters by stage; a turn excerpt is capped at 1,000 characters.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grants
from app.application.ports.model_grants import GrantedModel
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey, Run, Task
from app.domain.workforce.pipeline import Stage, TaskPipelineState, pipeline_stages
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
TaskRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context
router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"])
#: The most tasks a board reads, newest first (a stage filter looks only among these).
_BOARD_SCAN_LIMIT = 500
#: The most pipeline states read in one listing; the rest are looked up one by one.
_STATE_SCAN_LIMIT = 1000
#: The longest reply excerpt a turn carries. The full reply is not shown here.
_EXCERPT_CHARS = 1000
#: The most turns one task detail carries, and the most gate verdicts.
_DETAIL_LIMIT = 200
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class EngineStatusOut(BaseModel):
"""Whether the organization's engine can work, and how much of the team is in use."""
balance: float
currency: str
credit_ok: bool
automation_enabled: bool
max_employees: int
team_size: int
stage_counts: dict[str, int]
class EngineTaskOut(BaseModel):
"""One task on the board.
``stage``, ``pipeline`` and ``kind`` are null for a task the engine does not
own (a legacy task). ``cycle`` and ``max_cycles`` are 0 for those tasks too.
"""
id: str
title: str
status: str
stage: str | None
pipeline: str | None
kind: str | None
cycle: int
max_cycles: int
thread_id: str | None
assigned_employee_id: str | None
assignee_name: str | None
created_at: datetime
updated_at: datetime
class EngineTurnOut(BaseModel):
"""One employee turn: who ran, in which role and stage, and how it ended."""
id: str
run_id: str
role: str
mode: str
stage: str
employee_name: str | None
ok: bool
error_kind: str
tool_calls: int
reply_excerpt: str
started_at: datetime
finished_at: datetime | None
class EngineOrgTurnOut(EngineTurnOut):
"""A turn in the organization's recent list, with the task it belongs to."""
task_id: str
task_title: str
class EngineGateOut(BaseModel):
id: str
gate: str
ok: bool
detail: str
created_at: datetime
class EngineRunOut(BaseModel):
id: str
status: str
started_at: datetime | None
finished_at: datetime | None
class EngineTaskDetailOut(EngineTaskOut):
"""One task with its pipeline, its turns, its gate verdicts and its last diff."""
description: str
#: The stages of the task's pipeline in order; empty for a legacy task.
stages: list[str]
waiver_reason: str
branch: str
depends_on: list[str]
#: The sha the merge landed on, once the task is completed.
merged_sha: str
#: The last accepted diff (capped by the writer at 20,000 characters).
diff: str
turns: list[EngineTurnOut]
gates: list[EngineGateOut]
runs: list[EngineRunOut]
class EngineAnswerRequest(BaseModel):
"""A person's answer to the question a task is waiting on."""
content: str = Field(min_length=1, max_length=4000)
class TierOut(BaseModel):
tier: str
label: str
class TeamMemberOut(BaseModel):
"""One team member. ``tier`` is neutral: no model, provider or model id is shown."""
id: str
name: str
role_key: str
status: str
#: ``fast``, ``balanced``, ``strong``, or ``unknown`` when the model is not granted.
tier: str
busy_task_id: str | None
busy_task_title: str | None
hired_by: str
class TeamOut(BaseModel):
employees: list[TeamMemberOut]
#: The tiers the organization has granted models for, cheapest first.
tiers: list[TierOut]
#: The owner's cap on the headcount for this organization.
max_employees: int
count: int
class HireRequest(BaseModel):
"""A hire asks for a role and a tier. The model is chosen by the platform."""
role: RoleKey
tier: str = Field(min_length=1, max_length=20)
name: str = Field(default="", max_length=120)
# ---------------------------------------------------------------------------
# Mapping
# ---------------------------------------------------------------------------
#: Neutral tier labels, cheapest first: the order the team view lists them in.
_TIER_LABELS: dict[str, str] = {"fast": "سریع", "balanced": "متعادل", "strong": "قوی"}
def _task_out(
task: Task, state: TaskPipelineState | None, names: dict[str, str]
) -> EngineTaskOut:
return EngineTaskOut(
id=task.id,
title=task.title,
status=str(task.status),
stage=str(state.stage) if state else None,
pipeline=str(state.pipeline) if state else None,
kind=str(state.kind) if state else None,
cycle=state.cycle if state else 0,
max_cycles=state.max_cycles if state else 0,
thread_id=task.thread_id,
assigned_employee_id=task.assigned_employee_id,
assignee_name=names.get(task.assigned_employee_id or ""),
created_at=task.created_at,
updated_at=task.updated_at,
)
def _turn_out(turn: EngineTurn, names: dict[str, str]) -> EngineTurnOut:
return EngineTurnOut(
id=turn.id,
run_id=turn.run_id,
role=str(turn.role),
mode=str(turn.mode),
stage=str(turn.stage),
employee_name=names.get(turn.employee_id or ""),
ok=turn.ok,
error_kind=turn.error_kind,
tool_calls=turn.tool_calls,
reply_excerpt=turn.reply_excerpt[:_EXCERPT_CHARS],
started_at=turn.started_at,
finished_at=turn.finished_at,
)
async def _employee_names(
session: AsyncSession, organization_id: str, employee_ids: list[str | None]
) -> dict[str, str]:
"""Names of the employees a list refers to, read inside the organization only.
One listing covers the team in the common case; an id outside that listing is
looked up on its own, still scoped to the organization.
"""
wanted = {eid for eid in employee_ids if eid}
repo = EmployeeRepository(session)
employees: list[Employee] = await repo.list_for_org(organization_id, limit=500)
names = {e.id: e.name for e in employees if e.id in wanted}
for employee_id in wanted:
if employee_id not in names:
employee = await repo.get_for_org(organization_id, employee_id)
if employee is not None:
names[employee_id] = employee.name
return names
async def _task_titles(
session: AsyncSession, organization_id: str, task_ids: list[str]
) -> dict[str, str]:
"""Titles of the tasks a list refers to, read inside the organization only."""
wanted = set(task_ids)
repo = TaskRepository(session)
tasks: list[Task] = await repo.list_for_org(organization_id, limit=_BOARD_SCAN_LIMIT)
titles = {t.id: t.title for t in tasks if t.id in wanted}
for task_id in wanted:
if task_id not in titles:
task = await repo.get_for_org(organization_id, task_id)
if task is not None:
titles[task_id] = task.title
return titles
async def _granted_tiers(
session: AsyncSession, organization_id: str
) -> tuple[list[GrantedModel], dict[str, str]]:
"""The organization's granted models, and each one's neutral tier by model id."""
granted = await build_model_grants(session).granted_models(organization_id=organization_id)
return granted, {model.ai_model_id: model.tier for model in granted}
def _tier_list(granted: list[GrantedModel]) -> list[TierOut]:
present = {model.tier for model in granted}
return [
TierOut(tier=tier, label=label) for tier, label in _TIER_LABELS.items() if tier in present
]
async def _member_out(
session: AsyncSession,
organization_id: str,
employee: Employee,
tiers: dict[str, str],
) -> TeamMemberOut:
profile = await EmployeeProfileRepository(session).get(employee.id)
tier = "unknown"
busy_task_id: str | None = None
busy_task_title: str | None = None
hired_by = ""
if profile is not None:
tier = tiers.get(profile.ai_model_id or "", "unknown")
busy_task_id = profile.busy_task_id
hired_by = profile.hired_by
if busy_task_id:
busy = await TaskRepository(session).get_for_org(organization_id, busy_task_id)
busy_task_title = busy.title if busy is not None else None
return TeamMemberOut(
id=employee.id,
name=employee.name,
role_key=str(employee.role_key),
status=str(employee.status),
tier=tier,
busy_task_id=busy_task_id,
busy_task_title=busy_task_title,
hired_by=hired_by,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/status", response_model=EngineStatusOut)
async def engine_status(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineStatusOut:
"""The credit balance, the owner's policy, the team size and the stage counts.
``credit_ok`` is false for a missing wallet as well as for a zero balance,
because prepaid work needs credit to run (decision D-B).
"""
organization_id = context.org_id
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
return EngineStatusOut(
balance=wallet.balance if wallet is not None else 0.0,
currency=wallet.currency if wallet is not None else "USD",
credit_ok=wallet is not None and wallet.balance > 0,
automation_enabled=policy.automation_enabled,
max_employees=policy.max_employees,
team_size=await EmployeeRepository(session).count_for_org(organization_id),
stage_counts=await PipelineStateRepository(session).stage_counts(organization_id),
)
@router.get("/tasks", response_model=list[EngineTaskOut])
async def list_engine_tasks(
stage: Stage | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineTaskOut]:
"""The organization's tasks, newest first, with their stage when the engine owns them.
With ``stage`` only the tasks at that stage are returned, found among the newest
500 tasks of the organization.
"""
organization_id = context.org_id
states_repo = PipelineStateRepository(session)
states = {
s.task_id: s
for s in await states_repo.list_for_org(organization_id, limit=_STATE_SCAN_LIMIT)
}
rows: list[tuple[Task, TaskPipelineState | None]] = []
for task in await TaskRepository(session).list_for_org(
organization_id, limit=_BOARD_SCAN_LIMIT
):
state = states.get(task.id)
if state is None:
# A legacy task, or an engine task older than the newest states listed above.
state = await states_repo.get(task.id)
if stage is not None and (state is None or state.stage is not stage):
continue
rows.append((task, state))
if len(rows) >= limit:
break
names = await _employee_names(
session, organization_id, [task.assigned_employee_id for task, _ in rows]
)
return [_task_out(task, state, names) for task, state in rows]
@router.get("/tasks/{task_id}", response_model=EngineTaskDetailOut)
async def get_engine_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskDetailOut:
"""One task with its stages, turns, gate verdicts, runs, diff and merged sha."""
organization_id = context.org_id
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
turns = await EngineTurnRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
gates = await GateResultRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
runs: list[Run] = await RunRepository(session).list_for_task(task.id)
names = await _employee_names(
session,
organization_id,
[task.assigned_employee_id, *(turn.employee_id for turn in turns)],
)
base = _task_out(task, state, names)
return EngineTaskDetailOut(
**base.model_dump(),
description=task.description,
stages=[str(stage) for stage in pipeline_stages(state.pipeline)] if state else [],
waiver_reason=state.waiver_reason if state else "",
branch=state.branch if state else "",
depends_on=list(state.depends_on) if state else [],
merged_sha=state.merged_sha if state else "",
diff=state.last_diff if state else "",
turns=[_turn_out(turn, names) for turn in turns],
gates=[
EngineGateOut(
id=gate.id,
gate=str(gate.gate),
ok=gate.ok,
detail=gate.detail,
created_at=gate.created_at,
)
for gate in gates
],
runs=[
EngineRunOut(
id=run.id,
status=str(run.status),
started_at=run.started_at,
finished_at=run.finished_at,
)
for run in runs
],
)
@router.get("/turns", response_model=list[EngineOrgTurnOut])
async def list_recent_turns(
limit: int = Query(default=50, ge=1, le=200),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineOrgTurnOut]:
"""The organization's newest turns across all its tasks, with each task's title."""
organization_id = context.org_id
turns = await EngineTurnRepository(session).list_for_org(organization_id, limit=limit)
names = await _employee_names(session, organization_id, [turn.employee_id for turn in turns])
titles = await _task_titles(session, organization_id, [turn.task_id for turn in turns])
return [
EngineOrgTurnOut(
**_turn_out(turn, names).model_dump(),
task_id=turn.task_id,
task_title=titles.get(turn.task_id, ""),
)
for turn in turns
]
# ---------------------------------------------------------------------------
# Actions on one task
# ---------------------------------------------------------------------------
async def _task_view(session: AsyncSession, organization_id: str, task_id: str) -> EngineTaskOut:
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
names = await _employee_names(session, organization_id, [task.assigned_employee_id])
return _task_out(task, state, names)
async def _control(
*,
action: Literal["pause", "resume", "cancel", "answer"],
task_id: str,
request: Request,
context: OrgContext,
session: AsyncSession,
answer: str = "",
) -> EngineTaskOut:
context.require(CAN_RUN_TASKS)
# Imported inside the handler: the container wires the whole engine, and the read
# routes above do not need it.
from app.application.container import build_scheduler
await build_scheduler(session).user_action(
organization_id=context.org_id,
task_id=task_id,
action=action,
user_id=context.user.id,
answer=answer,
)
ip, ua = client_meta(request)
# Only the action is written: an answer's text stays out of the audit trail.
await record_audit(
session,
action=AuditAction.TASK_CONTROL,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="task",
target_id=task_id,
metadata={"action": action},
ip=ip,
user_agent=ua,
)
return await _task_view(session, context.org_id, task_id)
@router.post("/tasks/{task_id}/pause", response_model=EngineTaskOut)
async def pause_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Stop a task before its next turn. Its stage is kept, so resume picks it up."""
return await _control(
action="pause", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/resume", response_model=EngineTaskOut)
async def resume_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Send a paused task back to triage. Refused while the organization has no credit."""
return await _control(
action="resume", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/cancel", response_model=EngineTaskOut)
async def cancel_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""End a task. Its worktree is released and tasks waiting on it are parked."""
return await _control(
action="cancel", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/answer", response_model=EngineTaskOut)
async def answer_task(
task_id: str,
body: EngineAnswerRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Answer the question a task waits on. Only a task at a decision accepts one."""
return await _control(
action="answer",
task_id=task_id,
request=request,
context=context,
session=session,
answer=body.content,
)
# ---------------------------------------------------------------------------
# Team: the view, hire by role and tier, fire
# ---------------------------------------------------------------------------
@router.get("/team", response_model=TeamOut)
async def get_team(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamOut:
"""The team, without the archived members, with each member's neutral tier."""
organization_id = context.org_id
granted, tiers = await _granted_tiers(session, organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
employees = [
employee
for employee in await EmployeeRepository(session).list_for_org(organization_id, limit=500)
if employee.status is not EmployeeStatus.ARCHIVED
]
members = [await _member_out(session, organization_id, e, tiers) for e in employees]
return TeamOut(
employees=members,
tiers=_tier_list(granted),
max_employees=policy.max_employees,
count=len(members),
)
@router.post("/team/hire", response_model=TeamMemberOut, status_code=201)
async def hire_member(
body: HireRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Hire one employee for a role on a granted model of the requested tier."""
context.require(CAN_MANAGE_EMPLOYEES)
# Imported inside the handler, as for the task actions above.
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.hire(
organization_id=context.org_id,
role=body.role,
tier=body.tier,
name=body.name,
project_id=None,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"role_key": str(body.role), "tier": body.tier},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@router.post("/team/{employee_id}/fire", response_model=TeamMemberOut)
async def fire_member(
employee_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Archive one employee. The last active manager cannot be fired."""
context.require(CAN_MANAGE_EMPLOYEES)
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.fire(
organization_id=context.org_id, employee_id=employee_id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_UPDATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"action": "fire"},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@@ -145,6 +145,11 @@ async def create_organization(
)
# Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await session.flush()
from app.application.services.credit_service import CreditService
await CreditService(session).grant_starting_credit(org.id)
ip, ua = client_meta(request)
await record_audit(
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import (
admin,
admin_credits,
admin_domains,
admin_grants,
admin_users,
@@ -31,6 +32,7 @@ from app.interfaces.http.routers import (
auth,
billing,
delivery,
engine,
health,
invites,
knowledge,
@@ -82,6 +84,8 @@ def build_rpc_app() -> FastAPI:
application.include_router(memory.router, prefix="/api")
application.include_router(mcp_tokens.router, prefix="/api")
application.include_router(admin_grants.router, prefix="/api")
application.include_router(admin_credits.router, prefix="/api")
application.include_router(engine.router, prefix="/api")
application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.router, prefix="/api")
application.include_router(sandbox.admin_router, prefix="/api")