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
+1
View File
@@ -1023,6 +1023,7 @@ class QualityMetricRow(Base):
# parallel agents never edit the same file). Imported last so every table is
# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate.
# ---------------------------------------------------------------------------
from app.infrastructure.db import models_billing as _models_billing # noqa: E402,F401
from app.infrastructure.db import models_delivery as _models_delivery # noqa: E402,F401
from app.infrastructure.db import models_engine as _models_engine # noqa: E402,F401
from app.infrastructure.db import models_org as _models_org # noqa: E402,F401
@@ -0,0 +1,50 @@
"""ORM tables of the prepaid credit ledger (v2).
Use ``from app.core.database import Base`` and the helpers in
``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
Foreign keys to existing tables are written as plain strings, e.g.
``ForeignKey("organizations.id", ondelete="CASCADE")``.
``credit_transactions`` is the history of an organization's prepaid wallet
(``credit_wallets.balance``). Every change to the balance writes one row here, in
the same transaction, with the balance it left behind:
* ``owner_credit``, ``starting_credit`` and ``adjustment`` move the balance by a
signed amount (credit positive, debit negative);
* ``usage`` is the debit for one usage row. ``usage_event_id`` is unique, so one
usage row debits the wallet at most once, even when it is written twice.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.infrastructure.db.columns import created, pk
class CreditTransactionRow(Base):
__tablename__ = "credit_transactions"
__table_args__ = (
Index("ix_credit_tx_org_created", "organization_id", "created_at"),
UniqueConstraint("usage_event_id", name="uq_credit_transactions_usage_event_id"),
)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
#: owner_credit | starting_credit | usage | adjustment (see ``CreditKind``).
kind: Mapped[str] = mapped_column(String(16), nullable=False)
#: Signed: a credit is positive, a debit is negative.
amount: Mapped[float] = mapped_column(Float, nullable=False)
balance_after: Mapped[float] = mapped_column(Float, nullable=False)
reason: Mapped[str] = mapped_column(String(500), default="", nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
#: The usage row this debit paid for. Unique: the idempotency of a usage debit.
usage_event_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = created()
@@ -82,6 +82,11 @@ class TaskPipelineStateRow(Base):
#: A waived test pass must always be explainable: the reason is stored,
#: never inferred (PLAN-v2 §3, green gate).
waiver_reason: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The latest accepted diff (the writer caps it). The worktree is gone after the
#: merge, so the task page reads the diff from here.
last_diff: Mapped[str] = mapped_column(Text, default="", nullable=False)
#: The sha the merge landed on, once the state is COMPLETED.
merged_sha: Mapped[str] = mapped_column(String(64), default="", nullable=False)
parent_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
+1 -1
View File
@@ -143,7 +143,7 @@ class OrgPolicyRow(Base):
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
)
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
max_employees: Mapped[int] = mapped_column(Integer, default=7, nullable=False)
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
from app.domain.base import new_id
from app.infrastructure.db.models import (
AuditEventRow,
ErrorEventRow,
@@ -18,6 +19,7 @@ from app.infrastructure.db.models import (
TraceRow,
UsageEventRow,
)
from app.infrastructure.repositories.credit import CreditLedgerRepository
def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
@@ -107,14 +109,26 @@ class AuditRepository:
class UsageLedgerRepository:
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth."""
"""Concrete ``UsageLedgerPort``. The ledger is the billing source of truth.
Prepaid: every usage row also debits its organization's wallet, once, in the
same transaction (the debit is keyed on the usage row's id). The ledger stays
the source of truth; the wallet is the balance it leaves behind.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def record(self, *, usage: UsageRecord) -> None:
async def record(self, *, usage: UsageRecord) -> float | None:
"""Append one usage row, debit the wallet for it, return the new balance.
Both writes share the caller's transaction, so a rolled-back request leaves
neither behind. Returns ``None`` when the row cost nothing and nothing was
debited.
"""
total = usage.total_price or (usage.quantity * usage.unit_price)
row = UsageEventRow(
id=new_id(),
organization_id=usage.organization_id,
project_id=usage.project_id,
user_id=usage.user_id,
@@ -129,6 +143,12 @@ class UsageLedgerRepository:
)
self.session.add(row)
await self.session.flush()
debit = await CreditLedgerRepository(self.session).debit_for_usage(
organization_id=usage.organization_id,
usage_event_id=row.id,
amount=total,
)
return debit.balance_after if debit is not None else None
async def breakdown(
self,
@@ -0,0 +1,148 @@
"""The prepaid credit ledger: every balance change is one atomic update and one history row.
``credit_wallets.balance`` is the number the engine reads; ``credit_transactions``
is the history that explains it. Both change in the caller's transaction, so a
rolled-back request leaves neither behind.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.db.models import CreditWalletRow
from app.infrastructure.db.models_billing import CreditTransactionRow
from app.infrastructure.repositories.billing import CreditWalletRepository
#: Longest history page one call may return; the UI never needs more at once.
_MAX_HISTORY = 200
def _transaction(row: CreditTransactionRow) -> CreditTransaction:
return CreditTransaction(
id=row.id,
organization_id=row.organization_id,
kind=CreditKind(row.kind),
amount=float(row.amount),
balance_after=float(row.balance_after),
reason=row.reason,
actor_user_id=row.actor_user_id,
usage_event_id=row.usage_event_id,
created_at=row.created_at,
)
class CreditLedgerRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def apply(
self,
*,
organization_id: str,
amount: float,
kind: CreditKind,
reason: str = "",
actor_user_id: str | None = None,
usage_event_id: str | None = None,
) -> CreditTransaction:
"""Move the wallet by a signed ``amount`` and record the move.
The balance changes in one SQL ``UPDATE`` (``balance = balance + amount``),
never as a Python read-modify-write, so two writers cannot lose each
other's change. The history row records the balance that update left.
"""
await CreditWalletRepository(self.session).ensure(organization_id)
await self.session.execute(
update(CreditWalletRow)
.where(CreditWalletRow.organization_id == organization_id)
.values(balance=CreditWalletRow.balance + amount, updated_at=utcnow())
.execution_options(synchronize_session="fetch")
)
balance_after = (
await self.session.execute(
select(CreditWalletRow.balance).where(
CreditWalletRow.organization_id == organization_id
)
)
).scalar_one()
row = CreditTransactionRow(
organization_id=organization_id,
kind=str(kind),
amount=amount,
balance_after=float(balance_after),
reason=reason[:500],
actor_user_id=actor_user_id,
usage_event_id=usage_event_id,
)
self.session.add(row)
await self.session.flush()
return _transaction(row)
async def debit_for_usage(
self, *, organization_id: str, usage_event_id: str, amount: float
) -> CreditTransaction | None:
"""Debit the wallet for one usage row, at most once.
Returns ``None`` when the usage costs nothing or was already debited. The
debit runs in a savepoint: when two writers race on the same usage row, the
loser's insert is rolled back and it returns ``None`` instead of poisoning
the caller's transaction. Any other integrity failure is re-raised.
"""
if amount <= 0 or await self._debited(organization_id, usage_event_id):
return None
try:
async with self.session.begin_nested():
return await self.apply(
organization_id=organization_id,
amount=-amount,
kind=CreditKind.USAGE,
usage_event_id=usage_event_id,
)
except IntegrityError:
if await self._debited(organization_id, usage_event_id):
return None
raise
async def history(
self,
organization_id: str,
*,
kinds: tuple[CreditKind, ...] | None = None,
limit: int = 50,
) -> list[CreditTransaction]:
"""One organization's history, newest first, optionally one kind of entry."""
stmt = select(CreditTransactionRow).where(
CreditTransactionRow.organization_id == organization_id
)
if kinds:
stmt = stmt.where(CreditTransactionRow.kind.in_([str(k) for k in kinds]))
stmt = stmt.order_by(CreditTransactionRow.created_at.desc()).limit(
min(limit, _MAX_HISTORY)
)
return [_transaction(r) for r in (await self.session.execute(stmt)).scalars()]
async def last_credit_at_by_org(self) -> dict[str, datetime]:
"""When each organization last received credit (usage debits do not count).
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(CreditTransactionRow.organization_id, func.max(CreditTransactionRow.created_at))
.where(CreditTransactionRow.kind != str(CreditKind.USAGE))
.group_by(CreditTransactionRow.organization_id)
)
result = await self.session.execute(stmt)
return dict(result.tuples().all())
async def _debited(self, organization_id: str, usage_event_id: str) -> bool:
stmt = select(CreditTransactionRow.id).where(
CreditTransactionRow.organization_id == organization_id,
CreditTransactionRow.usage_event_id == usage_event_id,
)
return (await self.session.execute(stmt)).first() is not None
@@ -36,7 +36,7 @@ from app.domain.workforce.pipeline import (
TaskKind,
TaskPipelineState,
)
from app.infrastructure.db.models import RunRow, UsageEventRow
from app.infrastructure.db.models import RunRow, TaskRow, UsageEventRow
from app.infrastructure.db.models_engine import (
EmployeeProfileRow,
EngineTurnRow,
@@ -75,6 +75,8 @@ _PATCHABLE = frozenset(
"same_diff_count",
"retry_count",
"waiver_reason",
"last_diff",
"merged_sha",
"parent_task_id",
"depends_on",
}
@@ -97,6 +99,8 @@ def _state(row: TaskPipelineStateRow) -> TaskPipelineState:
same_diff_count=row.same_diff_count,
retry_count=row.retry_count,
waiver_reason=row.waiver_reason,
last_diff=row.last_diff,
merged_sha=row.merged_sha,
parent_task_id=row.parent_task_id,
depends_on=list(row.depends_on or []),
version=row.version,
@@ -185,6 +189,8 @@ class PipelineStateRepository:
same_diff_count=state.same_diff_count,
retry_count=state.retry_count,
waiver_reason=state.waiver_reason,
last_diff=state.last_diff,
merged_sha=state.merged_sha,
parent_task_id=state.parent_task_id,
depends_on=list(state.depends_on),
version=state.version,
@@ -268,6 +274,27 @@ class PipelineStateRepository:
)
return int((await self.session.execute(stmt)).scalar_one())
async def count_at_by_org(self, stage: Stage) -> dict[str, int]:
"""How many tasks sit at one stage, per organization.
Cross-organization on purpose: only the platform owner's views may use it.
"""
stmt = (
select(TaskPipelineStateRow.organization_id, func.count())
.where(TaskPipelineStateRow.stage == str(stage))
.group_by(TaskPipelineStateRow.organization_id)
)
return {org: int(n) for org, n in (await self.session.execute(stmt)).all()}
async def stage_counts(self, organization_id: str) -> dict[str, int]:
"""How many of one organization's tasks sit at each stage."""
stmt = (
select(TaskPipelineStateRow.stage, func.count())
.where(TaskPipelineStateRow.organization_id == organization_id)
.group_by(TaskPipelineStateRow.stage)
)
return {stage: int(n) for stage, n in (await self.session.execute(stmt)).all()}
# ---------------------------------------------------------------------------
# employee_profiles
@@ -410,6 +437,21 @@ class EngineTurnRepository:
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def list_for_org(self, organization_id: str, *, limit: int = 50) -> list[EngineTurn]:
"""The newest turns across one organization's tasks.
Turns carry no organization column, so the tenant filter joins through
the task: another organization's turn can never match.
"""
stmt = (
select(EngineTurnRow)
.join(TaskRow, EngineTurnRow.task_id == TaskRow.id)
.where(TaskRow.organization_id == organization_id)
.order_by(EngineTurnRow.started_at.desc())
.limit(min(limit, 200))
)
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def finish(
self,
turn_id: str,
@@ -39,7 +39,7 @@ class DefaultGrantRecord:
@dataclass(slots=True, kw_only=True)
class OrgPolicyRecord:
organization_id: str
max_employees: int = 5
max_employees: int = 7
monthly_credit_budget: float | None = None
automation_enabled: bool = True
mcp_enabled: bool = True
@@ -510,6 +510,15 @@ class TaskRepository:
await self.session.flush()
return _task(row)
async def append_description(self, task_id: str, text: str) -> Task | None:
"""Add text to the end of a task's description (a person's answer, for triage)."""
row = await self.session.get(TaskRow, task_id)
if row is None:
return None
row.description = f"{row.description or ''}{text}"
await self.session.flush()
return _task(row)
class RunRepository:
def __init__(self, session: AsyncSession) -> None: