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:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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("این کار قبلاً بسته شده است.")
|
||||||
|
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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:
|
|
||||||
|
The change is one history row of kind ``adjustment``, so it shows in the owner's
|
||||||
|
credit history for this organization.
|
||||||
|
"""
|
||||||
if not context.user.is_platform_owner:
|
if not context.user.is_platform_owner:
|
||||||
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
|
raise PermissionDeniedError(OWNER_ONLY_CREDIT)
|
||||||
from app.core.errors import PermissionDeniedError
|
if payload.amount == 0:
|
||||||
|
raise ValidationError("مقدار اعتبار نمیتواند صفر باشد.")
|
||||||
|
|
||||||
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
transaction = await CreditLedgerRepository(session).apply(
|
||||||
else:
|
|
||||||
context.require(CAN_MANAGE_BILLING)
|
|
||||||
|
|
||||||
wallet = await service.add_credit(
|
|
||||||
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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+15
-12
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,19 +376,20 @@ 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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
// A legacy task (no engine stage): its runs, approvals and employees, as before.
|
||||||
if (!orgId) {
|
const loadLegacy = React.useCallback(async () => {
|
||||||
setLoading(false);
|
if (!orgId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
|
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
|
||||||
api.getTask(orgId, taskId),
|
api.getTask(orgId, taskId),
|
||||||
api.listEmployees(orgId),
|
api.listEmployees(orgId),
|
||||||
@@ -192,12 +403,30 @@ export default function TaskDetailPage({
|
|||||||
// Hydrate the newest run so the timeline is visible without a click.
|
// Hydrate the newest run so the timeline is visible without a click.
|
||||||
const newest = nextTask.runs[0];
|
const newest = nextTask.runs[0];
|
||||||
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
|
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 () => {
|
||||||
|
if (!orgId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const detail = await engineApi.getTask(orgId, taskId);
|
||||||
|
if (detail.stage !== null) {
|
||||||
|
setEngine(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEngine(null);
|
||||||
|
await loadLegacy();
|
||||||
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{error && <ErrorState message={error} onRetry={retry} />}
|
||||||
|
{tasks.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="کاری یافت نشد"
|
title="کاری یافت نشد"
|
||||||
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
|
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{tasks.map((task) => {
|
|
||||||
const employee = employees.find((e) => e.id === task.assigned_employee_id);
|
|
||||||
return (
|
|
||||||
<Card key={task.id} className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-slate-500">
|
<TaskBoard
|
||||||
{employee ? `مسئول: ${employee.name}` : "بدون مسئول"}
|
orgId={orgId}
|
||||||
</p>
|
tasks={tasks}
|
||||||
</div>
|
employees={employees}
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
canRun={canRun}
|
||||||
<Badge tone={STATUS_TONES[task.status] ?? "neutral"}>
|
onChanged={load}
|
||||||
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
بازخوانی
|
بازخوانی
|
||||||
|
|||||||
@@ -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,6 +240,7 @@ export default function EmployeeDetailPage({
|
|||||||
label,
|
label,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
{isOwner && (
|
||||||
<Select
|
<Select
|
||||||
label="سیاست کاری"
|
label="سیاست کاری"
|
||||||
value={routingPolicyId}
|
value={routingPolicyId}
|
||||||
@@ -251,6 +251,7 @@ export default function EmployeeDetailPage({
|
|||||||
...choices.map((c) => ({ value: c.id, label: c.name })),
|
...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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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: "آزمونگر",
|
||||||
|
};
|
||||||
@@ -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
@@ -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: "شارژ پس از واریز" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user