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
+31 -1
View File
@@ -28,6 +28,7 @@ from app.application.ports.realtime import (
task_channel,
)
from app.core.config import settings
from app.domain.workforce.pipeline import PipelineKey, Stage, coarse_status
@dataclass(slots=True, kw_only=True)
@@ -204,4 +205,33 @@ async def emit_team_changed(
)
__all__ = ["RunFeed", "emit_team_changed"]
async def emit_task_step(
*,
task_id: str,
organization_id: str,
from_stage: Stage,
to_stage: Stage,
pipeline: PipelineKey,
) -> None:
"""A task moved one stage: the task page's stepper follows it live.
The payload carries stage names and the coarse status only, never a model
or an employee name.
"""
await publish(
RealtimeEvent(
type="task.step",
channels=(task_channel(task_id), org_channel(organization_id)),
organization_id=organization_id,
data={
"task_id": task_id,
"from": str(from_stage),
"to": str(to_stage),
"pipeline": str(pipeline),
"status": coarse_status(to_stage).value,
},
)
)
__all__ = ["RunFeed", "emit_task_step", "emit_team_changed"]
@@ -88,6 +88,7 @@ from app.infrastructure.repositories.ai_control import (
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
@@ -109,6 +110,11 @@ CHAT_MAX_ATTEMPTS = 2
#: The message a new organization sees when the owner has granted nothing yet.
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه"
#: The reply a zero-credit organization gets instead of a model call (prepaid, D-B).
CHAT_NO_CREDIT_MESSAGE = (
"اعتبار سازمان صفر است؛ مدیر پس از افزودن اعتبار توسط مالک سامانه پاسخ می‌دهد."
)
#: Bootstrap tiers per role, in the plan's order (strongest / balanced / fast).
BOOTSTRAP_TIERS: dict[RoleKey, str] = {
RoleKey.MANAGER: "strong",
@@ -223,6 +229,7 @@ class ManagerTurns:
states: PipelineStateRepository | None = None,
runner: TurnRunner | None = None,
feed=None,
wallets: CreditWalletRepository | None = None,
) -> None:
self.provider = provider
self.employees = employees
@@ -239,6 +246,7 @@ class ManagerTurns:
self.states = states
self.runner = runner
self.feed = feed
self.wallets = wallets
# ------------------------------------------------------------------ #
# 3.6 bootstrap #
@@ -404,6 +412,17 @@ class ManagerTurns:
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
# Prepaid: a reply costs a model call, so an org with no credit gets the
# note instead. No model is called and no tag is executed.
if await self._out_of_credit(organization_id):
note = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=CHAT_NO_CREDIT_MESSAGE,
)
)
return user_message, note
employee = await self._manager_of(organization_id, employee_id)
extra = "\n\n".join(
part
@@ -492,6 +511,13 @@ class ManagerTurns:
)
return user_message, assistant_message
async def _out_of_credit(self, organization_id: str) -> bool:
"""True when a wired wallet shows no credit left (a missing wallet counts as none)."""
if self.wallets is None:
return False
wallet = await self.wallets.get_for_org(organization_id)
return wallet is None or wallet.balance <= 0
def _chat_context(
self,
*,
@@ -1083,6 +1109,7 @@ class ManagerTurns:
__all__ = [
"BOOTSTRAP_TIERS",
"CHAT_NO_CREDIT_MESSAGE",
"CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE",
+100 -12
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Literal
from app.application.engine.gates import GateOutcome, GateRunner
from app.application.engine.manager import ManagerOutcome, ManagerTurns, _pick_granted_model
@@ -32,10 +33,11 @@ from app.application.engine.state import advance, park_dependents
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView
from app.application.ports.realtime import emit_task_status
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings
from app.core.errors import ConflictError
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import utcnow
from app.domain.workforce.engine import (
MAX_TRANSIENT_RETRIES,
@@ -43,8 +45,18 @@ from app.domain.workforce.engine import (
GateName,
is_transient,
)
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus, Task, TaskStatus
from app.domain.workforce.models import (
Employee,
Message,
MessageRole,
RoleKey,
Run,
RunStatus,
Task,
TaskStatus,
)
from app.domain.workforce.pipeline import (
TERMINAL_STAGES,
Outcome,
Stage,
TaskPipelineState,
@@ -85,8 +97,10 @@ MERGE_STAGES: tuple[Stage, ...] = (Stage.MERGE_REQUEST,)
#: address; the *name* is the employee's, which is what a reviewer reads.
ENGINE_COMMIT_EMAIL = "platform@smart-org.local"
#: Persian reason shown when work pauses because the money ran out.
BUDGET_PAUSED_MESSAGE = "اعتبار سازمان برای ادامهٔ کار کافی نیست؛ کار موقتاً متوقف شد."
#: Persian reason shown when work pauses because the credit ran out (prepaid, D-B).
BUDGET_PAUSED_MESSAGE = (
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد."
)
#: The stage a turn has to be at for each manager mode.
_MANAGER_STAGES: frozenset[Stage] = frozenset(
@@ -426,7 +440,14 @@ class Scheduler:
same_count=state.same_diff_count,
diff_text=diff.text,
)
await self._patch(state, last_diff_hash=new_hash, same_diff_count=same_count)
# One write for the hash, the counter and the diff the task page shows
# (capped at 20,000 chars). Only a diff that passed the guard is stored.
await self._patch(
state,
last_diff_hash=new_hash,
same_diff_count=same_count,
last_diff=diff.text[:20000],
)
if not progress.ok:
await self._send_back(state, task, progress.detail)
report.failed += 1
@@ -530,6 +551,7 @@ class Scheduler:
return
final_sha = pushed.sha or merged.sha or sha
await self._patch(state, merged_sha=final_sha)
await self._move(state, task, Stage.COMPLETED)
await self.repo_workspace.remove_worktree(worktree=worktree)
if task.project_id:
@@ -622,16 +644,14 @@ class Scheduler:
async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may.
Usage is post-paid: the ledger feeds invoices and nothing debits a wallet
from usage. A zero balance is therefore the normal starting state of every
org, not "out of money", so only an overdrawn wallet (below zero) pauses
work. The owner's spend lever is the org's monthly credit budget.
Billing is prepaid: the platform owner credits the wallet, and every usage
row debits it. Zero means "no credit left", so a zero, negative or missing
wallet pauses work before a turn is spent. The per-task cap and the org's
monthly budget still apply on top of that.
"""
if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be
# frozen out of its first task. A zero balance is not a pause either.
if wallet is not None and wallet.balance < 0:
if wallet is None or wallet.balance <= 0:
return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id)
@@ -648,6 +668,74 @@ class Scheduler:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None
# ------------------------------------------------------------------ #
# the person's controls #
# ------------------------------------------------------------------ #
async def user_action(
self,
*,
organization_id: str,
task_id: str,
action: Literal["pause", "resume", "cancel", "answer"],
user_id: str,
answer: str = "",
) -> TaskPipelineState:
"""Apply one control a person pressed on a task, and return its new state.
The task is looked up inside the caller's organization, so a foreign id is
a 404. A task outside the engine, or one already closed, is a conflict.
Resume is refused while the budget is spent, so a task paused for credit
stays paused until credit is added and resume is pressed again. ``user_id``
names the person who pressed the control; the engine keeps no actor column,
so the route writes the audit row.
"""
task = await self.tasks.get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await self.states.get(task_id)
if state is None:
raise ConflictError("این کار در موتور خودکار نیست.")
if state.stage in TERMINAL_STAGES:
raise ConflictError("این کار بسته شده است.")
if action == "pause":
await self._move(state, task, Stage.PAUSED)
elif action == "resume":
if state.stage is not Stage.PAUSED:
raise ConflictError("فقط کار متوقف‌شده را می‌توان ادامه داد.")
reason = await self._budget_reason(task)
if reason is not None:
raise ConflictError(reason)
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
elif action == "cancel":
await self._move(state, task, Stage.CANCELLED)
else:
await self._answer(state, task, answer)
updated = await self.states.get(task_id)
if updated is None: # pragma: no cover - the row was read above
raise NotFoundError("این کار وضعیت موتور ندارد.")
await emit_task_status(
task_id=task_id,
organization_id=organization_id,
status=coarse_status(updated.stage).value,
)
return updated
async def _answer(self, state: TaskPipelineState, task: Task, answer: str) -> None:
"""A person answers the decision a task waits on; triage reads it next."""
content = answer.strip()
if not 1 <= len(content) <= 4000:
raise ValidationError("پاسخ باید بین ۱ تا ۴۰۰۰ نویسه باشد.")
if state.stage is not Stage.PENDING_USER_DECISION:
raise ConflictError("این کار منتظر پاسخ شما نیست.")
# The move comes first, so a lost race raises before anything is written.
await self._move(state, task, Stage.PENDING_MANAGER_REVIEW)
await self.tasks.append_description(task.id, "\n\nپاسخ کاربر: " + content)
if task.thread_id:
await self.manager.messages.create(
Message(thread_id=task.thread_id, role=MessageRole.USER, content=content)
)
async def _retry_or_fail(
self, state: TaskPipelineState, task: Task, message: str, kind: str
) -> None:
+12 -2
View File
@@ -19,6 +19,7 @@ truth, the status column is the summary existing APIs keep reading.
from __future__ import annotations
from app.application.engine.events import emit_task_step
from app.core.errors import ConflictError, NotFoundError
from app.domain.workforce.pipeline import (
SIDE_STAGES,
@@ -46,14 +47,14 @@ async def advance(
Raises ``ConflictError`` when the transition is illegal or when another
worker moved the task first, and ``NotFoundError`` when the task has no
engine state row at all.
engine state row at all. A move that lands publishes one ``task.step``.
"""
if not can_transition_stage(expected_from, to):
raise ConflictError(
f"گذار نابجا از {expected_from.value} به {to.value}."
)
try:
return await states.advance(task_id=task_id, expected_from=expected_from, to=to)
moved = await states.advance(task_id=task_id, expected_from=expected_from, to=to)
except UnknownTaskError as exc:
raise NotFoundError("این کار وضعیت موتور ندارد.") from exc
except InvalidTransitionError as exc:
@@ -62,6 +63,15 @@ async def advance(
raise ConflictError(
"این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید."
) from exc
# The publisher never raises, so a broken bus cannot undo the move.
await emit_task_step(
task_id=moved.task_id,
organization_id=moved.organization_id,
from_stage=expected_from,
to_stage=to,
pipeline=moved.pipeline,
)
return moved
async def park_dependents(