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