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