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
+6
View File
@@ -140,3 +140,9 @@ ALLOW_CUSTOM_DOMAINS=false
# or `--upload-pack`. Set to true ONLY to test against a local bare repository. # or `--upload-pack`. Set to true ONLY to test against a local bare repository.
GIT_ALLOW_FILE_PROTOCOL=false GIT_ALLOW_FILE_PROTOCOL=false
# <<< agent-3 # <<< agent-3
# >>> integration (billing)
# Credit given to every new organization at creation (prepaid). 0 means the
# platform owner credits new organizations by hand.
BILLING_STARTING_CREDIT=0
# <<< integration
+5
View File
@@ -44,6 +44,8 @@ provider. این‌ها فقط در **Admin Console** و فقط برای مال
جابه‌جایی صندلی مالکیت فقط با `POST /admin/transfer-ownership` انجام می‌شود که در جابه‌جایی صندلی مالکیت فقط با `POST /admin/transfer-ownership` انجام می‌شود که در
یک تراکنش، مالک فعلی را به مدیر تنزل می‌دهد و جانشین را مالک می‌کند. یک تراکنش، مالک فعلی را به مدیر تنزل می‌دهد و جانشین را مالک می‌کند.
**اعتبار پیش‌پرداخت.** سازمان‌ها با اعتبار پیش‌پرداخت کار می‌کنند. مالک سامانه در `/admin/credits` به هر سازمان اعتبار می‌دهد و هر رکورد مصرف (مدل، sandbox و بازتاب حافظه) به‌محض ثبت، از کیف پول همان سازمان کم می‌شود. وقتی موجودی به صفر یا کمتر برسد، کارهای آن سازمان متوقف می‌شوند و گفتگوی مدیر هم مدل را صدا نمی‌زند. کارهای متوقف‌شده پس از شارژ خودکار ادامه نمی‌یابند؛ با دکمهٔ «ادامه» دستی و از همان مرحله ادامه داده می‌شوند.
--- ---
## ۲. پیش‌نیازها ## ۲. پیش‌نیازها
@@ -245,6 +247,7 @@ web/
| استقرار روی شبکهٔ خصوصی | `DEPLOY_ALLOW_PRIVATE_HOSTS` | `false` (پیش‌فرض)؛ `true` فقط برای مقصدی در شبکهٔ خصوصی که قابل اعتماد است | | استقرار روی شبکهٔ خصوصی | `DEPLOY_ALLOW_PRIVATE_HOSTS` | `false` (پیش‌فرض)؛ `true` فقط برای مقصدی در شبکهٔ خصوصی که قابل اعتماد است |
| MCP gateway | `MCP_GATEWAY_ENABLED` | `true` (پیش‌فرض) \| `false` | | MCP gateway | `MCP_GATEWAY_ENABLED` | `true` (پیش‌فرض) \| `false` |
| پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` | | پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` |
| اعتبار اولیهٔ سازمان‌های جدید | `BILLING_STARTING_CREDIT` | عدد؛ پیش‌فرض `0` (یعنی به سازمان جدید اعتباری داده نمی‌شود) |
اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و
adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند. adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند.
@@ -300,6 +303,8 @@ refresh tokenها فقط encrypted at rest ذخیره می‌شوند و هیچ
| `sandbox-gateway` | متوقف‌کردن sessionهای بی‌استفاده، حذف containerهای یتیم با label `smart-org.*`، و ثبت audit | `pnpm worker:sandbox-gateway` | | `sandbox-gateway` | متوقف‌کردن sessionهای بی‌استفاده، حذف containerهای یتیم با label `smart-org.*`، و ثبت audit | `pnpm worker:sandbox-gateway` |
| `delivery` | اجرای pipeline runهای در صف (از checkout تا route) و بررسی ریموت‌ها برای pushهای بیرونی هر `DELIVERY_POLL_SECONDS` | `pnpm worker:delivery` | | `delivery` | اجرای pipeline runهای در صف (از checkout تا route) و بررسی ریموت‌ها برای pushهای بیرونی هر `DELIVERY_POLL_SECONDS` | `pnpm worker:delivery` |
**پیش‌پرداخت:** پاس `ai-runtime` تسک‌های سازمانی را که کیف پول آن‌ها صفر، منفی یا نامشخص است، پیش از خرج کردن هر turn متوقف می‌کند. این تسک‌ها پس از شارژ دستی ادامه داده می‌شوند.
**MCP gateway** یک سرویس مداوم است که با `pnpm mcp:dev` اجرا می‌شود. عامل‌های بیرونی AI با توکن‌های محدود به یک سازمان به آن وصل می‌شوند. جزئیات در `services/mcp-gateway/README.md`. **MCP gateway** یک سرویس مداوم است که با `pnpm mcp:dev` اجرا می‌شود. عامل‌های بیرونی AI با توکن‌های محدود به یک سازمان به آن وصل می‌شوند. جزئیات در `services/mcp-gateway/README.md`.
هر دستور worker **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای مستمر `--loop` بدهید: هر دستور worker **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای مستمر `--loop` بدهید:
@@ -0,0 +1,82 @@
"""prepaid credit ledger, task diff/merged sha, default employee cap 7
Revision ID: c4d5e6f7a8b9
Revises: b9c0d1e2f3a4
Create Date: 2026-10-14 12:00:00.000000
Owned by the v2 prepaid-credit workstream (todo-v2-final.md T1, decisions D-A and
D-B). ``credit_transactions`` is the wallet history; ``usage_event_id`` is unique,
so one usage row debits the wallet at most once. ``last_diff`` and ``merged_sha``
back the task page. The default employee cap moves from 5 to 7.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c4d5e6f7a8b9"
down_revision: str | None = "b9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"credit_transactions",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("balance_after", sa.Float(), nullable=False),
sa.Column("reason", sa.String(length=500), nullable=False, server_default=""),
sa.Column("actor_user_id", sa.String(length=32), nullable=True),
sa.Column("usage_event_id", sa.String(length=32), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("usage_event_id", name="uq_credit_transactions_usage_event_id"),
)
op.create_index(
"ix_credit_transactions_organization_id", "credit_transactions", ["organization_id"]
)
op.create_index(
"ix_credit_tx_org_created", "credit_transactions", ["organization_id", "created_at"]
)
with op.batch_alter_table("task_pipeline_state") as batch:
batch.add_column(sa.Column("last_diff", sa.Text(), nullable=False, server_default=""))
batch.add_column(
sa.Column("merged_sha", sa.String(length=64), nullable=False, server_default="")
)
with op.batch_alter_table("org_policies") as batch:
batch.alter_column(
"max_employees",
existing_type=sa.Integer(),
existing_nullable=False,
server_default="7",
)
def downgrade() -> None:
with op.batch_alter_table("org_policies") as batch:
batch.alter_column(
"max_employees",
existing_type=sa.Integer(),
existing_nullable=False,
server_default="5",
)
with op.batch_alter_table("task_pipeline_state") as batch:
batch.drop_column("merged_sha")
batch.drop_column("last_diff")
op.drop_index("ix_credit_tx_org_created", table_name="credit_transactions")
op.drop_index("ix_credit_transactions_organization_id", table_name="credit_transactions")
op.drop_table("credit_transactions")
@@ -209,6 +209,7 @@ def _build_manager(
RateCardRepository, RateCardRepository,
) )
from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import ( from app.infrastructure.repositories.engine import (
EmployeeProfileRepository, EmployeeProfileRepository,
PipelineStateRepository, PipelineStateRepository,
@@ -235,6 +236,7 @@ def _build_manager(
profiles=EmployeeProfileRepository(session), profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session), states=PipelineStateRepository(session),
runner=runner, runner=runner,
wallets=CreditWalletRepository(session),
) )
+31 -1
View File
@@ -28,6 +28,7 @@ from app.application.ports.realtime import (
task_channel, task_channel,
) )
from app.core.config import settings from app.core.config import settings
from app.domain.workforce.pipeline import PipelineKey, Stage, coarse_status
@dataclass(slots=True, kw_only=True) @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, RateCardRepository,
) )
from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import ( from app.infrastructure.repositories.engine import (
EmployeeProfileRepository, EmployeeProfileRepository,
PipelineStateRepository, PipelineStateRepository,
@@ -109,6 +110,11 @@ CHAT_MAX_ATTEMPTS = 2
#: The message a new organization sees when the owner has granted nothing yet. #: The message a new organization sees when the owner has granted nothing yet.
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه" 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 per role, in the plan's order (strongest / balanced / fast).
BOOTSTRAP_TIERS: dict[RoleKey, str] = { BOOTSTRAP_TIERS: dict[RoleKey, str] = {
RoleKey.MANAGER: "strong", RoleKey.MANAGER: "strong",
@@ -223,6 +229,7 @@ class ManagerTurns:
states: PipelineStateRepository | None = None, states: PipelineStateRepository | None = None,
runner: TurnRunner | None = None, runner: TurnRunner | None = None,
feed=None, feed=None,
wallets: CreditWalletRepository | None = None,
) -> None: ) -> None:
self.provider = provider self.provider = provider
self.employees = employees self.employees = employees
@@ -239,6 +246,7 @@ class ManagerTurns:
self.states = states self.states = states
self.runner = runner self.runner = runner
self.feed = feed self.feed = feed
self.wallets = wallets
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3.6 bootstrap # # 3.6 bootstrap #
@@ -404,6 +412,17 @@ class ManagerTurns:
user_message = await self.messages.create( user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content) 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) employee = await self._manager_of(organization_id, employee_id)
extra = "\n\n".join( extra = "\n\n".join(
part part
@@ -492,6 +511,13 @@ class ManagerTurns:
) )
return user_message, assistant_message 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( def _chat_context(
self, self,
*, *,
@@ -1083,6 +1109,7 @@ class ManagerTurns:
__all__ = [ __all__ = [
"BOOTSTRAP_TIERS", "BOOTSTRAP_TIERS",
"CHAT_NO_CREDIT_MESSAGE",
"CHAT_TURN_TIMEOUT_SECONDS", "CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES", "HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE", "NO_MODEL_GRANTED_MESSAGE",
+100 -12
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal
from app.application.engine.gates import GateOutcome, GateRunner from app.application.engine.gates import GateOutcome, GateRunner
from app.application.engine.manager import ManagerOutcome, ManagerTurns, _pick_granted_model 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.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView 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.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings 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.base import utcnow
from app.domain.workforce.engine import ( from app.domain.workforce.engine import (
MAX_TRANSIENT_RETRIES, MAX_TRANSIENT_RETRIES,
@@ -43,8 +45,18 @@ from app.domain.workforce.engine import (
GateName, GateName,
is_transient, 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 ( from app.domain.workforce.pipeline import (
TERMINAL_STAGES,
Outcome, Outcome,
Stage, Stage,
TaskPipelineState, 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. #: address; the *name* is the employee's, which is what a reviewer reads.
ENGINE_COMMIT_EMAIL = "platform@smart-org.local" ENGINE_COMMIT_EMAIL = "platform@smart-org.local"
#: Persian reason shown when work pauses because the money ran out. #: Persian reason shown when work pauses because the credit ran out (prepaid, D-B).
BUDGET_PAUSED_MESSAGE = "اعتبار سازمان برای ادامهٔ کار کافی نیست؛ کار موقتاً متوقف شد." BUDGET_PAUSED_MESSAGE = (
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد."
)
#: The stage a turn has to be at for each manager mode. #: The stage a turn has to be at for each manager mode.
_MANAGER_STAGES: frozenset[Stage] = frozenset( _MANAGER_STAGES: frozenset[Stage] = frozenset(
@@ -426,7 +440,14 @@ class Scheduler:
same_count=state.same_diff_count, same_count=state.same_diff_count,
diff_text=diff.text, 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: if not progress.ok:
await self._send_back(state, task, progress.detail) await self._send_back(state, task, progress.detail)
report.failed += 1 report.failed += 1
@@ -530,6 +551,7 @@ class Scheduler:
return return
final_sha = pushed.sha or merged.sha or sha 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._move(state, task, Stage.COMPLETED)
await self.repo_workspace.remove_worktree(worktree=worktree) await self.repo_workspace.remove_worktree(worktree=worktree)
if task.project_id: if task.project_id:
@@ -622,16 +644,14 @@ class Scheduler:
async def _budget_reason(self, task: Task) -> str | None: async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may. """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 Billing is prepaid: the platform owner credits the wallet, and every usage
from usage. A zero balance is therefore the normal starting state of every row debits it. Zero means "no credit left", so a zero, negative or missing
org, not "out of money", so only an overdrawn wallet (below zero) pauses wallet pauses work before a turn is spent. The per-task cap and the org's
work. The owner's spend lever is the org's monthly credit budget. monthly budget still apply on top of that.
""" """
if self.wallets is not None: if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id) wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be if wallet is None or wallet.balance <= 0:
# frozen out of its first task. A zero balance is not a pause either.
if wallet is not None and wallet.balance < 0:
return BUDGET_PAUSED_MESSAGE return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0: if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id) used = await self.spend.spend_for_task(task.id)
@@ -648,6 +668,74 @@ class Scheduler:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد." return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None 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( async def _retry_or_fail(
self, state: TaskPipelineState, task: Task, message: str, kind: str self, state: TaskPipelineState, task: Task, message: str, kind: str
) -> None: ) -> 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 __future__ import annotations
from app.application.engine.events import emit_task_step
from app.core.errors import ConflictError, NotFoundError from app.core.errors import ConflictError, NotFoundError
from app.domain.workforce.pipeline import ( from app.domain.workforce.pipeline import (
SIDE_STAGES, SIDE_STAGES,
@@ -46,14 +47,14 @@ async def advance(
Raises ``ConflictError`` when the transition is illegal or when another Raises ``ConflictError`` when the transition is illegal or when another
worker moved the task first, and ``NotFoundError`` when the task has no 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): if not can_transition_stage(expected_from, to):
raise ConflictError( raise ConflictError(
f"گذار نابجا از {expected_from.value} به {to.value}." f"گذار نابجا از {expected_from.value} به {to.value}."
) )
try: 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: except UnknownTaskError as exc:
raise NotFoundError("این کار وضعیت موتور ندارد.") from exc raise NotFoundError("این کار وضعیت موتور ندارد.") from exc
except InvalidTransitionError as exc: except InvalidTransitionError as exc:
@@ -62,6 +63,15 @@ async def advance(
raise ConflictError( raise ConflictError(
"این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید." "این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید."
) from exc ) 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( async def park_dependents(
@@ -17,6 +17,7 @@ from app.application.ports import PaymentProviderPort
from app.application.ports.realtime import emit_wallet_changed from app.application.ports.realtime import emit_wallet_changed
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.domain.billing.models import ( from app.domain.billing.models import (
CreditKind,
CreditWallet, CreditWallet,
Invoice, Invoice,
Payment, Payment,
@@ -31,6 +32,7 @@ from app.infrastructure.repositories.billing import (
PlanRepository, PlanRepository,
SubscriptionRepository, SubscriptionRepository,
) )
from app.infrastructure.repositories.credit import CreditLedgerRepository
#: A tenant may not buy an arbitrary amount of credit in one action. #: A tenant may not buy an arbitrary amount of credit in one action.
MIN_TOPUP = 1.0 MIN_TOPUP = 1.0
@@ -56,6 +58,8 @@ class BillingService:
self.subscriptions = subscriptions self.subscriptions = subscriptions
self.ledger = ledger self.ledger = ledger
self.payment_provider = payment_provider self.payment_provider = payment_provider
# Settled payments are credited through the credit ledger, in the wallets' session.
self.credit_ledger = CreditLedgerRepository(wallets.session)
# -- reads --------------------------------------------------------------- # -- reads ---------------------------------------------------------------
@@ -107,6 +111,8 @@ class BillingService:
) -> CreditWallet: ) -> CreditWallet:
"""Grant (or, for a platform owner, claw back) credit. """Grant (or, for a platform owner, claw back) credit.
Superseded by the owner-only ``CreditService``, which writes the credit ledger.
``allow_negative`` is reserved for the platform owner console; an org ``allow_negative`` is reserved for the platform owner console; an org
admin may only ever add credit. admin may only ever add credit.
""" """
@@ -171,7 +177,9 @@ class BillingService:
This is the only path that turns money into credit, so it is idempotent This is the only path that turns money into credit, so it is idempotent
with respect to status: a payment that is already final is returned with respect to status: a payment that is already final is returned
untouched rather than credited twice. untouched rather than credited twice. The credit is one ``adjustment`` row in
the credit ledger, written in this session, so it shows in the owner's credit
history and updates ``last_credit_at``.
""" """
payment = next( payment = next(
(p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id), (p for p in await self.payments.list_for_org(organization_id) if p.id == payment_id),
@@ -183,12 +191,15 @@ class BillingService:
return payment return payment
if succeeded: if succeeded:
wallet = await self.wallets.adjust( credit = await self.credit_ledger.apply(
organization_id=organization_id, delta=payment.amount organization_id=organization_id,
amount=payment.amount,
kind=CreditKind.ADJUSTMENT,
reason="billing/settle-payment",
) )
final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED) final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED)
await emit_wallet_changed( await emit_wallet_changed(
organization_id=organization_id, balance=wallet.balance organization_id=organization_id, balance=credit.balance_after
) )
else: else:
final = await self.payments.set_status(payment_id, PaymentStatus.FAILED) final = await self.payments.set_status(payment_id, PaymentStatus.FAILED)
@@ -0,0 +1,136 @@
"""Prepaid credit: the platform owner's top-ups, the history and the balances.
An organization's wallet is only ever moved through ``CreditLedgerRepository``, so every
change has a history row. This service is the owner's side of that ledger: it credits one
organization by hand, reads its manual history, lists every organization's balance for the
owner's credit page, and grants the optional starting credit to a new organization. Usage
debits are not written here; ``UsageLedgerRepository.record`` writes them.
Only the platform owner may call the write methods and the cross-organization ``balances``.
The routers enforce that; this service does not look at who is calling.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.billing.models import CreditKind, CreditTransaction
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.identity import OrganizationRepository
#: The most one owner action may credit at once. A bigger top-up is made in steps.
MAX_OWNER_CREDIT = 100_000.0
#: The reason is required and stored with the history row (at most 500 characters).
_REASON_MIN = 3
_REASON_MAX = 500
#: The entries a history page shows: what someone did to the wallet, not usage debits.
_MANUAL_KINDS = (CreditKind.OWNER_CREDIT, CreditKind.STARTING_CREDIT, CreditKind.ADJUSTMENT)
@dataclass(slots=True, kw_only=True)
class OrgBalance:
"""One organization's row on the platform owner's credit page."""
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None
class CreditService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.ledger = CreditLedgerRepository(session)
self.wallets = CreditWalletRepository(session)
self.orgs = OrganizationRepository(session)
self.states = PipelineStateRepository(session)
async def owner_credit(
self,
*,
organization_id: str,
amount: float,
reason: str,
actor_user_id: str,
) -> CreditTransaction:
"""Credit one organization's wallet on the platform owner's word."""
if await self.orgs.get(organization_id) is None:
raise NotFoundError("سازمان یافت نشد.")
if not 0 < amount <= MAX_OWNER_CREDIT:
raise ValidationError(
f"مقدار اعتبار باید بیشتر از صفر و حداکثر {MAX_OWNER_CREDIT:g} باشد."
)
clean_reason = reason.strip()
if not _REASON_MIN <= len(clean_reason) <= _REASON_MAX:
raise ValidationError("دلیل افزودن اعتبار لازم است.")
transaction = await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.OWNER_CREDIT,
reason=clean_reason,
actor_user_id=actor_user_id,
)
# Live push: the sidebar credit badge follows the wallet as it moves.
await emit_wallet_changed(
organization_id=organization_id, balance=transaction.balance_after
)
return transaction
async def history(self, organization_id: str, *, limit: int = 50) -> list[CreditTransaction]:
"""One organization's manual credit history, newest first. Usage debits are left out."""
return await self.ledger.history(organization_id, kinds=_MANUAL_KINDS, limit=limit)
async def balances(self) -> list[OrgBalance]:
"""Every organization's balance, paused task count and last credit date.
Cross-organization on purpose: only the platform owner's credit page may call it.
A missing wallet is shown as zero.
"""
paused = await self.states.count_at_by_org(Stage.PAUSED)
last_credit = await self.ledger.last_credit_at_by_org()
rows: list[OrgBalance] = []
for org in await self.orgs.list_all():
wallet = await self.wallets.get_for_org(org.id)
rows.append(
OrgBalance(
organization_id=org.id,
name=org.name,
slug=org.slug,
balance=wallet.balance if wallet else 0.0,
currency=wallet.currency if wallet else "USD",
paused_tasks=paused.get(org.id, 0),
last_credit_at=last_credit.get(org.id),
)
)
return rows
async def grant_starting_credit(self, organization_id: str) -> CreditTransaction | None:
"""Give a new organization the configured starting credit, if one is set.
``BILLING_STARTING_CREDIT`` defaults to 0, so by default nothing is granted and the
owner credits new organizations by hand.
"""
amount = float(getattr(settings, "billing_starting_credit", 0.0))
if amount <= 0:
return None
return await self.ledger.apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.STARTING_CREDIT,
reason="اعتبار آغازین",
)
@@ -45,8 +45,9 @@ TIER_LABELS: dict[str, str] = {
#: Policy defaults for an organization that has never been configured. Kept in #: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org #: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first. #: without writing a row first. D-A: the default cap is 7 employees per
DEFAULT_MAX_EMPLOYEES = 5 #: organization; ``settings.engine_max_employees`` stays the upper bound.
DEFAULT_MAX_EMPLOYEES = 7
@dataclass(slots=True) @dataclass(slots=True)
@@ -221,6 +221,11 @@ class OrgSetupService:
) )
) )
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD")) self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await self.session.flush()
from app.application.services.credit_service import CreditService
await CreditService(self.session).grant_starting_credit(org.id)
# -- create the single app ------------------------------------------- # -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults) project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
@@ -32,6 +32,7 @@ from app.application.ports.realtime import (
emit_task_created, emit_task_created,
emit_task_status, emit_task_status,
emit_usage_recorded, emit_usage_recorded,
emit_wallet_changed,
) )
from app.core.errors import ( from app.core.errors import (
ConflictError, ConflictError,
@@ -61,6 +62,13 @@ from app.domain.workforce.models import (
ToolCallStatus, ToolCallStatus,
can_transition_run, can_transition_run,
) )
from app.domain.workforce.pipeline import (
Risk,
Stage,
TaskKind,
TaskPipelineState,
resolve_pipeline,
)
from app.domain.workforce.tools import ( from app.domain.workforce.tools import (
TOOL_CATALOG, TOOL_CATALOG,
ToolSpec, ToolSpec,
@@ -74,6 +82,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository, RoutingPolicyRepository,
) )
from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.projects import ProjectRepository from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import ( from app.infrastructure.repositories.workforce import (
AgentTemplateRepository, AgentTemplateRepository,
@@ -94,6 +103,12 @@ from app.infrastructure.repositories.workforce import (
#: non-zero placeholder and mark it in the usage metadata. #: non-zero placeholder and mark it in the usage metadata.
_UNPRICED = 0.0000001 _UNPRICED = 0.0000001
#: Said when the legacy "run" button is pressed on a task the engine owns.
_ENGINE_OWNS_TASK = (
"این کار را موتور خودکار اجرا می‌کند؛ "
"برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید."
)
_SYSTEM_PROMPT = ( _SYSTEM_PROMPT = (
"تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. " "تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. "
"هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن." "هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن."
@@ -126,7 +141,7 @@ async def record_model_usage(
card = await rate_cards.get_by_unit(unit) card = await rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0 internal_cost = card.internal_cost_estimate if card else 0.0
await ledger.record( balance = await ledger.record(
usage=UsageRecord( usage=UsageRecord(
organization_id=organization_id, organization_id=organization_id,
project_id=project_id, project_id=project_id,
@@ -141,6 +156,9 @@ async def record_model_usage(
metadata_json={"priced": card is not None}, metadata_json={"priced": card is not None},
) )
) )
if balance is not None:
# The debit moved the wallet: the sidebar badge follows it live.
await emit_wallet_changed(organization_id=organization_id, balance=balance)
# Live push: the billing page and the sidebar credit badge react to # Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load. # consumption as it happens instead of at the next page load.
await emit_usage_recorded( await emit_usage_recorded(
@@ -406,6 +424,7 @@ class WorkforceService:
""" """
from app.application.container_org import build_memory, build_model_grants from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import ( from app.infrastructure.repositories.engine import (
EmployeeProfileRepository, EmployeeProfileRepository,
PipelineStateRepository, PipelineStateRepository,
@@ -426,6 +445,7 @@ class WorkforceService:
memory=build_memory(session), memory=build_memory(session),
profiles=EmployeeProfileRepository(session), profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session), states=PipelineStateRepository(session),
wallets=CreditWalletRepository(session),
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -443,6 +463,13 @@ class WorkforceService:
thread_id: str | None = None, thread_id: str | None = None,
assigned_employee_id: str | None = None, assigned_employee_id: str | None = None,
) -> Task: ) -> Task:
"""A user order to the manager: the task enters the engine at manager review.
The task gets a pipeline state at ``pending_manager_review``, so the scheduler's
triage turn takes it from there and picks the real pipeline and assignee (D-D).
Without a state the legacy one-shot drain would run it outside the engine. MCP
``tasks_create`` calls this method, so its tasks enter the engine too.
"""
if not title.strip(): if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.") raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id) await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
@@ -451,6 +478,7 @@ class WorkforceService:
await self.get_employee_or_404( await self.get_employee_or_404(
organization_id=organization_id, employee_id=assigned_employee_id organization_id=organization_id, employee_id=assigned_employee_id
) )
project_id = project_id or await self._project_id_for(organization_id)
task = await self.tasks.create( task = await self.tasks.create(
Task( Task(
organization_id=organization_id, organization_id=organization_id,
@@ -462,6 +490,21 @@ class WorkforceService:
created_by_user_id=created_by_user_id, created_by_user_id=created_by_user_id,
) )
) )
await PipelineStateRepository(self.tasks.session).create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=None,
task_kind=TaskKind.FEATURE,
has_active_reviewer=False,
),
kind=TaskKind.FEATURE,
risk=Risk.MEDIUM,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
# Live push: the tasks list and the manager-chat sidebar update the # Live push: the tasks list and the manager-chat sidebar update the
# moment the task exists, whoever created it and from where. # moment the task exists, whoever created it and from where.
await emit_task_created( await emit_task_created(
@@ -472,6 +515,16 @@ class WorkforceService:
) )
return task return task
async def _project_id_for(self, organization_id: str) -> str | None:
"""The project a project-less order lands in: the one its employees work in.
Mirrors ``ManagerTurns._project_id_for``, so a task made here and a task the
manager makes from a chat get the same project.
"""
for employee in await self.employees.list_for_org(organization_id, limit=1):
return employee.project_id
return None
async def list_tasks( async def list_tasks(
self, self,
organization_id: str, organization_id: str,
@@ -506,6 +559,9 @@ class WorkforceService:
employee_id: str | None = None, employee_id: str | None = None,
) -> Run: ) -> Run:
task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id) task = await self.get_task_or_404(organization_id=organization_id, task_id=task_id)
if await PipelineStateRepository(self.tasks.session).get(task.id) is not None:
# The engine owns this task; its own buttons (pause, resume, cancel, answer) drive it.
raise ConflictError(_ENGINE_OWNS_TASK)
if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED): if task.status in (TaskStatus.DONE, TaskStatus.CANCELLED):
raise ConflictError("این کار قبلاً بسته شده است.") raise ConflictError("این کار قبلاً بسته شده است.")
+6
View File
@@ -194,6 +194,12 @@ class Settings(BaseSettings):
git_allow_file_protocol: bool = False git_allow_file_protocol: bool = False
# <<< agent-3 # <<< agent-3
# >>> integration (billing): add settings ONLY between these markers
#: Credit given to every new organization at creation (prepaid). 0 means the
#: platform owner credits new organizations by hand.
billing_starting_credit: float = 0.0
# <<< integration
@property @property
def cors_origin_list(self) -> list[str]: def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()] return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
+3
View File
@@ -63,6 +63,9 @@ class AuditAction:
PROJECT_CREATE = "project.create" PROJECT_CREATE = "project.create"
PROJECT_DELETE = "project.delete" PROJECT_DELETE = "project.delete"
TASK_CREATE = "task.create" TASK_CREATE = "task.create"
#: A person paused, resumed, cancelled or answered an engine task. The metadata
#: names the action only; an answer's text is never written to the audit trail.
TASK_CONTROL = "task.control"
RUN_CANCEL = "run.cancel" RUN_CANCEL = "run.cancel"
#: A platform owner triaged an error in the console. Auditable so "who #: A platform owner triaged an error in the console. Auditable so "who
#: decided this bug was fixed?" has an answer. #: decided this bug was fixed?" has an answer.
+28
View File
@@ -83,6 +83,34 @@ class CreditWallet:
self.updated_at = utcnow() self.updated_at = utcnow()
class CreditKind(StrEnum):
"""Why a wallet's balance moved."""
OWNER_CREDIT = "owner_credit"
STARTING_CREDIT = "starting_credit"
USAGE = "usage"
ADJUSTMENT = "adjustment"
@dataclass(slots=True, kw_only=True)
class CreditTransaction:
"""One change to a wallet's balance, with the balance it left behind.
``amount`` is signed: a credit is positive, a debit is negative.
``usage_event_id`` is set on usage debits only, so one usage row debits once.
"""
organization_id: str
kind: CreditKind
amount: float
balance_after: float
reason: str = ""
actor_user_id: str | None = None
usage_event_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True) @dataclass(slots=True, kw_only=True)
class UsageEvent: class UsageEvent:
"""One billable unit of work. Append-only.""" """One billable unit of work. Append-only."""
@@ -441,6 +441,10 @@ class TaskPipelineState:
same_diff_count: int = 0 same_diff_count: int = 0
retry_count: int = 0 retry_count: int = 0
waiver_reason: str = "" waiver_reason: str = ""
#: The latest accepted diff (the writer caps it); the task page shows it.
last_diff: str = ""
#: The sha the merge landed on, once the task is COMPLETED.
merged_sha: str = ""
parent_task_id: str | None = None parent_task_id: str | None = None
depends_on: list[str] = field(default_factory=list) depends_on: list[str] = field(default_factory=list)
version: int = 0 version: int = 0
+1
View File
@@ -1023,6 +1023,7 @@ class QualityMetricRow(Base):
# parallel agents never edit the same file). Imported last so every table is # parallel agents never edit the same file). Imported last so every table is
# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate. # 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_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_engine as _models_engine # noqa: E402,F401
from app.infrastructure.db import models_org as _models_org # 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, #: A waived test pass must always be explainable: the reason is stored,
#: never inferred (PLAN-v2 §3, green gate). #: never inferred (PLAN-v2 §3, green gate).
waiver_reason: Mapped[str] = mapped_column(Text, default="", nullable=False) 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( parent_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True 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( organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True 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). #: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True) monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) 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.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord
from app.domain.ai_control.models import RateUnit from app.domain.ai_control.models import RateUnit
from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace
from app.domain.base import new_id
from app.infrastructure.db.models import ( from app.infrastructure.db.models import (
AuditEventRow, AuditEventRow,
ErrorEventRow, ErrorEventRow,
@@ -18,6 +19,7 @@ from app.infrastructure.db.models import (
TraceRow, TraceRow,
UsageEventRow, UsageEventRow,
) )
from app.infrastructure.repositories.credit import CreditLedgerRepository
def _audit_to_domain(row: AuditEventRow) -> AuditEvent: def _audit_to_domain(row: AuditEventRow) -> AuditEvent:
@@ -107,14 +109,26 @@ class AuditRepository:
class UsageLedgerRepository: 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: def __init__(self, session: AsyncSession) -> None:
self.session = session 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) total = usage.total_price or (usage.quantity * usage.unit_price)
row = UsageEventRow( row = UsageEventRow(
id=new_id(),
organization_id=usage.organization_id, organization_id=usage.organization_id,
project_id=usage.project_id, project_id=usage.project_id,
user_id=usage.user_id, user_id=usage.user_id,
@@ -129,6 +143,12 @@ class UsageLedgerRepository:
) )
self.session.add(row) self.session.add(row)
await self.session.flush() 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( async def breakdown(
self, 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, TaskKind,
TaskPipelineState, 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 ( from app.infrastructure.db.models_engine import (
EmployeeProfileRow, EmployeeProfileRow,
EngineTurnRow, EngineTurnRow,
@@ -75,6 +75,8 @@ _PATCHABLE = frozenset(
"same_diff_count", "same_diff_count",
"retry_count", "retry_count",
"waiver_reason", "waiver_reason",
"last_diff",
"merged_sha",
"parent_task_id", "parent_task_id",
"depends_on", "depends_on",
} }
@@ -97,6 +99,8 @@ def _state(row: TaskPipelineStateRow) -> TaskPipelineState:
same_diff_count=row.same_diff_count, same_diff_count=row.same_diff_count,
retry_count=row.retry_count, retry_count=row.retry_count,
waiver_reason=row.waiver_reason, waiver_reason=row.waiver_reason,
last_diff=row.last_diff,
merged_sha=row.merged_sha,
parent_task_id=row.parent_task_id, parent_task_id=row.parent_task_id,
depends_on=list(row.depends_on or []), depends_on=list(row.depends_on or []),
version=row.version, version=row.version,
@@ -185,6 +189,8 @@ class PipelineStateRepository:
same_diff_count=state.same_diff_count, same_diff_count=state.same_diff_count,
retry_count=state.retry_count, retry_count=state.retry_count,
waiver_reason=state.waiver_reason, waiver_reason=state.waiver_reason,
last_diff=state.last_diff,
merged_sha=state.merged_sha,
parent_task_id=state.parent_task_id, parent_task_id=state.parent_task_id,
depends_on=list(state.depends_on), depends_on=list(state.depends_on),
version=state.version, version=state.version,
@@ -268,6 +274,27 @@ class PipelineStateRepository:
) )
return int((await self.session.execute(stmt)).scalar_one()) 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 # employee_profiles
@@ -410,6 +437,21 @@ class EngineTurnRepository:
) )
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()] 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( async def finish(
self, self,
turn_id: str, turn_id: str,
@@ -39,7 +39,7 @@ class DefaultGrantRecord:
@dataclass(slots=True, kw_only=True) @dataclass(slots=True, kw_only=True)
class OrgPolicyRecord: class OrgPolicyRecord:
organization_id: str organization_id: str
max_employees: int = 5 max_employees: int = 7
monthly_credit_budget: float | None = None monthly_credit_budget: float | None = None
automation_enabled: bool = True automation_enabled: bool = True
mcp_enabled: bool = True mcp_enabled: bool = True
@@ -510,6 +510,15 @@ class TaskRepository:
await self.session.flush() await self.session.flush()
return _task(row) 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: class RunRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
@@ -0,0 +1,144 @@
"""Owner credit console: ``/api/admin/credits`` and ``/api/admin/orgs/{org}/credit``.
Prepaid billing (D-B): the platform owner is the only one who puts credit into an
organization's wallet. Each credit writes one history row (``CreditService``), one audit row
and one live ``billing.wallet`` push, so the organization's credit badge follows the balance.
Owner-only at the router level, like ``admin_grants.py``. Nothing here names a model or a
provider, and no secret is returned. Organization members read their own balance through
``/api/organizations/{org}/billing``.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.credit_service import CreditService, OrgBalance
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditTransaction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import OrganizationRepository
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-credits"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OrgBalanceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
name: str
slug: str
balance: float
currency: str
paused_tasks: int
last_credit_at: datetime | None = None
class CreditRequest(BaseModel):
#: Checked by ``CreditService`` (a zero amount or a blank reason gets a Persian message).
amount: float
reason: str
class CreditTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
amount: float
balance_after: float
reason: str
created_at: datetime
def _balance_out(view: OrgBalance) -> OrgBalanceOut:
return OrgBalanceOut(**asdict(view))
def _transaction_out(transaction: CreditTransaction) -> CreditTransactionOut:
return CreditTransactionOut(
id=transaction.id,
kind=str(transaction.kind),
amount=transaction.amount,
balance_after=transaction.balance_after,
reason=transaction.reason,
created_at=transaction.created_at,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/credits", response_model=list[OrgBalanceOut])
async def list_balances(session: AsyncSession = Depends(get_session)) -> list[OrgBalanceOut]:
"""Every organization's balance, paused task count and last credit date."""
return [_balance_out(view) for view in await CreditService(session).balances()]
@router.post("/orgs/{org_id}/credit", response_model=CreditTransactionOut)
async def credit_org(
org_id: str,
payload: CreditRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CreditTransactionOut:
"""Add credit to one organization's wallet; the history and audit rows commit together."""
transaction = await CreditService(session).owner_credit(
organization_id=org_id,
amount=payload.amount,
reason=payload.reason,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.BILLING_CREDIT,
organization_id=org_id,
actor_user_id=owner.id,
target_type="credit_wallet",
# One wallet per organization, so the organization id names the wallet.
target_id=org_id,
metadata={
"amount": payload.amount,
"reason": transaction.reason,
"balance": transaction.balance_after,
},
ip=ip,
user_agent=ua,
)
return _transaction_out(transaction)
@router.get("/orgs/{org_id}/credit-history", response_model=list[CreditTransactionOut])
async def credit_history(
org_id: str,
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[CreditTransactionOut]:
"""One organization's manual credit history, newest first (usage debits are not listed)."""
if await OrganizationRepository(session).get(org_id) is None:
raise NotFoundError("سازمان یافت نشد.")
history = await CreditService(session).history(org_id, limit=limit)
return [_transaction_out(transaction) for transaction in history]
+45 -20
View File
@@ -1,8 +1,10 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout. """Billing endpoints: summary, usage ledger, invoices, payments and checkout.
Reading billing requires ``billing.view``; changing it requires Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the
``billing.manage``. A tenant can therefore never see — or move — another platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing
organization's money. requires ``billing.view``; ``checkout``, which only creates a pending payment, requires
``billing.manage``. A tenant can therefore never see or move another organization's money,
and an organization admin cannot give its own wallet credit.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,11 +15,16 @@ from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.realtime import emit_wallet_changed
from app.application.services.audit_service import record_audit from app.application.services.audit_service import record_audit
from app.application.services.billing_service import BillingService from app.application.services.billing_service import BillingService
from app.core.database import get_session from app.core.database import get_session
from app.core.errors import PermissionDeniedError, ValidationError
from app.domain.audit.models import AuditAction from app.domain.audit.models import AuditAction
from app.domain.billing.models import CreditKind
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.interfaces.http.deps import ( from app.interfaces.http.deps import (
OrgContext, OrgContext,
client_meta, client_meta,
@@ -27,6 +34,9 @@ from app.interfaces.http.deps import (
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"]) router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
#: Said to anyone who is not the platform owner and tries to move an organization's credit.
OWNER_ONLY_CREDIT = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schemas # Schemas
@@ -88,7 +98,9 @@ class PaymentOut(BaseModel):
class AddCreditRequest(BaseModel): class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.") amount: float = Field(
description="Platform owner only. Positive tops up; negative claws back."
)
class WalletOut(BaseModel): class WalletOut(BaseModel):
@@ -210,35 +222,47 @@ async def add_credit(
payload: AddCreditRequest, payload: AddCreditRequest,
request: Request, request: Request,
context: OrgContext = Depends(get_org_context), context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> WalletOut: ) -> WalletOut:
# A negative delta is a platform-owner-only clawback. """Add (or claw back) credit on the platform owner's word. Nobody else may (D-B).
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.") The change is one history row of kind ``adjustment``, so it shows in the owner's
else: credit history for this organization.
context.require(CAN_MANAGE_BILLING) """
if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
if payload.amount == 0:
raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.")
wallet = await service.add_credit( transaction = await CreditLedgerRepository(session).apply(
organization_id=context.org_id, organization_id=context.org_id,
amount=payload.amount, amount=payload.amount,
allow_negative=context.user.is_platform_owner, kind=CreditKind.ADJUSTMENT,
reason="billing/add-credit",
actor_user_id=context.user.id,
) )
if payload.amount < 0 and transaction.balance_after < 0:
# Refuse to leave the wallet in debt. The request's session rolls back on this
# error, so the balance change and its history row are both undone.
raise ValidationError("اعتبار کافی برای این کسر وجود ندارد.")
wallet = await CreditWalletRepository(session).ensure(context.org_id)
ip, ua = client_meta(request) ip, ua = client_meta(request)
await record_audit( await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id, session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet", actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id, target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance}, metadata={"amount": payload.amount, "balance": transaction.balance_after},
ip=ip, user_agent=ua, ip=ip, user_agent=ua,
) )
# Live push: the sidebar credit badge follows the wallet, whoever moved it.
await emit_wallet_changed(
organization_id=context.org_id, balance=transaction.balance_after
)
return WalletOut( return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low balance=transaction.balance_after,
currency=wallet.currency,
low_balance=transaction.balance_after <= wallet.low_balance_threshold,
) )
@@ -269,12 +293,13 @@ async def settle_payment(
service: BillingService = Depends(get_billing_service), service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PaymentOut: ) -> PaymentOut:
"""Finalise a payment. """Finalise a payment. Only the platform owner may: a succeeded payment credits the wallet.
In production the payment provider calls a webhook; this endpoint is the In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit. manual/dev equivalent. It is the only path that converts money into credit.
""" """
context.require(CAN_MANAGE_BILLING) if not context.user.is_platform_owner:
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
payment = await service.settle_payment( payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
) )
@@ -0,0 +1,670 @@
"""The AI workforce engine's API: status, task board, task detail and recent turns.
Every route resolves its tenant through ``get_org_context``. The reads are open to
every member of the organization, so the credit notice and the board work for
every role. The four actions on a task (pause, resume, cancel, answer) need the
``tasks.run`` capability and each writes one audit row. The team (view, hire by role
and tier, fire) is in this router too; hire and fire need ``employees.manage``.
Three things are load-bearing here:
* **Model identity never leaves.** No schema in this module has a field that can
hold a model id or name, a provider, an account, a container or a port. A turn
shows the employee's *name*, the role, the mode and the stage, never the model
that ran it. A future refactor cannot leak one without adding such a field.
* **Every query is tenant-scoped.** Tasks, states, turns, gates and runs are read
through repositories that filter by organization, and a task id that belongs to
another organization is a 404 (the same answer a missing id gets).
* **Bounded reads.** The board scans at most the newest 500 tasks of the
organization when it filters by stage; a turn excerpt is capped at 1,000 characters.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grants
from app.application.ports.model_grants import GrantedModel
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey, Run, Task
from app.domain.workforce.pipeline import Stage, TaskPipelineState, pipeline_stages
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
TaskRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context
router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"])
#: The most tasks a board reads, newest first (a stage filter looks only among these).
_BOARD_SCAN_LIMIT = 500
#: The most pipeline states read in one listing; the rest are looked up one by one.
_STATE_SCAN_LIMIT = 1000
#: The longest reply excerpt a turn carries. The full reply is not shown here.
_EXCERPT_CHARS = 1000
#: The most turns one task detail carries, and the most gate verdicts.
_DETAIL_LIMIT = 200
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class EngineStatusOut(BaseModel):
"""Whether the organization's engine can work, and how much of the team is in use."""
balance: float
currency: str
credit_ok: bool
automation_enabled: bool
max_employees: int
team_size: int
stage_counts: dict[str, int]
class EngineTaskOut(BaseModel):
"""One task on the board.
``stage``, ``pipeline`` and ``kind`` are null for a task the engine does not
own (a legacy task). ``cycle`` and ``max_cycles`` are 0 for those tasks too.
"""
id: str
title: str
status: str
stage: str | None
pipeline: str | None
kind: str | None
cycle: int
max_cycles: int
thread_id: str | None
assigned_employee_id: str | None
assignee_name: str | None
created_at: datetime
updated_at: datetime
class EngineTurnOut(BaseModel):
"""One employee turn: who ran, in which role and stage, and how it ended."""
id: str
run_id: str
role: str
mode: str
stage: str
employee_name: str | None
ok: bool
error_kind: str
tool_calls: int
reply_excerpt: str
started_at: datetime
finished_at: datetime | None
class EngineOrgTurnOut(EngineTurnOut):
"""A turn in the organization's recent list, with the task it belongs to."""
task_id: str
task_title: str
class EngineGateOut(BaseModel):
id: str
gate: str
ok: bool
detail: str
created_at: datetime
class EngineRunOut(BaseModel):
id: str
status: str
started_at: datetime | None
finished_at: datetime | None
class EngineTaskDetailOut(EngineTaskOut):
"""One task with its pipeline, its turns, its gate verdicts and its last diff."""
description: str
#: The stages of the task's pipeline in order; empty for a legacy task.
stages: list[str]
waiver_reason: str
branch: str
depends_on: list[str]
#: The sha the merge landed on, once the task is completed.
merged_sha: str
#: The last accepted diff (capped by the writer at 20,000 characters).
diff: str
turns: list[EngineTurnOut]
gates: list[EngineGateOut]
runs: list[EngineRunOut]
class EngineAnswerRequest(BaseModel):
"""A person's answer to the question a task is waiting on."""
content: str = Field(min_length=1, max_length=4000)
class TierOut(BaseModel):
tier: str
label: str
class TeamMemberOut(BaseModel):
"""One team member. ``tier`` is neutral: no model, provider or model id is shown."""
id: str
name: str
role_key: str
status: str
#: ``fast``, ``balanced``, ``strong``, or ``unknown`` when the model is not granted.
tier: str
busy_task_id: str | None
busy_task_title: str | None
hired_by: str
class TeamOut(BaseModel):
employees: list[TeamMemberOut]
#: The tiers the organization has granted models for, cheapest first.
tiers: list[TierOut]
#: The owner's cap on the headcount for this organization.
max_employees: int
count: int
class HireRequest(BaseModel):
"""A hire asks for a role and a tier. The model is chosen by the platform."""
role: RoleKey
tier: str = Field(min_length=1, max_length=20)
name: str = Field(default="", max_length=120)
# ---------------------------------------------------------------------------
# Mapping
# ---------------------------------------------------------------------------
#: Neutral tier labels, cheapest first: the order the team view lists them in.
_TIER_LABELS: dict[str, str] = {"fast": "سریع", "balanced": "متعادل", "strong": "قوی"}
def _task_out(
task: Task, state: TaskPipelineState | None, names: dict[str, str]
) -> EngineTaskOut:
return EngineTaskOut(
id=task.id,
title=task.title,
status=str(task.status),
stage=str(state.stage) if state else None,
pipeline=str(state.pipeline) if state else None,
kind=str(state.kind) if state else None,
cycle=state.cycle if state else 0,
max_cycles=state.max_cycles if state else 0,
thread_id=task.thread_id,
assigned_employee_id=task.assigned_employee_id,
assignee_name=names.get(task.assigned_employee_id or ""),
created_at=task.created_at,
updated_at=task.updated_at,
)
def _turn_out(turn: EngineTurn, names: dict[str, str]) -> EngineTurnOut:
return EngineTurnOut(
id=turn.id,
run_id=turn.run_id,
role=str(turn.role),
mode=str(turn.mode),
stage=str(turn.stage),
employee_name=names.get(turn.employee_id or ""),
ok=turn.ok,
error_kind=turn.error_kind,
tool_calls=turn.tool_calls,
reply_excerpt=turn.reply_excerpt[:_EXCERPT_CHARS],
started_at=turn.started_at,
finished_at=turn.finished_at,
)
async def _employee_names(
session: AsyncSession, organization_id: str, employee_ids: list[str | None]
) -> dict[str, str]:
"""Names of the employees a list refers to, read inside the organization only.
One listing covers the team in the common case; an id outside that listing is
looked up on its own, still scoped to the organization.
"""
wanted = {eid for eid in employee_ids if eid}
repo = EmployeeRepository(session)
employees: list[Employee] = await repo.list_for_org(organization_id, limit=500)
names = {e.id: e.name for e in employees if e.id in wanted}
for employee_id in wanted:
if employee_id not in names:
employee = await repo.get_for_org(organization_id, employee_id)
if employee is not None:
names[employee_id] = employee.name
return names
async def _task_titles(
session: AsyncSession, organization_id: str, task_ids: list[str]
) -> dict[str, str]:
"""Titles of the tasks a list refers to, read inside the organization only."""
wanted = set(task_ids)
repo = TaskRepository(session)
tasks: list[Task] = await repo.list_for_org(organization_id, limit=_BOARD_SCAN_LIMIT)
titles = {t.id: t.title for t in tasks if t.id in wanted}
for task_id in wanted:
if task_id not in titles:
task = await repo.get_for_org(organization_id, task_id)
if task is not None:
titles[task_id] = task.title
return titles
async def _granted_tiers(
session: AsyncSession, organization_id: str
) -> tuple[list[GrantedModel], dict[str, str]]:
"""The organization's granted models, and each one's neutral tier by model id."""
granted = await build_model_grants(session).granted_models(organization_id=organization_id)
return granted, {model.ai_model_id: model.tier for model in granted}
def _tier_list(granted: list[GrantedModel]) -> list[TierOut]:
present = {model.tier for model in granted}
return [
TierOut(tier=tier, label=label) for tier, label in _TIER_LABELS.items() if tier in present
]
async def _member_out(
session: AsyncSession,
organization_id: str,
employee: Employee,
tiers: dict[str, str],
) -> TeamMemberOut:
profile = await EmployeeProfileRepository(session).get(employee.id)
tier = "unknown"
busy_task_id: str | None = None
busy_task_title: str | None = None
hired_by = ""
if profile is not None:
tier = tiers.get(profile.ai_model_id or "", "unknown")
busy_task_id = profile.busy_task_id
hired_by = profile.hired_by
if busy_task_id:
busy = await TaskRepository(session).get_for_org(organization_id, busy_task_id)
busy_task_title = busy.title if busy is not None else None
return TeamMemberOut(
id=employee.id,
name=employee.name,
role_key=str(employee.role_key),
status=str(employee.status),
tier=tier,
busy_task_id=busy_task_id,
busy_task_title=busy_task_title,
hired_by=hired_by,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/status", response_model=EngineStatusOut)
async def engine_status(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineStatusOut:
"""The credit balance, the owner's policy, the team size and the stage counts.
``credit_ok`` is false for a missing wallet as well as for a zero balance,
because prepaid work needs credit to run (decision D-B).
"""
organization_id = context.org_id
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
return EngineStatusOut(
balance=wallet.balance if wallet is not None else 0.0,
currency=wallet.currency if wallet is not None else "USD",
credit_ok=wallet is not None and wallet.balance > 0,
automation_enabled=policy.automation_enabled,
max_employees=policy.max_employees,
team_size=await EmployeeRepository(session).count_for_org(organization_id),
stage_counts=await PipelineStateRepository(session).stage_counts(organization_id),
)
@router.get("/tasks", response_model=list[EngineTaskOut])
async def list_engine_tasks(
stage: Stage | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineTaskOut]:
"""The organization's tasks, newest first, with their stage when the engine owns them.
With ``stage`` only the tasks at that stage are returned, found among the newest
500 tasks of the organization.
"""
organization_id = context.org_id
states_repo = PipelineStateRepository(session)
states = {
s.task_id: s
for s in await states_repo.list_for_org(organization_id, limit=_STATE_SCAN_LIMIT)
}
rows: list[tuple[Task, TaskPipelineState | None]] = []
for task in await TaskRepository(session).list_for_org(
organization_id, limit=_BOARD_SCAN_LIMIT
):
state = states.get(task.id)
if state is None:
# A legacy task, or an engine task older than the newest states listed above.
state = await states_repo.get(task.id)
if stage is not None and (state is None or state.stage is not stage):
continue
rows.append((task, state))
if len(rows) >= limit:
break
names = await _employee_names(
session, organization_id, [task.assigned_employee_id for task, _ in rows]
)
return [_task_out(task, state, names) for task, state in rows]
@router.get("/tasks/{task_id}", response_model=EngineTaskDetailOut)
async def get_engine_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskDetailOut:
"""One task with its stages, turns, gate verdicts, runs, diff and merged sha."""
organization_id = context.org_id
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
turns = await EngineTurnRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
gates = await GateResultRepository(session).list_for_task(task.id, limit=_DETAIL_LIMIT)
runs: list[Run] = await RunRepository(session).list_for_task(task.id)
names = await _employee_names(
session,
organization_id,
[task.assigned_employee_id, *(turn.employee_id for turn in turns)],
)
base = _task_out(task, state, names)
return EngineTaskDetailOut(
**base.model_dump(),
description=task.description,
stages=[str(stage) for stage in pipeline_stages(state.pipeline)] if state else [],
waiver_reason=state.waiver_reason if state else "",
branch=state.branch if state else "",
depends_on=list(state.depends_on) if state else [],
merged_sha=state.merged_sha if state else "",
diff=state.last_diff if state else "",
turns=[_turn_out(turn, names) for turn in turns],
gates=[
EngineGateOut(
id=gate.id,
gate=str(gate.gate),
ok=gate.ok,
detail=gate.detail,
created_at=gate.created_at,
)
for gate in gates
],
runs=[
EngineRunOut(
id=run.id,
status=str(run.status),
started_at=run.started_at,
finished_at=run.finished_at,
)
for run in runs
],
)
@router.get("/turns", response_model=list[EngineOrgTurnOut])
async def list_recent_turns(
limit: int = Query(default=50, ge=1, le=200),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[EngineOrgTurnOut]:
"""The organization's newest turns across all its tasks, with each task's title."""
organization_id = context.org_id
turns = await EngineTurnRepository(session).list_for_org(organization_id, limit=limit)
names = await _employee_names(session, organization_id, [turn.employee_id for turn in turns])
titles = await _task_titles(session, organization_id, [turn.task_id for turn in turns])
return [
EngineOrgTurnOut(
**_turn_out(turn, names).model_dump(),
task_id=turn.task_id,
task_title=titles.get(turn.task_id, ""),
)
for turn in turns
]
# ---------------------------------------------------------------------------
# Actions on one task
# ---------------------------------------------------------------------------
async def _task_view(session: AsyncSession, organization_id: str, task_id: str) -> EngineTaskOut:
task = await TaskRepository(session).get_for_org(organization_id, task_id)
if task is None:
raise NotFoundError("کار یافت نشد.")
state = await PipelineStateRepository(session).get(task.id)
names = await _employee_names(session, organization_id, [task.assigned_employee_id])
return _task_out(task, state, names)
async def _control(
*,
action: Literal["pause", "resume", "cancel", "answer"],
task_id: str,
request: Request,
context: OrgContext,
session: AsyncSession,
answer: str = "",
) -> EngineTaskOut:
context.require(CAN_RUN_TASKS)
# Imported inside the handler: the container wires the whole engine, and the read
# routes above do not need it.
from app.application.container import build_scheduler
await build_scheduler(session).user_action(
organization_id=context.org_id,
task_id=task_id,
action=action,
user_id=context.user.id,
answer=answer,
)
ip, ua = client_meta(request)
# Only the action is written: an answer's text stays out of the audit trail.
await record_audit(
session,
action=AuditAction.TASK_CONTROL,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="task",
target_id=task_id,
metadata={"action": action},
ip=ip,
user_agent=ua,
)
return await _task_view(session, context.org_id, task_id)
@router.post("/tasks/{task_id}/pause", response_model=EngineTaskOut)
async def pause_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Stop a task before its next turn. Its stage is kept, so resume picks it up."""
return await _control(
action="pause", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/resume", response_model=EngineTaskOut)
async def resume_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Send a paused task back to triage. Refused while the organization has no credit."""
return await _control(
action="resume", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/cancel", response_model=EngineTaskOut)
async def cancel_task(
task_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""End a task. Its worktree is released and tasks waiting on it are parked."""
return await _control(
action="cancel", task_id=task_id, request=request, context=context, session=session
)
@router.post("/tasks/{task_id}/answer", response_model=EngineTaskOut)
async def answer_task(
task_id: str,
body: EngineAnswerRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> EngineTaskOut:
"""Answer the question a task waits on. Only a task at a decision accepts one."""
return await _control(
action="answer",
task_id=task_id,
request=request,
context=context,
session=session,
answer=body.content,
)
# ---------------------------------------------------------------------------
# Team: the view, hire by role and tier, fire
# ---------------------------------------------------------------------------
@router.get("/team", response_model=TeamOut)
async def get_team(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamOut:
"""The team, without the archived members, with each member's neutral tier."""
organization_id = context.org_id
granted, tiers = await _granted_tiers(session, organization_id)
policy = await build_model_grants(session).get_org_policy(organization_id)
employees = [
employee
for employee in await EmployeeRepository(session).list_for_org(organization_id, limit=500)
if employee.status is not EmployeeStatus.ARCHIVED
]
members = [await _member_out(session, organization_id, e, tiers) for e in employees]
return TeamOut(
employees=members,
tiers=_tier_list(granted),
max_employees=policy.max_employees,
count=len(members),
)
@router.post("/team/hire", response_model=TeamMemberOut, status_code=201)
async def hire_member(
body: HireRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Hire one employee for a role on a granted model of the requested tier."""
context.require(CAN_MANAGE_EMPLOYEES)
# Imported inside the handler, as for the task actions above.
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.hire(
organization_id=context.org_id,
role=body.role,
tier=body.tier,
name=body.name,
project_id=None,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"role_key": str(body.role), "tier": body.tier},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@router.post("/team/{employee_id}/fire", response_model=TeamMemberOut)
async def fire_member(
employee_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TeamMemberOut:
"""Archive one employee. The last active manager cannot be fired."""
context.require(CAN_MANAGE_EMPLOYEES)
from app.application.container import build_scheduler
employee = await build_scheduler(session).manager.fire(
organization_id=context.org_id, employee_id=employee_id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.EMPLOYEE_UPDATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="employee",
target_id=employee.id,
metadata={"action": "fire"},
ip=ip,
user_agent=ua,
)
_, tiers = await _granted_tiers(session, context.org_id)
return await _member_out(session, context.org_id, employee, tiers)
@@ -145,6 +145,11 @@ async def create_organization(
) )
# Every organization gets a wallet up front so billing views are never empty. # Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD")) session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# Prepaid: the optional starting credit (0 by default) goes through the ledger.
await session.flush()
from app.application.services.credit_service import CreditService
await CreditService(session).grant_starting_credit(org.id)
ip, ua = client_meta(request) ip, ua = client_meta(request)
await record_audit( await record_audit(
@@ -23,6 +23,7 @@ from app.interfaces.http.errors import register_exception_handlers
from app.interfaces.http.request_context import RequestContextMiddleware from app.interfaces.http.request_context import RequestContextMiddleware
from app.interfaces.http.routers import ( from app.interfaces.http.routers import (
admin, admin,
admin_credits,
admin_domains, admin_domains,
admin_grants, admin_grants,
admin_users, admin_users,
@@ -31,6 +32,7 @@ from app.interfaces.http.routers import (
auth, auth,
billing, billing,
delivery, delivery,
engine,
health, health,
invites, invites,
knowledge, knowledge,
@@ -82,6 +84,8 @@ def build_rpc_app() -> FastAPI:
application.include_router(memory.router, prefix="/api") application.include_router(memory.router, prefix="/api")
application.include_router(mcp_tokens.router, prefix="/api") application.include_router(mcp_tokens.router, prefix="/api")
application.include_router(admin_grants.router, prefix="/api") application.include_router(admin_grants.router, prefix="/api")
application.include_router(admin_credits.router, prefix="/api")
application.include_router(engine.router, prefix="/api")
application.include_router(delivery.router, prefix="/api") application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.router, prefix="/api") application.include_router(admin_domains.router, prefix="/api")
application.include_router(sandbox.admin_router, prefix="/api") application.include_router(sandbox.admin_router, prefix="/api")
+29
View File
@@ -0,0 +1,29 @@
"""Test helpers for the prepaid credit ledger.
Prepaid billing means an organization needs credit before engine work runs, so
tests that exercise that work fund the organization first. ``fund_org`` writes
the credit through the same repository the platform owner's endpoints use, so
the wallet and its history are written the real way.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.domain.billing.models import CreditKind, CreditTransaction
from app.infrastructure.repositories.credit import CreditLedgerRepository
async def fund_org(
session_factory: async_sessionmaker, organization_id: str, amount: float = 100.0
) -> CreditTransaction:
"""Credit an organization's wallet by ``amount`` as a labelled adjustment."""
async with session_factory() as session:
transaction = await CreditLedgerRepository(session).apply(
organization_id=organization_id,
amount=amount,
kind=CreditKind.ADJUSTMENT,
reason="test funding",
)
await session.commit()
return transaction
+36
View File
@@ -0,0 +1,36 @@
"""A pre-v2 task: a plain ``tasks`` row with no ``task_pipeline_state``.
Every task made since the engine owns direct tasks gets a pipeline state, so HTTP
``POST /tasks`` and MCP ``tasks_create`` no longer create one of these (decision D-D).
Tasks created before that have no state and still run through the legacy one-shot path
(``WorkforceService.start_run``). Tests of that path create their task with this helper.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.infrastructure.db.models import TaskRow
async def legacy_task(
session_factory: async_sessionmaker,
organization_id: str,
created_by_user_id: str,
*,
title: str = "کار قدیمی",
project_id: str | None = None,
assigned_employee_id: str | None = None,
) -> str:
"""Insert one task with no pipeline state and return its id."""
async with session_factory() as session:
row = TaskRow(
organization_id=organization_id,
project_id=project_id,
title=title,
assigned_employee_id=assigned_employee_id,
created_by_user_id=created_by_user_id,
)
session.add(row)
await session.commit()
return row.id
+205
View File
@@ -0,0 +1,205 @@
"""The owner's credit console: top-ups, an organization's history and the balances page.
What these tests defend:
* a credit from the platform owner moves the wallet, writes one history row, one audit row
that names the reason, and one live ``billing.wallet`` event with the new balance
* the history and the balances page show the credit under the right organization
* only the platform owner reaches the three endpoints: an organization owner and a plain user
get 403 and change nothing, an anonymous caller gets 401
* an unknown organization is a 404, and a zero or negative amount or a blank reason is refused
with a Persian message
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import select
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.domain.audit.models import AuditAction
from app.domain.base import new_id
from app.infrastructure.db.models import AuditEventRow, UserRow
from tests.conftest import auth_header
CREDITS = "/api/admin/credits"
ORGS = "/api/admin/orgs"
OWNER_EMAIL = "platform-owner@example.com"
REASON = "شارژ اولیهٔ سازمان"
class _Recorder:
"""Stands in for the realtime bus and keeps every event it is given."""
def __init__(self) -> None:
self.events: list[RealtimeEvent] = []
async def publish(self, event: RealtimeEvent) -> None:
self.events.append(event)
@pytest_asyncio.fixture
async def recorder():
"""Install a recording publisher for one test and put the previous one back."""
previous = get_publisher()
bus = _Recorder()
set_publisher(bus)
try:
yield bus
finally:
set_publisher(previous)
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
"""An organization the manager created through the wizard, as a customer would."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def test_the_owner_credit_moves_the_wallet_and_is_audited_and_pushed(
client, manager_token, platform_owner_token, seeded, session_factory, recorder
):
org_id = await _create_org(client, manager_token, "سازمان الف", "credit-alpha")
owner = auth_header(platform_owner_token)
credited = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": 25.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 200, credited.text
body = credited.json()
assert body["kind"] == "owner_credit"
assert body["amount"] == 25.0
assert body["balance_after"] == 25.0
assert body["reason"] == REASON
# The response carries only the history fields: no model, provider or secret.
assert set(body) == {"id", "kind", "amount", "balance_after", "reason", "created_at"}
balances = await client.get(CREDITS, headers=owner)
assert balances.status_code == 200, balances.text
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 25.0
assert row["currency"] == "USD"
assert row["paused_tasks"] == 0
assert row["last_credit_at"] is not None
history = await client.get(f"{ORGS}/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [h["reason"] for h in history.json()] == [REASON]
async with session_factory() as session:
owner_id = (
await session.execute(select(UserRow.id).where(UserRow.email == OWNER_EMAIL))
).scalar_one()
audits = (
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == AuditAction.BILLING_CREDIT,
AuditEventRow.organization_id == org_id,
)
)
).scalars().all()
assert len(audits) == 1
assert audits[0].actor_user_id == owner_id
assert audits[0].metadata_json["reason"] == REASON
assert audits[0].metadata_json["balance"] == 25.0
pushed = [e for e in recorder.events if e.type == "billing.wallet"]
assert pushed, "the owner's credit must push the new balance to the organization"
assert pushed[-1].data == {"organization_id": org_id, "balance": 25.0}
async def test_only_the_platform_owner_reaches_the_credit_console(
client, manager_token, plain_user_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "credit-beta")
calls = [
("get", CREDITS, None),
("post", f"{ORGS}/{org_id}/credit", {"amount": 5.0, "reason": REASON}),
("get", f"{ORGS}/{org_id}/credit-history", None),
]
# The manager is the organization's owner; the plain user is a customer with no seat.
for token in (manager_token, plain_user_token):
for method, path, body in calls:
kwargs: dict = {"headers": auth_header(token)}
if body is not None:
kwargs["json"] = body
response = await getattr(client, method)(path, **kwargs)
assert response.status_code == 403, response.text
anonymous = await client.get(CREDITS)
assert anonymous.status_code == 401
# The refused credits changed nothing.
balances = await client.get(CREDITS, headers=auth_header(platform_owner_token))
row = next(b for b in balances.json() if b["organization_id"] == org_id)
assert row["balance"] == 0.0
assert row["last_credit_at"] is None
async def test_an_unknown_organization_is_not_found(client, platform_owner_token, seeded):
owner = auth_header(platform_owner_token)
missing = new_id()
credited = await client.post(
f"{ORGS}/{missing}/credit", json={"amount": 5.0, "reason": REASON}, headers=owner
)
assert credited.status_code == 404, credited.text
history = await client.get(f"{ORGS}/{missing}/credit-history", headers=owner)
assert history.status_code == 404, history.text
async def test_the_history_limit_is_checked_and_the_list_is_scoped(
client, manager_token, platform_owner_token, seeded
):
org_a = await _create_org(client, manager_token, "سازمان الف", "credit-hist-a")
owner = auth_header(platform_owner_token)
for reason in ("شارژ نخست", "شارژ دوم"):
response = await client.post(
f"{ORGS}/{org_a}/credit", json={"amount": 10.0, "reason": reason}, headers=owner
)
assert response.status_code == 200, response.text
capped = await client.get(
f"{ORGS}/{org_a}/credit-history?limit=1", headers=owner
)
assert capped.status_code == 200, capped.text
assert [h["reason"] for h in capped.json()] == ["شارژ دوم"]
too_many = await client.get(f"{ORGS}/{org_a}/credit-history?limit=500", headers=owner)
assert too_many.status_code == 422, too_many.text
async def test_a_refused_credit_is_a_4xx_with_a_persian_message(
client, manager_token, platform_owner_token, seeded
):
org_id = await _create_org(client, manager_token, "سازمان پ", "credit-gamma")
owner = auth_header(platform_owner_token)
for amount, reason, message in (
(0.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(-3.0, REASON, "مقدار اعتبار باید بیشتر از صفر"),
(10.0, "", "دلیل افزودن اعتبار لازم است."),
):
response = await client.post(
f"{ORGS}/{org_id}/credit", json={"amount": amount, "reason": reason}, headers=owner
)
assert response.status_code == 422, response.text
assert message in response.json()["error"]["message"]
+161
View File
@@ -0,0 +1,161 @@
"""Prepaid billing (D-B): only the platform owner moves an organization's credit.
What these tests defend:
* an organization owner and an organization admin (who holds ``billing.manage``) get 403 from
``add-credit`` and from ``settle``, and the wallet does not move
* a member keeps reading the billing summary (``billing.view``) and gets 403 on ``add-credit``
* the platform owner's ``add-credit`` moves the wallet and writes one ``adjustment`` history row
with the reason ``billing/add-credit``; a clawback that would leave the wallet below zero is
refused and changes nothing
* the platform owner can settle a pending payment, and a succeeded payment credits the wallet
"""
from __future__ import annotations
from httpx import AsyncClient
from app.domain.identity.models import Role
from app.infrastructure.db.models import MembershipRow
from tests.conftest import auth_header, signup
OWNER_ONLY = "اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _join(session_factory, organization_id: str, user_id: str, role: Role) -> None:
async with session_factory() as session:
session.add(
MembershipRow(organization_id=organization_id, user_id=user_id, role=role.value)
)
await session.commit()
async def _pending_payment(client: AsyncClient, token: str, organization_id: str) -> str:
response = await client.post(
f"/api/organizations/{organization_id}/billing/checkout",
json={"amount": 30.0},
headers=auth_header(token),
)
assert response.status_code == 201, response.text
return response.json()["payment_id"]
async def test_an_organization_owner_and_admin_cannot_add_credit_or_settle(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان الف", "bill-owner")
admin = await signup(client, "billing-admin@example.com")
await _join(session_factory, org_id, admin["user"]["id"], Role.ADMIN)
payment_id = await _pending_payment(client, manager_token, org_id)
billing = f"/api/organizations/{org_id}/billing"
for token in (manager_token, admin["access_token"]):
headers = auth_header(token)
credit = await client.post(f"{billing}/add-credit", json={"amount": 50.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
settle = await client.post(
f"{billing}/payments/{payment_id}/settle",
json={"succeeded": True},
headers=headers,
)
assert settle.status_code == 403, settle.text
assert settle.json()["error"]["message"] == OWNER_ONLY
summary = await client.get(f"{billing}/summary", headers=auth_header(manager_token))
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
async def test_a_member_reads_the_summary_but_cannot_add_credit(
client, manager_token, session_factory, seeded
):
org_id = await _create_org(client, manager_token, "سازمان ب", "bill-member")
member = await signup(client, "billing-member@example.com")
await _join(session_factory, org_id, member["user"]["id"], Role.MEMBER)
billing = f"/api/organizations/{org_id}/billing"
headers = auth_header(member["access_token"])
summary = await client.get(f"{billing}/summary", headers=headers)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 0.0
credit = await client.post(f"{billing}/add-credit", json={"amount": 5.0}, headers=headers)
assert credit.status_code == 403, credit.text
assert credit.json()["error"]["message"] == OWNER_ONLY
async def test_the_platform_owner_adds_and_claws_back_credit_as_adjustments(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان پ", "bill-platform")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
credit = await client.post(f"{billing}/add-credit", json={"amount": 25.0}, headers=owner)
assert credit.status_code == 200, credit.text
assert credit.json()["balance"] == 25.0
assert credit.json()["low_balance"] is False
clawback = await client.post(f"{billing}/add-credit", json={"amount": -10.0}, headers=owner)
assert clawback.status_code == 200, clawback.text
assert clawback.json()["balance"] == 15.0
refused = await client.post(f"{billing}/add-credit", json={"amount": -50.0}, headers=owner)
assert refused.status_code == 422, refused.text
assert refused.json()["error"]["message"] == "اعتبار کافی برای این کسر وجود ندارد."
zero = await client.post(f"{billing}/add-credit", json={"amount": 0.0}, headers=owner)
assert zero.status_code == 422, zero.text
# The refused clawback rolled back: the balance is still 15.
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 15.0
history = await client.get(f"/api/admin/orgs/{org_id}/credit-history", headers=owner)
assert history.status_code == 200, history.text
assert [(h["kind"], h["amount"], h["reason"]) for h in history.json()] == [
("adjustment", -10.0, "billing/add-credit"),
("adjustment", 25.0, "billing/add-credit"),
]
async def test_the_platform_owner_settles_a_payment_and_the_wallet_is_credited(
client, platform_owner_token, seeded
):
org_id = await _create_org(client, platform_owner_token, "سازمان ت", "bill-settle")
owner = auth_header(platform_owner_token)
billing = f"/api/organizations/{org_id}/billing"
payment_id = await _pending_payment(client, platform_owner_token, org_id)
settled = await client.post(
f"{billing}/payments/{payment_id}/settle", json={"succeeded": True}, headers=owner
)
assert settled.status_code == 200, settled.text
assert settled.json()["status"] == "succeeded"
summary = await client.get(f"{billing}/summary", headers=owner)
assert summary.status_code == 200, summary.text
assert summary.json()["balance"] == 30.0
@@ -0,0 +1,112 @@
"""A settled payment is credited through the credit ledger, so the owner's history shows it.
What these tests defend:
* settling a pending payment as succeeded writes exactly one ``adjustment`` history row with the
reason ``billing/settle-payment`` and the payment's amount, the wallet goes up by that amount
once, and the organization's ``last_credit_at`` is set
* settling the same payment again changes nothing: the payment is already final, so neither the
wallet nor the history moves
* a failed payment credits nothing and writes no history row
"""
from __future__ import annotations
from app.application.container import build_billing_service
from app.domain.base import new_id
from app.domain.billing.models import CreditKind, Payment, PaymentStatus
from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository, PaymentRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
SETTLE_REASON = "billing/settle-payment"
async def _org_with_pending_payment(
session_factory, slug: str, amount: float
) -> tuple[str, str]:
"""Create an organization and one pending payment; return (organization id, payment id)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name="t",
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
payment = await PaymentRepository(session).create(
Payment(organization_id=org.id, amount=amount)
)
await session.commit()
return org.id, payment.id
async def _settle(
session_factory, organization_id: str, payment_id: str, *, succeeded: bool
) -> Payment:
"""Settle through the service the owner's endpoint calls, in its own transaction."""
async with session_factory() as session:
payment = await build_billing_service(session).settle_payment(
organization_id=organization_id, payment_id=payment_id, succeeded=succeeded
)
await session.commit()
return payment
async def _balance_and_history(session_factory, organization_id: str):
"""The wallet balance and every credit history row of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
history = await CreditLedgerRepository(session).history(organization_id)
return (wallet.balance if wallet else 0.0), history
async def test_a_settled_payment_writes_one_adjustment_row_and_credits_once(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-once", 30.0
)
payment = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert payment.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert [(t.kind, t.amount, t.balance_after, t.reason) for t in history] == [
(CreditKind.ADJUSTMENT, 30.0, 30.0, SETTLE_REASON)
]
async with session_factory() as session:
last_credit_at = await CreditLedgerRepository(session).last_credit_at_by_org()
assert organization_id in last_credit_at
async def test_settling_the_same_payment_twice_does_not_credit_twice(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-twice", 30.0
)
await _settle(session_factory, organization_id, payment_id, succeeded=True)
again = await _settle(session_factory, organization_id, payment_id, succeeded=True)
assert again.status == PaymentStatus.SUCCEEDED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 30.0
assert len(history) == 1
async def test_a_failed_payment_credits_nothing(session_factory):
organization_id, payment_id = await _org_with_pending_payment(
session_factory, "settle-failed", 30.0
)
failed = await _settle(session_factory, organization_id, payment_id, succeeded=False)
assert failed.status == PaymentStatus.FAILED
balance, history = await _balance_and_history(session_factory, organization_id)
assert balance == 0.0
assert history == []
@@ -0,0 +1,96 @@
"""The optional starting credit for new organizations (``BILLING_STARTING_CREDIT``, D-B).
What these tests defend:
* with the setting at 0, an organization created over HTTP or by the wizard starts with an
empty wallet and no ``starting_credit`` history row, so the platform owner credits it by hand
* with a positive setting, both paths give the new organization that credit and exactly one
``starting_credit`` history row, and no other organization is credited
"""
from __future__ import annotations
from httpx import AsyncClient
from app.core.config import settings
from app.domain.billing.models import CreditKind
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.conftest import auth_header, signup
STARTING_REASON = "اعتبار آغازین"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _http_org(client: AsyncClient, token: str, name: str) -> str:
response = await client.post(
"/api/organizations", json={"name": name}, headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["id"]
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _credit_state(session_factory, organization_id: str):
"""The wallet balance and the starting-credit history of one organization."""
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
starting = await CreditLedgerRepository(session).history(
organization_id, kinds=(CreditKind.STARTING_CREDIT,)
)
return (wallet.balance if wallet else 0.0), starting
async def test_with_the_default_setting_a_new_organization_starts_empty(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 0.0)
http_manager = await signup(client, "start-http@example.com")
wizard_manager = await signup(client, "start-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان HTTP")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ویزارد", "start-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 0.0
assert starting == []
async def test_a_positive_setting_credits_both_paths_once(
client, session_factory, seeded, monkeypatch
):
monkeypatch.setattr(settings, "billing_starting_credit", 3.0)
http_manager = await signup(client, "credit-http@example.com")
wizard_manager = await signup(client, "credit-wizard@example.com")
http_org = await _http_org(client, http_manager["access_token"], "سازمان الف")
wizard_org = await _wizard_org(
client, wizard_manager["access_token"], "سازمان ب", "credit-wizard-org"
)
for organization_id in (http_org, wizard_org):
balance, starting = await _credit_state(session_factory, organization_id)
assert balance == 3.0
assert len(starting) == 1
assert starting[0].amount == 3.0
assert starting[0].balance_after == 3.0
assert starting[0].reason == STARTING_REASON
+171
View File
@@ -0,0 +1,171 @@
"""The manager chat does not call a model for an organization with no credit.
What these tests defend:
* a zero-credit organization gets the note as the manager's reply: the model is
never called and the tags in the order are not executed (prepaid, decision D-B)
* a missing wallet is no credit either
* once the owner credits the organization, the same chat reaches the model
* a manager built without a wallet (the direct construction the other manager
tests use) keeps its old behaviour
The organization fixture and the manager's model profile come from
``tests.test_engine_manager``.
"""
from __future__ import annotations
from app.application.container_org import build_memory
from app.application.engine.manager import CHAT_NO_CREDIT_MESSAGE, ManagerTurns
from app.domain.workforce.models import MessageRole, RoleKey
from app.infrastructure.db.models import CreditWalletRow, ThreadRow
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.billing_helpers import fund_org
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
from tests.test_engine_manager import _with_model_profile
from tests.test_engine_manager import world as manager_world
#: The engine manager tests' organization fixture. Binding it under its own name
#: registers it for this module's tests (a bare import would be flagged as unused).
world = manager_world
_ORDER = '[[CREATE_TASK title="ورود" kind="feature"]]ساخت فرم ورود[[/CREATE_TASK]]'
_PLAIN_REPLY = "آماده‌ام."
async def _thread_with_manager(world, session) -> str:
"""A manager with a strong model profile, and a thread to talk in."""
manager = await world.employee(session, "مدیر", RoleKey.MANAGER)
await _with_model_profile(world, session, manager.id, world.strong_id)
thread = await ThreadRepository(session).create(
ThreadRow(organization_id=world.organization_id, title="گفتگو")
)
await session.commit()
return thread.id
async def _manager(session, *, scripted, grants, wired: bool) -> ManagerTurns:
"""The engine manager on real repositories; ``wired`` adds the prepaid gate."""
return ManagerTurns(
provider=scripted,
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=None,
wallets=CreditWalletRepository(session) if wired else None,
)
async def test_a_zero_credit_org_gets_the_note_and_no_model_call(world, session_factory):
async with session_factory() as session:
session.add(
CreditWalletRow(organization_id=world.organization_id, balance=0.0, currency="USD")
)
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_ORDER)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
user_message, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="یک فرم ورود بساز.",
user_id=world.user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(world.organization_id)
assert user_message.content == "یک فرم ورود بساز."
assert assistant.role is MessageRole.ASSISTANT
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
# The order's CREATE_TASK tag was not executed.
assert tasks == []
async def test_a_missing_wallet_counts_as_no_credit(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
async def test_after_the_owner_credits_the_org_the_model_answers(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
await fund_org(session_factory, world.organization_id, 50.0)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
async def test_a_manager_built_without_a_wallet_keeps_the_old_behaviour(world, session_factory):
"""No ``wallets`` means no gate: the direct constructions in other tests rely on it."""
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=False)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
+218
View File
@@ -0,0 +1,218 @@
"""Every usage row debits its organization's prepaid wallet once, in the same transaction.
What these tests defend:
* a model call debits its price: the balance falls by that price and exactly one
``usage`` history row names the usage row it paid for
* a zero-priced usage row is still recorded, but debits nothing
* a sandbox usage debits the wallet too (every unit goes through the one ledger)
* another organization's wallet is never touched
* a rolled-back session leaves neither the usage row nor the debit
* the live wallet event carries the new balance, so the sidebar badge follows it
"""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy import select
from app.application.ports.audit import UsageRecord
from app.application.ports.realtime import RealtimeEvent, get_publisher, set_publisher
from app.application.services.workforce_service import record_model_usage
from app.domain.ai_control.models import RateCard, RateUnit
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UsageEventRow, UserRow
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.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
class _Recorder:
"""Stands in for the realtime bus and keeps every event it is given."""
def __init__(self) -> None:
self.events: list[RealtimeEvent] = []
async def publish(self, event: RealtimeEvent) -> None:
self.events.append(event)
@pytest_asyncio.fixture
async def recorder():
"""Install a recording publisher for one test and put the previous one back."""
previous = get_publisher()
bus = _Recorder()
set_publisher(bus)
try:
yield bus
finally:
set_publisher(previous)
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"debit-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"debit-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def _usage_ids(session_factory, organization_id: str) -> list[str]:
async with session_factory() as session:
result = await session.execute(
select(UsageEventRow.id).where(UsageEventRow.organization_id == organization_id)
)
return list(result.scalars())
async def test_a_model_call_debits_its_price_and_names_its_usage_row(two_orgs, recorder):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await RateCardRepository(session).upsert(
RateCard(
name="توکن ورودی",
unit=RateUnit.MODEL_INPUT_TOKENS,
customer_price=2.5,
internal_cost_estimate=1.0,
)
)
await session.commit()
async with session_factory() as session:
await record_model_usage(
ledger=UsageLedgerRepository(session),
rate_cards=RateCardRepository(session),
organization_id=org_a,
unit=RateUnit.MODEL_INPUT_TOKENS,
quantity=4.0,
)
await session.commit()
# Four units at 2.5 each: the wallet falls by 10.
assert await _balance(session_factory, org_a) == 90.0
usage_ids = await _usage_ids(session_factory, org_a)
assert len(usage_ids) == 1
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(debits) == 1
assert debits[0].amount == -10.0
assert debits[0].balance_after == 90.0
assert debits[0].usage_event_id == usage_ids[0]
wallet_events = [e for e in recorder.events if e.type == "billing.wallet"]
assert len(wallet_events) == 1
assert wallet_events[0].data["balance"] == 90.0
async def test_a_zero_priced_usage_row_is_recorded_but_debits_nothing(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=0.0,
)
)
await session.commit()
assert balance_after is None
assert len(await _usage_ids(session_factory, org_a)) == 1
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
async def test_a_sandbox_usage_debits_the_wallet_too(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 20.0)
async with session_factory() as session:
balance_after = await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.SANDBOX_CPU_SECOND,
quantity=10.0,
unit_price=0.1,
total_price=1.0,
)
)
await session.commit()
assert balance_after == 19.0
assert await _balance(session_factory, org_a) == 19.0
async def test_another_organizations_wallet_is_untouched(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=5.0,
)
)
await session.commit()
assert await _balance(session_factory, org_a) == 45.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_debits = await CreditLedgerRepository(session).history(
org_b, kinds=(CreditKind.USAGE,)
)
assert b_debits == []
async def test_a_rolled_back_session_leaves_neither_the_usage_row_nor_the_debit(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
await UsageLedgerRepository(session).record(
usage=UsageRecord(
organization_id=org_a,
unit=RateUnit.TOOL_CALL,
quantity=1.0,
total_price=7.0,
)
)
await session.rollback()
assert await _usage_ids(session_factory, org_a) == []
assert await _balance(session_factory, org_a) == 100.0
async with session_factory() as session:
debits = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert debits == []
+163
View File
@@ -0,0 +1,163 @@
"""The prepaid credit ledger: each balance change is one atomic update and one history row.
What these tests defend:
* a credit then a debit leave the balance their history rows record
* the same usage row debits a wallet once, however many times it is written
* debiting one organization never moves another organization's wallet
* a missing wallet is created at zero, then credited
* the history is newest first and always scoped to one organization
"""
from __future__ import annotations
import pytest_asyncio
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.infrastructure.db.models import OrganizationRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from tests.billing_helpers import fund_org
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations created directly, so neither starts with a wallet."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"ledger-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"ledger-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
assert wallet is not None
return wallet.balance
async def test_a_credit_then_a_debit_leave_the_balance_their_history_records(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
async with session_factory() as session:
debit = await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=30.0
)
await session.commit()
assert debit is not None
assert debit.kind is CreditKind.USAGE
assert debit.amount == -30.0
assert debit.balance_after == 70.0
assert await _balance(session_factory, org_a) == 70.0
async with session_factory() as session:
history = await CreditLedgerRepository(session).history(org_a)
assert [t.kind for t in history] == [CreditKind.USAGE, CreditKind.ADJUSTMENT]
assert [t.balance_after for t in history] == [70.0, 100.0]
async def test_the_same_usage_row_debits_the_wallet_once(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
await fund_org(session_factory, org_a, 100.0)
usage_id = new_id()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
first = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
second = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=usage_id, amount=25.0
)
free = await repo.debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=0.0
)
await session.commit()
assert first is not None and first.balance_after == 75.0
assert second is None
assert free is None
assert await _balance(session_factory, org_a) == 75.0
async with session_factory() as session:
usage_rows = await CreditLedgerRepository(session).history(
org_a, kinds=(CreditKind.USAGE,)
)
assert len(usage_rows) == 1
assert usage_rows[0].usage_event_id == usage_id
async def test_debiting_one_organization_never_moves_another(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
await fund_org(session_factory, org_b, 50.0)
async with session_factory() as session:
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=20.0
)
await session.commit()
assert await _balance(session_factory, org_a) == 30.0
assert await _balance(session_factory, org_b) == 50.0
async with session_factory() as session:
b_history = await CreditLedgerRepository(session).history(org_b)
assert [t.kind for t in b_history] == [CreditKind.ADJUSTMENT]
async def test_a_missing_wallet_is_created_at_zero_then_credited(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
assert await CreditWalletRepository(session).get_for_org(org_a) is None
async with session_factory() as session:
credit = await CreditLedgerRepository(session).apply(
organization_id=org_a,
amount=25.0,
kind=CreditKind.OWNER_CREDIT,
reason="first credit",
)
await session.commit()
assert credit.balance_after == 25.0
assert await _balance(session_factory, org_a) == 25.0
async def test_history_is_newest_first_scoped_and_capped(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
repo = CreditLedgerRepository(session)
await repo.apply(
organization_id=org_a, amount=10.0, kind=CreditKind.STARTING_CREDIT, reason="start"
)
await repo.apply(
organization_id=org_a, amount=40.0, kind=CreditKind.OWNER_CREDIT, reason="owner"
)
# Org B never received credit: a usage debit alone is not a credit.
await repo.debit_for_usage(organization_id=org_b, usage_event_id=new_id(), amount=1.0)
await session.commit()
async with session_factory() as session:
repo = CreditLedgerRepository(session)
everything = await repo.history(org_a)
credits_only = await repo.history(org_a, kinds=(CreditKind.OWNER_CREDIT,))
newest_one = await repo.history(org_a, limit=1)
org_b_history = await repo.history(org_b)
last_credit = await repo.last_credit_at_by_org()
assert [t.reason for t in everything] == ["owner", "start"]
assert [t.reason for t in credits_only] == ["owner"]
assert [t.reason for t in newest_one] == ["owner"]
assert [t.kind for t in org_b_history] == [CreditKind.USAGE]
assert set(last_credit) == {org_a}
assert last_credit[org_a] is not None
+236
View File
@@ -0,0 +1,236 @@
"""The platform owner's credit service: top-ups, the manual history and the balances page.
What these tests defend:
* an owner credit moves the wallet by the amount and writes one history row that names
the owner who made it and the reason
* a zero, negative or oversized amount, and a blank or too-short reason, are refused before
any wallet or history row is written
* an unknown organization is a 404, never a silently created wallet
* the history shows manual credit only (no usage debits) and only one organization's rows
* the balances page lists every organization with its own balance, paused task count and
last credit date; a usage debit is not a credit
* the starting credit is granted only when the setting is positive
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from app.application.services.credit_service import MAX_OWNER_CREDIT, CreditService
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.billing.models import CreditKind
from app.domain.workforce.pipeline import Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.credit import CreditLedgerRepository
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
VALID_REASON = "شارژ دستی مالک"
async def _new_org(session, label: str) -> str:
"""An organization with its own owner and no wallet yet."""
user = UserRow(email=f"credit-svc-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"credit-svc-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
return org.id
@pytest_asyncio.fixture
async def two_orgs(session_factory):
async with session_factory() as session:
org_a = await _new_org(session, "الف")
org_b = await _new_org(session, "ب")
await session.commit()
return session_factory, (org_a, org_b)
async def _balance(session_factory, organization_id: str) -> float:
async with session_factory() as session:
wallet = await CreditWalletRepository(session).get_for_org(organization_id)
return wallet.balance if wallet else 0.0
async def _add_task_at(session_factory, organization_id: str, stage: Stage) -> None:
async with session_factory() as session:
task = TaskRow(organization_id=organization_id, title="کار آزمایشی")
session.add(task)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(task_id=task.id, organization_id=organization_id, stage=stage)
)
await session.commit()
async def test_an_owner_credit_moves_the_balance_and_writes_one_row(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
actor = new_id()
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a, amount=40.0, reason=VALID_REASON, actor_user_id=actor
)
await session.commit()
assert credit.kind is CreditKind.OWNER_CREDIT
assert credit.amount == 40.0
assert credit.balance_after == 40.0
assert await _balance(session_factory, org_a) == 40.0
async with session_factory() as session:
rows = await CreditLedgerRepository(session).history(org_a)
assert len(rows) == 1
assert rows[0].reason == VALID_REASON
assert rows[0].actor_user_id == actor
@pytest.mark.parametrize("amount", [0.0, -5.0, MAX_OWNER_CREDIT + 1])
async def test_a_zero_negative_or_oversized_amount_is_refused(two_orgs, amount):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError):
await CreditService(session).owner_credit(
organization_id=org_a, amount=amount, reason=VALID_REASON, actor_user_id=new_id()
)
async with session_factory() as session:
assert await CreditLedgerRepository(session).history(org_a) == []
assert await _balance(session_factory, org_a) == 0.0
async def test_exactly_the_maximum_amount_is_accepted(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
credit = await CreditService(session).owner_credit(
organization_id=org_a,
amount=MAX_OWNER_CREDIT,
reason=VALID_REASON,
actor_user_id=new_id(),
)
await session.commit()
assert credit.balance_after == MAX_OWNER_CREDIT
@pytest.mark.parametrize("reason", ["", " ", "ab", "x" * 501])
async def test_a_blank_short_or_long_reason_is_refused(two_orgs, reason):
session_factory, (org_a, _org_b) = two_orgs
async with session_factory() as session:
with pytest.raises(ValidationError) as caught:
await CreditService(session).owner_credit(
organization_id=org_a, amount=10.0, reason=reason, actor_user_id=new_id()
)
assert caught.value.message == "دلیل افزودن اعتبار لازم است."
assert await _balance(session_factory, org_a) == 0.0
async def test_an_unknown_organization_is_not_found(session_factory):
async with session_factory() as session:
with pytest.raises(NotFoundError) as caught:
await CreditService(session).owner_credit(
organization_id=new_id(),
amount=10.0,
reason=VALID_REASON,
actor_user_id=new_id(),
)
assert caught.value.status_code == 404
async def test_the_history_lists_manual_credit_for_one_organization_only(two_orgs):
session_factory, (org_a, org_b) = two_orgs
await fund_org(session_factory, org_a, 50.0)
async with session_factory() as session:
service = CreditService(session)
await service.owner_credit(
organization_id=org_a, amount=10.0, reason=VALID_REASON, actor_user_id=new_id()
)
await service.owner_credit(
organization_id=org_b, amount=7.0, reason="شارژ سازمان ب", actor_user_id=new_id()
)
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_a, usage_event_id=new_id(), amount=4.0
)
await session.commit()
async with session_factory() as session:
history = await CreditService(session).history(org_a)
newest = await CreditService(session).history(org_a, limit=1)
# The usage debit is not in the history; the other organization's credit is not either.
assert [t.kind for t in history] == [CreditKind.OWNER_CREDIT, CreditKind.ADJUSTMENT]
assert [t.reason for t in history] == [VALID_REASON, "test funding"]
assert [t.kind for t in newest] == [CreditKind.OWNER_CREDIT]
async def test_the_balances_list_each_organization_with_its_own_numbers(two_orgs):
session_factory, (org_a, org_b) = two_orgs
async with session_factory() as session:
org_c = await _new_org(session, "پ")
await session.commit()
async with session_factory() as session:
await CreditService(session).owner_credit(
organization_id=org_a, amount=30.0, reason=VALID_REASON, actor_user_id=new_id()
)
await session.commit()
async with session_factory() as session:
# A usage debit takes the wallet below zero; it is not a credit.
await CreditLedgerRepository(session).debit_for_usage(
organization_id=org_b, usage_event_id=new_id(), amount=2.0
)
await session.commit()
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_a, Stage.PAUSED)
await _add_task_at(session_factory, org_b, Stage.PENDING_IMPLEMENTATION)
async with session_factory() as session:
rows = {row.organization_id: row for row in await CreditService(session).balances()}
funded, debited, empty = rows[org_a], rows[org_b], rows[org_c]
assert funded.name == "سازمان الف"
assert funded.balance == 30.0
assert funded.paused_tasks == 2
assert funded.last_credit_at is not None
assert debited.name == "سازمان ب"
assert debited.balance == -2.0
assert debited.paused_tasks == 0
assert debited.last_credit_at is None
# An organization with no wallet is listed at zero, not left out.
assert empty.balance == 0.0
assert empty.currency == "USD"
assert empty.paused_tasks == 0
assert empty.last_credit_at is None
async def test_the_starting_credit_is_granted_only_when_the_setting_is_positive(
two_orgs, monkeypatch
):
session_factory, (org_a, org_b) = two_orgs
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=0.0),
)
async with session_factory() as session:
assert await CreditService(session).grant_starting_credit(org_a) is None
await session.commit()
assert await _balance(session_factory, org_a) == 0.0
monkeypatch.setattr(
"app.application.services.credit_service.settings",
SimpleNamespace(billing_starting_credit=3.0),
)
async with session_factory() as session:
granted = await CreditService(session).grant_starting_credit(org_b)
await session.commit()
assert granted is not None
assert granted.kind is CreditKind.STARTING_CREDIT
assert granted.balance_after == 3.0
+169
View File
@@ -0,0 +1,169 @@
"""Direct tasks enter the engine, and the legacy "run" button refuses them (decision D-D).
What these tests defend:
* a task made over HTTP ``POST /tasks`` gets a pipeline state at ``pending_manager_review``
in the organization's project, so the scheduler's manager takes it from there
* ``POST /tasks/{id}/runs`` on such a task is refused with 409: the engine owns it, and
nothing runs outside the engine
* a task made through the MCP gateway (``tasks_create``) gets the same state
* a pre-v2 task with no state still runs through the legacy path
"""
from __future__ import annotations
from httpx import AsyncClient
from app.application.services.mcp_service import McpService, TokenRateLimiter
from app.domain.base import new_id
from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
#: Part of the Persian refusal shown when the legacy run button is pressed on an engine task.
ENGINE_OWNS = "موتور خودکار"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _wizard_org(client: AsyncClient, token: str, name: str, slug: str) -> dict:
"""The wizard's answer: the organization, its project and its team."""
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()
async def _state(session_factory, task_id: str):
async with session_factory() as session:
return await PipelineStateRepository(session).get(task_id)
async def _org_with_owner(session_factory, slug: str) -> tuple[str, str]:
"""An organization and its owner, made directly (the MCP gateway needs no wizard)."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(MembershipRow(organization_id=org.id, user_id=user.id, role=Role.OWNER.value))
await session.commit()
return org.id, user.id
async def _mcp_token(session_factory, organization_id: str, user_id: str) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=organization_id,
name="agent",
scopes=["tasks:write", "tasks:read"],
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def test_a_task_made_over_http_enters_the_engine(client, session_factory, seeded):
manager = await signup(client, "direct-http@example.com")
body = await _wizard_org(client, manager["access_token"], "سازمان الف", "direct-http")
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks",
json={"title": "صفحهٔ ورود"},
headers=auth_header(manager["access_token"]),
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
state = await _state(session_factory, task_id)
assert state is not None, "a direct task must get a pipeline state"
assert state.organization_id == org_id
assert state.project_id == project_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async with session_factory() as session:
task = await TaskRepository(session).get(task_id)
assert task is not None and task.project_id == project_id
async def test_running_a_direct_task_is_refused_with_409(client, session_factory, seeded):
manager = await signup(client, "direct-run@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان ب", "direct-run")
org_id = body["organization"]["id"]
created = await client.post(
f"/api/organizations/{org_id}/tasks", json={"title": "کار مستقیم"}, headers=headers
)
assert created.status_code == 201, created.text
task_id = created.json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 409, run.text
assert ENGINE_OWNS in run.json()["error"]["message"]
# Nothing ran outside the engine: the task still has no run.
detail = await client.get(f"/api/organizations/{org_id}/tasks/{task_id}", headers=headers)
assert detail.status_code == 200, detail.text
assert detail.json()["runs"] == []
async def test_an_mcp_task_enters_the_engine(session_factory, seeded):
org_id, user_id = await _org_with_owner(session_factory, "direct-mcp")
token = await _mcp_token(session_factory, org_id, user_id)
gateway = McpGateway(
session_factory=session_factory, rate_limiter=TokenRateLimiter(limit_per_minute=None)
)
created = await gateway.call(
tool="tasks_create", arguments={"title": "کار از راه MCP"}, token=token
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
state = await _state(session_factory, task_id)
assert state is not None, "an MCP task must get a pipeline state too"
assert state.organization_id == org_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_a_pre_v2_task_still_runs_through_the_legacy_path(client, session_factory, seeded):
manager = await signup(client, "direct-legacy@example.com")
headers = auth_header(manager["access_token"])
body = await _wizard_org(client, manager["access_token"], "سازمان پ", "direct-legacy")
org_id = body["organization"]["id"]
task_id = await legacy_task(session_factory, org_id, manager["user"]["id"], title="کار قدیمی")
assert await _state(session_factory, task_id) is None
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", json={}, headers=headers
)
assert run.status_code == 201, run.text
assert run.json()["status"] == "completed", run.text
+32 -2
View File
@@ -24,7 +24,11 @@ import pytest_asyncio
from httpx import AsyncClient from httpx import AsyncClient
from app.domain.identity.models import PlatformRole from app.domain.identity.models import PlatformRole
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@@ -33,7 +37,9 @@ async def _platform_seed(seeded):
return seeded return seeded
async def test_end_to_end_smoke_path(client: AsyncClient): async def test_end_to_end_smoke_path(client: AsyncClient, session_factory):
"""Decision D-D: the task made over HTTP enters the engine and refuses the legacy run, so
the run, its metering and its audit are walked on a pre-v2 task (see step 10)."""
# -- 1. signup -------------------------------------------------------- # # -- 1. signup -------------------------------------------------------- #
# The platform owner, not a plain manager: step 6 drives the sandbox # The platform owner, not a plain manager: step 6 drives the sandbox
# directly, and v2 makes container/command access owner-only # directly, and v2 makes container/command access owner-only
@@ -184,6 +190,9 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
assert thread.status_code == 201, thread.text assert thread.status_code == 201, thread.text
thread_id = thread.json()["id"] thread_id = thread.json()["id"]
# prepaid (D-B): the HTTP org starts at zero credit, and the chat costs model calls.
await fund_org(session_factory, org_id)
message = await client.post( message = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages", f"/api/organizations/{org_id}/threads/{thread_id}/messages",
json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id}, json={"content": "برای ماژول پرداخت تست بنویس.", "employee_id": employee_id},
@@ -212,10 +221,31 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
) )
assert task.status_code == 201, task.text assert task.status_code == 201, task.text
task_id = task.json()["id"] task_id = task.json()["id"]
# Decision D-D: a task made over HTTP is the engine's from manager review, so it has a
# pipeline state and the legacy run button refuses it.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None and state.stage is Stage.PENDING_MANAGER_REVIEW
refused = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
assert refused.status_code == 409, refused.text
# -- 10. run task ------------------------------------------------------ # # -- 10. run task ------------------------------------------------------ #
# The legacy run path serves pre-v2 tasks, which have no pipeline state, so the run, its
# metering and its audit are checked on one of those (decision D-D).
run_task_id = await legacy_task(
session_factory,
org_id,
signup_body["user"]["id"],
title="افزودن تست برای ماژول پرداخت",
project_id=project_id,
assigned_employee_id=employee_id,
)
run = await client.post( run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{run_task_id}/runs",
json={"employee_id": employee_id}, json={"employee_id": employee_id},
headers=headers, headers=headers,
) )
+408
View File
@@ -0,0 +1,408 @@
"""The engine's read API: status, task board, task detail and recent turns.
What these tests defend:
* any member, including a viewer, reads every route; a foreign member gets 404 on every
route, and a task id of another organization is a 404 under the caller's own organization
* ``status`` reports the wallet balance (``credit_ok`` only above zero), the owner's cap, the
headcount and the stage counts of the organization
* the board lists engine and legacy tasks, and ``stage`` narrows it to one stage
* the task detail carries the pipeline stages, the turns, the gate verdicts, the runs, the
last diff and the merged sha; a turn excerpt is capped at 1,000 characters
* no response names a model, a provider or an ``ai_model_id`` (every seeded model and
provider name is scanned in every body)
"""
from __future__ import annotations
from dataclasses import dataclass
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.application.container_org import build_model_grants
from app.domain.base import utcnow
from app.domain.identity.models import Role
from app.domain.workforce.engine import EmployeeProfile, EngineTurn, GateName, GateResult
from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus
from app.domain.workforce.pipeline import (
Mode,
PipelineKey,
Stage,
TaskKind,
TaskPipelineState,
pipeline_stages,
)
from app.infrastructure.db.models import AiModelRow, AiProviderRow, MembershipRow, TaskRow
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import EmployeeRepository, RunRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup
DIFF = "diff --git a/app/login.py b/app/login.py\n+def login(): return True\n"
SHA = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432"
TITLE = "صفحهٔ ورود"
EMPLOYEE = "برنامه‌نویس یک"
def _base(org_id: str) -> str:
return f"/api/organizations/{org_id}/engine"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
@dataclass
class _Org:
id: str
project_id: str
owner_id: str
owner_token: str
@dataclass
class _Data:
org: _Org
model_id: str
done_task_id: str
testing_task_id: str
legacy_task_id: str
async def _make_org(client: AsyncClient, slug: str) -> _Org:
owner = await signup(client, f"{slug}-owner@example.com")
response = await client.post(
"/api/setup/complete",
json=_payload(f"سازمان {slug}", slug),
headers=auth_header(owner["access_token"]),
)
assert response.status_code == 201, response.text
body = response.json()
return _Org(
id=body["organization"]["id"],
project_id=body["project"]["id"],
owner_id=owner["user"]["id"],
owner_token=owner["access_token"],
)
async def _join(
client: AsyncClient,
session_factory: async_sessionmaker,
organization_id: str,
role: Role,
email: str,
) -> str:
"""Add a fresh account to an organization with ``role``; returns its token."""
user = await signup(client, email)
async with session_factory() as session:
session.add(
MembershipRow(
organization_id=organization_id, user_id=user["user"]["id"], role=role.value
)
)
await session.commit()
return user["access_token"]
async def _engine_task(session, org: _Org, *, title: str, stage: Stage, **fields) -> TaskRow:
employee_id = fields.pop("employee_id", None)
row = TaskRow(
organization_id=org.id,
title=title,
description="وصف کار",
project_id=org.project_id,
created_by_user_id=org.owner_id,
assigned_employee_id=employee_id,
)
session.add(row)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=row.id,
organization_id=org.id,
project_id=org.project_id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=stage,
**fields,
)
)
return row
async def _seed(session_factory: async_sessionmaker, org: _Org) -> _Data:
"""One finished engine task (turn, gate, run, diff, sha), one at a test stage, one legacy."""
async with session_factory() as session:
model = (await session.execute(select(AiModelRow).limit(1))).scalars().first()
assert model is not None
developer = await EmployeeRepository(session).create(
Employee(organization_id=org.id, name=EMPLOYEE, role_key=RoleKey.DEVELOPER)
)
await EmployeeProfileRepository(session).upsert(
EmployeeProfile(
employee_id=developer.id,
ai_model_id=model.id,
roles=[RoleKey.DEVELOPER.value],
)
)
done = await _engine_task(
session,
org,
title=TITLE,
stage=Stage.COMPLETED,
employee_id=developer.id,
last_diff=DIFF,
merged_sha=SHA,
)
testing = await _engine_task(session, org, title="جدول کاربران", stage=Stage.PENDING_TEST)
legacy = TaskRow(
organization_id=org.id,
title="کار قدیمی",
project_id=org.project_id,
created_by_user_id=org.owner_id,
)
session.add(legacy)
await session.flush()
run = await RunRepository(session).create(
Run(task_id=done.id, employee_id=developer.id, status=RunStatus.COMPLETED)
)
await EngineTurnRepository(session).create(
EngineTurn(
task_id=done.id,
run_id=run.id,
employee_id=developer.id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt="پاسخ " * 400,
ok=True,
finished_at=utcnow(),
)
)
await GateResultRepository(session).create(
GateResult(task_id=done.id, gate=GateName.GREEN, ok=True, detail="آزمون‌ها سبز شد.")
)
await session.commit()
return _Data(
org=org,
model_id=model.id,
done_task_id=done.id,
testing_task_id=testing.id,
legacy_task_id=legacy.id,
)
async def _get(client: AsyncClient, token: str, url: str):
return await client.get(url, headers=auth_header(token))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
async def test_status_reports_credit_policy_team_and_stage_counts(
client, session_factory, seeded
):
org = await _make_org(client, "eng-status")
await fund_org(session_factory, org.id, 40.0)
await _seed(session_factory, org)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
headcount = await EmployeeRepository(session).count_for_org(org.id)
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
body = response.json()
assert body["credit_ok"] is True
assert body["balance"] == 40.0
assert body["currency"] == "USD"
assert body["max_employees"] == cap
assert body["team_size"] == headcount
assert body["stage_counts"] == {"completed": 1, "pending_test": 1}
async def test_status_is_not_credit_ok_without_credit(client, session_factory, seeded):
org = await _make_org(client, "eng-zero")
response = await _get(client, org.owner_token, f"{_base(org.id)}/status")
assert response.status_code == 200, response.text
assert response.json()["credit_ok"] is False
assert response.json()["balance"] == 0.0
# ---------------------------------------------------------------------------
# board
# ---------------------------------------------------------------------------
async def test_the_board_lists_engine_and_legacy_tasks_and_filters_by_stage(
client, session_factory, seeded
):
org = await _make_org(client, "eng-board")
data = await _seed(session_factory, org)
board = f"{_base(org.id)}/tasks"
everything = await _get(client, org.owner_token, board)
assert everything.status_code == 200, everything.text
rows = {row["id"]: row for row in everything.json()}
assert set(rows) == {data.done_task_id, data.testing_task_id, data.legacy_task_id}
assert rows[data.legacy_task_id]["stage"] is None
assert rows[data.legacy_task_id]["pipeline"] is None
assert rows[data.legacy_task_id]["cycle"] == 0
assert rows[data.testing_task_id]["stage"] == "pending_test"
assert rows[data.done_task_id]["assignee_name"] == EMPLOYEE
testing = await client.get(
board, params={"stage": "pending_test"}, headers=auth_header(org.owner_token)
)
assert [row["id"] for row in testing.json()] == [data.testing_task_id]
limited = await client.get(board, params={"limit": 1}, headers=auth_header(org.owner_token))
assert len(limited.json()) == 1
unknown = await client.get(
board, params={"stage": "nope"}, headers=auth_header(org.owner_token)
)
assert unknown.status_code == 422
# ---------------------------------------------------------------------------
# detail and turns
# ---------------------------------------------------------------------------
async def test_task_detail_returns_stages_turns_gates_runs_diff_and_sha(
client, session_factory, seeded
):
org = await _make_org(client, "eng-detail")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/tasks/{data.done_task_id}")
assert response.status_code == 200, response.text
body = response.json()
assert body["stage"] == "completed"
assert body["description"] == "وصف کار"
assert body["stages"] == [str(s) for s in pipeline_stages(PipelineKey.PLAN_CODE_TEST)]
assert body["merged_sha"] == SHA
assert body["diff"] == DIFF
assert body["assignee_name"] == EMPLOYEE
[turn] = body["turns"]
assert turn["employee_name"] == EMPLOYEE
assert turn["role"] == "developer"
assert turn["mode"] == str(Mode.WORK)
assert len(turn["reply_excerpt"]) == 1000
[gate] = body["gates"]
assert gate["gate"] == "green"
assert gate["ok"] is True
[run] = body["runs"]
assert run["status"] == "completed"
async def test_recent_turns_carry_the_task_title_and_the_employee_name(
client, session_factory, seeded
):
org = await _make_org(client, "eng-turns")
data = await _seed(session_factory, org)
response = await _get(client, org.owner_token, f"{_base(org.id)}/turns")
assert response.status_code == 200, response.text
[turn] = response.json()
assert turn["task_id"] == data.done_task_id
assert turn["task_title"] == TITLE
assert turn["employee_name"] == EMPLOYEE
too_many = await _get(client, org.owner_token, f"{_base(org.id)}/turns?limit=0")
assert too_many.status_code == 422
# ---------------------------------------------------------------------------
# who may read
# ---------------------------------------------------------------------------
async def test_a_viewer_reads_every_route(client, session_factory, seeded):
org = await _make_org(client, "eng-viewer")
data = await _seed(session_factory, org)
token = await _join(
client, session_factory, org.id, Role.VIEWER, "eng-viewer-user@example.com"
)
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, token, f"{_base(org.id)}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
async def test_a_foreign_member_gets_404_on_every_route(client, session_factory, seeded):
org_a = await _make_org(client, "eng-a")
data = await _seed(session_factory, org_a)
org_b = await _make_org(client, "eng-b")
for path in ("/status", "/tasks", f"/tasks/{data.done_task_id}", "/turns"):
response = await _get(client, org_b.owner_token, f"{_base(org_a.id)}{path}")
assert response.status_code == 404, f"{path}: {response.text}"
# A's task id under B's own organization is not found, and B's board stays empty.
inside_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks/{data.done_task_id}")
assert inside_b.status_code == 404
board_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/tasks")
assert board_b.json() == []
turns_b = await _get(client, org_b.owner_token, f"{_base(org_b.id)}/turns")
assert turns_b.json() == []
# ---------------------------------------------------------------------------
# no model identity
# ---------------------------------------------------------------------------
async def test_no_response_names_a_model_a_provider_or_an_ai_model_id(
client, session_factory, seeded
):
org = await _make_org(client, "eng-names")
data = await _seed(session_factory, org)
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
forbidden = {data.model_id}
for model in models:
forbidden.update({model.internal_name, model.display_name_for_admin})
forbidden.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
forbidden = {value for value in forbidden if len(value) >= 3}
assert forbidden
base = _base(org.id)
paths = (
"/status",
"/tasks",
f"/tasks/{data.done_task_id}",
f"/tasks/{data.testing_task_id}",
"/turns",
)
for path in paths:
response = await _get(client, org.owner_token, f"{base}{path}")
assert response.status_code == 200, f"{path}: {response.text}"
for value in forbidden:
assert value not in response.text, f"{path} names {value!r}"
+186
View File
@@ -0,0 +1,186 @@
"""Pause, resume, cancel and answer on an engine task, through the HTTP routes.
What these tests defend:
* a member with ``tasks.run`` pauses and resumes a funded task; resume is refused with the
Persian budget reason while the organization has no credit, and the task stays paused
* cancel ends the task and writes one ``task.control`` audit row that names the action
* an answer is accepted only from ``pending_user_decision``; its text never reaches the audit
trail, and a blank answer is a 422
* a viewer gets 403, and a member of another organization gets 404, on every action
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
from app.domain.identity.models import Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import AuditEventRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header
from tests.test_engine_api import _base, _engine_task, _join, _make_org
ANSWER = "نسخهٔ دوم را انجام بده"
async def _post(client, token: str, organization_id: str, task_id: str, action: str, json=None):
return await client.post(
f"{_base(organization_id)}/tasks/{task_id}/{action}",
json=json,
headers=auth_header(token),
)
async def _stage_of(session_factory, task_id: str) -> Stage | None:
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
return state.stage if state is not None else None
async def _task_at(session_factory, org, stage: Stage, title: str = "صفحهٔ ورود") -> str:
async with session_factory() as session:
row = await _engine_task(session, org, title=title, stage=stage)
await session.commit()
return row.id
async def test_a_member_pauses_then_resumes_a_funded_task(client, session_factory, seeded):
org = await _make_org(client, "act-pause")
await fund_org(session_factory, org.id, 40.0)
member = await _join(client, session_factory, org.id, Role.MEMBER, "act-member@example.com")
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
paused = await _post(client, member, org.id, task_id, "pause")
assert paused.status_code == 200, paused.text
assert paused.json()["stage"] == "paused"
assert paused.json()["status"] == "waiting_approval"
resumed = await _post(client, member, org.id, task_id, "resume")
assert resumed.status_code == 200, resumed.text
assert resumed.json()["stage"] == "pending_manager_review"
async def test_resume_is_refused_without_credit_and_the_task_stays_paused(
client, session_factory, seeded
):
org = await _make_org(client, "act-zero")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-zero-member@example.com"
)
task_id = await _task_at(session_factory, org, Stage.PAUSED)
refused = await _post(client, member, org.id, task_id, "resume")
assert refused.status_code == 409, refused.text
assert refused.json()["error"]["message"] == BUDGET_PAUSED_MESSAGE
assert await _stage_of(session_factory, task_id) is Stage.PAUSED
async def test_cancel_ends_the_task_and_writes_one_audit_row(client, session_factory, seeded):
org = await _make_org(client, "act-cancel")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-cancel-member@example.com"
)
task_id = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION)
cancelled = await _post(client, member, org.id, task_id, "cancel")
assert cancelled.status_code == 200, cancelled.text
assert cancelled.json()["stage"] == "cancelled"
assert cancelled.json()["status"] == "cancelled"
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == "task.control",
AuditEventRow.target_id == task_id,
)
)
)
.scalars()
.all()
)
assert len(events) == 1
assert events[0].organization_id == org.id
assert events[0].target_type == "task"
assert events[0].metadata_json == {"action": "cancel"}
async def test_an_answer_is_accepted_only_from_a_decision_and_stays_out_of_the_audit(
client, session_factory, seeded
):
org = await _make_org(client, "act-answer")
member = await _join(
client, session_factory, org.id, Role.MEMBER, "act-answer-member@example.com"
)
working = await _task_at(session_factory, org, Stage.PENDING_IMPLEMENTATION, "کار یک")
deciding = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION, "کار دو")
refused = await _post(
client, member, org.id, working, "answer", json={"content": ANSWER}
)
assert refused.status_code == 409, refused.text
assert await _stage_of(session_factory, working) is Stage.PENDING_IMPLEMENTATION
blank = await _post(client, member, org.id, deciding, "answer", json={"content": ""})
assert blank.status_code == 422, blank.text
assert await _stage_of(session_factory, deciding) is Stage.PENDING_USER_DECISION
accepted = await _post(
client, member, org.id, deciding, "answer", json={"content": ANSWER}
)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["stage"] == "pending_manager_review"
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.action == "task.control",
AuditEventRow.target_id == deciding,
)
)
)
.scalars()
.all()
)
assert [event.metadata_json for event in events] == [{"action": "answer"}]
assert ANSWER not in repr(events[0].metadata_json)
async def test_a_viewer_is_refused_and_a_foreign_member_gets_not_found(
client, session_factory, seeded
):
org = await _make_org(client, "act-viewer")
viewer = await _join(
client, session_factory, org.id, Role.VIEWER, "act-viewer-user@example.com"
)
other = await _make_org(client, "act-other")
task_id = await _task_at(session_factory, org, Stage.PENDING_USER_DECISION)
for action in ("pause", "resume", "cancel"):
response = await _post(client, viewer, org.id, task_id, action)
assert response.status_code == 403, f"{action}: {response.text}"
answer = await _post(client, viewer, org.id, task_id, "answer", json={"content": ANSWER})
assert answer.status_code == 403, answer.text
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
for action in ("pause", "resume", "cancel"):
response = await _post(client, other.owner_token, org.id, task_id, action)
assert response.status_code == 404, f"{action}: {response.text}"
answer = await _post(
client, other.owner_token, org.id, task_id, "answer", json={"content": ANSWER}
)
assert answer.status_code == 404, answer.text
# The other organization's own routes do not reach this task either.
inside = await _post(client, other.owner_token, other.id, task_id, "pause")
assert inside.status_code == 404, inside.text
assert await _stage_of(session_factory, task_id) is Stage.PENDING_USER_DECISION
+255
View File
@@ -0,0 +1,255 @@
"""The engine's team API: the team with tiers, hire by role and tier, and fire.
What these tests defend:
* ``GET /team`` shows each member's neutral tier, ``unknown`` while no granted model backs it;
the tiers the organization has; the owner's cap; and not the archived members
* hire takes a role and a tier, never a model. A tier with no granted model is a 409, and the
cap is the owner's policy: a hire at the cap is a 409
* firing archives the member; the last active manager cannot be fired (409)
* a member without ``employees.manage`` gets 403 on hire and fire; a foreign member gets 404
* no response names a model, a provider or an ``ai_model_id``
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_org import build_model_grants
from app.domain.audit.models import AuditAction
from app.domain.identity.models import Role
from app.infrastructure.db.models import AiModelRow, AiProviderRow, AuditEventRow
from tests.conftest import auth_header
from tests.test_engine_api import _base, _join, _make_org
REFUSED_NO_GRANT = "مدلی از این سطح"
REFUSED_CAP = "سقف تعداد کارمندان"
REFUSED_LAST_MANAGER = "آخرین مدیر"
async def _strong_model_id(session_factory) -> str:
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
return next(m.id for m in models if "reasoning" in (m.capabilities or []))
async def _grant_strong(client, owner_token: str, organization_id: str, session_factory) -> str:
"""Give the organization the strong model only, so its tiers are exactly ``strong``."""
model_id = await _strong_model_id(session_factory)
response = await client.put(
f"/api/admin/model-grants/{organization_id}/models/{model_id}",
json={},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
return model_id
async def _set_cap(client, owner_token: str, organization_id: str, cap: int) -> None:
response = await client.put(
f"/api/admin/org-policies/{organization_id}",
json={"max_employees": cap},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
async def _team(client, token: str, organization_id: str) -> dict:
response = await client.get(f"{_base(organization_id)}/team", headers=auth_header(token))
assert response.status_code == 200, response.text
return response.json()
async def _hire(client, token: str, organization_id: str, role: str, tier: str, name: str = ""):
return await client.post(
f"{_base(organization_id)}/team/hire",
json={"role": role, "tier": tier, "name": name},
headers=auth_header(token),
)
async def _forbidden_values(session_factory, model_id: str) -> set[str]:
"""Every model name, admin label, provider name and model id the seed knows, plus this one."""
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
values = {model_id}
for model in models:
values.update({model.internal_name, model.display_name_for_admin, model.id})
values.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
return {value for value in values if len(value) >= 3}
async def test_the_team_view_shows_neutral_tiers_and_the_owners_cap(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-view")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
body = await _team(client, org.owner_token, org.id)
assert body["max_employees"] == cap
assert body["tiers"] == [{"tier": "strong", "label": "قوی"}]
assert body["count"] == len(body["employees"])
roles = {member["role_key"]: member for member in body["employees"]}
# The team was bootstrapped before the grant, so nobody runs on a granted model yet.
assert roles["manager"]["tier"] == "unknown"
assert roles["manager"]["status"] == "paused"
for value in await _forbidden_values(session_factory, model_id):
assert value not in str(body), f"team names {value!r}"
async def test_hire_takes_a_role_and_a_tier_and_returns_the_member(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-hire")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = (await _team(client, org.owner_token, org.id))["count"]
hired = await _hire(
client, org.owner_token, org.id, "developer", "strong", "برنامه‌نویس دوم"
)
assert hired.status_code == 201, hired.text
body = hired.json()
assert body["tier"] == "strong"
assert body["role_key"] == "developer"
assert body["name"] == "برنامه‌نویس دوم"
assert body["status"] == "active"
for value in await _forbidden_values(session_factory, model_id):
assert value not in hired.text, f"hire names {value!r}"
team = await _team(client, org.owner_token, org.id)
assert team["count"] == before + 1
assert body["id"] in {member["id"] for member in team["employees"]}
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == body["id"])
)
)
.scalars()
.all()
)
assert [e.action for e in events] == [str(AuditAction.EMPLOYEE_CREATE)]
assert events[0].metadata_json == {"role_key": "developer", "tier": "strong"}
async def test_a_tier_with_no_granted_model_is_a_conflict(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-tier")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
for tier in ("fast", "balanced", "ultra"):
response = await _hire(client, org.owner_token, org.id, "tester", tier)
assert response.status_code == 409, f"{tier}: {response.text}"
assert REFUSED_NO_GRANT in response.json()["error"]["message"]
async def test_the_cap_is_the_owners_policy_and_a_hire_stops_at_it(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-cap")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
count = (await _team(client, org.owner_token, org.id))["count"]
await _set_cap(client, platform_owner_token, org.id, count + 1)
first = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert first.status_code == 201, first.text
second = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert second.status_code == 409, second.text
assert REFUSED_CAP in second.json()["error"]["message"]
team = await _team(client, org.owner_token, org.id)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
assert team["max_employees"] == cap == count + 1
assert team["count"] == count + 1
async def test_the_last_manager_cannot_be_fired_and_a_fired_member_is_archived(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-fire")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = await _team(client, org.owner_token, org.id)
manager = next(m for m in before["employees"] if m["role_key"] == "manager")
headers = auth_header(org.owner_token)
refused = await client.post(f"{_base(org.id)}/team/{manager['id']}/fire", headers=headers)
assert refused.status_code == 409, refused.text
assert REFUSED_LAST_MANAGER in refused.json()["error"]["message"]
hired = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert hired.status_code == 201, hired.text
fired = await client.post(
f"{_base(org.id)}/team/{hired.json()['id']}/fire", headers=headers
)
assert fired.status_code == 200, fired.text
assert fired.json()["status"] == "archived"
after = await _team(client, org.owner_token, org.id)
assert hired.json()["id"] not in {m["id"] for m in after["employees"]}
assert after["count"] == before["count"]
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == hired.json()["id"])
)
)
.scalars()
.all()
)
actions = [(e.action, e.metadata_json) for e in events]
assert (str(AuditAction.EMPLOYEE_UPDATE), {"action": "fire"}) in actions
async def test_a_member_without_the_capability_is_refused_on_hire_and_fire(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-member")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
member = await _join(
client, session_factory, org.id, Role.MEMBER, "team-member-user@example.com"
)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
hired = await _hire(client, member, org.id, "developer", "strong")
assert hired.status_code == 403, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=auth_header(member))
assert fired.status_code == 403, fired.text
# The member still reads the team.
readable = await client.get(f"{_base(org.id)}/team", headers=auth_header(member))
assert readable.status_code == 200, readable.text
async def test_another_organization_gets_not_found_on_the_team(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-a")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
other = await _make_org(client, "team-b")
headers = auth_header(other.owner_token)
assert (await client.get(f"{_base(org.id)}/team", headers=headers)).status_code == 404
hired = await client.post(
f"{_base(org.id)}/team/hire",
json={"role": "developer", "tier": "strong"},
headers=headers,
)
assert hired.status_code == 404, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=headers)
assert fired.status_code == 404, fired.text
+263
View File
@@ -0,0 +1,263 @@
"""The person's controls on an engine task: pause, resume, cancel and answer.
``Scheduler.user_action`` is the one place these moves are made for a person.
What it defends:
* pause moves a running task to ``paused``; resume brings a paused task back to
triage, and is refused while the wallet is empty (the task stays paused)
* cancel ends the task and releases its worktree
* an answer is accepted only from ``pending_user_decision``; it reaches the task
description and the thread, and the task goes back to triage
* a task of another organization is a 404, never a conflict (tenant isolation)
"""
# ruff: noqa: F811 - the `world` fixture is imported from test_engine_flow; the test parameters
# named `world` are that fixture, which ruff would otherwise read as a redefinition.
from __future__ import annotations
import pytest
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import FakeRepoWorkspace
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.base import new_id
from app.domain.workforce.models import MessageRole, TaskStatus, Thread
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import OrganizationRow, TaskRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import (
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel
from tests.test_engine_flow import (
_calls,
_FundedWallet,
_scheduler,
_task,
_Wallets,
_ZeroWallet,
world, # noqa: F401 - pytest injects this fixture into the tests below
)
def _scheduler_for(session, org, *, workspace=None, wallets=None):
return _scheduler(
session,
org,
scripted=ScriptedModel([]),
sandbox=FakeSandbox(),
workspace=workspace if workspace is not None else FakeRepoWorkspace(),
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=wallets,
)
async def test_pause_moves_a_running_task_to_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
async def test_resume_brings_a_paused_task_back_to_triage_when_funded(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
async def test_resume_with_a_zero_wallet_is_refused_and_the_task_stays_paused(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PAUSED)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_ZeroWallet()))
with pytest.raises(ConflictError) as refused:
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert refused.value.message == BUDGET_PAUSED_MESSAGE
assert state is not None and state.stage is Stage.PAUSED
async def test_resume_is_refused_for_a_task_that_is_not_paused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, wallets=_Wallets(_FundedWallet()))
with pytest.raises(ConflictError, match="فقط کار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="resume",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
async def test_cancel_ends_the_task_and_releases_its_worktree(world, session_factory):
workspace = FakeRepoWorkspace()
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world, workspace=workspace)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="cancel",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
assert state.stage is Stage.CANCELLED
assert task is not None and task.status is TaskStatus.CANCELLED
assert _calls(workspace, "remove_worktree")
async def test_an_answer_reaches_the_description_and_the_thread_then_triage(
world, session_factory
):
async with session_factory() as session:
thread = await ThreadRepository(session).create(
Thread(
organization_id=world.organization_id,
title="گفتگو",
project_id=world.project_id,
)
)
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
row.thread_id = thread.id
await session.commit()
scheduler = _scheduler_for(session, world)
state = await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" نسخهٔ دوم را انجام بده ",
user_id=world.user_id,
)
task = await TaskRepository(session).get(row.id)
messages = await MessageRepository(session).list_for_thread(thread.id)
assert state.stage is Stage.PENDING_MANAGER_REVIEW
assert task is not None
assert task.description.endswith("پاسخ کاربر: نسخهٔ دوم را انجام بده")
assert [(m.role, m.content) for m in messages] == [
(MessageRole.USER, "نسخهٔ دوم را انجام بده")
]
async def test_an_answer_is_refused_unless_the_task_waits_for_a_decision(
world, session_factory
):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
assert task is not None and "پاسخ کاربر" not in task.description
async def test_a_blank_answer_is_refused(world, session_factory):
async with session_factory() as session:
row = await _task(session, world, stage=Stage.PENDING_USER_DECISION)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ValidationError):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="answer",
answer=" ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_USER_DECISION
async def test_a_legacy_task_without_an_engine_state_is_a_conflict(world, session_factory):
async with session_factory() as session:
row = TaskRow(
organization_id=world.organization_id,
title="کار قدیمی",
project_id=world.project_id,
created_by_user_id=world.user_id,
)
session.add(row)
await session.commit()
scheduler = _scheduler_for(session, world)
with pytest.raises(ConflictError, match="موتور خودکار"):
await scheduler.user_action(
organization_id=world.organization_id,
task_id=row.id,
action="pause",
user_id=world.user_id,
)
async def test_another_organization_gets_not_found_for_every_control(world, session_factory):
async with session_factory() as session:
other = OrganizationRow(
name="سازمان دیگر",
slug=f"other-{new_id()[:6]}",
owner_user_id=world.user_id,
)
session.add(other)
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
await session.commit()
scheduler = _scheduler_for(session, world)
for action in ("pause", "resume", "cancel", "answer"):
with pytest.raises(NotFoundError):
await scheduler.user_action(
organization_id=other.id,
task_id=row.id,
action=action,
answer="پاسخ",
user_id=world.user_id,
)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
+67 -10
View File
@@ -14,7 +14,8 @@ git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
* red tests and a forbidden diff send the work *back* with cycle + 1 instead of * red tests and a forbidden diff send the work *back* with cycle + 1 instead of
merging it merging it
* ``analysis_only`` never creates a worktree * ``analysis_only`` never creates a worktree
* an overdrawn wallet pauses the task before a single turn is spent * an empty, overdrawn or missing wallet pauses the task before a single turn is
spent (prepaid billing, decision D-B)
* a transient provider failure is retried, then handed to the manager * a transient provider failure is retried, then handed to the manager
""" """
@@ -92,13 +93,19 @@ class _OverdrawnWallet:
class _ZeroWallet: class _ZeroWallet:
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org.""" """A wallet at exactly zero: prepaid, so no credit is left."""
balance = 0.0 balance = 0.0
class _FundedWallet:
"""A wallet with credit left: the org may spend a turn."""
balance = 5.0
class _Wallets: class _Wallets:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None: def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | None) -> None:
self._wallet = wallet self._wallet = wallet
async def get_for_org(self, organization_id: str): async def get_for_org(self, organization_id: str):
@@ -567,14 +574,12 @@ async def test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent(
assert BUDGET_PAUSED_MESSAGE assert BUDGET_PAUSED_MESSAGE
async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory): async def test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent(world, session_factory):
"""A 0.0 balance is every org's starting state, so it must not pause the task. """A zero balance is no credit left: the task pauses before a turn is spent.
This is an intentional behaviour change. The scheduler used to pause on Prepaid billing (decision D-B) reverses the post-paid rule of the earlier plan
``balance <= 0``, which froze every new org before its first turn: usage is (its T3), under which a zero balance was every new org's normal state and did
post-paid and nothing debits a wallet. Only an overdrawn wallet (below zero) not pause. Now the platform owner credits an org before any work runs.
pauses now. The old test that encoded the previous rule was renamed and
updated, as PLAN-v2 §7.4 requires for a deliberate behaviour change.
""" """
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")]) scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace() workspace = FakeRepoWorkspace()
@@ -594,6 +599,58 @@ async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
) )
await _run(scheduler, session, 1) await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id) state = await PipelineStateRepository(session).get(row.id)
task = await TaskRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
assert scripted.calls == 0
async def test_a_missing_wallet_pauses_the_task(world, session_factory):
"""No wallet row at all counts as zero credit (prepaid): the task pauses."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(None),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PAUSED
assert scripted.calls == 0
async def test_a_funded_wallet_does_not_pause(world, session_factory):
"""Credit left means the org may spend a turn: the task is not paused."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
workspace = FakeRepoWorkspace()
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=workspace,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_Wallets(_FundedWallet()),
)
await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is not Stage.PAUSED assert state is not None and state.stage is not Stage.PAUSED
assert scripted.calls == 1 assert scripted.calls == 1
+171
View File
@@ -0,0 +1,171 @@
"""The engine repositories behind the v2 UI: read and write paths.
What these tests defend:
* ``last_diff`` and ``merged_sha`` are patched under the version lock and read
back unchanged (the task page shows both)
* the organization turn feed never returns another organization's turns, even
when both organizations worked in the same window (turns carry no
organization column, so the tenant filter goes through the task)
* stage counts are per organization: one organization's board does not count
another organization's tasks
"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest_asyncio
from app.domain.base import new_id, utcnow
from app.domain.workforce.engine import EngineTurn
from app.domain.workforce.models import RoleKey
from app.domain.workforce.pipeline import Mode, Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, RunRow, TaskRow, UserRow
from app.infrastructure.repositories.engine import (
EngineTurnRepository,
PipelineStateRepository,
)
@pytest_asyncio.fixture
async def two_orgs(session_factory):
"""Two organizations, each with its own owner: the tenant boundary under test."""
org_ids: list[str] = []
async with session_factory() as session:
for label in ("الف", "ب"):
user = UserRow(email=f"repo-ui-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"repo-ui-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
org_ids.append(org.id)
await session.commit()
return session_factory, org_ids
async def _add_task(session_factory, organization_id: str, title: str) -> str:
async with session_factory() as session:
task = TaskRow(organization_id=organization_id, title=title)
session.add(task)
await session.commit()
return task.id
async def _add_run(session_factory, task_id: str) -> str:
async with session_factory() as session:
run = RunRow(task_id=task_id)
session.add(run)
await session.commit()
return run.id
async def _add_state(
session_factory, organization_id: str, task_id: str, stage: Stage
) -> TaskPipelineState:
async with session_factory() as session:
state = await PipelineStateRepository(session).create(
TaskPipelineState(task_id=task_id, organization_id=organization_id, stage=stage)
)
await session.commit()
return state
async def _add_turn(
session_factory, task_id: str, run_id: str, excerpt: str, started_at: datetime
) -> None:
async with session_factory() as session:
await EngineTurnRepository(session).create(
EngineTurn(
task_id=task_id,
run_id=run_id,
role=RoleKey.DEVELOPER,
mode=Mode.WORK,
stage=Stage.PENDING_IMPLEMENTATION,
reply_excerpt=excerpt,
started_at=started_at,
)
)
await session.commit()
async def test_last_diff_and_merged_sha_are_patched_and_read_back(two_orgs):
session_factory, (org_a, _org_b) = two_orgs
task_id = await _add_task(session_factory, org_a, "کار دیف")
state = await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
assert state.last_diff == ""
assert state.merged_sha == ""
diff_text = "diff --git a/app.py b/app.py\n+print('سلام')\n"
sha = "ab12cd34" * 5
async with session_factory() as session:
patched = await PipelineStateRepository(session).patch(
task_id=task_id,
expected_version=state.version,
last_diff=diff_text,
merged_sha=sha,
)
await session.commit()
assert patched.last_diff == diff_text
assert patched.merged_sha == sha
assert patched.version == state.version + 1
async with session_factory() as session:
read_back = await PipelineStateRepository(session).get_or_raise(task_id)
assert read_back.last_diff == diff_text
assert read_back.merged_sha == sha
async def test_the_org_turn_feed_never_returns_another_organizations_turns(two_orgs):
session_factory, (org_a, org_b) = two_orgs
base = utcnow()
task_a = await _add_task(session_factory, org_a, "کار الف")
task_b = await _add_task(session_factory, org_b, "کار ب")
run_a = await _add_run(session_factory, task_a)
run_b = await _add_run(session_factory, task_b)
# Interleaved in time, and org B's turn is the newest of all.
await _add_turn(session_factory, task_a, run_a, "الف-یک", base + timedelta(seconds=1))
await _add_turn(session_factory, task_b, run_b, "ب-دو", base + timedelta(seconds=2))
await _add_turn(session_factory, task_a, run_a, "الف-سه", base + timedelta(seconds=3))
await _add_turn(session_factory, task_b, run_b, "ب-ده", base + timedelta(seconds=10))
async with session_factory() as session:
repo = EngineTurnRepository(session)
feed_a = await repo.list_for_org(org_a)
feed_b = await repo.list_for_org(org_b)
newest_a = await repo.list_for_org(org_a, limit=1)
assert [t.reply_excerpt for t in feed_a] == ["الف-سه", "الف-یک"]
assert all(t.task_id == task_a for t in feed_a)
assert [t.reply_excerpt for t in feed_b] == ["ب-ده", "ب-دو"]
assert all(t.task_id == task_b for t in feed_b)
assert [t.reply_excerpt for t in newest_a] == ["الف-سه"]
async def test_stage_counts_are_per_organization(two_orgs):
session_factory, (org_a, org_b) = two_orgs
for title in ("الف یک", "الف دو"):
task_id = await _add_task(session_factory, org_a, title)
await _add_state(session_factory, org_a, task_id, Stage.PENDING_IMPLEMENTATION)
review_id = await _add_task(session_factory, org_a, "الف سه")
await _add_state(session_factory, org_a, review_id, Stage.PENDING_MANAGER_REVIEW)
b_task = await _add_task(session_factory, org_b, "ب یک")
await _add_state(session_factory, org_b, b_task, Stage.PENDING_IMPLEMENTATION)
async with session_factory() as session:
repo = PipelineStateRepository(session)
counts_a = await repo.stage_counts(org_a)
counts_b = await repo.stage_counts(org_b)
at_implementation = await repo.count_at_by_org(Stage.PENDING_IMPLEMENTATION)
at_review = await repo.count_at_by_org(Stage.PENDING_MANAGER_REVIEW)
assert counts_a == {
str(Stage.PENDING_IMPLEMENTATION): 2,
str(Stage.PENDING_MANAGER_REVIEW): 1,
}
assert counts_b == {str(Stage.PENDING_IMPLEMENTATION): 1}
assert at_implementation == {org_a: 2, org_b: 1}
assert at_review == {org_a: 1}
+150
View File
@@ -0,0 +1,150 @@
"""Every stage move publishes ``task.step`` once, on the task and the org channel.
What these tests defend:
* a legal move publishes exactly one ``task.step``: the stage it left, the stage
it reached, the pipeline and the coarse status, on ``task:<id>`` and ``org:<id>``
* a refused move (an illegal transition) publishes nothing, so a client is never
told that a task moved when it did not
* a broken realtime bus cannot undo a move that already happened
* the payload names stages and statuses only, never a model or an employee
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from app.application.engine.state import advance
from app.application.ports.realtime import (
RealtimeEvent,
get_publisher,
org_channel,
set_publisher,
task_channel,
)
from app.core.errors import ConflictError
from app.domain.base import new_id
from app.domain.workforce.models import TaskStatus
from app.domain.workforce.pipeline import Stage, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
class _Recorder:
"""Stands in for the realtime bus and keeps every event it is given."""
def __init__(self) -> None:
self.events: list[RealtimeEvent] = []
async def publish(self, event: RealtimeEvent) -> None:
self.events.append(event)
class _BrokenBus:
async def publish(self, event: RealtimeEvent) -> None:
raise RuntimeError("bus is down")
@pytest_asyncio.fixture
async def recorder():
"""Install a recording publisher for one test and put the previous one back."""
previous = get_publisher()
bus = _Recorder()
set_publisher(bus)
try:
yield bus
finally:
set_publisher(previous)
@pytest_asyncio.fixture
async def one_task(session_factory):
"""One task at ``pending_manager_review`` in a fresh organization."""
async with session_factory() as session:
user = UserRow(email=f"step-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان گام", slug=f"step-{new_id()[:8]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
task = TaskRow(organization_id=org.id, title="کار گام")
session.add(task)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=task.id,
organization_id=org.id,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
await session.commit()
return session_factory, org.id, task.id
def _steps(bus: _Recorder) -> list[RealtimeEvent]:
return [event for event in bus.events if event.type == "task.step"]
async def test_a_legal_move_publishes_one_task_step_on_both_channels(one_task, recorder):
session_factory, org_id, task_id = one_task
async with session_factory() as session:
moved = await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.PENDING_ANALYSIS,
)
await session.commit()
assert moved.stage is Stage.PENDING_ANALYSIS
steps = _steps(recorder)
assert len(steps) == 1
step = steps[0]
assert step.channels == (task_channel(task_id), org_channel(org_id))
assert step.organization_id == org_id
assert step.data == {
"task_id": task_id,
"from": str(Stage.PENDING_MANAGER_REVIEW),
"to": str(Stage.PENDING_ANALYSIS),
"pipeline": str(moved.pipeline),
"status": TaskStatus.RUNNING.value,
}
async def test_a_refused_move_publishes_nothing(one_task, recorder):
session_factory, _org_id, task_id = one_task
async with session_factory() as session:
with pytest.raises(ConflictError):
await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.COMPLETED,
)
await session.rollback()
assert _steps(recorder) == []
async def test_a_broken_bus_does_not_undo_the_move(one_task):
session_factory, _org_id, task_id = one_task
previous = get_publisher()
set_publisher(_BrokenBus())
try:
async with session_factory() as session:
moved = await advance(
PipelineStateRepository(session),
task_id=task_id,
expected_from=Stage.PENDING_MANAGER_REVIEW,
to=Stage.PENDING_ANALYSIS,
)
await session.commit()
finally:
set_publisher(previous)
assert moved.stage is Stage.PENDING_ANALYSIS
async with session_factory() as session:
stored = await PipelineStateRepository(session).get_or_raise(task_id)
assert stored.stage is Stage.PENDING_ANALYSIS
+68
View File
@@ -0,0 +1,68 @@
"""The scheduler stores what the task page shows: the last diff and the merged sha.
What this test defends:
* the diff the diff guard accepted is kept on the state (``last_diff``), so the
task page can show it after the worktree is gone
* the sha the platform pushed to ``dev`` is kept on the state (``merged_sha``),
so the task page can link to it
The flow fixtures and the scripted replies come from ``tests.test_engine_flow``.
"""
from __future__ import annotations
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import DiffResult, FakeRepoWorkspace
from app.domain.workforce.pipeline import Stage
from app.infrastructure.repositories.engine import PipelineStateRepository
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel, ScriptedReply
from tests.test_engine_flow import _run, _scheduler, _task, _team
from tests.test_engine_flow import world as flow_world
#: The flow tests' organization fixture. Binding it under its own name registers it
#: for this module's tests (a bare import would be flagged as unused and shadowed).
world = flow_world
async def test_the_last_diff_and_the_merged_sha_are_stored_on_the_state(world, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_review_test"/]]'),
ScriptedReply(content="[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]"),
ScriptedReply(content="[[WORK_REPORT]]پیاده‌سازی شد[[/WORK_REPORT]]"),
ScriptedReply(content="[[REVIEW_PASS]]قابل قبول است[[/REVIEW_PASS]]"),
ScriptedReply(content="[[TEST_PASS]]سبز است[[/TEST_PASS]]"),
ScriptedReply(content="[[COMPLETE/]]"),
]
)
sandbox = FakeSandbox()
workspace = FakeRepoWorkspace()
memory = RecordingMemory()
delivery = NullDeliveryHooks()
diff_text = "--- a/app/main.py\n+++ b/app/main.py\n@@\n+x = 1\n"
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
workspace.diffs[row.id] = DiffResult(changed_files=["app/main.py"], text=diff_text)
await session.commit()
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=sandbox,
workspace=workspace,
memory=memory,
delivery=delivery,
)
await _run(scheduler, session, 7)
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.COMPLETED
# The fake workspace pushes the merged commit to ``dev``; that is the sha the
# platform reports, and it must be the one stored on the state.
pushed_sha = workspace.heads[(world.project_id, "dev")]
assert len(pushed_sha) == 40
assert state.last_diff == diff_text
assert state.merged_sha == pushed_sha
+16 -14
View File
@@ -429,25 +429,27 @@ async def test_a_failed_task_run_is_persisted_with_its_tenant(
seeded, client: AsyncClient, session_factory, monkeypatch seeded, client: AsyncClient, session_factory, monkeypatch
): ):
"""A task that cannot run is a tenant-visible problem, so the row must name """A task that cannot run is a tenant-visible problem, so the row must name
the organization — otherwise nobody can find it in the console.""" the organization — otherwise nobody can find it in the console.
Decision D-D: the legacy drain is the path under test, so the task is a pre-v2 row
(no pipeline state); a task made over HTTP is the engine's now.
"""
from app.application.services.workforce_service import WorkforceService from app.application.services.workforce_service import WorkforceService
from app.workers.ai_runtime import AiRuntimeWorker from app.workers.ai_runtime import AiRuntimeWorker
from tests.test_workers import _make_org_with_employee from tests.legacy_tasks import legacy_task
from tests.test_workers import _make_org_with_employee, _owner_of
org_id, headers, project_id, employee_id = await _make_org_with_employee( org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "errlog-worker@example.com" client, "errlog-worker@example.com"
) )
task_id = ( task_id = await legacy_task(
await client.post( session_factory,
f"/api/organizations/{org_id}/tasks", org_id,
json={ await _owner_of(session_factory, org_id),
"title": "کاری که شکست می‌خورد", title="کاری که شکست می‌خورد",
"project_id": project_id, project_id=project_id,
"assigned_employee_id": employee_id, assigned_employee_id=employee_id,
}, )
headers=headers,
)
).json()["id"]
async def _boom(*args, **kwargs): async def _boom(*args, **kwargs):
raise RuntimeError("provider returned a malformed response") raise RuntimeError("provider returned a malformed response")
@@ -22,6 +22,7 @@ from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import AiModelRow from app.infrastructure.db.models import AiModelRow
from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
from tests.engine_fakes import ScriptedModel, ScriptedReply from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete from tests.test_org_wizard import _complete
@@ -78,6 +79,9 @@ async def test_a_manager_order_over_http_gets_a_pipeline_state(
assert created.json()["project_id"] is None assert created.json()["project_id"] is None
thread_id = created.json()["id"] thread_id = created.json()["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The order goes through the real message route. # The order goes through the real message route.
posted = await client.post( posted = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages", f"/api/organizations/{org_id}/threads/{thread_id}/messages",
@@ -16,6 +16,7 @@ from app.application.container_engine import build_scheduler
from app.infrastructure.db.models import AiModelRow, OrganizationRow from app.infrastructure.db.models import AiModelRow, OrganizationRow
from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository from app.infrastructure.repositories.workforce import TaskRepository
from tests.billing_helpers import fund_org
from tests.conftest import auth_header from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager from tests.test_org_wizard import _complete, _manager
@@ -58,6 +59,9 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
org_id = body["organization"]["id"] org_id = body["organization"]["id"]
project_id = body["project"]["id"] project_id = body["project"]["id"]
# prepaid (D-B): the wizard's org starts at zero credit, and an order costs model calls.
await fund_org(session_factory, org_id)
# The web UI's way: a thread with no project_id. # The web UI's way: a thread with no project_id.
created = await client.post( created = await client.post(
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
@@ -66,8 +70,10 @@ async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
assert created.json()["project_id"] is None assert created.json()["project_id"] is None
thread_id = created.json()["id"] thread_id = created.json()["id"]
# The order goes to the engine's manager. The HTTP message route builds its manager # The order goes to the engine's manager. The HTTP message route builds its manager with
# without pipeline states (workforce_service._manager_turns), so it cannot show the state. # the pipeline states too (workforce_service._manager_turns, covered by
# test_integration_order_route.py); this test calls the manager directly only to read the
# reply and the states in the same session.
async with session_factory() as session: async with session_factory() as session:
owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id
_, assistant = await build_scheduler(session).manager.chat( _, assistant = await build_scheduler(session).manager.chat(
+9
View File
@@ -36,6 +36,7 @@ from app.core.config import settings
from app.core.errors import ValidationError from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role from app.domain.identity.models import PlatformRole, Role
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import ( from app.infrastructure.db.models import (
AuditEventRow, AuditEventRow,
MembershipRow, MembershipRow,
@@ -49,6 +50,7 @@ from app.infrastructure.db.models_delivery import (
ProductionDeployRow, ProductionDeployRow,
) )
from app.infrastructure.db.models_org import McpTokenRow from app.infrastructure.db.models_org import McpTokenRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.interfaces.mcp.gateway import McpGateway from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
@@ -348,6 +350,7 @@ async def test_pipeline_status_reports_the_delivery_state_and_no_host_details(
async def test_a_task_created_through_a_token_is_attributed_to_its_minter( async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded session_factory, seeded
): ):
"""The task is attributed to the minter and enters the engine at manager review (D-D)."""
org_id, user_id = await _org(session_factory, "task-org") org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory) gateway = await _gateway(session_factory)
token = await _token( token = await _token(
@@ -374,6 +377,12 @@ async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
assert row is not None assert row is not None
assert row.created_by_user_id == user_id assert row.created_by_user_id == user_id
# Decision D-D: a task created through the gateway enters the engine at manager review.
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
assert state is not None
assert state.stage is Stage.PENDING_MANAGER_REVIEW
# …and a task from another organization is a 404, not data. # …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other") other_org, _ = await _org(session_factory, "task-other")
other_token = await _token( other_token = await _token(
+9 -2
View File
@@ -16,6 +16,7 @@ from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
from app.application.container_org import build_model_grant_service, build_model_grants from app.application.container_org import build_model_grant_service, build_model_grants
from app.application.engine.manager import BOOTSTRAP_TIERS
from app.core.config import settings from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
@@ -323,13 +324,18 @@ async def test_policy_defaults_caps_and_audit(
session_factory, session_factory,
seeded, seeded,
): ):
"""D-A: the default cap is 7 employees per organization (it was 5).
The starting team (``BOOTSTRAP_TIERS``, 4 employees) must fit under that default.
"""
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org") org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
owner = auth_header(platform_owner_token) owner = auth_header(platform_owner_token)
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner) fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
assert fresh.status_code == 200, fresh.text assert fresh.status_code == 200, fresh.text
body = fresh.json() body = fresh.json()
assert body["max_employees"] == 5 assert body["max_employees"] == 7
assert len(BOOTSTRAP_TIERS) <= body["max_employees"]
assert body["automation_enabled"] is True assert body["automation_enabled"] is True
assert body["mcp_enabled"] is True assert body["mcp_enabled"] is True
assert body["monthly_credit_budget"] is None assert body["monthly_credit_budget"] is None
@@ -383,6 +389,7 @@ async def test_policy_defaults_caps_and_audit(
async def test_policy_is_isolated_per_organization( async def test_policy_is_isolated_per_organization(
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
): ):
"""D-A: a cap written for one organization leaves the other at the default of 7."""
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org") org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org") org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
owner = auth_header(platform_owner_token) owner = auth_header(platform_owner_token)
@@ -392,4 +399,4 @@ async def test_policy_is_isolated_per_organization(
a = await client.get(f"{POLICIES}/{org_a}", headers=owner) a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
b = await client.get(f"{POLICIES}/{org_b}", headers=owner) b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
assert a.json()["max_employees"] == 2 assert a.json()["max_employees"] == 2
assert b.json()["max_employees"] == 5 assert b.json()["max_employees"] == 7
+16 -13
View File
@@ -127,13 +127,18 @@ async def test_platform_owner_is_not_joined_to_any_organization(
async def test_seeded_install_can_use_the_product( async def test_seeded_install_can_use_the_product(
client: AsyncClient, seeded_install client: AsyncClient, seeded_install, session_factory
): ):
"""The seed's platform rows are enough to create a project and run a task. """The seed's platform rows are enough to create a project and run a task.
A seed that satisfies the login test but leaves the catalogue empty would A seed that satisfies the login test but leaves the catalogue empty would
still fail the moment a developer tried anything real. still fail the moment a developer tried anything real.
Decision D-D: the run is the legacy path's, so the task is a pre-v2 row (no pipeline
state). A task made over HTTP enters the engine and is refused by the run button.
""" """
from tests.legacy_tasks import legacy_task
login = await client.post( login = await client.post(
"/api/auth/login", "/api/auth/login",
json={ json={
@@ -142,7 +147,8 @@ async def test_seeded_install_can_use_the_product(
}, },
) )
headers = auth_header(login.json()["access_token"]) headers = auth_header(login.json()["access_token"])
org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"] me = (await client.get("/api/auth/me", headers=headers)).json()
org_id = me["organizations"][0]["id"]
# The platform catalogue the seed installs must be visible to a tenant. # The platform catalogue the seed installs must be visible to a tenant.
templates = await client.get( templates = await client.get(
@@ -177,17 +183,14 @@ async def test_seeded_install_can_use_the_product(
) )
).json()["id"] ).json()["id"]
task_id = ( task_id = await legacy_task(
await client.post( session_factory,
f"/api/organizations/{org_id}/tasks", org_id,
json={ me["user"]["id"],
"title": "کار seed", title="کار seed",
"project_id": project_id, project_id=project_id,
"assigned_employee_id": employee_id, assigned_employee_id=employee_id,
}, )
headers=headers,
)
).json()["id"]
run = await client.post( run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{task_id}/runs",
+6 -2
View File
@@ -38,6 +38,7 @@ from app.infrastructure.repositories.delivery import PipelineRunRepository
from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository
from app.workers.ai_runtime import AiRuntimeWorker from app.workers.ai_runtime import AiRuntimeWorker
from tests.billing_helpers import fund_org
from tests.conftest import auth_header from tests.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager from tests.test_org_wizard import _complete, _manager
@@ -107,6 +108,8 @@ async def test_a_manager_order_ships_through_the_engine(
org_id = body["organization"]["id"] org_id = body["organization"]["id"]
slug = body["organization"]["slug"] slug = body["organization"]["slug"]
project_id = body["project"]["id"] project_id = body["project"]["id"]
# prepaid: the owner credits a new org before work
await fund_org(session_factory, org_id, 1000.0)
async with session_factory() as session: async with session_factory() as session:
roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)} roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)}
@@ -132,8 +135,9 @@ async def test_a_manager_order_ships_through_the_engine(
thread_id = created.json()["id"] thread_id = created.json()["id"]
# A manager order in that thread becomes one task with a pipeline state. The order goes to # A manager order in that thread becomes one task with a pipeline state. The order goes to
# the engine's manager directly: the HTTP message route builds its manager without pipeline # the engine's manager directly, only to read the reply and the states in this session: the
# states (see test_integration_thread_project.py). # HTTP message route builds its manager with the pipeline states too
# (test_integration_order_route.py).
async with session_factory() as session: async with session_factory() as session:
await build_scheduler(session).manager.chat( await build_scheduler(session).manager.chat(
organization_id=org_id, organization_id=org_id,
+59 -44
View File
@@ -22,11 +22,13 @@ from httpx import AsyncClient
from app.domain.base import utcnow from app.domain.base import utcnow
from app.domain.identity.models import PlatformRole from app.domain.identity.models import PlatformRole
from app.infrastructure.db.models import OrganizationRow
from app.workers import base as worker_base from app.workers import base as worker_base
from app.workers.ai_runtime import AiRuntimeWorker from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.billing import BillingWorker from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker from app.workers.sandbox_gateway import SandboxGatewayWorker
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@@ -89,25 +91,32 @@ async def _make_org_with_employee(
return org_id, headers, project_id, employee_id return org_id, headers, project_id, employee_id
async def _owner_of(session_factory, organization_id: str) -> str:
"""The user who created an organization: the accountable creator of its tasks."""
async with session_factory() as session:
return (await session.get(OrganizationRow, organization_id)).owner_user_id
# -- ai-runtime -------------------------------------------------------------- # # -- ai-runtime -------------------------------------------------------------- #
async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient): async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient, session_factory):
"""A task created without pressing "run" must still be picked up.""" """A task created without pressing "run" must still be picked up.
Decision D-D: the drain picks up pre-v2 tasks (no pipeline state), so the task is one
of those. A task made over HTTP is the engine's, and the scheduler takes it instead.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee( org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime@example.com" client, "worker-runtime@example.com"
) )
task_id = ( task_id = await legacy_task(
await client.post( session_factory,
f"/api/organizations/{org_id}/tasks", org_id,
json={ await _owner_of(session_factory, org_id),
"title": "کار بی‌صاحب", title="کار بی‌صاحب",
"project_id": project_id, project_id=project_id,
"assigned_employee_id": employee_id, assigned_employee_id=employee_id,
}, )
headers=headers,
)
).json()["id"]
# Nothing has run it yet. # Nothing has run it yet.
detail = await client.get( detail = await client.get(
@@ -130,19 +139,21 @@ async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient):
assert runs[0]["status"] == "completed" assert runs[0]["status"] == "completed"
async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient): async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient, session_factory):
"""A closed task must not be re-run — re-running would double-bill.""" """A closed task must not be re-run — re-running would double-bill.
org_id, headers, project_id, employee_id = await _make_org_with_employee(
Decision D-D: a pre-v2 task (no pipeline state), because that is the drain's path.
"""
org_id, _headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-runtime-twice@example.com" client, "worker-runtime-twice@example.com"
) )
await client.post( await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={ org_id,
"title": "کار یک‌باره", await _owner_of(session_factory, org_id),
"project_id": project_id, title="کار یک‌باره",
"assigned_employee_id": employee_id, project_id=project_id,
}, assigned_employee_id=employee_id,
headers=headers,
) )
first = await AiRuntimeWorker().drain() first = await AiRuntimeWorker().drain()
@@ -156,19 +167,21 @@ async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient):
# -- billing-worker ---------------------------------------------------------- # # -- billing-worker ---------------------------------------------------------- #
async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient): async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient, session_factory):
"""Usage in the period must become exactly one draft invoice.""" """Usage in the period must become exactly one draft invoice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee( org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing@example.com" client, "worker-billing@example.com"
) )
await client.post( await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={ org_id,
"title": "کار قابل صورتحساب", await _owner_of(session_factory, org_id),
"project_id": project_id, title="کار قابل صورتحساب",
"assigned_employee_id": employee_id, project_id=project_id,
}, assigned_employee_id=employee_id,
headers=headers,
) )
assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered" assert (await AiRuntimeWorker().drain()).processed == 1, "the run must be metered"
@@ -190,19 +203,21 @@ async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient):
assert body[0]["amount"] > 0, "the invoice must carry the ledger's total" assert body[0]["amount"] > 0, "the invoice must carry the ledger's total"
async def test_billing_roll_up_is_idempotent(client: AsyncClient): async def test_billing_roll_up_is_idempotent(client: AsyncClient, session_factory):
"""Two passes over the same period must not bill twice.""" """Two passes over the same period must not bill twice.
Decision D-D: the usage comes from the drain, so the task is a pre-v2 one.
"""
org_id, headers, project_id, employee_id = await _make_org_with_employee( org_id, headers, project_id, employee_id = await _make_org_with_employee(
client, "worker-billing-twice@example.com" client, "worker-billing-twice@example.com"
) )
await client.post( await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={ org_id,
"title": "کار یک‌بار صورتحساب", await _owner_of(session_factory, org_id),
"project_id": project_id, title="کار یک‌بار صورتحساب",
"assigned_employee_id": employee_id, project_id=project_id,
}, assigned_employee_id=employee_id,
headers=headers,
) )
await AiRuntimeWorker().drain() await AiRuntimeWorker().drain()
+48 -35
View File
@@ -17,6 +17,7 @@ import pytest_asyncio
from httpx import AsyncClient from httpx import AsyncClient
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
from tests.legacy_tasks import legacy_task
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@@ -207,23 +208,24 @@ async def test_tools_catalog_requires_no_secrets(client: AsyncClient):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def test_starting_a_run_produces_a_timeline(client: AsyncClient): async def test_starting_a_run_produces_a_timeline(client: AsyncClient, session_factory):
"""Every run must be reconstructable from its steps.""" """Every run must be reconstructable from its steps.
token = (await signup(client, "wf-run@example.com"))["access_token"]
The task is a pre-v2 row with no pipeline state, so it runs through the legacy path.
A task made over HTTP now enters the engine and is refused here (decision D-D).
"""
signed = await signup(client, "wf-run@example.com")
token = signed["access_token"]
org_id = await _org(client, token) org_id = await _org(client, token)
employee = await _employee(client, token, org_id) employee = await _employee(client, token, org_id)
task = await client.post( task_id = await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={ org_id,
"title": "افزودن تست برای ماژول پرداخت", signed["user"]["id"],
"description": "پوشش تست واحد برای محاسبهٔ تخفیف", title="افزودن تست برای ماژول پرداخت",
"assigned_employee_id": employee["id"], assigned_employee_id=employee["id"],
},
headers=auth_header(token),
) )
assert task.status_code == 201, task.text
task_id = task.json()["id"]
run = await client.post( run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -241,17 +243,20 @@ async def test_starting_a_run_produces_a_timeline(client: AsyncClient):
) )
async def test_run_payload_never_leaks_model_identity(client: AsyncClient): async def test_run_payload_never_leaks_model_identity(client: AsyncClient, session_factory):
token = (await signup(client, "wf-runsecret@example.com"))["access_token"] """The legacy run's payload names no model (a pre-v2 task, decision D-D)."""
signed = await signup(client, "wf-runsecret@example.com")
token = signed["access_token"]
org_id = await _org(client, token) org_id = await _org(client, token)
employee = await _employee(client, token, org_id) employee = await _employee(client, token, org_id)
task = await client.post( task_id = await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]}, org_id,
headers=auth_header(token), signed["user"]["id"],
title="کار برای بررسی نشت",
assigned_employee_id=employee["id"],
) )
task_id = task.json()["id"]
run = await client.post( run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee["id"]}, json={"employee_id": employee["id"]},
@@ -263,17 +268,20 @@ async def test_run_payload_never_leaks_model_identity(client: AsyncClient):
assert forbidden not in payload, f"{forbidden} leaked in run payload" assert forbidden not in payload, f"{forbidden} leaked in run payload"
async def test_task_detail_lists_its_runs(client: AsyncClient): async def test_task_detail_lists_its_runs(client: AsyncClient, session_factory):
token = (await signup(client, "wf-taskdetail@example.com"))["access_token"] """A pre-v2 task (no pipeline state, decision D-D) keeps its runs on its detail."""
signed = await signup(client, "wf-taskdetail@example.com")
token = signed["access_token"]
org_id = await _org(client, token) org_id = await _org(client, token)
employee = await _employee(client, token, org_id) employee = await _employee(client, token, org_id)
task = await client.post( task_id = await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]}, org_id,
headers=auth_header(token), signed["user"]["id"],
title="کار با چند اجرا",
assigned_employee_id=employee["id"],
) )
task_id = task.json()["id"]
run = await client.post( run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -292,19 +300,24 @@ async def test_task_detail_lists_its_runs(client: AsyncClient):
assert runs[0]["task_id"] == task_id assert runs[0]["task_id"] == task_id
async def test_a_closed_task_cannot_be_run_again(client: AsyncClient): async def test_a_closed_task_cannot_be_run_again(client: AsyncClient, session_factory):
"""A successful run closes its task; re-running it must be refused, not """A successful run closes its task; re-running it must be refused, not
silently create a second run against a task that is already done.""" silently create a second run against a task that is already done.
token = (await signup(client, "wf-closedtask@example.com"))["access_token"]
The task is a pre-v2 row (no pipeline state, decision D-D), so this is the legacy path.
"""
signed = await signup(client, "wf-closedtask@example.com")
token = signed["access_token"]
org_id = await _org(client, token) org_id = await _org(client, token)
employee = await _employee(client, token, org_id) employee = await _employee(client, token, org_id)
task = await client.post( task_id = await legacy_task(
f"/api/organizations/{org_id}/tasks", session_factory,
json={"title": "کار بسته‌شده", "assigned_employee_id": employee["id"]}, org_id,
headers=auth_header(token), signed["user"]["id"],
title="کار بسته‌شده",
assigned_employee_id=employee["id"],
) )
task_id = task.json()["id"]
first = await client.post( first = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs", f"/api/organizations/{org_id}/tasks/{task_id}/runs",
@@ -0,0 +1,417 @@
"use client";
/**
* Platform owner console: the prepaid credit of every organization (D-B).
*
* The owner adds credit by hand. Each addition writes one history row and one audit row on
* the server, and an organization's engine work runs again once its balance is above zero.
* A balance at or below zero pauses that work, so its row says so. The page reloads after
* each action: the wallet event goes to the organization's channel, not to the admin channel.
*
* Nothing here names a model or a provider. The admin layout guards the route; the API is
* the real boundary.
*/
import * as React from "react";
import {
Badge,
Button,
Drawer,
EmptyState,
ErrorState,
Field,
Modal,
Stat,
Table,
type TableColumn,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type CreditTransaction, type OrgBalance, creditApi } from "@/lib/api-credit";
/** Mirrors ``MAX_OWNER_CREDIT`` in ``app/application/services/credit_service.py``. */
const MAX_OWNER_CREDIT = 100_000;
/** Mirrors the service: a reason of 3 to 500 characters once trimmed. */
const REASON_MIN = 3;
const REASON_MAX = 500;
/** The open credit dialog: the organization, what was typed, and the step it is on. */
interface CreditDraft {
org: OrgBalance;
amount: string;
reason: string;
step: "edit" | "confirm";
}
/** The open history drawer for one organization. */
interface HistoryView {
org: OrgBalance;
rows: CreditTransaction[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
/** The typed amount as a number, or null when it is not a positive amount within the ceiling. */
function parseAmount(text: string): number | null {
if (text.trim() === "") return null;
const value = Number(text);
return Number.isFinite(value) && value > 0 && value <= MAX_OWNER_CREDIT ? value : null;
}
function isReasonValid(text: string): boolean {
const length = text.trim().length;
return length >= REASON_MIN && length <= REASON_MAX;
}
function money(value: number, currency: string): string {
const formatted = value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
return currency ? `${formatted} ${currency}` : formatted;
}
function dateTime(iso: string | null): string {
return iso ? new Date(iso).toLocaleString("fa-IR") : "—";
}
function historyColumns(currency: string): Array<TableColumn<CreditTransaction>> {
return [
{
key: "when",
header: "زمان",
render: (row) => <span className="text-xs text-slate-400">{dateTime(row.created_at)}</span>,
},
{
key: "amount",
header: "مبلغ",
render: (row) => (
<span className="ltr-inline text-xs text-slate-300">{money(row.amount, currency)}</span>
),
},
{
key: "reason",
header: "دلیل",
render: (row) => <span className="text-xs text-slate-200">{row.reason}</span>,
},
{
key: "after",
header: "موجودی پس از ثبت",
render: (row) => (
<span className="ltr-inline text-xs text-slate-300">
{money(row.balance_after, currency)}
</span>
),
},
];
}
export default function AdminCreditsPage() {
const [rows, setRows] = React.useState<OrgBalance[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [draft, setDraft] = React.useState<CreditDraft | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const [draftError, setDraftError] = React.useState<string | null>(null);
const [history, setHistory] = React.useState<HistoryView | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
// A reload after an action keeps the rows on screen; only the first load shows the loading state.
const load = React.useCallback(async () => {
setError(null);
try {
setRows(await creditApi.balances());
} catch (err) {
setError(errorMessage(err, "دریافت اعتبار سازمان‌ها ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const openCredit = (org: OrgBalance) => {
setDraft({ org, amount: "", reason: "", step: "edit" });
setDraftError(null);
};
const closeCredit = () => {
if (submitting) return;
setDraft(null);
setDraftError(null);
};
const continueCredit = (event: React.FormEvent) => {
event.preventDefault();
if (!draft || parseAmount(draft.amount) === null || !isReasonValid(draft.reason)) return;
setDraft({ ...draft, step: "confirm" });
};
const submitCredit = async () => {
if (!draft) return;
const amount = parseAmount(draft.amount);
if (amount === null || !isReasonValid(draft.reason)) return;
const { org } = draft;
setSubmitting(true);
setDraftError(null);
try {
const transaction = await creditApi.credit(org.organization_id, {
amount,
reason: draft.reason.trim(),
});
setDraft(null);
toast(
"اعتبار افزوده شد",
"success",
`${money(transaction.amount, org.currency)} به «${org.name}» افزوده شد. موجودی: ${money(transaction.balance_after, org.currency)}`,
);
await load();
} catch (err) {
setDraftError(errorMessage(err, "افزودن اعتبار ناموفق بود."));
} finally {
setSubmitting(false);
}
};
const openHistory = async (org: OrgBalance) => {
setHistory({ org, rows: [], loading: true, error: null });
try {
const next = await creditApi.history(org.organization_id);
setHistory((current) =>
current?.org.organization_id === org.organization_id
? { ...current, rows: next, loading: false, error: null }
: current,
);
} catch (err) {
const message = errorMessage(err, "دریافت تاریخچه ناموفق بود.");
setHistory((current) =>
current?.org.organization_id === org.organization_id
? { ...current, loading: false, error: message }
: current,
);
}
};
const columns: Array<TableColumn<OrgBalance>> = [
{
key: "name",
header: "نام",
render: (row) => <span className="text-slate-100">{row.name}</span>,
},
{
key: "slug",
header: "شناسه",
render: (row) => <span className="ltr-inline text-xs text-slate-500">{row.slug}</span>,
},
{
key: "balance",
header: "اعتبار",
render: (row) => (
<div className="flex flex-wrap items-center gap-2">
<span className="ltr-inline text-xs text-slate-300">
{money(row.balance, row.currency)}
</span>
{row.balance <= 0 && <Badge tone="danger">بدون اعتبار</Badge>}
</div>
),
},
{
key: "paused",
header: "کارهای متوقف",
align: "center",
render: (row) => row.paused_tasks,
},
{
key: "last",
header: "آخرین افزایش",
render: (row) => (
<span className="text-xs text-slate-400">{dateTime(row.last_credit_at)}</span>
),
},
{
key: "actions",
header: "عملیات",
align: "end",
render: (row) => (
<div className="flex justify-end gap-2">
<Button size="sm" onClick={() => openCredit(row)}>
افزودن اعتبار
</Button>
<Button size="sm" variant="secondary" onClick={() => void openHistory(row)}>
تاریخچه
</Button>
</div>
),
},
];
const withoutCredit = rows.filter((row) => row.balance <= 0).length;
const pausedTotal = rows.reduce((sum, row) => sum + row.paused_tasks, 0);
const amountValue = draft ? parseAmount(draft.amount) : null;
const reasonOk = draft ? isReasonValid(draft.reason) : false;
const canContinue = amountValue !== null && reasonOk;
const amountInvalid = draft !== null && draft.amount.trim() !== "" && amountValue === null;
const reasonInvalid = draft !== null && draft.reason.trim() !== "" && !reasonOk;
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">اعتبار سازمان‌ها</h1>
<p className="text-sm text-slate-400">
اعتبار پیش‌پرداخت هر سازمان را این‌جا افزایش می‌دهید. هر افزایش یک ردیف تاریخچه و یک
ردیف ممیزی ثبت می‌کند. سازمانی که موجودی آن به صفر برسد، کار کارمندان هوش مصنوعی‌اش
متوقف می‌شود.
</p>
</header>
{!loading && !error && (
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="تعداد سازمان‌ها" value={rows.length} />
<Stat
label="بدون اعتبار"
value={withoutCredit}
tone={withoutCredit > 0 ? "danger" : "neutral"}
/>
<Stat label="کارهای متوقف" value={pausedTotal} />
</div>
)}
<Table
caption="فهرست سازمان‌ها و اعتبار آن‌ها"
columns={columns}
rows={rows}
rowKey={(row) => row.organization_id}
loading={loading}
error={error}
onRetry={() => void load()}
empty={
<EmptyState
title="سازمانی وجود ندارد"
description="هنوز هیچ سازمانی ساخته نشده است."
/>
}
/>
{draft && (
<Modal
open
title={`افزودن اعتبار به «${draft.org.name}»`}
description={
draft.step === "edit"
? "مبلغ و دلیل را وارد کنید. در مرحلهٔ بعد تأیید می‌خواهیم."
: "مبلغ و دلیل را بررسی کنید."
}
onClose={closeCredit}
footer={
draft.step === "edit" ? (
<>
<Button variant="secondary" onClick={closeCredit}>
انصراف
</Button>
<Button type="submit" form="credit-form" disabled={!canContinue}>
ادامه
</Button>
</>
) : (
<>
<Button
variant="secondary"
disabled={submitting}
onClick={() => setDraft({ ...draft, step: "edit" })}
>
بازگشت
</Button>
<Button loading={submitting} onClick={() => void submitCredit()}>
تأیید و افزودن
</Button>
</>
)
}
>
{draft.step === "edit" ? (
<form id="credit-form" onSubmit={continueCredit} className="flex flex-col gap-4">
<Field
label="مبلغ"
inputMode="decimal"
className="ltr-inline"
placeholder="مثلاً 50"
value={draft.amount}
onChange={(event) => setDraft({ ...draft, amount: event.target.value })}
error={
amountInvalid
? `مبلغ باید عددی بیشتر از صفر و حداکثر ${MAX_OWNER_CREDIT.toLocaleString("en-US")} باشد.`
: undefined
}
/>
<Field
label="دلیل"
maxLength={REASON_MAX}
placeholder="مثلاً واریز بانکی"
value={draft.reason}
onChange={(event) => setDraft({ ...draft, reason: event.target.value })}
hint="این دلیل در تاریخچه و گزارش ممیزی ثبت می‌شود."
error={reasonInvalid ? `دلیل باید حداقل ${REASON_MIN} نویسه باشد.` : undefined}
/>
</form>
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-slate-200">
مبلغ{" "}
<span className="ltr-inline">
{money(amountValue ?? 0, draft.org.currency)}
</span>{" "}
به اعتبار «{draft.org.name}» افزوده شود؟
</p>
<p className="text-xs text-slate-400">دلیل: {draft.reason.trim()}</p>
<p className="text-xs text-slate-500">
این عملیات در تاریخچهٔ اعتبار و گزارش ممیزی ثبت می‌شود.
</p>
{draftError && <ErrorState title="افزودن اعتبار ناموفق" message={draftError} />}
</div>
)}
</Modal>
)}
<Drawer open={history !== null} title="تاریخچهٔ اعتبار" onClose={() => setHistory(null)}>
{history && (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-slate-200">{history.org.name}</p>
<Table
caption={`تاریخچهٔ اعتبار «${history.org.name}»`}
columns={historyColumns(history.org.currency)}
rows={history.rows}
rowKey={(row) => row.id}
loading={history.loading}
error={history.error}
onRetry={() => void openHistory(history.org)}
empty={<EmptyState title="هنوز اعتبار دستی ثبت نشده است." />}
/>
</div>
)}
</Drawer>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))}
/>
</div>
);
}
@@ -67,7 +67,7 @@ export default function AdminModelGrantsPage() {
const [toasts, setToasts] = React.useState<ToastMessage[]>([]); const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [budget, setBudget] = React.useState(""); const [budget, setBudget] = React.useState("");
const [maxEmployees, setMaxEmployees] = React.useState("5"); const [maxEmployees, setMaxEmployees] = React.useState("7");
const toast = React.useCallback( const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => { (title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
+21 -5
View File
@@ -21,8 +21,8 @@ import {
Stat, Stat,
UsageBar, UsageBar,
} from "@agy/ui"; } from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime"; import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
@@ -53,8 +53,14 @@ function money(value: number, currency = ""): string {
return currency ? `${formatted} ${currency}` : formatted; return currency ? `${formatted} ${currency}` : formatted;
} }
/** The engine's credit notice for the active organization; empty while credit is there. */
function BillingCreditNotice({ orgId }: { orgId: string }) {
const { status } = useEngineStatus(orgId);
return <CreditNotice status={status} />;
}
export default function BillingPage() { export default function BillingPage() {
const { activeOrganization } = useSession(); const { activeOrganization, user } = useSession();
const orgId = activeOrganization?.id ?? null; const orgId = activeOrganization?.id ?? null;
const [summary, setSummary] = React.useState<BillingSummary | null>(null); const [summary, setSummary] = React.useState<BillingSummary | null>(null);
@@ -189,7 +195,6 @@ export default function BillingPage() {
if (error) return <ErrorState message={error} onRetry={load} />; if (error) return <ErrorState message={error} onRetry={load} />;
const currency = summary?.currency ?? ""; const currency = summary?.currency ?? "";
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
return ( return (
<div className="anim-stagger flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
@@ -201,6 +206,8 @@ export default function BillingPage() {
</p> </p>
</header> </header>
<BillingCreditNotice orgId={activeOrganization.id} />
{summary?.low_balance && ( {summary?.low_balance && (
<Card className="border-warning/50"> <Card className="border-warning/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -223,11 +230,11 @@ export default function BillingPage() {
<Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" /> <Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" />
</div> </div>
{canManage && ( {user?.is_platform_owner && (
<Card> <Card>
<CardTitle>افزایش اعتبار</CardTitle> <CardTitle>افزایش اعتبار</CardTitle>
<CardHint> <CardHint>
افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر افزایش اعتبار دستی توسط مالک سامانه. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر
پرداخت آنلاین هم استفاده کنید. پرداخت آنلاین هم استفاده کنید.
</CardHint> </CardHint>
<form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"> <form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
@@ -270,6 +277,15 @@ export default function BillingPage() {
</Card> </Card>
)} )}
{!user?.is_platform_owner && (
<Card>
<CardHint>
اعتبار سازمان به صورت پیش‌پرداخت توسط مالک سامانه افزوده می‌شود. هر مصرف از همین اعتبار
کم می‌شود.
</CardHint>
</Card>
)}
<Card> <Card>
<CardTitle>تفکیک بر اساس واحد مصرف</CardTitle> <CardTitle>تفکیک بر اساس واحد مصرف</CardTitle>
{!summary || summary.by_unit.length === 0 ? ( {!summary || summary.by_unit.length === 0 ? (
@@ -0,0 +1,224 @@
"use client";
/**
* The engine's summary on the dashboard: the credit balance (with the notice when work has
* stopped), the active tasks, how many tasks sit in each board column, and the latest completed
* tasks.
*
* The credit and the stage counts come from ``useEngineStatus``. The two task lists come from
* ``engineApi.listTasks``, loaded again on every ``task.step`` of the organization. A legacy task
* (no engine stage) is never listed here: the engine does not own it.
*/
import Link from "next/link";
import * as React from "react";
import { Badge, Card, CardHint, CardTitle, ErrorState, Stat } from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
import { BOARD_COLUMNS, STAGE_LABELS, stageTone } from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** How many tasks each list shows. */
const LIST_LIMIT = 5;
/** A task at one of these stages has finished, so it is not active. */
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
interface EngineTasksState {
tasks: EngineTask[];
loading: boolean;
error: string | null;
}
/** The organization's engine tasks, loaded again on every ``task.step``. */
function useEngineTasks(orgId: string): EngineTasksState {
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setTasks(await engineApi.listTasks(orgId));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "فهرست کارها دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.step") void load();
});
return { tasks, loading, error };
}
/** An engine task (one with a stage) that has not finished yet. */
function isActive(task: EngineTask): boolean {
return task.stage !== null && !FINISHED.has(task.stage);
}
function TaskList({
tasks,
empty,
meta,
}: {
tasks: EngineTask[];
empty: string;
meta: (task: EngineTask) => React.ReactNode;
}) {
if (tasks.length === 0) return <p className="text-xs text-slate-500">{empty}</p>;
return (
<ul className="flex flex-col gap-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<Link
href={`/tasks/${task.id}`}
className="min-w-0 truncate text-xs text-slate-200 transition-colors hover:text-accent"
>
{task.title}
</Link>
{meta(task)}
</li>
))}
</ul>
);
}
export function EngineWidgets({ orgId }: { orgId: string }) {
const engine = useEngineStatus(orgId);
const board = useEngineTasks(orgId);
const { status } = engine;
if (status === null) {
return (
<section aria-label="کارمندان هوش مصنوعی">
{engine.error ? (
<ErrorState
title="وضعیت کارمندان دریافت نشد."
message={engine.error}
onRetry={engine.reload}
/>
) : (
<p className="text-xs text-slate-500">در حال دریافت وضعیت کارمندان…</p>
)}
</section>
);
}
const activeAll = board.tasks.filter(isActive);
const active = activeAll.slice(0, LIST_LIMIT);
const completed = board.tasks
.filter((task) => task.stage === "completed")
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))
.slice(0, LIST_LIMIT);
const columns = BOARD_COLUMNS.map((column) => ({
key: column.key,
label: column.label,
count: column.stages.reduce((sum, stage) => sum + (status.stage_counts[stage] ?? 0), 0),
}));
const largest = Math.max(1, ...columns.map((column) => column.count));
return (
<section aria-label="کارمندان هوش مصنوعی" className="flex flex-col gap-4">
<CreditNotice status={status} />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat
label="موجودی اعتبار"
value={
<>
{status.balance.toLocaleString("fa-IR")}{" "}
<span className="ltr-inline text-sm text-slate-400">{status.currency}</span>
</>
}
tone={status.credit_ok ? "neutral" : "danger"}
/>
</div>
{board.error && (
<p role="alert" className="text-xs text-danger">
{board.error}
</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardTitle>کارهای فعال</CardTitle>
<CardHint>کارهایی که هنوز کامل نشده‌اند. برای جزئیات، عنوان کار را بزنید.</CardHint>
<div className="mt-4">
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
{!board.loading && !board.error && (
<TaskList
tasks={active}
empty="کار فعالی وجود ندارد."
meta={(task) =>
task.stage && (
<Badge tone={stageTone(task.stage)}>{STAGE_LABELS[task.stage]}</Badge>
)
}
/>
)}
{!board.loading && !board.error && activeAll.length > LIST_LIMIT && (
<p className="mt-2 text-xs text-slate-500">
و {(activeAll.length - LIST_LIMIT).toLocaleString("fa-IR")} کار دیگر
</p>
)}
</div>
</Card>
<Card>
<CardTitle>توزیع مراحل</CardTitle>
<CardHint>تعداد کارها در هر ستون تابلوی کارها.</CardHint>
<ul aria-label="توزیع مراحل" className="mt-4 flex flex-col gap-3">
{columns.map((column) => (
<li key={column.key} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{column.label}</span>
<span className="text-slate-200">{column.count.toLocaleString("fa-IR")}</span>
</div>
<div
aria-hidden="true"
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-overlay"
>
<div
className="h-full rounded-full bg-accent transition-[width] duration-500 ease-out"
style={{ width: `${Math.round((column.count / largest) * 100)}%` }}
/>
</div>
</li>
))}
</ul>
</Card>
</div>
<Card>
<CardTitle>آخرین کارهای انجام‌شده</CardTitle>
<CardHint>پنج کار آخری که به مرحلهٔ «انجام شد» رسیده‌اند.</CardHint>
<div className="mt-4">
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
{!board.loading && !board.error && (
<TaskList
tasks={completed}
empty="هنوز کاری انجام نشده است."
meta={(task) => (
<span className="text-xs text-slate-500">
{new Date(task.updated_at).toLocaleString("fa-IR")}
</span>
)}
/>
)}
</div>
</Card>
</section>
);
}
@@ -17,6 +17,7 @@ import {
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime"; import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { EngineWidgets } from "./_components/engine-widgets";
export default function DashboardPage() { export default function DashboardPage() {
const { activeOrganization, user } = useSession(); const { activeOrganization, user } = useSession();
@@ -105,6 +106,8 @@ export default function DashboardPage() {
</p> </p>
</header> </header>
<EngineWidgets orgId={activeOrganization.id} />
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="اعتبار" value="—" hint="پس از فاز billing" /> <Stat label="اعتبار" value="—" hint="پس از فاز billing" />
<Stat label="وظایف فعال" value="0" hint="پس از فاز Task engine" /> <Stat label="وظایف فعال" value="0" hint="پس از فاز Task engine" />
@@ -0,0 +1,104 @@
"use client";
/**
* The tasks that wait for a person's answer, shown at the top of the manager page.
*
* A task at ``pending_user_decision`` has asked a question. Each one gets its own answer box and
* «ارسال پاسخ», which calls ``engineApi.answerTask``. A refusal from the server is shown in Persian
* under its box. The banner is hidden while no task waits for an answer.
*/
import Link from "next/link";
import * as React from "react";
import { Button, Card, CardTitle } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
/** The server accepts an answer of 1 to 4,000 characters. */
const MAX_ANSWER_LENGTH = 4000;
export interface DecisionBannerProps {
orgId: string;
/** Any tasks of the organization; only those at ``pending_user_decision`` are shown. */
tasks: EngineTask[];
/** Called once an answer is accepted, so the page reloads its task list. */
onAnswered: () => void;
}
export function DecisionBanner({ orgId, tasks, onAnswered }: DecisionBannerProps) {
const waiting = tasks.filter((task) => task.stage === "pending_user_decision");
if (waiting.length === 0) return null;
return (
<Card className="flex flex-col gap-3 border-warning/50">
<CardTitle>در انتظار تصمیم شما</CardTitle>
<ul className="flex flex-col gap-4">
{waiting.map((task) => (
<li key={task.id}>
<DecisionForm orgId={orgId} task={task} onAnswered={onAnswered} />
</li>
))}
</ul>
</Card>
);
}
interface DecisionFormProps {
orgId: string;
task: EngineTask;
onAnswered: () => void;
}
function DecisionForm({ orgId, task, onAnswered }: DecisionFormProps) {
const [answer, setAnswer] = React.useState("");
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const content = answer.trim();
const fieldId = `decision-${task.id}`;
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!content || sending) return;
setSending(true);
setError(null);
try {
await engineApi.answerTask(orgId, task.id, content);
setAnswer("");
onAnswered();
} catch (err) {
setError(err instanceof ApiError ? err.message : "پاسخ ارسال نشد. دوباره تلاش کنید.");
} finally {
setSending(false);
}
};
return (
<form onSubmit={onSubmit} className="flex flex-col gap-2">
<Link href={`/tasks/${task.id}`} className="text-sm text-slate-100 hover:text-accent">
{task.title}
</Link>
<label htmlFor={fieldId} className="text-xs font-medium text-slate-300">
پاسخ شما
</label>
<textarea
id={fieldId}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
rows={3}
maxLength={MAX_ANSWER_LENGTH}
disabled={sending}
placeholder="پاسخ خود را بنویسید…"
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" loading={sending} disabled={!content}>
ارسال پاسخ
</Button>
</div>
</form>
);
}
@@ -0,0 +1,73 @@
"use client";
/**
* The tasks of the active conversation, shown as compact cards under its messages.
*
* An engine task shows its Persian stage (``STAGE_LABELS``, toned by ``stageTone``). A legacy
* task has no engine stage, so it shows its coarse status instead. Each card links to the task
* page. The manager page patches a card's stage in place on ``task.step``, so the badge moves
* without a reload.
*/
import Link from "next/link";
import { Badge } from "@agy/ui";
import type { EngineTask } from "@/lib/api-engine";
import { STAGE_LABELS, type StageTone, stageTone } from "@/lib/engine-labels";
/** A legacy task has no stage; these are the Persian names of its coarse statuses. */
const LEGACY_LABELS: Record<string, string> = {
new: "جدید",
triaged: "واگذار شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام شد",
failed: "ناموفق",
cancelled: "لغو شد",
};
function legacyTone(status: string): StageTone {
if (status === "done") return "success";
if (status === "failed" || status === "cancelled") return "danger";
if (status === "waiting_approval") return "warning";
return "neutral";
}
export interface ThreadTaskCardsProps {
/** The tasks of the active thread, newest first. Nothing is shown for an empty list. */
tasks: EngineTask[];
}
export function ThreadTaskCards({ tasks }: ThreadTaskCardsProps) {
if (tasks.length === 0) return null;
return (
<section className="flex flex-col gap-2">
<p className="text-xs font-medium text-slate-400">کارهای این گفتگو</p>
<ul className="flex flex-col gap-2">
{tasks.map((task) => (
<li key={task.id}>
<TaskCardLink task={task} />
</li>
))}
</ul>
</section>
);
}
function TaskCardLink({ task }: { task: EngineTask }) {
const label =
task.stage === null
? (LEGACY_LABELS[task.status] ?? task.status)
: (STAGE_LABELS[task.stage] ?? task.stage);
const tone = task.stage === null ? legacyTone(task.status) : stageTone(task.stage);
return (
<Link
href={`/tasks/${task.id}`}
className="flex flex-col gap-1 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs transition-colors hover:border-accent/50"
>
<span className="flex items-start justify-between gap-2">
<span className="min-w-0 break-words text-slate-100">{task.title}</span>
<Badge tone={tone}>{label}</Badge>
</span>
{task.assignee_name && <span className="text-slate-500">{task.assignee_name}</span>}
</Link>
);
}
+58 -49
View File
@@ -1,10 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts"; import type { Employee, Message, TaskStepEventData, Thread } from "@agy/contracts";
import { import {
Badge,
Button, Button,
Card, Card,
CardHint, CardHint,
@@ -15,10 +13,20 @@ import {
LoadingState, LoadingState,
Select, Select,
} from "@agy/ui"; } from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { type EngineStage, type EngineTask, engineApi } from "@/lib/api-engine";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime"; import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { DecisionBanner } from "./_components/decision-banner";
import { ThreadTaskCards } from "./_components/task-cards";
/** The credit notice of one organization; its own component, so no status call runs without one. */
function EngineCreditNotice({ orgId }: { orgId: string }) {
const { status } = useEngineStatus(orgId);
return <CreditNotice status={status} />;
}
/** /**
* Manager Chat — three columns, matching the plan: conversations, the * Manager Chat — three columns, matching the plan: conversations, the
@@ -32,7 +40,8 @@ export default function ManagerPage() {
const [employees, setEmployees] = React.useState<Employee[]>([]); const [employees, setEmployees] = React.useState<Employee[]>([]);
const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null); const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]); const [messages, setMessages] = React.useState<Message[]>([]);
const [tasks, setTasks] = React.useState<Task[]>([]); const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [tasksError, setTasksError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [loadingThread, setLoadingThread] = React.useState(false); const [loadingThread, setLoadingThread] = React.useState(false);
@@ -49,7 +58,18 @@ export default function ManagerPage() {
const bottomRef = React.useRef<HTMLDivElement | null>(null); const bottomRef = React.useRef<HTMLDivElement | null>(null);
// Live feed: new messages in the active thread, and tasks created from chat. // The engine's task list: the conversation's cards and the decision banner read it.
const reloadTasks = React.useCallback(async () => {
if (!orgId) return;
try {
setTasks(await engineApi.listTasks(orgId));
setTasksError(null);
} catch (err) {
setTasksError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
}
}, [orgId]);
// Live feed: new messages in the active thread, tasks created, and stage moves.
const liveChannels = React.useMemo(() => { const liveChannels = React.useMemo(() => {
const base: string[] = []; const base: string[] = [];
if (orgId) base.push(`org:${orgId}`); if (orgId) base.push(`org:${orgId}`);
@@ -79,26 +99,18 @@ export default function ManagerPage() {
} }
break; break;
} }
case "task.created": { case "task.created":
const data = event.data as { task_id: string; title: string; status: string }; void reloadTasks();
setTasks((current) => { break;
if (current.some((t) => t.id === data.task_id)) return current; case "task.step": {
return [ const data = event.data as unknown as TaskStepEventData;
{ setTasks((current) =>
id: data.task_id, current.map((task) =>
organization_id: orgId ?? "", task.id === data.task_id
project_id: null, ? { ...task, stage: data.to as EngineStage, status: data.status }
thread_id: activeThreadId, : task,
title: data.title, ),
description: "", );
status: data.status as TaskStatus,
assigned_employee_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
...current,
];
});
break; break;
} }
default: default:
@@ -117,11 +129,12 @@ export default function ManagerPage() {
const [nextThreads, nextEmployees, nextTasks] = await Promise.all([ const [nextThreads, nextEmployees, nextTasks] = await Promise.all([
api.listThreads(orgId), api.listThreads(orgId),
api.listEmployees(orgId), api.listEmployees(orgId),
api.listTasks(orgId), engineApi.listTasks(orgId),
]); ]);
setThreads(nextThreads); setThreads(nextThreads);
setEmployees(nextEmployees); setEmployees(nextEmployees);
setTasks(nextTasks); setTasks(nextTasks);
setTasksError(null);
setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null); setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود."); setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود.");
@@ -196,13 +209,13 @@ export default function ManagerPage() {
setTaskError(null); setTaskError(null);
setCreatingTask(true); setCreatingTask(true);
try { try {
const task = await api.createTask(orgId, { await api.createTask(orgId, {
title: taskDraft.trim(), title: taskDraft.trim(),
description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`, description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`,
thread_id: activeThreadId, thread_id: activeThreadId,
assigned_employee_id: taskEmployee || null, assigned_employee_id: taskEmployee || null,
}); });
setTasks((current) => [task, ...current]); void reloadTasks();
setTaskDraft(""); setTaskDraft("");
} catch (err) { } catch (err) {
setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود."); setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
@@ -240,6 +253,13 @@ export default function ManagerPage() {
</Button> </Button>
</header> </header>
{orgId && (
<>
<EngineCreditNotice orgId={orgId} />
<DecisionBanner orgId={orgId} tasks={tasks} onAnswered={() => void reloadTasks()} />
</>
)}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[16rem_1fr_20rem]"> <div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[16rem_1fr_20rem]">
{/* Column 1 — conversations */} {/* Column 1 — conversations */}
<Card className="flex min-h-0 flex-col gap-2 overflow-y-auto"> <Card className="flex min-h-0 flex-col gap-2 overflow-y-auto">
@@ -293,6 +313,8 @@ export default function ManagerPage() {
</div> </div>
)) ))
)} )}
<ThreadTaskCards tasks={threadTasks} />
{tasksError && <ErrorState title="دریافت کارها ناموفق بود" message={tasksError} />}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
@@ -318,29 +340,16 @@ export default function ManagerPage() {
</form> </form>
</Card> </Card>
{/* Column 3 — the work this conversation produced */} {/* Column 3 — a new task. The conversation's tasks are the cards under its messages. */}
<Card className="flex min-h-0 flex-col gap-3 overflow-y-auto"> <Card className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<CardTitle>کارهای این گفتگو</CardTitle> <CardTitle>کار جدید</CardTitle>
<CardHint>از گفتگو یک کار بسازید تا کارمند هوش مصنوعی آن را اجرا کند.</CardHint> {canRun ? (
<CardHint>
{threadTasks.length === 0 ? ( کار جدید نخست به مدیر هوش مصنوعی می‌رسد. مدیر آن را بررسی می‌کند و در صورت نیاز به
<p className="text-xs text-slate-500">کاری ساخته نشده است.</p> کارمند مسئول می‌سپارد.
</CardHint>
) : ( ) : (
<ul className="flex flex-col gap-2"> <CardHint>ساخت کار فقط برای اعضایی ممکن است که اجازهٔ اجرای کار دارند.</CardHint>
{threadTasks.map((task) => (
<li key={task.id}>
<Link
href={`/tasks/${task.id}`}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs text-slate-300 hover:border-accent/50"
>
<span className="truncate">{task.title}</span>
<Badge tone={task.status === "done" ? "success" : "neutral"}>
{task.status}
</Badge>
</Link>
</li>
))}
</ul>
)} )}
{canRun && ( {canRun && (
@@ -0,0 +1,60 @@
"use client";
/**
* The last diff of one engine task, read-only, and the sha the change was merged and pushed as.
*
* The diff reads left to right in a monospace block: a line that starts with `+` is green, one that
* starts with `-` is red, and a `@@` hunk header is muted. The text is the engine's and renders as
* text only. Once the task is merged, the short sha shows with a link to the app's pipeline view.
*/
import Link from "next/link";
import { cx } from "@agy/ui";
export interface DiffViewerProps {
/** The task's last diff in unified format; an empty string when none was recorded. */
diff: string;
/** The sha the change was merged and pushed as; an empty string before that. */
mergedSha: string;
}
function lineClass(line: string): string {
if (line.startsWith("+")) return "bg-positive/10 text-positive";
if (line.startsWith("-")) return "bg-danger/10 text-danger";
if (line.startsWith("@@")) return "text-slate-500";
return "text-slate-300";
}
export function DiffViewer({ diff, mergedSha }: DiffViewerProps) {
const text = diff.replace(/\n+$/, "");
const lines = text ? text.split("\n") : [];
return (
<div className="flex flex-col gap-3">
{mergedSha && (
<p className="flex flex-wrap items-center gap-2 text-xs text-slate-300">
<span>ادغام و ارسال شد: </span>
<span dir="ltr" className="font-mono text-slate-100">
{mergedSha.slice(0, 12)}
</span>
<Link href="/app" className="text-accent hover:underline">
مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن
</Link>
</p>
)}
{lines.length === 0 ? (
<p className="text-xs text-slate-500">هنوز تغییری ثبت نشده است.</p>
) : (
<pre
dir="ltr"
className="max-h-[32rem] overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5"
>
{lines.map((line, index) => (
<span key={index} className={cx("block whitespace-pre", lineClass(line))}>
{line || " "}
</span>
))}
</pre>
)}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
/**
* The gate verdicts of one engine task, one row per verdict in the order the engine recorded them.
*
* Each row shows the gate's Persian name, a green or red badge, when the verdict was recorded, and
* a button that opens its detail. The detail is the engine's own text, shown as text in a `pre`.
* When the tests were waived, the reason shows in a warning box.
*/
import * as React from "react";
import { Badge } from "@agy/ui";
import type { EngineGate } from "@/lib/api-engine";
/** The Persian name of each gate the engine runs. An unknown gate shows its own key. */
const GATE_LABELS: Record<string, string> = {
tests: "تست‌ها",
diff_guard: "نگهبان تغییرات",
no_progress: "بدون پیشرفت",
green: "دروازهٔ سبز",
lint: "لینت",
};
export interface GatesPanelProps {
gates: readonly EngineGate[];
/** The reason the tests were waived; an empty string when they were not. */
waiverReason: string;
}
function formatTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
}
function GateRow({ gate }: { gate: EngineGate }) {
const [open, setOpen] = React.useState(false);
const detailId = React.useId();
const label = GATE_LABELS[gate.gate] ?? gate.gate;
return (
<li className="flex flex-col gap-2 py-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-200">{label}</span>
<Badge tone={gate.ok ? "success" : "danger"}>{gate.ok ? "قبول شد" : "رد شد"}</Badge>
<time dateTime={gate.created_at} className="text-xs text-slate-500">
{formatTime(gate.created_at)}
</time>
{gate.detail && (
<button
type="button"
aria-expanded={open}
aria-controls={detailId}
onClick={() => setOpen((value) => !value)}
className="text-xs text-accent hover:underline"
>
{open ? "بستن جزئیات" : "جزئیات"}
</button>
)}
</div>
{open && gate.detail && (
<pre
id={detailId}
dir="ltr"
className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 font-mono text-xs leading-5 text-slate-300"
>
{gate.detail}
</pre>
)}
</li>
);
}
export function GatesPanel({ gates, waiverReason }: GatesPanelProps) {
return (
<div className="flex flex-col gap-3">
{waiverReason && (
<div
role="note"
className="rounded-xl border border-warning/50 bg-warning/10 px-4 py-3 text-xs leading-6 text-warning"
>
صرف‌نظر از تست با دلیل: {waiverReason}
</div>
)}
{gates.length === 0 ? (
<p className="text-xs text-slate-500">هنوز نتیجهٔ دروازه‌ای ثبت نشده است.</p>
) : (
<ul className="divide-y divide-surface-border">
{gates.map((gate) => (
<GateRow key={gate.id} gate={gate} />
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,124 @@
"use client";
/**
* The live log of one engine task: what its runs print, and which tools they called, as the
* server pushes them on the `task:<id>` channel.
*
* A `run.log` event appends all of its lines in one state update; a `run.tool` event appends one
* line in Persian. The panel keeps the newest 2000 lines. It follows the newest line until the
* reader pauses it, with the toggle or by scrolling up by hand. The log body is left-to-right and
* monospace. The lines are server text, and they render as text only.
*/
import * as React from "react";
import type { WsEvent } from "@agy/contracts";
import { Button } from "@agy/ui";
import { useLiveChannel } from "@/lib/realtime";
/** The most lines the panel keeps; the oldest go first. */
const LOG_LIMIT = 2000;
/** How close to the bottom, in pixels, still counts as following the newest line. */
const BOTTOM_SLACK = 8;
interface LogLine {
id: number;
text: string;
}
export interface LiveLogProps {
taskId: string;
}
function keepNewest(lines: LogLine[]): LogLine[] {
return lines.length > LOG_LIMIT ? lines.slice(lines.length - LOG_LIMIT) : lines;
}
export function LiveLog({ taskId }: LiveLogProps) {
const [lines, setLines] = React.useState<LogLine[]>([]);
const [paused, setPaused] = React.useState(false);
const boxRef = React.useRef<HTMLDivElement>(null);
const nextId = React.useRef(0);
// One call per event: every line the event carries is added in a single state update.
const append = React.useCallback((texts: string[]) => {
if (texts.length === 0) return;
const added = texts.map((text) => {
nextId.current += 1;
return { id: nextId.current, text };
});
setLines((previous) => keepNewest([...previous, ...added]));
}, []);
useLiveChannel([`task:${taskId}`], (event: WsEvent) => {
if (event.type === "run.log") {
const data = event.data as { task_id: string; lines: string[] };
if (data.task_id === taskId) append(data.lines);
} else if (event.type === "run.tool") {
const data = event.data as { task_id: string; tool: string; ok: boolean; summary: string };
if (data.task_id !== taskId) return;
const outcome = data.ok ? "موفق" : "ناموفق";
append([
data.summary
? `ابزار ${data.tool}: ${outcome} — ${data.summary}`
: `ابزار ${data.tool}: ${outcome}`,
]);
}
});
// Follow the newest line unless paused. The scroll position is set only here, after the lines
// or the pause state change, so a paused panel never moves.
React.useEffect(() => {
const box = boxRef.current;
if (box && !paused) box.scrollTop = box.scrollHeight;
}, [lines, paused]);
// A scroll up by hand pauses following. Returning to the bottom does not resume it: that is the
// toggle's job, so the reader keeps the position they chose.
const onScroll = () => {
const box = boxRef.current;
if (!box || paused) return;
const distance = box.scrollHeight - box.scrollTop - box.clientHeight;
if (distance > BOTTOM_SLACK) setPaused(true);
};
return (
<section aria-label="گزارش زندهٔ کار" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-slate-400">گزارش زنده · {lines.length.toLocaleString("fa-IR")} سطر</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => setPaused((value) => !value)}>
{paused ? "ادامهٔ پیمایش" : "توقف پیمایش"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={lines.length === 0}
onClick={() => setLines([])}
>
پاک کردن
</Button>
</div>
</div>
<div
ref={boxRef}
role="log"
aria-live="off"
dir="ltr"
onScroll={onScroll}
className="h-72 overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5 text-slate-300"
>
{lines.length === 0 ? (
<p dir="rtl" className="font-sans text-slate-500">
هنوز خروجی‌ای ثبت نشده است.
</p>
) : (
lines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap break-all">
{line.text}
</div>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,91 @@
"use client";
/**
* The stages of one engine task's pipeline, in order, with the task's current stage marked.
*
* The stages before the current one are check-marked, the current one is highlighted, and the
* rest wait. A side state (paused, pending_user_decision, error_review, cancelled, failed) is not
* a stage of the pipeline, so no step is highlighted: the state is shown as a badge beside the
* steps instead. A completed task has every step check-marked. The labels are the engine's
* Persian stage names.
*/
import { Badge, cx } from "@agy/ui";
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
export interface PipelineStepperProps {
/** The stages of the task's pipeline in order (``EngineTaskDetail.stages``). */
stages: readonly string[];
/** The task's current stage (``EngineTask.stage``); null renders nothing. */
current: string | null;
}
type StepState = "done" | "current" | "pending";
const STEP_CLASSES: Record<StepState, string> = {
done: "border-positive/40 text-positive",
current: "border-accent bg-accent-soft font-medium text-accent",
pending: "border-surface-border text-slate-500",
};
function CheckMark() {
return (
<svg
aria-hidden
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.5 8.5l3 3 6-7" />
</svg>
);
}
function stepState(index: number, position: number, finished: boolean): StepState {
if (finished) return "done";
if (position === -1) return "pending";
if (index < position) return "done";
if (index === position) return "current";
return "pending";
}
export function PipelineStepper({ stages, current }: PipelineStepperProps) {
if (current === null) return null;
const position = stages.indexOf(current);
const finished = current === "completed";
const offPath = position === -1 && !finished;
return (
<div dir="rtl" className="flex flex-wrap items-center gap-3">
{stages.length > 0 && (
<ol aria-label="مراحل خط لوله" className="flex flex-wrap items-center gap-2">
{stages.map((stage, index) => {
const state = stepState(index, position, finished);
return (
<li
key={stage}
data-state={state}
aria-current={state === "current" ? "step" : undefined}
className={cx(
"inline-flex items-center gap-2 rounded-lg border px-2.5 py-1 text-xs",
STEP_CLASSES[state],
)}
>
<span aria-hidden className="inline-flex h-4 w-4 items-center justify-center">
{state === "done" ? <CheckMark /> : (index + 1).toLocaleString("fa-IR")}
</span>
<span>{STAGE_LABELS[stage] ?? stage}</span>
{state === "done" && <span className="sr-only">(انجام شد)</span>}
</li>
);
})}
</ol>
)}
{offPath && <Badge tone={stageTone(current)}>{STAGE_LABELS[current] ?? current}</Badge>}
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
/**
* The turns of one engine task as a timeline, in the order the API gives them (oldest first).
*
* Each turn shows who ran (the role in Persian and the employee's name), the stage it ran in, when
* it started, how it ended (موفق, or خطا with the engine's error kind), how many tools it called,
* and the start of its reply in a block the reader opens on demand. Only the fields the API
* defines are read, so a turn never shows a model or provider name, even if one were sent.
*
* ``Timeline`` from ``@agy/ui`` takes plain-text items only, so this list keeps its look but
* can hold the reply block.
*/
import { Badge, EmptyState, cx } from "@agy/ui";
import type { EngineTurn } from "@/lib/api-engine";
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
export interface TurnTimelineProps {
turns: readonly EngineTurn[];
}
function turnTitle(turn: EngineTurn): string {
const role = ROLE_LABELS[turn.role] ?? turn.role;
return turn.employee_name ? `${role} — ${turn.employee_name}` : role;
}
function formatStart(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
}
export function TurnTimeline({ turns }: TurnTimelineProps) {
if (turns.length === 0) {
return <EmptyState title="هنوز نوبتی برای این کار ثبت نشده است." />;
}
return (
<ol aria-label="نوبت‌های کار" className="flex flex-col gap-4">
{turns.map((turn) => (
<li key={turn.id} className="flex gap-3">
<span
aria-hidden
className={cx(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
turn.ok ? "bg-positive" : "bg-danger",
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-xs text-slate-200">{turnTitle(turn)}</p>
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span>{STAGE_LABELS[turn.stage] ?? turn.stage}</span>
<time dateTime={turn.started_at}>{formatStart(turn.started_at)}</time>
<span>{turn.tool_calls.toLocaleString("fa-IR")} فراخوانی ابزار</span>
</p>
<div>
{turn.ok ? (
<Badge tone="success">موفق</Badge>
) : (
<Badge tone="danger">خطا: {turn.error_kind || "نامشخص"}</Badge>
)}
</div>
{turn.reply_excerpt && (
<details className="text-xs">
<summary className="cursor-pointer text-slate-400 hover:text-slate-200">
متن پاسخ
</summary>
<pre
dir="auto"
className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 leading-6 text-slate-200"
>
{turn.reply_excerpt}
</pre>
</details>
)}
</div>
</li>
))}
</ol>
);
}
+257 -17
View File
@@ -15,10 +15,18 @@ import {
Stat, Stat,
Timeline, Timeline,
} from "@agy/ui"; } from "@agy/ui";
import { TaskActions } from "@/components/engine/task-actions";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { type EngineTaskDetail, engineApi } from "@/lib/api-engine";
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime"; import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { DiffViewer } from "./_components/diff-viewer";
import { GatesPanel } from "./_components/gates-panel";
import { LiveLog } from "./_components/live-log";
import { PipelineStepper } from "./_components/pipeline-stepper";
import { TurnTimeline } from "./_components/turn-timeline";
const STATUS_LABELS: Record<TaskStatus, string> = { const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید", new: "جدید",
@@ -45,6 +53,177 @@ const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "da
const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]); const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]);
/** The answer box of a task that waits for the person's decision. */
function AnswerBox({
orgId,
taskId,
lastReply,
onAnswered,
}: {
orgId: string;
taskId: string;
lastReply: string;
onAnswered: () => void;
}) {
const [content, setContent] = React.useState("");
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const answer = content.trim();
if (!answer) return;
setSending(true);
setError(null);
try {
await engineApi.answerTask(orgId, taskId, answer);
setContent("");
onAnswered();
} catch (err) {
setError(err instanceof ApiError ? err.message : "ارسال پاسخ ناموفق بود.");
} finally {
setSending(false);
}
};
return (
<Card className="border-warning/40">
<CardTitle>این کار منتظر تصمیم شماست</CardTitle>
<CardHint>پاسخ خود را بنویسید تا کار از همان مرحله ادامه یابد.</CardHint>
{lastReply && (
<div className="mt-3">
<p className="mb-1 text-xs text-slate-500">آخرین پاسخ ثبت‌شده در این کار</p>
<pre
dir="auto"
className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 text-xs leading-6 text-slate-300"
>
{lastReply}
</pre>
</div>
)}
<form onSubmit={submit} className="mt-3 flex flex-col gap-2">
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={3}
aria-label="پاسخ شما"
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" loading={sending} disabled={!content.trim()}>
ارسال پاسخ
</Button>
</div>
</form>
</Card>
);
}
/** The engine view of a task with a pipeline stage: stepper, live log, gates, diff and turns. */
function EngineTaskView({
orgId,
detail,
canRun,
actionError,
onChanged,
}: {
orgId: string;
detail: EngineTaskDetail;
canRun: boolean;
actionError: string | null;
onChanged: () => void;
}) {
const lastTurn = detail.turns[detail.turns.length - 1];
const assignee = detail.assignee_name
? `کارمند مسئول: ${detail.assignee_name}`
: "بدون کارمند مسئول — مدیر سازمان اجرا می‌کند.";
return (
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/tasks" className="transition-colors hover:text-slate-300">
کارها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{detail.title}</span>
</nav>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{detail.title}</h1>
{detail.stage !== null && (
<Badge tone={stageTone(detail.stage)}>
{STAGE_LABELS[detail.stage] ?? detail.stage}
</Badge>
)}
</div>
{detail.description && <p className="text-sm text-slate-400">{detail.description}</p>}
<p className="text-xs text-slate-500">{assignee}</p>
</div>
<TaskActions
orgId={orgId}
task={{ id: detail.id, stage: detail.stage }}
canRun={canRun}
onChanged={onChanged}
/>
</header>
{actionError && <ErrorState title="عملیات ناموفق" message={actionError} />}
{canRun && detail.stage === "pending_user_decision" && (
<AnswerBox
orgId={orgId}
taskId={detail.id}
lastReply={lastTurn?.reply_excerpt ?? ""}
onAnswered={onChanged}
/>
)}
<Card>
<CardTitle>خط لوله</CardTitle>
<div className="mt-4">
<PipelineStepper stages={detail.stages} current={detail.stage} />
</div>
</Card>
<Card>
<CardTitle>گزارش زنده</CardTitle>
<div className="mt-4">
<LiveLog taskId={detail.id} />
</div>
</Card>
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardTitle>دروازه‌ها</CardTitle>
<div className="mt-4">
<GatesPanel gates={detail.gates} waiverReason={detail.waiver_reason} />
</div>
</Card>
<Card>
<CardTitle>تغییرات</CardTitle>
<div className="mt-4">
<DiffViewer diff={detail.diff} mergedSha={detail.merged_sha} />
</div>
</Card>
</div>
<Card>
<CardTitle>نوبت‌های کار</CardTitle>
<div className="mt-4">
<TurnTimeline turns={detail.turns} />
</div>
</Card>
</div>
);
}
export default function TaskDetailPage({ export default function TaskDetailPage({
params, params,
}: { }: {
@@ -64,6 +243,8 @@ export default function TaskDetailPage({
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [running, setRunning] = React.useState(false); const [running, setRunning] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null); const [actionError, setActionError] = React.useState<string | null>(null);
const [engine, setEngine] = React.useState<EngineTaskDetail | null>(null);
const reloadTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Live feed: task status, run steps, approvals, artifacts — all push. // Live feed: task status, run steps, approvals, artifacts — all push.
const channels = React.useMemo(() => { const channels = React.useMemo(() => {
@@ -74,6 +255,35 @@ export default function TaskDetailPage({
return base; return base;
}, [orgId, taskId, selectedRun?.id]); }, [orgId, taskId, selectedRun?.id]);
// The engine view reloads without the loading state, so its live log keeps its lines.
const reloadEngine = React.useCallback(async () => {
if (!orgId) return;
try {
setEngine(await engineApi.getTask(orgId, taskId));
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "به‌روزرسانی کار ناموفق بود.");
}
}, [orgId, taskId]);
// A burst of stage moves and gate verdicts reloads once, 500 ms after the last of them.
const reloadLatest = React.useRef(reloadEngine);
React.useEffect(() => {
reloadLatest.current = reloadEngine;
}, [reloadEngine]);
const scheduleEngineReload = React.useCallback(() => {
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
reloadTimer.current = setTimeout(() => {
reloadTimer.current = null;
void reloadLatest.current();
}, 500);
}, []);
React.useEffect(
() => () => {
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
},
[],
);
useLiveChannel(channels, (event) => { useLiveChannel(channels, (event) => {
switch (event.type) { switch (event.type) {
case "task.status": { case "task.status": {
@@ -166,11 +376,36 @@ export default function TaskDetailPage({
}); });
break; break;
} }
case "task.step":
case "task.gate": {
const data = event.data as { task_id: string };
if (data.task_id === taskId) scheduleEngineReload();
break;
}
default: default:
break; break;
} }
}); });
// A legacy task (no engine stage): its runs, approvals and employees, as before.
const loadLegacy = React.useCallback(async () => {
if (!orgId) return;
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
api.getTask(orgId, taskId),
api.listEmployees(orgId),
// Deny-by-default: a dangerous tool stays blocked until a human decides,
// so this list is the only thing standing between a run and the action.
api.listPendingApprovals(orgId),
]);
setTask(nextTask);
setEmployees(nextEmployees);
setApprovals(nextApprovals);
// Hydrate the newest run so the timeline is visible without a click.
const newest = nextTask.runs[0];
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
}, [orgId, taskId]);
// An engine task (it has a stage) shows the engine view; anything else is a legacy task.
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!orgId) { if (!orgId) {
setLoading(false); setLoading(false);
@@ -179,25 +414,19 @@ export default function TaskDetailPage({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([ const detail = await engineApi.getTask(orgId, taskId);
api.getTask(orgId, taskId), if (detail.stage !== null) {
api.listEmployees(orgId), setEngine(detail);
// Deny-by-default: a dangerous tool stays blocked until a human decides, return;
// so this list is the only thing standing between a run and the action. }
api.listPendingApprovals(orgId), setEngine(null);
]); await loadLegacy();
setTask(nextTask);
setEmployees(nextEmployees);
setApprovals(nextApprovals);
// Hydrate the newest run so the timeline is visible without a click.
const newest = nextTask.runs[0];
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود."); setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orgId, taskId]); }, [orgId, taskId, loadLegacy]);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
@@ -217,7 +446,7 @@ export default function TaskDetailPage({
delete next[toolCallId]; delete next[toolCallId];
return next; return next;
}); });
await load(); await loadLegacy();
} catch (err) { } catch (err) {
setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود."); setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود.");
} finally { } finally {
@@ -232,7 +461,7 @@ export default function TaskDetailPage({
try { try {
const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null); const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null);
setSelectedRun(detail); setSelectedRun(detail);
await load(); await loadLegacy();
} catch (err) { } catch (err) {
setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود."); setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود.");
} finally { } finally {
@@ -246,7 +475,7 @@ export default function TaskDetailPage({
setRunning(true); setRunning(true);
try { try {
setSelectedRun(await api.cancelRun(orgId, selectedRun.id)); setSelectedRun(await api.cancelRun(orgId, selectedRun.id));
await load(); await loadLegacy();
} catch (err) { } catch (err) {
setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود."); setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود.");
} finally { } finally {
@@ -274,6 +503,17 @@ export default function TaskDetailPage({
if (loading) return <LoadingState label="در حال دریافت کار…" />; if (loading) return <LoadingState label="در حال دریافت کار…" />;
if (error) return <ErrorState message={error} onRetry={load} />; if (error) return <ErrorState message={error} onRetry={load} />;
if (engine) {
return (
<EngineTaskView
orgId={activeOrganization.id}
detail={engine}
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
actionError={actionError}
onChanged={reloadEngine}
/>
);
}
if (!task) { if (!task) {
return ( return (
<EmptyState <EmptyState
@@ -0,0 +1,209 @@
"use client";
/**
* The engine's task board: one column per group of stages (``BOARD_COLUMNS``), and a card per
* task with its stage, assignee, cycle and controls. The filters narrow the cards on the client.
* A legacy task (no engine stage) sits in the column of its coarse status.
*
* The board keeps its own copy of the tasks, so a live ``task.step`` moves a card at once. A
* fresh list from the page replaces that copy. A ``task.created`` asks the page to reload.
*/
import Link from "next/link";
import * as React from "react";
import type { Employee, TaskStatus, TaskStepEventData } from "@agy/contracts";
import { Badge, Card, Checkbox, Field, Select } from "@agy/ui";
import { TaskActions } from "@/components/engine/task-actions";
import type { EngineStage, EngineTask } from "@/lib/api-engine";
import {
BOARD_COLUMNS,
columnFor,
groupByColumn,
STAGE_LABELS,
type StageTone,
stageTone,
} from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** The Persian name of a legacy status, for a task the engine does not own. */
const LEGACY_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const LEGACY_TONES: Record<TaskStatus, StageTone> = {
new: "neutral",
triaged: "info",
waiting_approval: "warning",
running: "info",
done: "success",
failed: "danger",
cancelled: "neutral",
};
export interface TaskBoardProps {
orgId: string;
tasks: EngineTask[];
employees: Pick<Employee, "id" | "name">[];
/** False for a viewer: the cards show no controls. */
canRun: boolean;
/** Called after a card's control changed a task, and when a task is created. */
onChanged: () => void;
}
export function TaskBoard({ orgId, tasks, employees, canRun, onChanged }: TaskBoardProps) {
const [items, setItems] = React.useState<EngineTask[]>(tasks);
const [query, setQuery] = React.useState("");
const [assignee, setAssignee] = React.useState("");
const [activeOnly, setActiveOnly] = React.useState(false);
// A fresh list from the page replaces the board's copy.
React.useEffect(() => {
setItems(tasks);
}, [tasks]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.created") {
onChanged();
return;
}
if (event.type !== "task.step") return;
const step = event.data as unknown as TaskStepEventData;
setItems((current) =>
current.map((task) =>
task.id === step.task_id
? { ...task, stage: step.to as EngineStage, status: step.status }
: task,
),
);
});
const needle = query.trim().toLowerCase();
const visible = items.filter((task) => {
if (needle && !task.title.toLowerCase().includes(needle)) return false;
if (assignee === "none" && task.assigned_employee_id !== null) return false;
if (assignee !== "" && assignee !== "none" && task.assigned_employee_id !== assignee) {
return false;
}
if (activeOnly && columnFor(task.stage, task.status) === "done") return false;
return true;
});
const groups = groupByColumn(visible);
const assigneeOptions = [
{ value: "", label: "همه" },
{ value: "none", label: "بدون مسئول" },
...employees.map((employee) => ({ value: employee.id, label: employee.name })),
];
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[12rem] flex-1">
<Field
label="جستجو در عنوان"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="عنوان کار…"
/>
</div>
<div className="w-full sm:w-56">
<Select
label="مسئول"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
options={assigneeOptions}
/>
</div>
<div className="pb-2">
<Checkbox
label="فقط کارهای فعال"
checked={activeOnly}
onChange={(e) => setActiveOnly(e.target.checked)}
/>
</div>
</div>
<div className="flex items-start gap-3 overflow-x-auto pb-3">
{BOARD_COLUMNS.map((column) => {
const cards = groups[column.key];
return (
<section
key={column.key}
aria-label={column.label}
className="flex w-64 shrink-0 flex-col gap-3 rounded-xl border border-surface-border bg-surface-raised/50 p-3"
>
<header className="flex items-center justify-between gap-2">
<h2 className="text-xs font-medium text-slate-300">{column.label}</h2>
<Badge>{cards.length.toLocaleString("fa-IR")}</Badge>
</header>
{cards.length === 0 ? (
<p className="py-6 text-center text-xs text-slate-500">کاری نیست</p>
) : (
cards.map((task) => (
<BoardCard
key={task.id}
orgId={orgId}
task={task}
canRun={canRun}
onChanged={onChanged}
/>
))
)}
</section>
);
})}
</div>
</div>
);
}
function BoardCard({
orgId,
task,
canRun,
onChanged,
}: {
orgId: string;
task: EngineTask;
canRun: boolean;
onChanged: () => void;
}) {
const label =
task.stage === null ? LEGACY_LABELS[task.status] : (STAGE_LABELS[task.stage] ?? task.stage);
const tone = task.stage === null ? LEGACY_TONES[task.status] : stageTone(task.stage);
return (
<Card className="flex flex-col gap-2 p-4">
<div className="flex items-start justify-between gap-2">
<Link
href={`/tasks/${task.id}`}
className="line-clamp-2 min-w-0 text-sm font-medium leading-6 text-slate-100 hover:text-accent"
>
{task.title}
</Link>
<Badge tone={tone} className="shrink-0">
{label}
</Badge>
</div>
<p className="text-xs text-slate-400">{task.assignee_name ?? "بدون مسئول"}</p>
{task.max_cycles > 0 && (
<p className="text-xs text-slate-500">
چرخه {task.cycle.toLocaleString("fa-IR")}/{task.max_cycles.toLocaleString("fa-IR")}
</p>
)}
<TaskActions
orgId={orgId}
task={{ id: task.id, stage: task.stage }}
canRun={canRun}
onChanged={onChanged}
/>
</Card>
);
}
+56 -96
View File
@@ -1,10 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import type { Employee, Task, TaskStatus } from "@agy/contracts"; import type { Employee } from "@agy/contracts";
import { import {
Badge,
Button, Button,
Card, Card,
CardHint, CardHint,
@@ -15,37 +13,39 @@ import {
LoadingState, LoadingState,
Select, Select,
} from "@agy/ui"; } from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions"; import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { TaskBoard } from "./_components/task-board";
const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const STATUS_TONES: Record<TaskStatus, "neutral" | "info" | "success" | "warning" | "danger"> = {
new: "neutral",
triaged: "info",
waiting_approval: "warning",
running: "info",
done: "success",
failed: "danger",
cancelled: "neutral",
};
export default function TasksPage() { export default function TasksPage() {
const { activeOrganization } = useSession(); const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tasks, setTasks] = React.useState<Task[]>([]); if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارها ابتدا یک سازمان بسازید."
/>
);
}
return (
<TasksWorkspace
key={activeOrganization.id}
orgId={activeOrganization.id}
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
/>
);
}
function TasksWorkspace({ orgId, canRun }: { orgId: string; canRun: boolean }) {
const engine = useEngineStatus(orgId);
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [employees, setEmployees] = React.useState<Employee[]>([]); const [employees, setEmployees] = React.useState<Employee[]>([]);
const [filter, setFilter] = React.useState<TaskStatus | "">("");
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -57,46 +57,45 @@ export default function TasksPage() {
const [formError, setFormError] = React.useState<string | null>(null); const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try { try {
const [nextTasks, nextEmployees] = await Promise.all([ const [nextTasks, nextEmployees] = await Promise.all([
api.listTasks(orgId, filter || undefined), engineApi.listTasks(orgId),
api.listEmployees(orgId), api.listEmployees(orgId),
]); ]);
setTasks(nextTasks); setTasks(nextTasks);
setEmployees(nextEmployees); setEmployees(nextEmployees);
setError(null);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود."); setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orgId, filter]); }, [orgId]);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
const retry = () => {
setLoading(true);
void load();
};
const onCreate = async (event: React.FormEvent) => { const onCreate = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!orgId) return;
setFormError(null); setFormError(null);
setCreating(true); setCreating(true);
try { try {
const created = await api.createTask(orgId, { await api.createTask(orgId, {
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
assigned_employee_id: assignedTo || null, assigned_employee_id: assignedTo || null,
}); });
setTasks((current) => [created, ...current]);
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setAssignedTo(""); setAssignedTo("");
setShowForm(false); setShowForm(false);
await load();
} catch (err) { } catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود."); setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
} finally { } finally {
@@ -104,19 +103,10 @@ export default function TasksPage() {
} }
}; };
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارها ابتدا یک سازمان بسازید."
/>
);
}
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
return ( return (
<div className="anim-stagger flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<CreditNotice status={engine.status} />
<header className="flex items-start justify-between gap-3"> <header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارها</h1> <h1 className="text-lg font-medium text-slate-100">کارها</h1>
@@ -134,6 +124,7 @@ export default function TasksPage() {
{showForm && ( {showForm && (
<Card> <Card>
<CardTitle>ساخت کار</CardTitle> <CardTitle>ساخت کار</CardTitle>
<CardHint>کار جدید ابتدا به مدیر AI سپرده می‌شود.</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4"> <form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field <Field
@@ -174,57 +165,26 @@ export default function TasksPage() {
</Card> </Card>
)} )}
<div className="flex items-center gap-3">
<div className="w-56">
<Select
label="فیلتر وضعیت"
value={filter}
onChange={(e) => setFilter(e.target.value as TaskStatus | "")}
options={[
{ value: "", label: "همه" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
/>
</div>
</div>
{loading ? ( {loading ? (
<LoadingState label="در حال دریافت کارها…" /> <LoadingState label="در حال دریافت کارها…" />
) : error ? ( ) : error && tasks.length === 0 ? (
<ErrorState message={error} onRetry={load} /> <ErrorState message={error} onRetry={retry} />
) : tasks.length === 0 ? (
<EmptyState
title="کاری یافت نشد"
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
/>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-4">
{tasks.map((task) => { {error && <ErrorState message={error} onRetry={retry} />}
const employee = employees.find((e) => e.id === task.assigned_employee_id); {tasks.length === 0 && (
return ( <EmptyState
<Card key={task.id} className="flex flex-wrap items-center justify-between gap-3"> title="کاری یافت نشد"
<div className="flex min-w-0 flex-col gap-1"> description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
<p className="truncate text-sm font-medium text-slate-100">{task.title}</p> />
{task.description && ( )}
<p className="line-clamp-1 text-xs text-slate-500">{task.description}</p> <TaskBoard
)} orgId={orgId}
<p className="text-xs text-slate-500"> tasks={tasks}
{employee ? `مسئول: ${employee.name}` : "بدون مسئول"} employees={employees}
</p> canRun={canRun}
</div> onChanged={load}
<div className="flex shrink-0 items-center gap-2"> />
<Badge tone={STATUS_TONES[task.status] ?? "neutral"}>
{STATUS_LABELS[task.status] ?? task.status}
</Badge>
<Link href={`/tasks/${task.id}`}>
<Button variant="secondary" size="sm">
جزئیات
</Button>
</Link>
</div>
</Card>
);
})}
</div> </div>
)} )}
@@ -0,0 +1,136 @@
"use client";
/**
* The organization's newest engine turns, one row per employee turn: the task it belongs to,
* the role and stage it ran at, its result (a failed turn shows its error kind), the tool calls
* it made and how long it took.
*
* The reply text is never shown here: it is the model's own output. The list is loaded again on
* every ``task.step`` of the organization.
*/
import Link from "next/link";
import * as React from "react";
import {
Badge,
Card,
CardHint,
CardTitle,
EmptyState,
Table,
type TableColumn,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineOrgTurn, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** How many of the newest turns the table shows. */
const TURN_LIMIT = 30;
/** The duration of a turn, in the same units as the run list above it. */
function formatDuration(turn: EngineOrgTurn): string {
if (turn.finished_at === null) return "—";
const ms = new Date(turn.finished_at).getTime() - new Date(turn.started_at).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60_000)}m`;
}
const COLUMNS: Array<TableColumn<EngineOrgTurn>> = [
{
key: "task",
header: "کار",
render: (turn) => (
<Link
href={`/tasks/${turn.task_id}`}
className="transition-colors text-xs text-slate-200 hover:text-accent"
>
{turn.task_title || "—"}
</Link>
),
},
{
key: "role",
header: "نقش",
render: (turn) => <span className="text-xs">{ROLE_LABELS[turn.role] ?? turn.role}</span>,
},
{
key: "stage",
header: "مرحله",
render: (turn) => <span className="text-xs">{STAGE_LABELS[turn.stage] ?? turn.stage}</span>,
},
{
key: "result",
header: "نتیجه",
render: (turn) => {
if (turn.finished_at === null) return <Badge tone="info">در جریان</Badge>;
if (turn.ok) return <Badge tone="success">موفق</Badge>;
return (
<div className="flex flex-wrap items-center gap-2">
<Badge tone="danger">ناموفق</Badge>
{turn.error_kind && (
<span className="ltr-inline text-xs text-slate-400">{turn.error_kind}</span>
)}
</div>
);
},
},
{
key: "tools",
header: "ابزارها",
align: "center",
render: (turn) => <span className="text-xs">{turn.tool_calls}</span>,
},
{
key: "duration",
header: "مدت",
render: (turn) => <span className="text-xs text-slate-400">{formatDuration(turn)}</span>,
},
];
export function EngineTurns({ orgId }: { orgId: string }) {
const [turns, setTurns] = React.useState<EngineOrgTurn[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setTurns(await engineApi.listTurns(orgId, TURN_LIMIT));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "نوبت‌های کارمندان دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.step") void load();
});
return (
<Card>
<CardTitle>نوبت‌های اخیر کارمندان</CardTitle>
<CardHint>
هر نوبت: کارمند با کدام نقش و در کدام مرحله کار کرد و چه نتیجه‌ای داشت. متن پاسخ مدل در این
جدول نمایش داده نمی‌شود.
</CardHint>
<div className="mt-4">
<Table
caption="نوبت‌های اخیر کارمندان"
columns={COLUMNS}
rows={turns}
rowKey={(turn) => turn.id}
loading={loading}
error={error}
onRetry={load}
empty={<EmptyState title="هنوز نوبتی ثبت نشده است." />}
/>
</div>
</Card>
);
}
+3
View File
@@ -34,6 +34,7 @@ import {
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime"; import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { EngineTurns } from "./_components/engine-turns";
/** How many tasks to fan out into. Bounds the request count. */ /** How many tasks to fan out into. Bounds the request count. */
const TASK_SCAN_LIMIT = 12; const TASK_SCAN_LIMIT = 12;
@@ -427,6 +428,8 @@ export default function TracesPage() {
</Card> </Card>
</div> </div>
{orgId && <EngineTurns orgId={orgId} />}
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={load}> <Button variant="secondary" size="sm" onClick={load}>
بازخوانی بازخوانی
+31 -30
View File
@@ -6,7 +6,6 @@ import type {
EmployeeDetail, EmployeeDetail,
EmployeeStatus, EmployeeStatus,
PromptVersion, PromptVersion,
RoleKey,
RoutingChoice, RoutingChoice,
ToolSpec, ToolSpec,
} from "@agy/contracts"; } from "@agy/contracts";
@@ -25,19 +24,10 @@ import {
Timeline, Timeline,
} from "@agy/ui"; } from "@agy/ui";
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { type TeamMember, engineApi } from "@/lib/api-engine";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions"; import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { roleLabel, tierLabel } from "../_components/team-panel";
const ROLE_LABELS: Record<RoleKey, string> = {
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_LABELS: Record<EmployeeStatus, string> = { const STATUS_LABELS: Record<EmployeeStatus, string> = {
active: "فعال", active: "فعال",
@@ -51,10 +41,13 @@ export default function EmployeeDetailPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id: employeeId } = React.use(params); const { id: employeeId } = React.use(params);
const { activeOrganization } = useSession(); const { activeOrganization, user } = useSession();
const orgId = activeOrganization?.id ?? null; const orgId = activeOrganization?.id ?? null;
// Only the platform owner chooses a routing policy. Everyone else sees the employee's tier.
const isOwner = user?.is_platform_owner === true;
const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null); const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null);
const [member, setMember] = React.useState<TeamMember | null>(null);
const [choices, setChoices] = React.useState<RoutingChoice[]>([]); const [choices, setChoices] = React.useState<RoutingChoice[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -82,14 +75,17 @@ export default function EmployeeDetailPage({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [nextEmployee, nextChoices, nextTools] = await Promise.all([ const [nextEmployee, nextTools, nextChoices, team] = await Promise.all([
api.getEmployee(orgId, employeeId), api.getEmployee(orgId, employeeId),
api.listRoutingChoices(orgId),
api.listTools(orgId), api.listTools(orgId),
// The routing choices belong to the owner's settings, so no one else asks for them.
isOwner ? api.listRoutingChoices(orgId) : Promise.resolve<RoutingChoice[]>([]),
engineApi.getTeam(orgId),
]); ]);
setEmployee(nextEmployee); setEmployee(nextEmployee);
setChoices(nextChoices); setChoices(nextChoices);
setTools(nextTools); setTools(nextTools);
setMember(team.employees.find((entry) => entry.id === employeeId) ?? null);
setName(nextEmployee.name); setName(nextEmployee.name);
setStatus(nextEmployee.status); setStatus(nextEmployee.status);
setRoutingPolicyId(nextEmployee.routing_policy_id ?? ""); setRoutingPolicyId(nextEmployee.routing_policy_id ?? "");
@@ -98,7 +94,7 @@ export default function EmployeeDetailPage({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orgId, employeeId]); }, [orgId, employeeId, isOwner]);
const onToggleTool = async (toolKey: string, allowed: boolean) => { const onToggleTool = async (toolKey: string, allowed: boolean) => {
if (!orgId) return; if (!orgId) return;
@@ -132,7 +128,8 @@ export default function EmployeeDetailPage({
await api.updateEmployee(orgId, employeeId, { await api.updateEmployee(orgId, employeeId, {
name: name.trim(), name: name.trim(),
status, status,
routing_policy_id: routingPolicyId || null, // Only the owner sends a routing policy; for anyone else the field is left out.
...(isOwner ? { routing_policy_id: routingPolicyId || null } : {}),
}); });
setSaved(true); setSaved(true);
window.setTimeout(() => setSaved(false), 1800); window.setTimeout(() => setSaved(false), 1800);
@@ -203,7 +200,8 @@ export default function EmployeeDetailPage({
<header className="flex flex-wrap items-center gap-2"> <header className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{employee.name}</h1> <h1 className="text-lg font-medium text-slate-100">{employee.name}</h1>
<Badge tone="info">{ROLE_LABELS[employee.role_key]}</Badge> <Badge tone="info">{roleLabel(employee.role_key)}</Badge>
{member && <Badge tone="neutral">سطح: {tierLabel(member.tier)}</Badge>}
<Badge tone={employee.status === "active" ? "success" : "neutral"}> <Badge tone={employee.status === "active" ? "success" : "neutral"}>
{STATUS_LABELS[employee.status]} {STATUS_LABELS[employee.status]}
</Badge> </Badge>
@@ -218,8 +216,9 @@ export default function EmployeeDetailPage({
<Card> <Card>
<CardTitle>تنظیمات کارمند</CardTitle> <CardTitle>تنظیمات کارمند</CardTitle>
<CardHint> <CardHint>
«سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار {isOwner
سامانه است و نمایش داده نمی‌شود. ? "«سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار سامانه است و نمایش داده نمی‌شود."
: "سطح کارمند نشان می‌دهد در کدام رده کار می‌کند. انتخاب مدل و ارائه‌دهنده در اختیار سامانه است و نمایش داده نمی‌شود."}
</CardHint> </CardHint>
<form onSubmit={onSave} className="mt-4 flex flex-col gap-4"> <form onSubmit={onSave} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
@@ -241,16 +240,18 @@ export default function EmployeeDetailPage({
label, label,
}))} }))}
/> />
<Select {isOwner && (
label="سیاست کاری" <Select
value={routingPolicyId} label="سیاست کاری"
onChange={(e) => setRoutingPolicyId(e.target.value)} value={routingPolicyId}
disabled={!canManage} onChange={(e) => setRoutingPolicyId(e.target.value)}
options={[ disabled={!canManage}
{ value: "", label: "پیش‌فرض سازمان" }, options={[
...choices.map((c) => ({ value: c.id, label: c.name })), { value: "", label: "پیش‌فرض سازمان" },
]} ...choices.map((c) => ({ value: c.id, label: c.name })),
/> ]}
/>
)}
</div> </div>
{canManage && ( {canManage && (
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
@@ -0,0 +1,143 @@
"use client";
/**
* Hire one employee: a role, a tier from the tiers the organization has been granted, and an
* optional name. The platform picks the model behind the tier, so the dialog never names one.
* Hiring is disabled when no tier is granted, or when the team is at the owner's cap.
*/
import * as React from "react";
import { Button, Field, Modal, Select } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type TierOption, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS } from "@/lib/engine-labels";
/** The roles a team can be given from this dialog (the engine's hirable roles). */
type HirableRole = "manager" | "planner" | "developer" | "reviewer" | "tester";
const HIRABLE_ROLES: readonly HirableRole[] = [
"manager",
"planner",
"developer",
"reviewer",
"tester",
];
const DEFAULT_ROLE: HirableRole = "developer";
export const NO_TIER_HINT = "مالک سامانه هنوز مدلی برای این سازمان تخصیص نداده است.";
export const AT_CAP_HINT = "سقف تعداد کارمندان سازمان پر است.";
export interface HireDialogProps {
orgId: string;
/** The tiers the organization has been granted, from the team view. */
tiers: TierOption[];
/** True once the team holds the owner's cap: nobody more can be hired. */
atCap: boolean;
/** Called after a hire, so the page reloads the team. */
onHired: () => void;
}
export function HireDialog({ orgId, tiers, atCap, onHired }: HireDialogProps) {
const [open, setOpen] = React.useState(false);
const [role, setRole] = React.useState<HirableRole>(DEFAULT_ROLE);
const [tierKey, setTierKey] = React.useState("");
const [name, setName] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// The chosen tier, or the first granted one while no choice (or a tier no longer granted) is set.
const tier = tiers.find((option) => option.tier === tierKey) ?? tiers[0] ?? null;
const noTier = tiers.length === 0;
const disabled = noTier || atCap;
const hint = noTier ? NO_TIER_HINT : atCap ? AT_CAP_HINT : null;
const openDialog = () => {
setError(null);
setOpen(true);
};
const closeDialog = () => {
if (busy) return;
setOpen(false);
setError(null);
};
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (tier === null || disabled) return;
setBusy(true);
setError(null);
const trimmed = name.trim();
try {
await engineApi.hire(orgId, {
role,
tier: tier.tier,
...(trimmed ? { name: trimmed } : {}),
});
setOpen(false);
setName("");
onHired();
} catch (err) {
setError(err instanceof ApiError ? err.message : "استخدام کارمند انجام نشد.");
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<Button onClick={openDialog} disabled={disabled}>
کارمند جدید
</Button>
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
<Modal
open={open}
title="استخدام کارمند جدید"
description="نقش و سطح را انتخاب کنید. نام اختیاری است."
onClose={closeDialog}
size="sm"
>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Select
label="نقش"
value={role}
disabled={busy}
onChange={(e) => setRole(e.target.value as HirableRole)}
options={HIRABLE_ROLES.map((value) => ({ value, label: ROLE_LABELS[value] }))}
/>
<Select
label="سطح"
value={tier?.tier ?? ""}
disabled={busy || noTier}
onChange={(e) => setTierKey(e.target.value)}
options={tiers.map((option) => ({ value: option.tier, label: option.label }))}
/>
<Field
label="نام (اختیاری)"
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
placeholder="اگر خالی بماند، نام پیش‌فرض نقش داده می‌شود"
/>
{hint && <p className="text-xs text-warning">{hint}</p>}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closeDialog} disabled={busy}>
انصراف
</Button>
<Button type="submit" size="sm" loading={busy} disabled={disabled || tier === null}>
استخدام
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
/**
* The organization's AI team: one row per member, with its role, its tier, its status and the
* task it is busy with. A member is shown by its neutral tier only; no model or provider name is
* ever rendered. Firing a member archives it, and asks first in a confirm dialog.
*/
import Link from "next/link";
import * as React from "react";
import type { EmployeeStatus } from "@agy/contracts";
import { Badge, Button, ConfirmDialog, EmptyState } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type TeamMember, type TeamView, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS, TIER_LABELS } from "@/lib/engine-labels";
/** Roles a team can hold that the hire form does not offer; they keep their own labels. */
const OTHER_ROLE_LABELS: Record<string, string> = {
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_LABELS: Record<EmployeeStatus, string> = {
active: "فعال",
paused: "متوقف",
archived: "بایگانی",
};
const STATUS_TONES: Record<EmployeeStatus, "success" | "warning" | "neutral"> = {
active: "success",
paused: "warning",
archived: "neutral",
};
/** The Persian name of a role. A role without a label shows its key rather than nothing. */
export function roleLabel(roleKey: string): string {
return ROLE_LABELS[roleKey] ?? OTHER_ROLE_LABELS[roleKey] ?? roleKey;
}
/** The neutral name of a tier. ``unknown`` shows while no granted model backs a member. */
export function tierLabel(tier: string): string {
return TIER_LABELS[tier] ?? TIER_LABELS.unknown;
}
export interface TeamPanelProps {
orgId: string;
team: TeamView;
/** False for anyone who may not manage employees: they see the team but cannot fire from it. */
canManage: boolean;
/** Called after a member was fired, so the page reloads the team. */
onChanged: () => void;
}
export function TeamPanel({ orgId, team, canManage, onChanged }: TeamPanelProps) {
const [firing, setFiring] = React.useState<TeamMember | null>(null);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const confirmFire = async () => {
if (firing === null) return;
setBusy(true);
setError(null);
try {
await engineApi.fire(orgId, firing.id);
setFiring(null);
onChanged();
} catch (err) {
setFiring(null);
setError(err instanceof ApiError ? err.message : "حذف کارمند از تیم انجام نشد.");
} finally {
setBusy(false);
}
};
return (
<section aria-label="تیم هوش مصنوعی" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium text-slate-200">اعضای تیم</h2>
<Badge tone="info">
{team.count.toLocaleString("fa-IR")} از {team.max_employees.toLocaleString("fa-IR")} نفر
</Badge>
</div>
{team.employees.length === 0 ? (
<EmptyState
title="هنوز کارمندی در تیم نیست"
description={
canManage
? "با «کارمند جدید» اولین کارمند را استخدام کنید."
: "مدیر سازمان می‌تواند کارمند استخدام کند."
}
/>
) : (
<ul className="flex flex-col gap-2">
{team.employees.map((member) => (
<li
key={member.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-surface-border bg-surface-raised px-4 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<Link
href={`/workforce/${member.id}`}
className="truncate text-sm font-medium text-slate-100 transition-colors hover:text-accent"
>
{member.name}
</Link>
<p className="flex flex-wrap items-center gap-x-2 text-xs text-slate-400">
<span>{roleLabel(member.role_key)}</span>
<span aria-hidden>·</span>
<span>سطح: {tierLabel(member.tier)}</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge tone={STATUS_TONES[member.status]}>{STATUS_LABELS[member.status]}</Badge>
{member.busy_task_id ? (
<Link
href={`/tasks/${member.busy_task_id}`}
className="max-w-xs truncate text-xs text-accent hover:underline"
>
{member.busy_task_title ?? "کار جاری"}
</Link>
) : (
<span className="text-xs text-slate-500">بیکار</span>
)}
{canManage && (
<Button variant="ghost" size="sm" onClick={() => setFiring(member)}>
حذف از تیم
</Button>
)}
</div>
</li>
))}
</ul>
)}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<ConfirmDialog
open={firing !== null}
title={firing === null ? "" : `«${firing.name}» از تیم حذف شود؟`}
description="کارمند بایگانی می‌شود و دیگر کاری به او داده نمی‌شود. آخرین مدیر سازمان را نمی‌توان حذف کرد."
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
onConfirm={() => void confirmFire()}
onCancel={() => setFiring(null)}
/>
</section>
);
}
+38 -214
View File
@@ -1,144 +1,48 @@
"use client"; "use client";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import type { import { EmptyState, ErrorState, LoadingState } from "@agy/ui";
AgentTemplate, import { ApiError } from "@/lib/api";
Employee, import { type TeamView, engineApi } from "@/lib/api-engine";
EmployeeDetail,
RoleKey,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions"; import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { HireDialog } from "./_components/hire-dialog";
const ROLE_LABELS: Record<RoleKey, string> = { import { TeamPanel } from "./_components/team-panel";
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_TONES = {
active: "success",
paused: "warning",
archived: "neutral",
} as const;
/**
* Above this size we stop enriching cards with per-employee detail, because the
* enrichment costs one request per employee. The page stays usable either way.
*/
const ENRICH_LIMIT = 50;
export default function WorkforcePage() { export default function WorkforcePage() {
const { activeOrganization } = useSession(); const { activeOrganization } = useSession();
const [employees, setEmployees] = React.useState<Employee[]>([]); const orgId = activeOrganization?.id ?? null;
const [details, setDetails] = React.useState<Record<string, EmployeeDetail>>({});
const [templates, setTemplates] = React.useState<AgentTemplate[]>([]); const [team, setTeam] = React.useState<TeamView | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [showForm, setShowForm] = React.useState(false);
const [name, setName] = React.useState("");
const [templateId, setTemplateId] = React.useState("");
const [roleKey, setRoleKey] = React.useState<RoleKey>("developer");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const orgId = activeOrganization?.id ?? null;
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!orgId) { if (!orgId) {
setLoading(false); setLoading(false);
return; return;
} }
setLoading(true);
setError(null); setError(null);
try { try {
const [nextEmployees, nextTemplates] = await Promise.all([ setTeam(await engineApi.getTeam(orgId));
api.listEmployees(orgId),
api.listAgentTemplates(orgId),
]);
setEmployees(nextEmployees);
setTemplates(nextTemplates);
if (nextEmployees.length > 0 && nextEmployees.length <= ENRICH_LIMIT) {
const enriched = await Promise.all(
nextEmployees.map(async (employee) => {
try {
return [employee.id, await api.getEmployee(orgId, employee.id)] as const;
} catch {
// A single failed enrichment must not blank the whole page.
return null;
}
}),
);
setDetails(
Object.fromEntries(
enriched.filter((entry): entry is readonly [string, EmployeeDetail] => entry !== null),
),
);
} else {
setDetails({});
}
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود."); setError(err instanceof ApiError ? err.message : "دریافت تیم ناموفق بود.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orgId]); }, [orgId]);
React.useEffect(() => { React.useEffect(() => {
setLoading(true);
setTeam(null);
void load(); void load();
}, [load]); }, [load]);
const onTemplateChange = (value: string) => { // A hire or a firing by anyone in the organization reloads the team in place.
setTemplateId(value); useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
const template = templates.find((t) => t.id === value); if (event.type === "team.changed") void load();
if (template) { });
setRoleKey(template.role_key);
if (!name.trim()) setName(template.name);
}
};
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setFormError(null);
setCreating(true);
try {
await api.createEmployee(orgId, {
name: name.trim(),
role_key: roleKey,
template_id: templateId || null,
});
setShowForm(false);
setName("");
setTemplateId("");
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کارمند ناموفق بود.");
} finally {
setCreating(false);
}
};
if (!activeOrganization) { if (!activeOrganization) {
return ( return (
@@ -149,121 +53,41 @@ export default function WorkforcePage() {
); );
} }
if (loading) return <LoadingState label="در حال دریافت تیم…" />;
if (team === null) {
return <ErrorState message={error ?? "دریافت تیم ناموفق بود."} onRetry={load} />;
}
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES); const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
const atCap = team.count >= team.max_employees;
return ( return (
<div className="anim-stagger flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex items-start justify-between gap-3"> <header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1> <h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
هر کارمند یک نقش، یک پرامپت نسخه‌بندی‌شده و یک سیاست مسیریابی دارد. جزئیات مدل هرگز هر کارمند یک نقش و یک سطح دارد. جزئیات فنی هرگز نمایش داده نمی‌شود.
نمایش داده نمی‌شود.
</p> </p>
</div> </div>
{canManage && ( {canManage && (
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}> <HireDialog
{showForm ? "بستن" : "کارمند جدید"} orgId={activeOrganization.id}
</Button> tiers={team.tiers}
atCap={atCap}
onHired={load}
/>
)} )}
</header> </header>
{showForm && ( {error && <ErrorState title="به‌روزرسانی تیم ناموفق بود" message={error} />}
<Card>
<CardTitle>ساخت کارمند</CardTitle>
<CardHint>
با انتخاب یک قالب، پرامپت نسخهٔ ۱ و دسترسی ابزارها به‌صورت خودکار ساخته می‌شود.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Select
label="قالب"
value={templateId}
onChange={(e) => onTemplateChange(e.target.value)}
options={[
{ value: "", label: "بدون قالب (سفارشی)" },
...templates.map((t) => ({ value: t.id, label: t.name })),
]}
/>
<Field
label="نام کارمند"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً برنامه‌نویس ارشد"
/>
<Select
label="نقش"
value={roleKey}
onChange={(e) => setRoleKey(e.target.value as RoleKey)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<div className="flex justify-end">
<Button type="submit" loading={creating}>
ساخت کارمند
</Button>
</div>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت کارمند ناموفق" message={formError} />
</div>
)}
</Card>
)}
{loading ? ( <TeamPanel
<LoadingState label="در حال دریافت کارمندها…" /> orgId={activeOrganization.id}
) : error ? ( team={team}
<ErrorState message={error} onRetry={load} /> canManage={canManage}
) : employees.length === 0 ? ( onChanged={load}
<EmptyState />
title="هنوز کارمندی ساخته نشده است"
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{employees.map((employee) => {
const detail = details[employee.id];
return (
<Card key={employee.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{employee.name}</p>
<p className="text-xs text-slate-500">{ROLE_LABELS[employee.role_key]}</p>
</div>
<Badge tone={STATUS_TONES[employee.status] ?? "neutral"}>
{employee.status}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<Badge tone="neutral">
کارهای باز: {detail ? detail.open_task_count : "—"}
</Badge>
<Badge tone="neutral">
نسخهٔ پرامپت: {detail ? detail.prompt_versions.length : "—"}
</Badge>
<Badge tone="neutral">
ابزارها: {detail ? detail.tool_permissions.filter((p) => p.allowed).length : "—"}
</Badge>
</div>
<Link href={`/workforce/${employee.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
تنظیمات و جزئیات
</Button>
</Link>
</Card>
);
})}
</div>
)}
</div> </div>
); );
} }
+34
View File
@@ -21,6 +21,7 @@ import {
Select, Select,
} from "@agy/ui"; } from "@agy/ui";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { engineApi } from "@/lib/api-engine";
import { setupApi } from "@/lib/api-org"; import { setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
@@ -369,6 +370,7 @@ export default function OnboardingPage() {
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند. «{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
حالا می‌توانید تیم هوش‌مصنوعی خود را راه‌بیندازید. حالا می‌توانید تیم هوش‌مصنوعی خود را راه‌بیندازید.
</CardHint> </CardHint>
<WizardCreditHint orgId={result.organization.id} />
<div className="mt-5"> <div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/manager")}> <Button className="w-full" onClick={() => router.replace("/manager")}>
برو به مدیر AI برو به مدیر AI
@@ -440,6 +442,38 @@ export default function OnboardingPage() {
); );
} }
/**
* The credit line on the success screen. Work waits for credit when the engine reports none, and
* also when the status cannot be read, so the owner's action is named either way. Nothing shows
* while the status loads or once credit is there.
*/
function WizardCreditHint({ orgId }: { orgId: string }) {
const [creditOk, setCreditOk] = React.useState<boolean | null>(null);
React.useEffect(() => {
let current = true;
const check = async () => {
try {
const status = await engineApi.status(orgId);
if (current) setCreditOk(status.credit_ok);
} catch {
if (current) setCreditOk(false);
}
};
void check();
return () => {
current = false;
};
}, [orgId]);
if (creditOk !== false) return null;
return (
<p className="mt-3 text-xs leading-6 text-warning">
کار کارمندان پس از افزودن اعتبار توسط مالک سامانه آغاز می‌شود.
</p>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stepper // Stepper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -0,0 +1,69 @@
"use client";
/**
* The engine's credit state for one organization, and the notice that work has stopped.
*
* ``useEngineStatus`` loads ``GET /engine/status`` and loads it again when the balance, a
* task's stage or the team changes, so the notice and the counts follow the live feed.
* ``CreditNotice`` says, in Persian, why work is stopped; it shows nothing while credit is
* available or while the status is still loading.
*/
import * as React from "react";
import { Card, CardTitle } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineStatus, engineApi } from "@/lib/api-engine";
import { useLiveChannel } from "@/lib/realtime";
/** Live events that change the credit state or the team the status describes. */
const RELOAD_EVENTS: ReadonlySet<string> = new Set(["billing.wallet", "task.step", "team.changed"]);
export interface EngineStatusState {
status: EngineStatus | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export function useEngineStatus(orgId: string): EngineStatusState {
const [status, setStatus] = React.useState<EngineStatus | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setStatus(await engineApi.status(orgId));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "وضعیت موتور دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (RELOAD_EVENTS.has(event.type)) void load();
});
return { status, loading, error, reload: load };
}
export function CreditNotice({ status }: { status: EngineStatus | null }) {
if (status === null || status.credit_ok) return null;
return (
<Card role="status" className="border-warning/50">
<CardTitle>اعتبار سازمان به پایان رسیده است</CardTitle>
<p className="text-xs leading-6 text-slate-300">
کار کارمندان هوش مصنوعی تا افزودن اعتبار توسط مالک سامانه متوقف است. پس از افزودن اعتبار،
کارهای متوقف‌شده را از صفحهٔ وظایف ادامه دهید.
</p>
<p className="mt-2 text-xs text-slate-400">
موجودی: {status.balance.toLocaleString("fa-IR")}{" "}
<span className="ltr-inline">{status.currency}</span>
</p>
</Card>
);
}
@@ -0,0 +1,126 @@
"use client";
/**
* The pause, resume and cancel controls of one engine task.
*
* Each control asks first, in a confirm dialog, and then calls the engine API. A refusal from
* the server (for example a resume while the organization has no credit) is shown in Persian
* under the buttons, with the server's own message. A legacy task has no engine stage, so it
* shows nothing; neither does anyone without the right to run tasks.
*/
import * as React from "react";
import { Button, ConfirmDialog } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineStage, engineApi } from "@/lib/api-engine";
type Action = "pause" | "resume" | "cancel";
/** A finished task takes no action at all. */
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
interface ActionCopy {
button: string;
title: string;
description: string;
confirm: string;
}
const COPY: Record<Action, ActionCopy> = {
pause: {
button: "توقف",
title: "کار متوقف شود؟",
description: "کار پیش از نوبت بعدی متوقف می‌شود و با «ادامه» از همان مرحله برمی‌گردد.",
confirm: "توقف کن",
},
resume: {
button: "ادامه",
title: "کار ادامه یابد؟",
description: "کار به بررسی مدیر برمی‌گردد. اگر اعتبار سازمان تمام شده باشد، ادامه ممکن نیست.",
confirm: "ادامه بده",
},
cancel: {
button: "لغو",
title: "کار لغو شود؟",
description:
"کار بسته می‌شود و کارهایی که به آن وابسته‌اند به تصمیم شما منتقل می‌شوند. این کار برگشت‌پذیر نیست.",
confirm: "لغو کن",
},
};
export interface TaskActionsProps {
orgId: string;
task: { id: string; stage: EngineStage | null };
/** False for a viewer, or for anyone who may not run tasks. */
canRun: boolean;
/** Called after a control succeeds, so the page reloads the task. */
onChanged: () => void;
size?: "sm" | "md";
}
export function TaskActions({ orgId, task, canRun, onChanged, size = "sm" }: TaskActionsProps) {
const [pending, setPending] = React.useState<Action | null>(null);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
if (!canRun || task.stage === null) return null;
const stage: string = task.stage;
const finished = FINISHED.has(stage);
const paused = stage === "paused";
const actions: Action[] = [];
if (!finished && !paused) actions.push("pause");
if (paused) actions.push("resume");
if (!finished) actions.push("cancel");
const confirm = async () => {
if (pending === null) return;
setBusy(true);
setError(null);
try {
if (pending === "pause") await engineApi.pauseTask(orgId, task.id);
else if (pending === "resume") await engineApi.resumeTask(orgId, task.id);
else await engineApi.cancelTask(orgId, task.id);
setPending(null);
onChanged();
} catch (err) {
setPending(null);
setError(err instanceof ApiError ? err.message : "این کار انجام نشد.");
} finally {
setBusy(false);
}
};
const current = pending === null ? null : COPY[pending];
return (
<div dir="rtl" className="flex flex-wrap items-center gap-2">
{actions.map((action) => (
<Button
key={action}
size={size}
variant={action === "cancel" ? "danger" : "secondary"}
disabled={busy}
onClick={() => {
setError(null);
setPending(action);
}}
>
{COPY[action].button}
</Button>
))}
{error && (
<p role="alert" className="w-full text-xs text-danger">
{error}
</p>
)}
<ConfirmDialog
open={current !== null}
title={current?.title ?? ""}
description={current?.description}
confirmLabel={busy ? "در حال انجام…" : (current?.confirm ?? "تأیید")}
onConfirm={confirm}
onCancel={() => setPending(null)}
/>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Typed client for the platform owner's credit console (prepaid billing, D-B).
*
* Mirrors the owner routes in ``app/interfaces/http/routers/admin_credits.py`` key for key.
* Owner-only on the server: a member never calls these and reads its own balance through
* the billing and engine status routes. No type here names a model or a provider.
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/
import { apiRequest } from "./api";
/** One organization's wallet as the platform owner sees it. */
export interface OrgBalance {
organization_id: string;
name: string;
slug: string;
balance: number;
currency: string;
/** Tasks the engine has paused for this organization. */
paused_tasks: number;
/** When credit was last added by hand, or null when it never was. */
last_credit_at: string | null;
}
/** One credit history row: a manual credit, the starting credit or an adjustment. */
export interface CreditTransaction {
id: string;
kind: string;
amount: number;
balance_after: number;
reason: string;
created_at: string;
}
/** The body of a credit. The server checks the amount range and the reason length. */
export interface CreditRequest {
amount: number;
reason: string;
}
function orgBase(orgId: string): string {
return `/api/admin/orgs/${encodeURIComponent(orgId)}`;
}
export const creditApi = {
/** Every organization's balance, paused task count and last credit date. */
balances: () => apiRequest<OrgBalance[]>("/api/admin/credits"),
/** Add credit to one organization's wallet; returns the history row it wrote. */
credit: (orgId: string, body: CreditRequest) =>
apiRequest<CreditTransaction>(`${orgBase(orgId)}/credit`, {
method: "POST",
body: { amount: body.amount, reason: body.reason },
}),
/** One organization's manual credit history, newest first; ``limit`` only when given. */
history: (orgId: string, limit?: number) =>
apiRequest<CreditTransaction[]>(
`${orgBase(orgId)}/credit-history${limit === undefined ? "" : `?limit=${limit}`}`,
),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
+195 -2
View File
@@ -1,10 +1,203 @@
/** /**
* Typed client functions for the AI-workforce workstream (docs/plan/PLAN-v2.md §6). * Typed client functions for the AI-workforce engine (docs/plan/PLAN-v2.md §6).
*
* Mirrors the engine routes in ``app/interfaces/http/routers/engine.py`` key for key.
* No type here names a model or a provider: a team member shows a neutral tier only.
* *
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/ */
import type { EmployeeStatus, RoleKey, RunStatus, TaskStatus } from "@agy/contracts";
import { apiRequest } from "./api"; import { apiRequest } from "./api";
/** The thirteen stages a task can be at, in the engine's own names. */
export type EngineStage =
| "pending_manager_review"
| "pending_analysis"
| "pending_implementation"
| "pending_review"
| "pending_test"
| "pending_final_review"
| "merge_request"
| "completed"
| "pending_user_decision"
| "error_review"
| "paused"
| "cancelled"
| "failed";
/** A tier the organization may be granted, cheapest first. Never a model name. */
export type GrantedTier = "fast" | "balanced" | "strong";
/** A member's tier; ``unknown`` while no granted model backs the member. */
export type EngineTier = GrantedTier | "unknown";
/** ``GET /status``: the credit, the owner's policy, the team size and the stage counts. */
export interface EngineStatus {
balance: number;
currency: string;
credit_ok: boolean;
automation_enabled: boolean;
max_employees: number;
team_size: number;
stage_counts: Record<string, number>;
}
/** One task on the board. ``stage`` is null for a legacy task the engine does not own. */
export interface EngineTask {
id: string;
title: string;
status: TaskStatus;
stage: EngineStage | null;
pipeline: string | null;
kind: string | null;
/** 0 for a legacy task. */
cycle: number;
/** 0 for a legacy task. */
max_cycles: number;
thread_id: string | null;
assigned_employee_id: string | null;
assignee_name: string | null;
created_at: string;
updated_at: string;
}
/** One employee turn: who ran, in which role and stage, and how it ended. */
export interface EngineTurn {
id: string;
run_id: string;
role: RoleKey;
mode: string;
stage: EngineStage;
employee_name: string | null;
ok: boolean;
error_kind: string;
tool_calls: number;
/** At most 1,000 characters of the reply. */
reply_excerpt: string;
started_at: string;
finished_at: string | null;
}
/** A turn in the organization's recent list, with the task it belongs to. */
export interface EngineOrgTurn extends EngineTurn {
task_id: string;
task_title: string;
}
/** One gate verdict on a task. */
export interface EngineGate {
id: string;
gate: string;
ok: boolean;
detail: string;
created_at: string;
}
/** One run of a task. */
export interface EngineRun {
id: string;
status: RunStatus;
started_at: string | null;
finished_at: string | null;
}
/** ``GET /tasks/{id}``: the task with its pipeline, turns, gates, runs and last diff. */
export interface EngineTaskDetail extends EngineTask {
description: string;
/** The stages of the task's pipeline in order; empty for a legacy task. */
stages: EngineStage[];
waiver_reason: string;
branch: string;
depends_on: string[];
merged_sha: string;
diff: string;
turns: EngineTurn[];
gates: EngineGate[];
runs: EngineRun[];
}
/** One team member. */
export interface TeamMember {
id: string;
name: string;
role_key: RoleKey;
status: EmployeeStatus;
tier: EngineTier;
busy_task_id: string | null;
busy_task_title: string | null;
hired_by: string;
}
/** A tier the organization has granted models for, with its Persian label. */
export interface TierOption {
tier: GrantedTier;
label: string;
}
/** ``GET /team``: the members (not archived), the tiers, the owner's cap and the headcount. */
export interface TeamView {
employees: TeamMember[];
tiers: TierOption[];
max_employees: number;
count: number;
}
/** A hire asks for a role and a tier. The platform picks the model. */
export interface HireRequest {
role: RoleKey;
tier: GrantedTier;
name?: string;
}
function base(orgId: string): string {
return `/api/organizations/${orgId}/engine`;
}
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
export const engineApi = { export const engineApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`), /** Credit, policy, team size and stage counts. Every member may read it. */
status: (orgId: string) => apiRequest<EngineStatus>(`${base(orgId)}/status`),
/** The organization's tasks, newest first; ``stage`` narrows the board to one stage. */
listTasks: (orgId: string, stage?: EngineStage) =>
apiRequest<EngineTask[]>(`${base(orgId)}/tasks${query({ stage })}`),
getTask: (orgId: string, taskId: string) =>
apiRequest<EngineTaskDetail>(`${base(orgId)}/tasks/${taskId}`),
/** The organization's newest turns, across its tasks. */
listTurns: (orgId: string, limit?: number) =>
apiRequest<EngineOrgTurn[]>(`${base(orgId)}/turns${query({ limit })}`),
pauseTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/pause`, { method: "POST" }),
resumeTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/resume`, { method: "POST" }),
cancelTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/cancel`, { method: "POST" }),
/** Answer the question a task is waiting on (only at ``pending_user_decision``). */
answerTask: (orgId: string, taskId: string, content: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/answer`, {
method: "POST",
body: { content },
}),
getTeam: (orgId: string) => apiRequest<TeamView>(`${base(orgId)}/team`),
hire: (orgId: string, body: HireRequest) =>
apiRequest<TeamMember>(`${base(orgId)}/team/hire`, { method: "POST", body }),
fire: (orgId: string, employeeId: string) =>
apiRequest<TeamMember>(`${base(orgId)}/team/${employeeId}/fire`, { method: "POST" }),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>; } satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
+4 -4
View File
@@ -18,6 +18,7 @@ import type {
SetupDraft, SetupDraft,
WizardCompleteBody, WizardCompleteBody,
WizardCompleteResult, WizardCompleteResult,
WsEventType,
} from "@agy/contracts"; } from "@agy/contracts";
import { apiRequest } from "./api"; import { apiRequest } from "./api";
@@ -303,11 +304,10 @@ export const memoryApi = {
/** /**
* The event the API publishes on ``org:<id>`` whenever memory changes. * The event the API publishes on ``org:<id>`` whenever memory changes.
* *
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a * The type itself is part of ``WsEventType`` in ``@agy/contracts``; this
* frozen, foundation-owned list, so a page compares the raw string instead of * constant only names it, so a page compares against a literal the union checks.
* widening the union for one new type.
*/ */
export const MEMORY_CHANGED_EVENT = "memory.changed"; export const MEMORY_CHANGED_EVENT = "memory.changed" as const satisfies WsEventType;
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* MCP tokens (org admin) + the owner's switches */ /* MCP tokens (org admin) + the owner's switches */
+125
View File
@@ -0,0 +1,125 @@
/**
* Persian labels, badge tones and board columns for the AI-workforce engine.
*
* Pure data and pure functions: no React and no transport. The stage names are the
* engine's own (``app/domain/workforce/pipeline.py``). A legacy task has no engine
* stage, so it is placed on the board by its coarse status instead. A tier is always
* neutral: no label here names a model or a provider.
*/
import type { EngineStage } from "./api-engine";
/** The tone of a badge: the ``Badge`` tones of ``@agy/ui``. */
export type StageTone = "neutral" | "info" | "success" | "warning" | "danger";
/** Persian label for each of the thirteen stages. */
export const STAGE_LABELS: Record<string, string> = {
pending_manager_review: "در انتظار بررسی مدیر",
pending_analysis: "در انتظار تحلیل",
pending_implementation: "در انتظار پیاده‌سازی",
pending_review: "در انتظار بازبینی",
pending_test: "در انتظار تست",
pending_final_review: "در انتظار بازبینی نهایی",
merge_request: "در انتظار ادغام",
completed: "انجام شد",
pending_user_decision: "نیازمند تصمیم شما",
error_review: "بررسی خطا",
paused: "متوقف شده",
cancelled: "لغو شد",
failed: "ناموفق",
};
/** Green when the task is done, red when it ended badly, amber when a person must act. */
export function stageTone(stage: string): StageTone {
if (stage === "completed") return "success";
if (stage === "failed" || stage === "cancelled") return "danger";
if (stage === "paused" || stage === "pending_user_decision" || stage === "error_review") {
return "warning";
}
return "info";
}
export type BoardColumnKey =
| "manager_review"
| "analysis"
| "implementation"
| "review"
| "test"
| "merge"
| "done"
| "decision";
export interface BoardColumn {
key: BoardColumnKey;
label: string;
/** The engine stages that land in this column. */
stages: readonly EngineStage[];
}
/** The board's columns, left to right. Every stage belongs to exactly one column. */
export const BOARD_COLUMNS: readonly BoardColumn[] = [
{ key: "manager_review", label: "بررسی مدیر", stages: ["pending_manager_review"] },
{ key: "analysis", label: "تحلیل", stages: ["pending_analysis"] },
{ key: "implementation", label: "پیاده‌سازی", stages: ["pending_implementation"] },
{ key: "review", label: "بازبینی", stages: ["pending_review", "pending_final_review"] },
{ key: "test", label: "تست", stages: ["pending_test"] },
{ key: "merge", label: "ادغام", stages: ["merge_request"] },
{ key: "done", label: "انجام‌شده", stages: ["completed", "cancelled", "failed"] },
{
key: "decision",
label: "نیازمند تصمیم",
stages: ["pending_user_decision", "error_review", "paused"],
},
];
const STAGE_COLUMN: Record<string, BoardColumnKey | undefined> = Object.fromEntries(
BOARD_COLUMNS.flatMap((column) => column.stages.map((stage) => [stage, column.key])),
);
/**
* Where a legacy task (no engine stage) sits, by its coarse status. ``triaged`` is a task
* the manager has already assigned, so it waits for work rather than for a review.
*/
const LEGACY_COLUMN: Record<string, BoardColumnKey | undefined> = {
new: "manager_review",
triaged: "implementation",
running: "implementation",
waiting_approval: "decision",
done: "done",
failed: "done",
cancelled: "done",
};
/**
* The column a task belongs to. An unknown stage or status goes to the decision column,
* so that a task the board does not recognise is still visible to a person.
*/
export function columnFor(stage: string | null, status: string): BoardColumnKey {
if (stage !== null) return STAGE_COLUMN[stage] ?? "decision";
return LEGACY_COLUMN[status] ?? "decision";
}
/** Tasks grouped by column, every column present, each column in the order it was given. */
export function groupByColumn<T extends { stage: string | null; status: string }>(
tasks: readonly T[],
): Record<BoardColumnKey, T[]> {
const groups = {} as Record<BoardColumnKey, T[]>;
for (const column of BOARD_COLUMNS) groups[column.key] = [];
for (const task of tasks) groups[columnFor(task.stage, task.status)].push(task);
return groups;
}
/** Neutral tier labels. ``unknown`` shows when no granted model backs a member. */
export const TIER_LABELS: Record<string, string> = {
fast: "سریع",
balanced: "متعادل",
strong: "قوی",
unknown: "نامشخص",
};
export const ROLE_LABELS: Record<string, string> = {
manager: "مدیر",
planner: "برنامه‌ریز",
developer: "برنامه‌نویس",
reviewer: "بازبین",
tester: "آزمونگر",
};
+1
View File
@@ -39,6 +39,7 @@ export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/rate-cards", label: "تعرفه‌ها", hint: "قیمت واحدها" }, { href: "/admin/rate-cards", label: "تعرفه‌ها", hint: "قیمت واحدها" },
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" }, { href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
{ href: "/admin/organizations", label: "سازمان‌ها", hint: "مستأجرها" }, { href: "/admin/organizations", label: "سازمان‌ها", hint: "مستأجرها" },
{ href: "/admin/credits", label: "اعتبار سازمان‌ها", hint: "شارژ پیش‌پرداخت" },
{ href: "/admin/model-grants", label: "مدل‌های سازمان‌ها", hint: "تخصیص مدل به هر سازمان" }, { href: "/admin/model-grants", label: "مدل‌های سازمان‌ها", hint: "تخصیص مدل به هر سازمان" },
{ href: "/admin/domains", label: "دامنه‌ها", hint: "مسیریابی Caddy" }, { href: "/admin/domains", label: "دامنه‌ها", hint: "مسیریابی Caddy" },
{ href: "/admin/sandbox", label: "سندباکس‌ها", hint: "کانتینرها و محدودیت‌ها" }, { href: "/admin/sandbox", label: "سندباکس‌ها", hint: "کانتینرها و محدودیت‌ها" },
+35 -10
View File
@@ -67,7 +67,10 @@ export class RealtimeClient {
private status: RealtimeStatus = "idle"; private status: RealtimeStatus = "idle";
private readonly listeners = new Set<EventListener>(); private readonly listeners = new Set<EventListener>();
private readonly statusListeners = new Set<StatusListener>(); private readonly statusListeners = new Set<StatusListener>();
private readonly subscriptions = new Set<string>(); /** Channel -> how many callers hold it. A channel stays on the wire while its
* count is above zero, so two live hooks on ``org:<id>`` do not lose their
* feed when one of them unmounts. */
private readonly subscriptions = new Map<string, number>();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null; private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null; private pongTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0; private reconnectAttempts = 0;
@@ -164,8 +167,9 @@ export class RealtimeClient {
// Re-play subscriptions: a new socket knows nothing about what this // Re-play subscriptions: a new socket knows nothing about what this
// client cared about, and pages do not (and should not) re-run their // client cared about, and pages do not (and should not) re-run their
// effects to fix it. // effects to fix it.
if (this.subscriptions.size > 0) { const held = this.activeSubscriptions();
this.send({ action: "subscribe", channels: [...this.subscriptions] }); if (held.length > 0) {
this.send({ action: "subscribe", channels: held });
} }
resolve(); resolve();
}; };
@@ -316,22 +320,43 @@ export class RealtimeClient {
// -- subscriptions --------------------------------------------------------- // -- subscriptions ---------------------------------------------------------
/**
* Take one reference on each channel. Only a channel that nobody held before
* is sent to the server; a second holder just adds to the count.
*/
subscribe(channels: string[]): void { subscribe(channels: string[]): void {
const fresh = channels.filter((channel) => !this.subscriptions.has(channel)); const fresh: string[] = [];
for (const channel of channels) {
const held = this.subscriptions.get(channel) ?? 0;
this.subscriptions.set(channel, held + 1);
if (held === 0) fresh.push(channel);
}
if (fresh.length === 0) return; if (fresh.length === 0) return;
fresh.forEach((channel) => this.subscriptions.add(channel));
this.send({ action: "subscribe", channels: fresh }); this.send({ action: "subscribe", channels: fresh });
} }
/**
* Release one reference on each channel. The server is told only when the
* last holder lets go. A channel nobody holds is ignored.
*/
unsubscribe(channels: string[]): void { unsubscribe(channels: string[]): void {
const active = channels.filter((channel) => this.subscriptions.has(channel)); const released: string[] = [];
if (active.length === 0) return; for (const channel of channels) {
active.forEach((channel) => this.subscriptions.delete(channel)); const held = this.subscriptions.get(channel);
this.send({ action: "unsubscribe", channels: active }); if (held === undefined) continue;
if (held > 1) {
this.subscriptions.set(channel, held - 1);
continue;
}
this.subscriptions.delete(channel);
released.push(channel);
}
if (released.length === 0) return;
this.send({ action: "unsubscribe", channels: released });
} }
activeSubscriptions(): string[] { activeSubscriptions(): string[] {
return [...this.subscriptions]; return [...this.subscriptions.keys()];
} }
// -- listeners -------------------------------------------------------------- // -- listeners --------------------------------------------------------------
@@ -0,0 +1,176 @@
/**
* The platform owner's credit page: every organization's balance, the add-credit dialog (a valid
* amount and reason, then a confirm step) and the history drawer. The API client is mocked, so
* the test sees exactly the calls the page makes and the text the owner reads.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
creditApi: { balances: vi.fn(), credit: vi.fn(), history: vi.fn() },
};
});
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError }));
vi.mock("@/lib/api-credit", () => ({ creditApi: mocks.creditApi }));
import AdminCreditsPage from "@/app/(app)/admin/credits/page";
const ACME = {
organization_id: "org-1",
name: "Acme",
slug: "acme",
balance: 120,
currency: "USD",
paused_tasks: 0,
last_credit_at: "2026-10-01T10:00:00Z",
};
const BETA = {
organization_id: "org-2",
name: "Beta",
slug: "beta",
balance: 0,
currency: "USD",
paused_tasks: 3,
last_credit_at: null,
};
beforeEach(() => {
for (const fn of Object.values(mocks.creditApi)) fn.mockReset();
mocks.creditApi.balances.mockResolvedValue([ACME, BETA]);
mocks.creditApi.credit.mockResolvedValue({
id: "tx-1",
kind: "owner_credit",
amount: 50,
balance_after: 170,
reason: "شارژ دستی",
created_at: "2026-10-14T09:00:00Z",
});
mocks.creditApi.history.mockResolvedValue([]);
});
/** The table row that names the organization, so a button can be pressed inside it. */
async function rowFor(name: string): Promise<HTMLElement> {
const cell = await screen.findByText(name);
return cell.closest("tr") as HTMLElement;
}
/** Opens the add-credit dialog for one organization and returns it. */
async function openCreditDialog(name: string): Promise<HTMLElement> {
fireEvent.click(within(await rowFor(name)).getByRole("button", { name: "افزودن اعتبار" }));
return screen.findByRole("dialog", { name: `افزودن اعتبار به «${name}»` });
}
describe("owner credit page: balances", () => {
it("lists every organization with its balance and marks the one without credit", async () => {
render(<AdminCreditsPage />);
expect(await screen.findByText("Acme")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("120.00 USD")).toBeInTheDocument();
expect(within(await rowFor("Beta")).getByText("بدون اعتبار")).toBeInTheDocument();
expect(within(await rowFor("Acme")).queryByText("بدون اعتبار")).not.toBeInTheDocument();
expect(within(await rowFor("Beta")).getByText("3")).toBeInTheDocument();
});
});
describe("owner credit page: add credit", () => {
it("keeps the continue button disabled until the amount and the reason are valid", async () => {
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
const next = within(dialog).getByRole("button", { name: "ادامه" });
expect(next).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
expect(next).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "ab" } });
expect(next).toBeDisabled();
expect(within(dialog).getByText(/دلیل باید حداقل/)).toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "واریز بانکی" } });
expect(next).toBeEnabled();
for (const amount of ["0", "-5", "100001"]) {
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: amount } });
expect(next).toBeDisabled();
}
});
it("asks for a confirmation, then calls creditApi.credit with the amount and the reason", async () => {
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "شارژ دستی" } });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
expect(mocks.creditApi.credit).not.toHaveBeenCalled();
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
await waitFor(() =>
expect(mocks.creditApi.credit).toHaveBeenCalledWith("org-1", {
amount: 50,
reason: "شارژ دستی",
}),
);
expect(await screen.findByText("اعتبار افزوده شد")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).not.toBeInTheDocument(),
);
// The balances are read again after the credit, so the row shows the new state.
expect(mocks.creditApi.balances).toHaveBeenCalledTimes(2);
});
it("shows the server's Persian message when the credit is refused", async () => {
mocks.creditApi.credit.mockRejectedValueOnce(
new mocks.FakeApiError(422, "validation_error", "دلیل افزودن اعتبار لازم است."),
);
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "10" } });
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "تست خطا" } });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"دلیل افزودن اعتبار لازم است.",
);
expect(screen.getByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).toBeInTheDocument();
});
});
describe("owner credit page: history", () => {
it("opens a drawer that lists one organization's credit history", async () => {
mocks.creditApi.history.mockResolvedValueOnce([
{
id: "tx-9",
kind: "owner_credit",
amount: 20,
balance_after: 140,
reason: "واریز مرداد",
created_at: "2026-10-01T10:00:00Z",
},
]);
render(<AdminCreditsPage />);
fireEvent.click(within(await rowFor("Acme")).getByRole("button", { name: "تاریخچه" }));
expect(await screen.findByText("واریز مرداد")).toBeInTheDocument();
expect(mocks.creditApi.history).toHaveBeenCalledWith("org-1");
expect(screen.getByText("140.00 USD")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /تاریخچه/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
/**
* The owner credit client calls the owner routes it names, with the right method and body.
* ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
import { creditApi } from "@/lib/api-credit";
const ORG = "org-1";
beforeEach(() => {
mocks.apiRequest.mockReset();
mocks.apiRequest.mockResolvedValue({});
});
describe("creditApi reads", () => {
it("reads every organization's balance with a plain GET", async () => {
await creditApi.balances();
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/credits");
});
it("reads one organization's history, with the limit only when one is given", async () => {
await creditApi.history(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit-history");
await creditApi.history(ORG, 20);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(
"/api/admin/orgs/org-1/credit-history?limit=20",
);
});
it("encodes the organization id in the path", async () => {
await creditApi.history("a/b");
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/a%2Fb/credit-history");
});
});
describe("creditApi actions", () => {
it("posts the amount and the reason as the body, and nothing else", async () => {
await creditApi.credit(ORG, { amount: 50, reason: "شارژ پس از واریز" });
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit", {
method: "POST",
body: { amount: 50, reason: "شارژ پس از واریز" },
});
});
});
+249
View File
@@ -0,0 +1,249 @@
/**
* Prepaid billing (D-B) for the members: the billing page shows the add-credit form only to the
* platform owner, tells every other member where credit comes from, and shows the engine's credit
* notice while the balance is zero. The wizard's success screen also says that work waits for
* credit. The API and the engine status are mocked, so the test reads what each page renders.
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
replace: vi.fn(),
session: {
ready: true,
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "member" },
user: null as null | {
id: string;
email: string;
is_platform_owner: boolean;
platform_role?: string;
},
organizations: [] as Array<Record<string, unknown>>,
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
api: {
billingSummary: vi.fn(),
billingUsageEvents: vi.fn(),
billingInvoices: vi.fn(),
billingPayments: vi.fn(),
billingAddCredit: vi.fn(),
billingCheckout: vi.fn(),
listEmployees: vi.fn(),
},
setupApi: {
getCatalog: vi.fn(),
getDraft: vi.fn(),
saveDraft: vi.fn(async () => {}),
complete: vi.fn(),
},
status: vi.fn(),
};
});
vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
vi.mock("@/lib/realtime", () => ({ useLiveChannel: () => undefined }));
import OnboardingPage from "@/app/onboarding/page";
import BillingPage from "@/app/(app)/billing/page";
const SUMMARY = {
balance: 0,
currency: "USD",
low_balance: true,
low_balance_threshold: 5,
total_spend: 12.5,
by_unit: [],
by_employee: {},
};
const CATALOG = {
app_types: ["web_app"],
app_type_labels: { web_app: "وب‌اپ" },
git_sources: ["platform", "external"],
languages: [
{
key: "node",
display: "Node.js",
frameworks: [
{
key: "next",
display: "Next.js",
app_type: "web_app",
defaults: {
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: 3000,
healthcheck_path: "/",
docker_image: "node:20",
},
},
],
},
],
};
function status(overrides: Partial<{ balance: number; credit_ok: boolean }> = {}) {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
/** Lets the pending status call finish and its state update land before the assertion. */
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
beforeEach(() => {
for (const fn of [...Object.values(mocks.api), ...Object.values(mocks.setupApi), mocks.status]) {
fn.mockReset();
}
mocks.api.billingSummary.mockResolvedValue(SUMMARY);
mocks.api.billingUsageEvents.mockResolvedValue([]);
mocks.api.billingInvoices.mockResolvedValue([]);
mocks.api.billingPayments.mockResolvedValue([]);
mocks.api.listEmployees.mockResolvedValue([]);
mocks.status.mockResolvedValue(status());
mocks.setupApi.saveDraft.mockResolvedValue(undefined);
});
describe("billing page for a member", () => {
beforeEach(() => {
mocks.session.user = { id: "u2", email: "member@example.com", is_platform_owner: false };
});
it("offers no add-credit form and says the platform owner adds the credit", async () => {
render(<BillingPage />);
expect(await screen.findByText(/توسط مالک سامانه افزوده/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "افزودن اعتبار" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "پرداخت آنلاین" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("مبلغ")).not.toBeInTheDocument();
});
it("shows the credit notice at zero balance", async () => {
render(<BillingPage />);
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith("org-1");
});
});
describe("billing page for the platform owner", () => {
beforeEach(() => {
mocks.session.user = { id: "u1", email: "owner@example.com", is_platform_owner: true };
});
it("shows the add-credit form and not the member hint", async () => {
render(<BillingPage />);
expect(await screen.findByRole("button", { name: "افزودن اعتبار" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "پرداخت آنلاین" })).toBeInTheDocument();
expect(screen.queryByText(/توسط مالک سامانه افزوده/)).not.toBeInTheDocument();
});
it("hides the credit notice once the organization has credit", async () => {
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 40 }));
render(<BillingPage />);
await screen.findByRole("button", { name: "افزودن اعتبار" });
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
await settle();
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).not.toBeInTheDocument();
});
});
describe("wizard success screen", () => {
beforeEach(() => {
mocks.session.user = {
id: "u1",
email: "owner@example.com",
is_platform_owner: false,
platform_role: "manager",
};
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
// Resume the wizard at its last step with every field filled in, so one click completes it.
mocks.setupApi.getDraft.mockResolvedValue({
step: 5,
data: {
org_name: "Acme",
slug: "acme",
app_name: "app",
app_type: "web_app",
language: "node",
framework: "next",
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: "3000",
healthcheck_path: "/",
git_source: "platform",
},
});
mocks.setupApi.complete.mockResolvedValue({
organization: { id: "org-1", name: "Acme", slug: "acme" },
project: { id: "p-1", name: "app", slug: "app" },
deploy_target_id: null,
});
});
async function completeWizard(): Promise<void> {
render(<OnboardingPage />);
fireEvent.click(await screen.findByRole("button", { name: "ساخت سازمان" }));
expect(await screen.findByText("سازمان ساخته شد")).toBeInTheDocument();
}
it("says that work starts only after credit is added when the engine reports none", async () => {
await completeWizard();
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith("org-1");
});
it("says nothing about credit when the organization already has credit", async () => {
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
await completeWizard();
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
await settle();
expect(screen.queryByText(/پس از افزودن اعتبار/)).not.toBeInTheDocument();
});
it("still says it when the engine status cannot be read", async () => {
mocks.status.mockRejectedValue(new mocks.FakeApiError(503, "unavailable", "موتور در دسترس نیست."));
await completeWizard();
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* The engine client sends each call to the engine route it names, with the right method and
* body. ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
import { engineApi } from "@/lib/api-engine";
const ORG = "org-1";
const BASE = "/api/organizations/org-1/engine";
beforeEach(() => {
mocks.apiRequest.mockReset();
mocks.apiRequest.mockResolvedValue({});
});
describe("engineApi reads", () => {
it("reads the status with a plain GET", async () => {
await engineApi.status(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/status`);
});
it("lists the board, with the stage only when one is given", async () => {
await engineApi.listTasks(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks`);
await engineApi.listTasks(ORG, "pending_test");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks?stage=pending_test`);
});
it("reads one task by id", async () => {
await engineApi.getTask(ORG, "task-9");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-9`);
});
it("lists the recent turns, with the limit only when one is given", async () => {
await engineApi.listTurns(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns`);
await engineApi.listTurns(ORG, 20);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns?limit=20`);
});
it("reads the team with a plain GET", async () => {
await engineApi.getTeam(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team`);
});
});
describe("engineApi actions", () => {
it("posts pause, resume and cancel with no body", async () => {
await engineApi.pauseTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/pause`, {
method: "POST",
});
await engineApi.resumeTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/resume`, {
method: "POST",
});
await engineApi.cancelTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/cancel`, {
method: "POST",
});
});
it("posts the answer as the content field", async () => {
await engineApi.answerTask(ORG, "task-2", "نسخهٔ دوم را انجام بده");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-2/answer`, {
method: "POST",
body: { content: "نسخهٔ دوم را انجام بده" },
});
});
it("hires with the role, the tier and the name, and fires by employee id", async () => {
await engineApi.hire(ORG, { role: "developer", tier: "strong", name: "علی" });
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/hire`, {
method: "POST",
body: { role: "developer", tier: "strong", name: "علی" },
});
await engineApi.fire(ORG, "emp-3");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/emp-3/fire`, {
method: "POST",
});
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* The task board: one column per group of stages, every stage and a legacy task in the right
* column, a live task.step moving its card, a task.created asking the page to reload, the filters
* narrowing the cards, and the controls shown only to someone who may run tasks.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
pauseTask: vi.fn(),
resumeTask: vi.fn(),
cancelTask: vi.fn(),
channels: [] as string[][],
listener: null as null | ((event: { type: string; data: Record<string, unknown> }) => void),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({
engineApi: { pauseTask: mocks.pauseTask, resumeTask: mocks.resumeTask, cancelTask: mocks.cancelTask },
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (
channels: string[],
onEvent: (event: { type: string; data: Record<string, unknown> }) => void,
) => {
mocks.channels.push(channels);
mocks.listener = onEvent;
},
}));
import { type TaskBoardProps, TaskBoard } from "@/app/(app)/tasks/_components/task-board";
import type { EngineStage, EngineTask } from "@/lib/api-engine";
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
const ORG = "org-1";
const EMPLOYEES = [
{ id: "emp-1", name: "علی" },
{ id: "emp-2", name: "سارا" },
];
/** The coarse status a stage implies (a finished stage is a finished task). */
const COARSE: Partial<Record<EngineStage, EngineTask["status"]>> = {
completed: "done",
failed: "failed",
cancelled: "cancelled",
};
function engineTask(stage: EngineStage, overrides: Partial<EngineTask> = {}): EngineTask {
return {
id: `task-${stage}`,
title: `کار ${stage}`,
status: COARSE[stage] ?? "running",
stage,
pipeline: "feature",
kind: "feature",
cycle: 1,
max_cycles: 3,
thread_id: null,
assigned_employee_id: "emp-1",
assignee_name: "علی",
created_at: "2026-10-01T08:00:00Z",
updated_at: "2026-10-01T08:00:00Z",
...overrides,
};
}
const LEGACY: EngineTask = {
id: "legacy-1",
title: "کار قدیمی",
status: "running",
stage: null,
pipeline: null,
kind: null,
cycle: 0,
max_cycles: 0,
thread_id: null,
assigned_employee_id: null,
assignee_name: null,
created_at: "2025-01-01T08:00:00Z",
updated_at: "2025-01-01T08:00:00Z",
};
/** Every one of the thirteen stages once, and one legacy task after the implementation card. */
const FIXTURE: EngineTask[] = [
engineTask("pending_manager_review"),
engineTask("pending_analysis"),
engineTask("pending_implementation"),
LEGACY,
engineTask("pending_review", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
engineTask("pending_final_review"),
engineTask("pending_test", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
engineTask("merge_request"),
engineTask("completed"),
engineTask("cancelled"),
engineTask("failed"),
engineTask("pending_user_decision"),
engineTask("error_review"),
engineTask("paused"),
];
/** The card titles each column shows, in the order of the fixture. */
const EXPECTED: Record<BoardColumnKey, string[]> = {
manager_review: ["کار pending_manager_review"],
analysis: ["کار pending_analysis"],
implementation: ["کار pending_implementation", "کار قدیمی"],
review: ["کار pending_review", "کار pending_final_review"],
test: ["کار pending_test"],
merge: ["کار merge_request"],
done: ["کار completed", "کار cancelled", "کار failed"],
decision: ["کار pending_user_decision", "کار error_review", "کار paused"],
};
function columnLabel(key: BoardColumnKey): string {
const column = BOARD_COLUMNS.find((item) => item.key === key);
if (!column) throw new Error(`no board column ${key}`);
return column.label;
}
function cardTitles(): string[] {
return screen.getAllByRole("link").map((link) => link.textContent ?? "");
}
function renderBoard(overrides: Partial<TaskBoardProps> = {}) {
const props: TaskBoardProps = {
orgId: ORG,
tasks: FIXTURE,
employees: EMPLOYEES,
canRun: true,
onChanged: vi.fn(),
...overrides,
};
return { ...render(<TaskBoard {...props} />), props };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [];
mocks.listener = null;
});
describe("TaskBoard columns", () => {
it("shows the eight columns in board order", () => {
renderBoard();
const regions = screen.getAllByRole("region");
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual(
BOARD_COLUMNS.map((column) => column.label),
);
});
it("puts every stage and a legacy task in exactly one column", () => {
renderBoard();
for (const column of BOARD_COLUMNS) {
const region = screen.getByRole("region", { name: column.label });
const titles = within(region)
.getAllByRole("link")
.map((link) => link.textContent);
expect(titles).toEqual(EXPECTED[column.key]);
}
expect(cardTitles()).toHaveLength(FIXTURE.length);
});
it("shows the Persian stage on each card", () => {
renderBoard();
const link = within(screen.getByRole("region", { name: columnLabel("test") })).getByRole(
"link",
{ name: "کار pending_test" },
);
expect(link.parentElement).toHaveTextContent(STAGE_LABELS.pending_test);
});
});
describe("TaskBoard live updates", () => {
it("subscribes to the organization channel", () => {
renderBoard();
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
});
it("moves a card to the column of its new stage on task.step", () => {
const { props } = renderBoard();
act(() => {
mocks.listener?.({
type: "task.step",
data: {
task_id: "task-pending_test",
from: "pending_test",
to: "merge_request",
pipeline: "feature",
status: "running",
},
});
});
const test = screen.getByRole("region", { name: columnLabel("test") });
expect(within(test).queryByRole("link", { name: "کار pending_test" })).toBeNull();
const merge = screen.getByRole("region", { name: columnLabel("merge") });
const moved = within(merge).getByRole("link", { name: "کار pending_test" });
expect(moved.parentElement).toHaveTextContent(STAGE_LABELS.merge_request);
expect(props.onChanged).not.toHaveBeenCalled();
});
it("asks the page to reload on task.created", () => {
const { props } = renderBoard();
act(() => {
mocks.listener?.({ type: "task.created", data: {} });
});
expect(props.onChanged).toHaveBeenCalledTimes(1);
});
it("takes a fresh list from the page in place of its moved copy", () => {
const { rerender, props } = renderBoard();
act(() => {
mocks.listener?.({
type: "task.step",
data: {
task_id: "task-pending_test",
from: "pending_test",
to: "merge_request",
pipeline: "feature",
status: "running",
},
});
});
rerender(<TaskBoard {...props} tasks={[...FIXTURE]} />);
const test = screen.getByRole("region", { name: columnLabel("test") });
expect(within(test).getByRole("link", { name: "کار pending_test" })).toBeInTheDocument();
});
});
describe("TaskBoard filters", () => {
it("hides the cards whose title does not match the search", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("جستجو در عنوان"), { target: { value: "merge" } });
expect(cardTitles()).toEqual(["کار merge_request"]);
});
it("shows only the tasks of the chosen assignee", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "emp-2" } });
expect(cardTitles()).toEqual(["کار pending_review", "کار pending_test"]);
});
it("shows only the unassigned tasks for the no-assignee choice", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "none" } });
expect(cardTitles()).toEqual(["کار قدیمی"]);
});
it("empties the finished column when only active tasks are asked for", () => {
renderBoard();
fireEvent.click(screen.getByLabelText("فقط کارهای فعال"));
const done = screen.getByRole("region", { name: columnLabel("done") });
expect(within(done).queryAllByRole("link")).toHaveLength(0);
expect(cardTitles()).toContain("کار pending_test");
expect(cardTitles()).not.toContain("کار completed");
});
});
describe("TaskBoard controls", () => {
it("shows the task controls only to someone who may run tasks", () => {
const { unmount } = renderBoard({ canRun: false });
expect(screen.queryByRole("button", { name: "توقف" })).toBeNull();
unmount();
renderBoard({ canRun: true });
expect(screen.getAllByRole("button", { name: "توقف" }).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,117 @@
/**
* The engine's credit notice shows at zero credit and not otherwise. The status hook loads the
* status for the organization's channel, and loads it again on a balance, stage or team change,
* but not on any other live event.
*/
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
status: vi.fn(),
channels: [] as string[][],
listener: null as null | ((event: { type: string }) => void),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: { type: string }) => void) => {
mocks.channels.push(channels);
mocks.listener = onEvent;
},
}));
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import type { EngineStatus } from "@/lib/api-engine";
const ORG = "org-1";
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
function Probe({ orgId }: { orgId: string }) {
const { status: current, loading } = useEngineStatus(orgId);
if (loading) return <p>در حال بارگذاری</p>;
return <p>{current?.credit_ok ? "اعتبار موجود" : "بدون اعتبار"}</p>;
}
beforeEach(() => {
mocks.status.mockReset();
mocks.channels.length = 0;
mocks.listener = null;
});
describe("CreditNotice", () => {
it("explains in Persian why work stopped, at zero credit", () => {
render(<CreditNotice status={status()} />);
expect(screen.getByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
);
});
it("shows nothing while credit is available", () => {
const { container } = render(
<CreditNotice status={status({ credit_ok: true, balance: 12 })} />,
);
expect(container).toBeEmptyDOMElement();
});
it("shows nothing before the status has loaded", () => {
const { container } = render(<CreditNotice status={null} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("useEngineStatus", () => {
it("subscribes to the organization and reloads on a balance, stage or team change only", async () => {
mocks.status
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
render(<Probe orgId={ORG} />);
expect(await screen.findByText("بدون اعتبار")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith(ORG);
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
act(() => mocks.listener?.({ type: "run.log" }));
expect(mocks.status).toHaveBeenCalledTimes(1);
act(() => mocks.listener?.({ type: "billing.wallet" }));
expect(await screen.findByText("اعتبار موجود")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledTimes(2);
act(() => mocks.listener?.({ type: "task.step" }));
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(3));
act(() => mocks.listener?.({ type: "team.changed" }));
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(4));
});
});
+306
View File
@@ -0,0 +1,306 @@
/**
* The dashboard's engine summary: the balance and the credit notice, at most five active engine
* tasks (never a finished or a legacy task), the count in each board column, the five newest
* completed tasks, a Persian message when the task list fails, and a reload on a task step only.
*/
import * as React from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
status: vi.fn(),
listTasks: vi.fn(),
listeners: new Set<(event: { type: string }) => void>(),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({
engineApi: { status: mocks.status, listTasks: mocks.listTasks },
}));
// The realtime client keeps one subscription per channel, so every hook registers its own listener
// and the test delivers an event to all of them.
vi.mock("@/lib/realtime", async () => {
const { useEffect, useRef } = await import("react");
return {
useLiveChannel: (_channels: string[], onEvent: (event: { type: string }) => void) => {
const latest = useRef(onEvent);
latest.current = onEvent;
useEffect(() => {
const listener = (event: { type: string }) => latest.current(event);
mocks.listeners.add(listener);
return () => {
mocks.listeners.delete(listener);
};
}, []);
},
};
});
import { ApiError } from "@/lib/api";
import type { EngineStage, EngineStatus, EngineTask } from "@/lib/api-engine";
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
import { EngineWidgets } from "@/app/(app)/dashboard/_components/engine-widgets";
const ORG = "org-1";
/** The Persian digits the widget prints for a number. */
function fa(value: number): string {
return value.toLocaleString("fa-IR");
}
/** A board column's Persian label, read from the module so the test never retypes it. */
function label(key: BoardColumnKey): string {
const column = BOARD_COLUMNS.find((item) => item.key === key);
if (!column) throw new Error(`no board column ${key}`);
return column.label;
}
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
function taskStatus(stage: EngineStage | null): EngineTask["status"] {
if (stage === "completed") return "done";
if (stage === "failed" || stage === "cancelled") return stage;
return "running";
}
function task(
id: string,
stage: EngineStage | null,
updatedAt = "2026-10-14T10:00:00Z",
): EngineTask {
return {
id,
title: `کار ${id}`,
status: taskStatus(stage),
stage,
pipeline: stage === null ? null : "default",
kind: null,
cycle: 0,
max_cycles: 0,
thread_id: null,
assigned_employee_id: null,
assignee_name: null,
created_at: "2026-10-14T09:00:00Z",
updated_at: updatedAt,
};
}
/** Delivers one live event to every subscribed hook. */
function emit(type: string) {
act(() => {
for (const listener of [...mocks.listeners]) listener({ type });
});
}
/** The row of the distribution list for one board column. */
function columnRow(key: BoardColumnKey): HTMLElement {
const list = screen.getByRole("list", { name: "توزیع مراحل" });
const row = within(list).getByText(label(key)).closest("li");
if (!row) throw new Error(`no row for ${key}`);
return row;
}
/** The card under a section title (the title sits directly inside its card), once it has rendered. */
async function cardOf(title: RegExp): Promise<HTMLElement> {
const heading = await screen.findByRole("heading", { name: title });
const card = heading.parentElement;
if (!card) throw new Error(`no card under ${String(title)}`);
return card;
}
beforeEach(() => {
mocks.status.mockReset();
mocks.listTasks.mockReset();
mocks.listeners.clear();
});
describe("EngineWidgets credit", () => {
it("shows the balance and the credit notice at zero credit", async () => {
mocks.status.mockResolvedValue(status({ balance: 0, credit_ok: false }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
);
});
it("shows the balance and no notice while credit is available", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByText("موجودی اعتبار")).toBeInTheDocument();
expect(screen.getByText("موجودی اعتبار").parentElement).toHaveTextContent(fa(50));
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).toBeNull();
});
});
describe("EngineWidgets board", () => {
it("counts the tasks in each board column from the stage counts", async () => {
mocks.status.mockResolvedValue(
status({
balance: 50,
credit_ok: true,
stage_counts: {
pending_analysis: 2,
pending_review: 1,
pending_final_review: 3,
completed: 4,
paused: 1,
},
}),
);
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
const list = await screen.findByRole("list", { name: "توزیع مراحل" });
expect(within(list).getAllByRole("listitem")).toHaveLength(BOARD_COLUMNS.length);
const expected: Array<[BoardColumnKey, number]> = [
["manager_review", 0],
["analysis", 2],
["implementation", 0],
["review", 4],
["test", 0],
["merge", 0],
["done", 4],
["decision", 1],
];
for (const [key, count] of expected) {
expect(columnRow(key).textContent).toBe(`${label(key)}${fa(count)}`);
}
// The bars are relative to the largest column.
const bar = (key: BoardColumnKey) =>
columnRow(key).querySelector<HTMLElement>('[aria-hidden="true"] > div');
expect(bar("review")).toHaveStyle({ width: "100%" });
expect(bar("analysis")).toHaveStyle({ width: "50%" });
expect(bar("test")).toHaveStyle({ width: "0%" });
});
it("lists at most five active engine tasks, each with its stage and a link", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([
task("a1", "pending_analysis"),
task("a2", "pending_test"),
task("a3", "paused"),
task("done1", "completed"),
task("a4", "pending_manager_review"),
task("legacy1", null),
task("a5", "pending_implementation"),
task("a6", "pending_review"),
task("failed1", "failed"),
]);
render(<EngineWidgets orgId={ORG} />);
const card = await cardOf(/^کارهای فعال$/);
expect(await within(card).findByRole("link", { name: "کار a1" })).toHaveAttribute(
"href",
"/tasks/a1",
);
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
"کار a1",
"کار a2",
"کار a3",
"کار a4",
"کار a5",
]);
expect(within(card).getByText(STAGE_LABELS.pending_analysis)).toBeInTheDocument();
expect(within(card).getByText(STAGE_LABELS.paused)).toBeInTheDocument();
expect(within(card).getByText(`و ${fa(1)} کار دیگر`)).toBeInTheDocument();
});
it("lists the five newest completed tasks, newest first", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([
task("c1", "completed", "2026-10-10T10:00:00Z"),
task("c2", "completed", "2026-10-11T10:00:00Z"),
task("c3", "completed", "2026-10-12T10:00:00Z"),
task("c4", "completed", "2026-10-13T10:00:00Z"),
task("c5", "completed", "2026-10-14T08:00:00Z"),
task("c6", "completed", "2026-10-14T09:00:00Z"),
task("a1", "pending_analysis"),
]);
render(<EngineWidgets orgId={ORG} />);
const card = await cardOf(/^آخرین کارهای انجام/);
await waitFor(() => expect(within(card).getAllByRole("link")).toHaveLength(5));
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
"کار c6",
"کار c5",
"کار c4",
"کار c3",
"کار c2",
]);
expect(within(card).queryByRole("link", { name: "کار c1" })).toBeNull();
expect(within(card).queryByRole("link", { name: "کار a1" })).toBeNull();
});
it("shows the task list's failure in Persian and keeps the balance", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockRejectedValue(
new ApiError(500, "server_error", "فهرست کارها در دسترس نیست."),
);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByRole("alert")).toHaveTextContent("فهرست کارها در دسترس نیست.");
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
});
it("reloads the status and the task list on a task step, and on no other event", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
await screen.findByText("موجودی اعتبار");
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(1));
expect(mocks.listTasks).toHaveBeenCalledWith(ORG);
expect(mocks.status).toHaveBeenCalledTimes(1);
emit("run.log");
expect(mocks.status).toHaveBeenCalledTimes(1);
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
emit("task.step");
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(2));
expect(mocks.status).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,116 @@
/**
* The gates panel and the diff viewer: each gate is named in Persian with its verdict, a red gate
* opens to its detail, a waiver shows its reason, diff lines carry their colour, and a merged task
* shows its short sha with a link to the pipeline view.
*/
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffViewer } from "@/app/(app)/tasks/[id]/_components/diff-viewer";
import { GatesPanel } from "@/app/(app)/tasks/[id]/_components/gates-panel";
import type { EngineGate } from "@/lib/api-engine";
const GATES: EngineGate[] = [
{
id: "gate-1",
gate: "tests",
ok: true,
detail: "3 passed",
created_at: "2026-10-13T08:00:00Z",
},
{
id: "gate-2",
gate: "diff_guard",
ok: false,
detail: "diff touches protected/settings.py",
created_at: "2026-10-13T08:05:00Z",
},
];
const DIFF = ["@@ -1,2 +1,2 @@", " keep", "-old line", "+new line"].join("\n");
function rowOf(label: string): HTMLElement {
const row = screen.getByText(label).closest("li");
if (!row) throw new Error(`no row for ${label}`);
return row;
}
describe("GatesPanel", () => {
it("names each gate in Persian with a green or red verdict", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
expect(screen.getByText("تست‌ها")).toBeInTheDocument();
expect(screen.getByText("نگهبان تغییرات")).toBeInTheDocument();
expect(screen.getByText("قبول شد")).toBeInTheDocument();
expect(screen.getByText("رد شد")).toBeInTheDocument();
});
it("opens a red gate to its detail", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
const row = rowOf("نگهبان تغییرات");
expect(within(row).queryByText("diff touches protected/settings.py")).toBeNull();
fireEvent.click(within(row).getByRole("button", { name: "جزئیات" }));
expect(within(row).getByText("diff touches protected/settings.py").tagName).toBe("PRE");
expect(within(row).getByRole("button", { name: "بستن جزئیات" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("shows the waiver reason in a warning box", () => {
render(<GatesPanel gates={[]} waiverReason="اجرای تست روی محیط قبلی" />);
expect(screen.getByRole("note")).toHaveTextContent(
"صرف‌نظر از تست با دلیل: اجرای تست روی محیط قبلی",
);
});
it("shows no warning box without a waiver reason", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
expect(screen.queryByRole("note")).toBeNull();
});
it("says so when no gate has run yet", () => {
render(<GatesPanel gates={[]} waiverReason="" />);
expect(screen.getByText("هنوز نتیجهٔ دروازه‌ای ثبت نشده است.")).toBeInTheDocument();
});
});
describe("DiffViewer", () => {
it("colours added, removed and hunk lines", () => {
render(<DiffViewer diff={DIFF} mergedSha="" />);
expect(screen.getByText("+new line")).toHaveClass("text-positive");
expect(screen.getByText("-old line")).toHaveClass("text-danger");
expect(screen.getByText("@@ -1,2 +1,2 @@")).toHaveClass("text-slate-500");
});
it("says so when no change has been recorded", () => {
render(<DiffViewer diff="" mergedSha="" />);
expect(screen.getByText("هنوز تغییری ثبت نشده است.")).toBeInTheDocument();
});
it("shows the short merged sha with a link to the pipeline view", () => {
render(<DiffViewer diff={DIFF} mergedSha="0123456789abcdef0123" />);
const line = screen.getByText(/ادغام و ارسال شد/).closest("p");
expect(line).toHaveTextContent("ادغام و ارسال شد: 0123456789ab");
expect(screen.queryByText("0123456789abcdef0123")).toBeNull();
expect(screen.getByRole("link", { name: "مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن" })).toHaveAttribute(
"href",
"/app",
);
});
it("shows no sha and no link before the change is merged", () => {
render(<DiffViewer diff={DIFF} mergedSha="" />);
expect(screen.queryByRole("link")).toBeNull();
expect(screen.queryByText(/ادغام و ارسال شد/)).toBeNull();
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* The engine's labels and board columns: every stage lands in exactly one column, a legacy
* task lands by its status, and grouping keeps the order the tasks came in.
*/
import { describe, expect, it } from "vitest";
import {
BOARD_COLUMNS,
columnFor,
groupByColumn,
ROLE_LABELS,
STAGE_LABELS,
stageTone,
TIER_LABELS,
} from "@/lib/engine-labels";
const STAGES = [
"pending_manager_review",
"pending_analysis",
"pending_implementation",
"pending_review",
"pending_test",
"pending_final_review",
"merge_request",
"completed",
"pending_user_decision",
"error_review",
"paused",
"cancelled",
"failed",
];
describe("the board", () => {
it("places every one of the thirteen stages in exactly one column", () => {
for (const stage of STAGES) {
const homes = BOARD_COLUMNS.filter((column) => column.stages.includes(stage as never));
expect(homes, stage).toHaveLength(1);
expect(STAGE_LABELS[stage], stage).toBeTruthy();
}
const placed = BOARD_COLUMNS.flatMap((column) => column.stages as readonly string[]);
expect([...placed].sort()).toEqual([...STAGES].sort());
});
it("uses the stage column whenever the engine owns the task", () => {
expect(columnFor("pending_test", "running")).toBe("test");
expect(columnFor("pending_final_review", "running")).toBe("review");
expect(columnFor("paused", "waiting_approval")).toBe("decision");
expect(columnFor("completed", "done")).toBe("done");
});
it("places a legacy task by its coarse status", () => {
expect(columnFor(null, "new")).toBe("manager_review");
expect(columnFor(null, "triaged")).toBe("implementation");
expect(columnFor(null, "running")).toBe("implementation");
expect(columnFor(null, "waiting_approval")).toBe("decision");
for (const status of ["done", "failed", "cancelled"]) {
expect(columnFor(null, status)).toBe("done");
}
});
it("sends an unknown stage or status to the decision column", () => {
expect(columnFor("some_new_stage", "running")).toBe("decision");
expect(columnFor(null, "some_new_status")).toBe("decision");
});
it("groups tasks by column, keeps every column, and keeps the order inside each", () => {
const tasks = [
{ id: "a", stage: "pending_test", status: "running" },
{ id: "b", stage: null, status: "new" },
{ id: "c", stage: "pending_test", status: "running" },
{ id: "d", stage: "pending_final_review", status: "running" },
];
const groups = groupByColumn(tasks);
expect(groups.test.map((task) => task.id)).toEqual(["a", "c"]);
expect(groups.review.map((task) => task.id)).toEqual(["d"]);
expect(groups.manager_review.map((task) => task.id)).toEqual(["b"]);
expect(groups.merge).toEqual([]);
expect(Object.keys(groups)).toHaveLength(BOARD_COLUMNS.length);
});
});
describe("labels and tones", () => {
it("tones a stage by how it ended or whether a person must act", () => {
expect(stageTone("completed")).toBe("success");
expect(stageTone("failed")).toBe("danger");
expect(stageTone("cancelled")).toBe("danger");
expect(stageTone("paused")).toBe("warning");
expect(stageTone("pending_user_decision")).toBe("warning");
expect(stageTone("error_review")).toBe("warning");
expect(stageTone("pending_test")).toBe("info");
});
it("labels the four tiers in Persian", () => {
expect(TIER_LABELS).toEqual({
fast: "سریع",
balanced: "متعادل",
strong: "قوی",
unknown: "نامشخص",
});
});
it("labels the five hirable roles in Persian", () => {
expect(Object.keys(ROLE_LABELS).sort()).toEqual(
["developer", "manager", "planner", "reviewer", "tester"].sort(),
);
expect(ROLE_LABELS.manager).toBe("مدیر");
expect(ROLE_LABELS.reviewer).toBe("بازبین");
expect(ROLE_LABELS.tester).toBe("آزمونگر");
});
});

Some files were not shown because too many files have changed in this diff Show More