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
@@ -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,