From d532929189cc529d87ff6b483b02cb87a75690bd Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Wed, 14 Oct 2026 23:19:05 +0330 Subject: [PATCH] feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 6 + README.md | 5 + ...d5e6f7a8b9_prepaid_credit_and_engine_ui.py | 82 ++ apps/api/app/application/container_engine.py | 2 + apps/api/app/application/engine/events.py | 32 +- apps/api/app/application/engine/manager.py | 27 + apps/api/app/application/engine/scheduler.py | 112 ++- apps/api/app/application/engine/state.py | 14 +- .../application/services/billing_service.py | 19 +- .../application/services/credit_service.py | 136 +++ .../services/model_grant_service.py | 5 +- .../application/services/org_setup_service.py | 5 + .../application/services/workforce_service.py | 58 +- apps/api/app/core/config.py | 6 + apps/api/app/domain/audit/models.py | 3 + apps/api/app/domain/billing/models.py | 28 + apps/api/app/domain/workforce/pipeline.py | 4 + apps/api/app/infrastructure/db/models.py | 1 + .../app/infrastructure/db/models_billing.py | 50 + .../app/infrastructure/db/models_engine.py | 5 + apps/api/app/infrastructure/db/models_org.py | 2 +- .../app/infrastructure/repositories/audit.py | 24 +- .../app/infrastructure/repositories/credit.py | 148 +++ .../app/infrastructure/repositories/engine.py | 44 +- .../infrastructure/repositories/org_grants.py | 2 +- .../infrastructure/repositories/workforce.py | 9 + .../interfaces/http/routers/admin_credits.py | 144 +++ .../app/interfaces/http/routers/billing.py | 65 +- .../api/app/interfaces/http/routers/engine.py | 670 +++++++++++++ .../interfaces/http/routers/organizations.py | 5 + apps/api/app/interfaces/realtime/rpc_app.py | 4 + apps/api/tests/billing_helpers.py | 29 + apps/api/tests/legacy_tasks.py | 36 + apps/api/tests/test_admin_credits.py | 205 ++++ apps/api/tests/test_billing_prepaid.py | 161 ++++ apps/api/tests/test_billing_settle_ledger.py | 112 +++ .../api/tests/test_billing_starting_credit.py | 96 ++ apps/api/tests/test_credit_chat_gate.py | 171 ++++ apps/api/tests/test_credit_debit.py | 218 +++++ apps/api/tests/test_credit_ledger.py | 163 ++++ apps/api/tests/test_credit_service.py | 236 +++++ apps/api/tests/test_direct_tasks_engine.py | 169 ++++ apps/api/tests/test_e2e_smoke.py | 34 +- apps/api/tests/test_engine_api.py | 408 ++++++++ apps/api/tests/test_engine_api_actions.py | 186 ++++ apps/api/tests/test_engine_api_team.py | 255 +++++ apps/api/tests/test_engine_control.py | 263 +++++ apps/api/tests/test_engine_flow.py | 77 +- apps/api/tests/test_engine_repo_ui.py | 171 ++++ apps/api/tests/test_engine_step_event.py | 150 +++ apps/api/tests/test_engine_ui_fields.py | 68 ++ apps/api/tests/test_error_log.py | 30 +- .../api/tests/test_integration_order_route.py | 4 + .../tests/test_integration_thread_project.py | 10 +- apps/api/tests/test_mcp.py | 9 + apps/api/tests/test_model_grants.py | 11 +- apps/api/tests/test_seed.py | 29 +- apps/api/tests/test_v2_e2e_smoke.py | 8 +- apps/api/tests/test_workers.py | 103 +- apps/api/tests/test_workforce.py | 83 +- apps/web/src/app/(app)/admin/credits/page.tsx | 417 ++++++++ .../src/app/(app)/admin/model-grants/page.tsx | 2 +- apps/web/src/app/(app)/billing/page.tsx | 26 +- .../dashboard/_components/engine-widgets.tsx | 224 +++++ apps/web/src/app/(app)/dashboard/page.tsx | 3 + .../manager/_components/decision-banner.tsx | 104 ++ .../(app)/manager/_components/task-cards.tsx | 73 ++ apps/web/src/app/(app)/manager/page.tsx | 107 ++- .../tasks/[id]/_components/diff-viewer.tsx | 60 ++ .../tasks/[id]/_components/gates-panel.tsx | 94 ++ .../(app)/tasks/[id]/_components/live-log.tsx | 124 +++ .../[id]/_components/pipeline-stepper.tsx | 91 ++ .../tasks/[id]/_components/turn-timeline.tsx | 80 ++ apps/web/src/app/(app)/tasks/[id]/page.tsx | 274 +++++- .../(app)/tasks/_components/task-board.tsx | 209 ++++ apps/web/src/app/(app)/tasks/page.tsx | 152 ++- .../(app)/traces/_components/engine-turns.tsx | 136 +++ apps/web/src/app/(app)/traces/page.tsx | 3 + .../web/src/app/(app)/workforce/[id]/page.tsx | 61 +- .../workforce/_components/hire-dialog.tsx | 143 +++ .../workforce/_components/team-panel.tsx | 153 +++ apps/web/src/app/(app)/workforce/page.tsx | 252 +---- apps/web/src/app/onboarding/page.tsx | 34 + .../src/components/engine/credit-notice.tsx | 69 ++ .../src/components/engine/task-actions.tsx | 126 +++ apps/web/src/lib/api-credit.ts | 61 ++ apps/web/src/lib/api-engine.ts | 197 +++- apps/web/src/lib/api-org.ts | 8 +- apps/web/src/lib/engine-labels.ts | 125 +++ apps/web/src/lib/nav.ts | 1 + apps/web/src/lib/ws.ts | 45 +- .../src/test/billing-admin-credits.test.tsx | 176 ++++ apps/web/src/test/billing-api-credit.test.ts | 50 + apps/web/src/test/billing-prepaid.test.tsx | 249 +++++ apps/web/src/test/engine-api.test.ts | 92 ++ apps/web/src/test/engine-board.test.tsx | 298 ++++++ .../src/test/engine-credit-notice.test.tsx | 117 +++ apps/web/src/test/engine-dashboard.test.tsx | 306 ++++++ apps/web/src/test/engine-gates-diff.test.tsx | 116 +++ apps/web/src/test/engine-labels.test.ts | 111 +++ apps/web/src/test/engine-live-log.test.tsx | 158 +++ .../web/src/test/engine-manager-page.test.tsx | 232 +++++ apps/web/src/test/engine-manager.test.tsx | 152 +++ .../web/src/test/engine-task-actions.test.tsx | 147 +++ apps/web/src/test/engine-task-detail.test.tsx | 129 +++ apps/web/src/test/engine-task-page.test.tsx | 222 +++++ apps/web/src/test/engine-traces.test.tsx | 220 +++++ .../src/test/engine-workforce-detail.test.tsx | 180 ++++ apps/web/src/test/engine-workforce.test.tsx | 211 ++++ apps/web/src/test/ws-refcount.test.ts | 181 ++++ docs/plan/PLAN-v2.md | 15 +- docs/plan/TODO-agent-2-ai-workforce.md | 58 +- docs/plan/reports/agent-2-final.md | 216 +++++ docs/plan/reports/agent-3-final.md | 15 +- packages/contracts/src/index.ts | 51 +- todo-finish-plan.md | 487 ++++++++++ todo-v2-final.md | 904 ++++++++++++++++++ 117 files changed, 13119 insertions(+), 640 deletions(-) create mode 100644 apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py create mode 100644 apps/api/app/application/services/credit_service.py create mode 100644 apps/api/app/infrastructure/db/models_billing.py create mode 100644 apps/api/app/infrastructure/repositories/credit.py create mode 100644 apps/api/app/interfaces/http/routers/admin_credits.py create mode 100644 apps/api/app/interfaces/http/routers/engine.py create mode 100644 apps/api/tests/billing_helpers.py create mode 100644 apps/api/tests/legacy_tasks.py create mode 100644 apps/api/tests/test_admin_credits.py create mode 100644 apps/api/tests/test_billing_prepaid.py create mode 100644 apps/api/tests/test_billing_settle_ledger.py create mode 100644 apps/api/tests/test_billing_starting_credit.py create mode 100644 apps/api/tests/test_credit_chat_gate.py create mode 100644 apps/api/tests/test_credit_debit.py create mode 100644 apps/api/tests/test_credit_ledger.py create mode 100644 apps/api/tests/test_credit_service.py create mode 100644 apps/api/tests/test_direct_tasks_engine.py create mode 100644 apps/api/tests/test_engine_api.py create mode 100644 apps/api/tests/test_engine_api_actions.py create mode 100644 apps/api/tests/test_engine_api_team.py create mode 100644 apps/api/tests/test_engine_control.py create mode 100644 apps/api/tests/test_engine_repo_ui.py create mode 100644 apps/api/tests/test_engine_step_event.py create mode 100644 apps/api/tests/test_engine_ui_fields.py create mode 100644 apps/web/src/app/(app)/admin/credits/page.tsx create mode 100644 apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx create mode 100644 apps/web/src/app/(app)/manager/_components/decision-banner.tsx create mode 100644 apps/web/src/app/(app)/manager/_components/task-cards.tsx create mode 100644 apps/web/src/app/(app)/tasks/[id]/_components/diff-viewer.tsx create mode 100644 apps/web/src/app/(app)/tasks/[id]/_components/gates-panel.tsx create mode 100644 apps/web/src/app/(app)/tasks/[id]/_components/live-log.tsx create mode 100644 apps/web/src/app/(app)/tasks/[id]/_components/pipeline-stepper.tsx create mode 100644 apps/web/src/app/(app)/tasks/[id]/_components/turn-timeline.tsx create mode 100644 apps/web/src/app/(app)/tasks/_components/task-board.tsx create mode 100644 apps/web/src/app/(app)/traces/_components/engine-turns.tsx create mode 100644 apps/web/src/app/(app)/workforce/_components/hire-dialog.tsx create mode 100644 apps/web/src/app/(app)/workforce/_components/team-panel.tsx create mode 100644 apps/web/src/components/engine/credit-notice.tsx create mode 100644 apps/web/src/components/engine/task-actions.tsx create mode 100644 apps/web/src/lib/api-credit.ts create mode 100644 apps/web/src/lib/engine-labels.ts create mode 100644 apps/web/src/test/billing-admin-credits.test.tsx create mode 100644 apps/web/src/test/billing-api-credit.test.ts create mode 100644 apps/web/src/test/billing-prepaid.test.tsx create mode 100644 apps/web/src/test/engine-api.test.ts create mode 100644 apps/web/src/test/engine-board.test.tsx create mode 100644 apps/web/src/test/engine-credit-notice.test.tsx create mode 100644 apps/web/src/test/engine-dashboard.test.tsx create mode 100644 apps/web/src/test/engine-gates-diff.test.tsx create mode 100644 apps/web/src/test/engine-labels.test.ts create mode 100644 apps/web/src/test/engine-live-log.test.tsx create mode 100644 apps/web/src/test/engine-manager-page.test.tsx create mode 100644 apps/web/src/test/engine-manager.test.tsx create mode 100644 apps/web/src/test/engine-task-actions.test.tsx create mode 100644 apps/web/src/test/engine-task-detail.test.tsx create mode 100644 apps/web/src/test/engine-task-page.test.tsx create mode 100644 apps/web/src/test/engine-traces.test.tsx create mode 100644 apps/web/src/test/engine-workforce-detail.test.tsx create mode 100644 apps/web/src/test/engine-workforce.test.tsx create mode 100644 apps/web/src/test/ws-refcount.test.ts create mode 100644 docs/plan/reports/agent-2-final.md create mode 100644 todo-finish-plan.md create mode 100644 todo-v2-final.md diff --git a/.env.example b/.env.example index dad4d3e..959e724 100644 --- a/.env.example +++ b/.env.example @@ -140,3 +140,9 @@ ALLOW_CUSTOM_DOMAINS=false # or `--upload-pack`. Set to true ONLY to test against a local bare repository. GIT_ALLOW_FILE_PROTOCOL=false # <<< 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 diff --git a/README.md b/README.md index 2f7e677..75ce1ea 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ provider. این‌ها فقط در **Admin Console** و فقط برای مال جابه‌جایی صندلی مالکیت فقط با `POST /admin/transfer-ownership` انجام می‌شود که در یک تراکنش، مالک فعلی را به مدیر تنزل می‌دهد و جانشین را مالک می‌کند. +**اعتبار پیش‌پرداخت.** سازمان‌ها با اعتبار پیش‌پرداخت کار می‌کنند. مالک سامانه در `/admin/credits` به هر سازمان اعتبار می‌دهد و هر رکورد مصرف (مدل، sandbox و بازتاب حافظه) به‌محض ثبت، از کیف پول همان سازمان کم می‌شود. وقتی موجودی به صفر یا کمتر برسد، کارهای آن سازمان متوقف می‌شوند و گفتگوی مدیر هم مدل را صدا نمی‌زند. کارهای متوقف‌شده پس از شارژ خودکار ادامه نمی‌یابند؛ با دکمهٔ «ادامه» دستی و از همان مرحله ادامه داده می‌شوند. + --- ## ۲. پیش‌نیازها @@ -245,6 +247,7 @@ web/ | استقرار روی شبکهٔ خصوصی | `DEPLOY_ALLOW_PRIVATE_HOSTS` | `false` (پیش‌فرض)؛ `true` فقط برای مقصدی در شبکهٔ خصوصی که قابل اعتماد است | | MCP gateway | `MCP_GATEWAY_ENABLED` | `true` (پیش‌فرض) \| `false` | | پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` | +| اعتبار اولیهٔ سازمان‌های جدید | `BILLING_STARTING_CREDIT` | عدد؛ پیش‌فرض `0` (یعنی به سازمان جدید اعتباری داده نمی‌شود) | اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و 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` | | `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`. هر دستور worker **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای مستمر `--loop` بدهید: diff --git a/apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py b/apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py new file mode 100644 index 0000000..75fed3d --- /dev/null +++ b/apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py @@ -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") diff --git a/apps/api/app/application/container_engine.py b/apps/api/app/application/container_engine.py index 610c52f..a8280c7 100644 --- a/apps/api/app/application/container_engine.py +++ b/apps/api/app/application/container_engine.py @@ -209,6 +209,7 @@ def _build_manager( RateCardRepository, ) from app.infrastructure.repositories.audit import UsageLedgerRepository + from app.infrastructure.repositories.billing import CreditWalletRepository from app.infrastructure.repositories.engine import ( EmployeeProfileRepository, PipelineStateRepository, @@ -235,6 +236,7 @@ def _build_manager( profiles=EmployeeProfileRepository(session), states=PipelineStateRepository(session), runner=runner, + wallets=CreditWalletRepository(session), ) diff --git a/apps/api/app/application/engine/events.py b/apps/api/app/application/engine/events.py index bcb309f..2a27cb6 100644 --- a/apps/api/app/application/engine/events.py +++ b/apps/api/app/application/engine/events.py @@ -28,6 +28,7 @@ from app.application.ports.realtime import ( task_channel, ) from app.core.config import settings +from app.domain.workforce.pipeline import PipelineKey, Stage, coarse_status @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"] diff --git a/apps/api/app/application/engine/manager.py b/apps/api/app/application/engine/manager.py index 60f3479..383e089 100644 --- a/apps/api/app/application/engine/manager.py +++ b/apps/api/app/application/engine/manager.py @@ -88,6 +88,7 @@ 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.engine import ( EmployeeProfileRepository, PipelineStateRepository, @@ -109,6 +110,11 @@ CHAT_MAX_ATTEMPTS = 2 #: The message a new organization sees when the owner has granted nothing yet. 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: dict[RoleKey, str] = { RoleKey.MANAGER: "strong", @@ -223,6 +229,7 @@ class ManagerTurns: states: PipelineStateRepository | None = None, runner: TurnRunner | None = None, feed=None, + wallets: CreditWalletRepository | None = None, ) -> None: self.provider = provider self.employees = employees @@ -239,6 +246,7 @@ class ManagerTurns: self.states = states self.runner = runner self.feed = feed + self.wallets = wallets # ------------------------------------------------------------------ # # 3.6 bootstrap # @@ -404,6 +412,17 @@ class ManagerTurns: user_message = await self.messages.create( 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) extra = "\n\n".join( part @@ -492,6 +511,13 @@ class ManagerTurns: ) 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( self, *, @@ -1083,6 +1109,7 @@ class ManagerTurns: __all__ = [ "BOOTSTRAP_TIERS", + "CHAT_NO_CREDIT_MESSAGE", "CHAT_TURN_TIMEOUT_SECONDS", "HIRABLE_ROLES", "NO_MODEL_GRANTED_MESSAGE", diff --git a/apps/api/app/application/engine/scheduler.py b/apps/api/app/application/engine/scheduler.py index cc53d7f..a2e1140 100644 --- a/apps/api/app/application/engine/scheduler.py +++ b/apps/api/app/application/engine/scheduler.py @@ -23,6 +23,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass, field +from typing import Literal from app.application.engine.gates import GateOutcome, GateRunner from app.application.engine.manager import ManagerOutcome, ManagerTurns, _pick_granted_model @@ -32,10 +33,11 @@ from app.application.engine.state import advance, park_dependents from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome from app.application.ports.model_grants import OrgPolicyView +from app.application.ports.realtime import emit_task_status from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef from app.application.ports.sandbox_provider import SandboxProviderPort from app.core.config import settings -from app.core.errors import ConflictError +from app.core.errors import ConflictError, NotFoundError, ValidationError from app.domain.base import utcnow from app.domain.workforce.engine import ( MAX_TRANSIENT_RETRIES, @@ -43,8 +45,18 @@ from app.domain.workforce.engine import ( GateName, is_transient, ) -from app.domain.workforce.models import Employee, RoleKey, Run, RunStatus, Task, TaskStatus +from app.domain.workforce.models import ( + Employee, + Message, + MessageRole, + RoleKey, + Run, + RunStatus, + Task, + TaskStatus, +) from app.domain.workforce.pipeline import ( + TERMINAL_STAGES, Outcome, Stage, TaskPipelineState, @@ -85,8 +97,10 @@ MERGE_STAGES: tuple[Stage, ...] = (Stage.MERGE_REQUEST,) #: address; the *name* is the employee's, which is what a reviewer reads. ENGINE_COMMIT_EMAIL = "platform@smart-org.local" -#: Persian reason shown when work pauses because the money ran out. -BUDGET_PAUSED_MESSAGE = "اعتبار سازمان برای ادامهٔ کار کافی نیست؛ کار موقتاً متوقف شد." +#: Persian reason shown when work pauses because the credit ran out (prepaid, D-B). +BUDGET_PAUSED_MESSAGE = ( + "اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد." +) #: The stage a turn has to be at for each manager mode. _MANAGER_STAGES: frozenset[Stage] = frozenset( @@ -426,7 +440,14 @@ class Scheduler: same_count=state.same_diff_count, diff_text=diff.text, ) - await self._patch(state, last_diff_hash=new_hash, same_diff_count=same_count) + # One write for the hash, the counter and the diff the task page shows + # (capped at 20,000 chars). Only a diff that passed the guard is stored. + await self._patch( + state, + last_diff_hash=new_hash, + same_diff_count=same_count, + last_diff=diff.text[:20000], + ) if not progress.ok: await self._send_back(state, task, progress.detail) report.failed += 1 @@ -530,6 +551,7 @@ class Scheduler: return final_sha = pushed.sha or merged.sha or sha + await self._patch(state, merged_sha=final_sha) await self._move(state, task, Stage.COMPLETED) await self.repo_workspace.remove_worktree(worktree=worktree) if task.project_id: @@ -622,16 +644,14 @@ class Scheduler: async def _budget_reason(self, task: Task) -> str | None: """Why this task may not spend a turn now, or ``None`` when it may. - Usage is post-paid: the ledger feeds invoices and nothing debits a wallet - from usage. A zero balance is therefore the normal starting state of every - org, not "out of money", so only an overdrawn wallet (below zero) pauses - work. The owner's spend lever is the org's monthly credit budget. + Billing is prepaid: the platform owner credits the wallet, and every usage + row debits it. Zero means "no credit left", so a zero, negative or missing + wallet pauses work before a turn is spent. The per-task cap and the org's + monthly budget still apply on top of that. """ if self.wallets is not None: wallet = await self.wallets.get_for_org(task.organization_id) - # No wallet means no budget was ever set: a fresh org must not be - # frozen out of its first task. A zero balance is not a pause either. - if wallet is not None and wallet.balance < 0: + if wallet is None or wallet.balance <= 0: return BUDGET_PAUSED_MESSAGE if self.spend is not None and settings.engine_max_task_credits > 0: used = await self.spend.spend_for_task(task.id) @@ -648,6 +668,74 @@ class Scheduler: return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد." return None + # ------------------------------------------------------------------ # + # the person's controls # + # ------------------------------------------------------------------ # + + async def user_action( + self, + *, + organization_id: str, + task_id: str, + action: Literal["pause", "resume", "cancel", "answer"], + user_id: str, + answer: str = "", + ) -> TaskPipelineState: + """Apply one control a person pressed on a task, and return its new state. + + The task is looked up inside the caller's organization, so a foreign id is + a 404. A task outside the engine, or one already closed, is a conflict. + Resume is refused while the budget is spent, so a task paused for credit + stays paused until credit is added and resume is pressed again. ``user_id`` + names the person who pressed the control; the engine keeps no actor column, + so the route writes the audit row. + """ + task = await self.tasks.get_for_org(organization_id, task_id) + if task is None: + raise NotFoundError("کار یافت نشد.") + state = await self.states.get(task_id) + if state is None: + raise ConflictError("این کار در موتور خودکار نیست.") + if state.stage in TERMINAL_STAGES: + raise ConflictError("این کار بسته شده است.") + if action == "pause": + await self._move(state, task, Stage.PAUSED) + elif action == "resume": + if state.stage is not Stage.PAUSED: + raise ConflictError("فقط کار متوقف‌شده را می‌توان ادامه داد.") + reason = await self._budget_reason(task) + if reason is not None: + raise ConflictError(reason) + await self._move(state, task, Stage.PENDING_MANAGER_REVIEW) + elif action == "cancel": + await self._move(state, task, Stage.CANCELLED) + else: + await self._answer(state, task, answer) + updated = await self.states.get(task_id) + if updated is None: # pragma: no cover - the row was read above + raise NotFoundError("این کار وضعیت موتور ندارد.") + await emit_task_status( + task_id=task_id, + organization_id=organization_id, + status=coarse_status(updated.stage).value, + ) + return updated + + async def _answer(self, state: TaskPipelineState, task: Task, answer: str) -> None: + """A person answers the decision a task waits on; triage reads it next.""" + content = answer.strip() + if not 1 <= len(content) <= 4000: + raise ValidationError("پاسخ باید بین ۱ تا ۴۰۰۰ نویسه باشد.") + if state.stage is not Stage.PENDING_USER_DECISION: + raise ConflictError("این کار منتظر پاسخ شما نیست.") + # The move comes first, so a lost race raises before anything is written. + await self._move(state, task, Stage.PENDING_MANAGER_REVIEW) + await self.tasks.append_description(task.id, "\n\nپاسخ کاربر: " + content) + if task.thread_id: + await self.manager.messages.create( + Message(thread_id=task.thread_id, role=MessageRole.USER, content=content) + ) + async def _retry_or_fail( self, state: TaskPipelineState, task: Task, message: str, kind: str ) -> None: diff --git a/apps/api/app/application/engine/state.py b/apps/api/app/application/engine/state.py index 6fc579c..0b20d98 100644 --- a/apps/api/app/application/engine/state.py +++ b/apps/api/app/application/engine/state.py @@ -19,6 +19,7 @@ truth, the status column is the summary existing APIs keep reading. from __future__ import annotations +from app.application.engine.events import emit_task_step from app.core.errors import ConflictError, NotFoundError from app.domain.workforce.pipeline import ( SIDE_STAGES, @@ -46,14 +47,14 @@ async def advance( Raises ``ConflictError`` when the transition is illegal or when another 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): raise ConflictError( f"گذار نابجا از {expected_from.value} به {to.value}." ) 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: raise NotFoundError("این کار وضعیت موتور ندارد.") from exc except InvalidTransitionError as exc: @@ -62,6 +63,15 @@ async def advance( raise ConflictError( "این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید." ) 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( diff --git a/apps/api/app/application/services/billing_service.py b/apps/api/app/application/services/billing_service.py index b9a9e21..298b7ae 100644 --- a/apps/api/app/application/services/billing_service.py +++ b/apps/api/app/application/services/billing_service.py @@ -17,6 +17,7 @@ from app.application.ports import PaymentProviderPort from app.application.ports.realtime import emit_wallet_changed from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError from app.domain.billing.models import ( + CreditKind, CreditWallet, Invoice, Payment, @@ -31,6 +32,7 @@ from app.infrastructure.repositories.billing import ( PlanRepository, SubscriptionRepository, ) +from app.infrastructure.repositories.credit import CreditLedgerRepository #: A tenant may not buy an arbitrary amount of credit in one action. MIN_TOPUP = 1.0 @@ -56,6 +58,8 @@ class BillingService: self.subscriptions = subscriptions self.ledger = ledger self.payment_provider = payment_provider + # Settled payments are credited through the credit ledger, in the wallets' session. + self.credit_ledger = CreditLedgerRepository(wallets.session) # -- reads --------------------------------------------------------------- @@ -107,6 +111,8 @@ class BillingService: ) -> CreditWallet: """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 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 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( (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 if succeeded: - wallet = await self.wallets.adjust( - organization_id=organization_id, delta=payment.amount + credit = await self.credit_ledger.apply( + organization_id=organization_id, + amount=payment.amount, + kind=CreditKind.ADJUSTMENT, + reason="billing/settle-payment", ) final = await self.payments.set_status(payment_id, PaymentStatus.SUCCEEDED) await emit_wallet_changed( - organization_id=organization_id, balance=wallet.balance + organization_id=organization_id, balance=credit.balance_after ) else: final = await self.payments.set_status(payment_id, PaymentStatus.FAILED) diff --git a/apps/api/app/application/services/credit_service.py b/apps/api/app/application/services/credit_service.py new file mode 100644 index 0000000..488bab7 --- /dev/null +++ b/apps/api/app/application/services/credit_service.py @@ -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="اعتبار آغازین", + ) diff --git a/apps/api/app/application/services/model_grant_service.py b/apps/api/app/application/services/model_grant_service.py index 5a5ccef..82cda2f 100644 --- a/apps/api/app/application/services/model_grant_service.py +++ b/apps/api/app/application/services/model_grant_service.py @@ -45,8 +45,9 @@ TIER_LABELS: dict[str, str] = { #: 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 -#: without writing a row first. -DEFAULT_MAX_EMPLOYEES = 5 +#: without writing a row first. D-A: the default cap is 7 employees per +#: organization; ``settings.engine_max_employees`` stays the upper bound. +DEFAULT_MAX_EMPLOYEES = 7 @dataclass(slots=True) diff --git a/apps/api/app/application/services/org_setup_service.py b/apps/api/app/application/services/org_setup_service.py index 81cb463..f77009b 100644 --- a/apps/api/app/application/services/org_setup_service.py +++ b/apps/api/app/application/services/org_setup_service.py @@ -221,6 +221,11 @@ class OrgSetupService: ) ) 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 ------------------------------------------- project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults) diff --git a/apps/api/app/application/services/workforce_service.py b/apps/api/app/application/services/workforce_service.py index 84ab49e..d3455c2 100644 --- a/apps/api/app/application/services/workforce_service.py +++ b/apps/api/app/application/services/workforce_service.py @@ -32,6 +32,7 @@ from app.application.ports.realtime import ( emit_task_created, emit_task_status, emit_usage_recorded, + emit_wallet_changed, ) from app.core.errors import ( ConflictError, @@ -61,6 +62,13 @@ from app.domain.workforce.models import ( ToolCallStatus, can_transition_run, ) +from app.domain.workforce.pipeline import ( + Risk, + Stage, + TaskKind, + TaskPipelineState, + resolve_pipeline, +) from app.domain.workforce.tools import ( TOOL_CATALOG, ToolSpec, @@ -74,6 +82,7 @@ from app.infrastructure.repositories.ai_control import ( RoutingPolicyRepository, ) 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.workforce import ( AgentTemplateRepository, @@ -94,6 +103,12 @@ from app.infrastructure.repositories.workforce import ( #: non-zero placeholder and mark it in the usage metadata. _UNPRICED = 0.0000001 +#: Said when the legacy "run" button is pressed on a task the engine owns. +_ENGINE_OWNS_TASK = ( + "این کار را موتور خودکار اجرا می‌کند؛ " + "برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید." +) + _SYSTEM_PROMPT = ( "تو دستیار هوش مصنوعی سازمان هستی. پاسخ‌ها را کوتاه، دقیق و عملی بده. " "هیچ‌گاه نام مدل، ارائه‌دهنده یا جزئیات زیرساخت را فاش نکن." @@ -126,7 +141,7 @@ async def record_model_usage( card = await rate_cards.get_by_unit(unit) unit_price = card.customer_price if card else _UNPRICED internal_cost = card.internal_cost_estimate if card else 0.0 - await ledger.record( + balance = await ledger.record( usage=UsageRecord( organization_id=organization_id, project_id=project_id, @@ -141,6 +156,9 @@ async def record_model_usage( 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 # consumption as it happens instead of at the next page load. await emit_usage_recorded( @@ -406,6 +424,7 @@ class WorkforceService: """ from app.application.container_org import build_memory, build_model_grants from app.application.engine.manager import ManagerTurns + from app.infrastructure.repositories.billing import CreditWalletRepository from app.infrastructure.repositories.engine import ( EmployeeProfileRepository, PipelineStateRepository, @@ -426,6 +445,7 @@ class WorkforceService: memory=build_memory(session), profiles=EmployeeProfileRepository(session), states=PipelineStateRepository(session), + wallets=CreditWalletRepository(session), ) # ------------------------------------------------------------------ # @@ -443,6 +463,13 @@ class WorkforceService: thread_id: str | None = None, assigned_employee_id: str | None = None, ) -> 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(): raise ValidationError("عنوان کار نمی‌تواند خالی باشد.") 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( 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( organization_id=organization_id, @@ -462,6 +490,21 @@ class WorkforceService: 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 # moment the task exists, whoever created it and from where. await emit_task_created( @@ -472,6 +515,16 @@ class WorkforceService: ) 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( self, organization_id: str, @@ -506,6 +559,9 @@ class WorkforceService: employee_id: str | None = None, ) -> Run: 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): raise ConflictError("این کار قبلاً بسته شده است.") diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index d630e07..6e39ecc 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -194,6 +194,12 @@ class Settings(BaseSettings): git_allow_file_protocol: bool = False # <<< 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 def cors_origin_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",") if o.strip()] diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py index 31f8db8..0bcd951 100644 --- a/apps/api/app/domain/audit/models.py +++ b/apps/api/app/domain/audit/models.py @@ -63,6 +63,9 @@ class AuditAction: PROJECT_CREATE = "project.create" PROJECT_DELETE = "project.delete" 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" #: A platform owner triaged an error in the console. Auditable so "who #: decided this bug was fixed?" has an answer. diff --git a/apps/api/app/domain/billing/models.py b/apps/api/app/domain/billing/models.py index bf7f54f..4595a7c 100644 --- a/apps/api/app/domain/billing/models.py +++ b/apps/api/app/domain/billing/models.py @@ -83,6 +83,34 @@ class CreditWallet: 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) class UsageEvent: """One billable unit of work. Append-only.""" diff --git a/apps/api/app/domain/workforce/pipeline.py b/apps/api/app/domain/workforce/pipeline.py index 7b1e300..bfeb07b 100644 --- a/apps/api/app/domain/workforce/pipeline.py +++ b/apps/api/app/domain/workforce/pipeline.py @@ -441,6 +441,10 @@ class TaskPipelineState: same_diff_count: int = 0 retry_count: int = 0 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 depends_on: list[str] = field(default_factory=list) version: int = 0 diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py index f6797f2..a303849 100644 --- a/apps/api/app/infrastructure/db/models.py +++ b/apps/api/app/infrastructure/db/models.py @@ -1023,6 +1023,7 @@ class QualityMetricRow(Base): # parallel agents never edit the same file). Imported last so every table is # 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_engine as _models_engine # noqa: E402,F401 from app.infrastructure.db import models_org as _models_org # noqa: E402,F401 diff --git a/apps/api/app/infrastructure/db/models_billing.py b/apps/api/app/infrastructure/db/models_billing.py new file mode 100644 index 0000000..df44495 --- /dev/null +++ b/apps/api/app/infrastructure/db/models_billing.py @@ -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() diff --git a/apps/api/app/infrastructure/db/models_engine.py b/apps/api/app/infrastructure/db/models_engine.py index ded500c..033b702 100644 --- a/apps/api/app/infrastructure/db/models_engine.py +++ b/apps/api/app/infrastructure/db/models_engine.py @@ -82,6 +82,11 @@ class TaskPipelineStateRow(Base): #: A waived test pass must always be explainable: the reason is stored, #: never inferred (PLAN-v2 §3, green gate). 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( ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True ) diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py index 0db2df6..eab63a7 100644 --- a/apps/api/app/infrastructure/db/models_org.py +++ b/apps/api/app/infrastructure/db/models_org.py @@ -143,7 +143,7 @@ class OrgPolicyRow(Base): organization_id: Mapped[str] = mapped_column( 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). monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True) automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/apps/api/app/infrastructure/repositories/audit.py b/apps/api/app/infrastructure/repositories/audit.py index 60e19eb..f886d38 100644 --- a/apps/api/app/infrastructure/repositories/audit.py +++ b/apps/api/app/infrastructure/repositories/audit.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.application.ports.audit import UsageAggregate, UsageBreakdown, UsageRecord from app.domain.ai_control.models import RateUnit from app.domain.audit.models import AuditEvent, ErrorEvent, QualityMetric, Span, Trace +from app.domain.base import new_id from app.infrastructure.db.models import ( AuditEventRow, ErrorEventRow, @@ -18,6 +19,7 @@ from app.infrastructure.db.models import ( TraceRow, UsageEventRow, ) +from app.infrastructure.repositories.credit import CreditLedgerRepository def _audit_to_domain(row: AuditEventRow) -> AuditEvent: @@ -107,14 +109,26 @@ class AuditRepository: 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: 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) row = UsageEventRow( + id=new_id(), organization_id=usage.organization_id, project_id=usage.project_id, user_id=usage.user_id, @@ -129,6 +143,12 @@ class UsageLedgerRepository: ) self.session.add(row) 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( self, diff --git a/apps/api/app/infrastructure/repositories/credit.py b/apps/api/app/infrastructure/repositories/credit.py new file mode 100644 index 0000000..9bfc5c4 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/credit.py @@ -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 diff --git a/apps/api/app/infrastructure/repositories/engine.py b/apps/api/app/infrastructure/repositories/engine.py index 2102250..914ff81 100644 --- a/apps/api/app/infrastructure/repositories/engine.py +++ b/apps/api/app/infrastructure/repositories/engine.py @@ -36,7 +36,7 @@ from app.domain.workforce.pipeline import ( TaskKind, TaskPipelineState, ) -from app.infrastructure.db.models import RunRow, UsageEventRow +from app.infrastructure.db.models import RunRow, TaskRow, UsageEventRow from app.infrastructure.db.models_engine import ( EmployeeProfileRow, EngineTurnRow, @@ -75,6 +75,8 @@ _PATCHABLE = frozenset( "same_diff_count", "retry_count", "waiver_reason", + "last_diff", + "merged_sha", "parent_task_id", "depends_on", } @@ -97,6 +99,8 @@ def _state(row: TaskPipelineStateRow) -> TaskPipelineState: same_diff_count=row.same_diff_count, retry_count=row.retry_count, waiver_reason=row.waiver_reason, + last_diff=row.last_diff, + merged_sha=row.merged_sha, parent_task_id=row.parent_task_id, depends_on=list(row.depends_on or []), version=row.version, @@ -185,6 +189,8 @@ class PipelineStateRepository: same_diff_count=state.same_diff_count, retry_count=state.retry_count, waiver_reason=state.waiver_reason, + last_diff=state.last_diff, + merged_sha=state.merged_sha, parent_task_id=state.parent_task_id, depends_on=list(state.depends_on), version=state.version, @@ -268,6 +274,27 @@ class PipelineStateRepository: ) return int((await self.session.execute(stmt)).scalar_one()) + async def count_at_by_org(self, stage: Stage) -> dict[str, int]: + """How many tasks sit at one stage, per organization. + + Cross-organization on purpose: only the platform owner's views may use it. + """ + stmt = ( + select(TaskPipelineStateRow.organization_id, func.count()) + .where(TaskPipelineStateRow.stage == str(stage)) + .group_by(TaskPipelineStateRow.organization_id) + ) + return {org: int(n) for org, n in (await self.session.execute(stmt)).all()} + + async def stage_counts(self, organization_id: str) -> dict[str, int]: + """How many of one organization's tasks sit at each stage.""" + stmt = ( + select(TaskPipelineStateRow.stage, func.count()) + .where(TaskPipelineStateRow.organization_id == organization_id) + .group_by(TaskPipelineStateRow.stage) + ) + return {stage: int(n) for stage, n in (await self.session.execute(stmt)).all()} + # --------------------------------------------------------------------------- # employee_profiles @@ -410,6 +437,21 @@ class EngineTurnRepository: ) return [_turn(r) for r in (await self.session.execute(stmt)).scalars()] + async def list_for_org(self, organization_id: str, *, limit: int = 50) -> list[EngineTurn]: + """The newest turns across one organization's tasks. + + Turns carry no organization column, so the tenant filter joins through + the task: another organization's turn can never match. + """ + stmt = ( + select(EngineTurnRow) + .join(TaskRow, EngineTurnRow.task_id == TaskRow.id) + .where(TaskRow.organization_id == organization_id) + .order_by(EngineTurnRow.started_at.desc()) + .limit(min(limit, 200)) + ) + return [_turn(r) for r in (await self.session.execute(stmt)).scalars()] + async def finish( self, turn_id: str, diff --git a/apps/api/app/infrastructure/repositories/org_grants.py b/apps/api/app/infrastructure/repositories/org_grants.py index 96f3366..2c72681 100644 --- a/apps/api/app/infrastructure/repositories/org_grants.py +++ b/apps/api/app/infrastructure/repositories/org_grants.py @@ -39,7 +39,7 @@ class DefaultGrantRecord: @dataclass(slots=True, kw_only=True) class OrgPolicyRecord: organization_id: str - max_employees: int = 5 + max_employees: int = 7 monthly_credit_budget: float | None = None automation_enabled: bool = True mcp_enabled: bool = True diff --git a/apps/api/app/infrastructure/repositories/workforce.py b/apps/api/app/infrastructure/repositories/workforce.py index 5d053bc..0a4e1b3 100644 --- a/apps/api/app/infrastructure/repositories/workforce.py +++ b/apps/api/app/infrastructure/repositories/workforce.py @@ -510,6 +510,15 @@ class TaskRepository: await self.session.flush() 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: def __init__(self, session: AsyncSession) -> None: diff --git a/apps/api/app/interfaces/http/routers/admin_credits.py b/apps/api/app/interfaces/http/routers/admin_credits.py new file mode 100644 index 0000000..2b2f7bb --- /dev/null +++ b/apps/api/app/interfaces/http/routers/admin_credits.py @@ -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] diff --git a/apps/api/app/interfaces/http/routers/billing.py b/apps/api/app/interfaces/http/routers/billing.py index d05f0f2..cd16743 100644 --- a/apps/api/app/interfaces/http/routers/billing.py +++ b/apps/api/app/interfaces/http/routers/billing.py @@ -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.manage``. A tenant can therefore never see — or move — another -organization's money. +Billing is prepaid (D-B): an organization spends the credit in its wallet, and only the +platform owner adds credit, with ``add-credit`` or by settling a payment. Reading billing +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 @@ -13,11 +15,16 @@ from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, ConfigDict, Field 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.billing_service import BillingService from app.core.database import get_session +from app.core.errors import PermissionDeniedError, ValidationError 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.infrastructure.repositories.billing import CreditWalletRepository +from app.infrastructure.repositories.credit import CreditLedgerRepository from app.interfaces.http.deps import ( OrgContext, client_meta, @@ -27,6 +34,9 @@ from app.interfaces.http.deps import ( 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 @@ -88,7 +98,9 @@ class PaymentOut(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): @@ -210,35 +222,47 @@ async def add_credit( payload: AddCreditRequest, request: Request, context: OrgContext = Depends(get_org_context), - service: BillingService = Depends(get_billing_service), session: AsyncSession = Depends(get_session), ) -> WalletOut: - # A negative delta is a platform-owner-only clawback. - if payload.amount < 0: - if not context.user.is_platform_owner: - context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError - from app.core.errors import PermissionDeniedError + """Add (or claw back) credit on the platform owner's word. Nobody else may (D-B). - raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.") - else: - context.require(CAN_MANAGE_BILLING) + 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: + raise PermissionDeniedError(OWNER_ONLY_CREDIT) + if payload.amount == 0: + raise ValidationError("مقدار اعتبار نمی‌تواند صفر باشد.") - wallet = await service.add_credit( + transaction = await CreditLedgerRepository(session).apply( organization_id=context.org_id, 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) await record_audit( session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id, actor_user_id=context.user.id, target_type="credit_wallet", target_id=wallet.id, - metadata={"amount": payload.amount, "balance": wallet.balance}, + metadata={"amount": payload.amount, "balance": transaction.balance_after}, 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( - 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), session: AsyncSession = Depends(get_session), ) -> 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 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( organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded ) diff --git a/apps/api/app/interfaces/http/routers/engine.py b/apps/api/app/interfaces/http/routers/engine.py new file mode 100644 index 0000000..e3e953e --- /dev/null +++ b/apps/api/app/interfaces/http/routers/engine.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/organizations.py b/apps/api/app/interfaces/http/routers/organizations.py index 4689929..bd512b1 100644 --- a/apps/api/app/interfaces/http/routers/organizations.py +++ b/apps/api/app/interfaces/http/routers/organizations.py @@ -145,6 +145,11 @@ async def create_organization( ) # 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")) + # 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) await record_audit( diff --git a/apps/api/app/interfaces/realtime/rpc_app.py b/apps/api/app/interfaces/realtime/rpc_app.py index cc9bf0d..d7d6ce3 100644 --- a/apps/api/app/interfaces/realtime/rpc_app.py +++ b/apps/api/app/interfaces/realtime/rpc_app.py @@ -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.routers import ( admin, + admin_credits, admin_domains, admin_grants, admin_users, @@ -31,6 +32,7 @@ from app.interfaces.http.routers import ( auth, billing, delivery, + engine, health, invites, knowledge, @@ -82,6 +84,8 @@ def build_rpc_app() -> FastAPI: application.include_router(memory.router, prefix="/api") application.include_router(mcp_tokens.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(admin_domains.router, prefix="/api") application.include_router(sandbox.admin_router, prefix="/api") diff --git a/apps/api/tests/billing_helpers.py b/apps/api/tests/billing_helpers.py new file mode 100644 index 0000000..4d81d34 --- /dev/null +++ b/apps/api/tests/billing_helpers.py @@ -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 diff --git a/apps/api/tests/legacy_tasks.py b/apps/api/tests/legacy_tasks.py new file mode 100644 index 0000000..60c8de1 --- /dev/null +++ b/apps/api/tests/legacy_tasks.py @@ -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 diff --git a/apps/api/tests/test_admin_credits.py b/apps/api/tests/test_admin_credits.py new file mode 100644 index 0000000..e9d0cae --- /dev/null +++ b/apps/api/tests/test_admin_credits.py @@ -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"] diff --git a/apps/api/tests/test_billing_prepaid.py b/apps/api/tests/test_billing_prepaid.py new file mode 100644 index 0000000..badc5dd --- /dev/null +++ b/apps/api/tests/test_billing_prepaid.py @@ -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 diff --git a/apps/api/tests/test_billing_settle_ledger.py b/apps/api/tests/test_billing_settle_ledger.py new file mode 100644 index 0000000..51e2109 --- /dev/null +++ b/apps/api/tests/test_billing_settle_ledger.py @@ -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 == [] diff --git a/apps/api/tests/test_billing_starting_credit.py b/apps/api/tests/test_billing_starting_credit.py new file mode 100644 index 0000000..6abd361 --- /dev/null +++ b/apps/api/tests/test_billing_starting_credit.py @@ -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 diff --git a/apps/api/tests/test_credit_chat_gate.py b/apps/api/tests/test_credit_chat_gate.py new file mode 100644 index 0000000..688443b --- /dev/null +++ b/apps/api/tests/test_credit_chat_gate.py @@ -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 diff --git a/apps/api/tests/test_credit_debit.py b/apps/api/tests/test_credit_debit.py new file mode 100644 index 0000000..3aa9fb3 --- /dev/null +++ b/apps/api/tests/test_credit_debit.py @@ -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 == [] diff --git a/apps/api/tests/test_credit_ledger.py b/apps/api/tests/test_credit_ledger.py new file mode 100644 index 0000000..c6307d1 --- /dev/null +++ b/apps/api/tests/test_credit_ledger.py @@ -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 diff --git a/apps/api/tests/test_credit_service.py b/apps/api/tests/test_credit_service.py new file mode 100644 index 0000000..31641d1 --- /dev/null +++ b/apps/api/tests/test_credit_service.py @@ -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 diff --git a/apps/api/tests/test_direct_tasks_engine.py b/apps/api/tests/test_direct_tasks_engine.py new file mode 100644 index 0000000..318eae4 --- /dev/null +++ b/apps/api/tests/test_direct_tasks_engine.py @@ -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 diff --git a/apps/api/tests/test_e2e_smoke.py b/apps/api/tests/test_e2e_smoke.py index 892f91f..c1d23b8 100644 --- a/apps/api/tests/test_e2e_smoke.py +++ b/apps/api/tests/test_e2e_smoke.py @@ -24,7 +24,11 @@ import pytest_asyncio from httpx import AsyncClient 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.legacy_tasks import legacy_task @pytest_asyncio.fixture(autouse=True) @@ -33,7 +37,9 @@ async def _platform_seed(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 -------------------------------------------------------- # # The platform owner, not a plain manager: step 6 drives the sandbox # 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 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( f"/api/organizations/{org_id}/threads/{thread_id}/messages", 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 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 ------------------------------------------------------ # + # 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( - f"/api/organizations/{org_id}/tasks/{task_id}/runs", + f"/api/organizations/{org_id}/tasks/{run_task_id}/runs", json={"employee_id": employee_id}, headers=headers, ) diff --git a/apps/api/tests/test_engine_api.py b/apps/api/tests/test_engine_api.py new file mode 100644 index 0000000..bad1f91 --- /dev/null +++ b/apps/api/tests/test_engine_api.py @@ -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}" diff --git a/apps/api/tests/test_engine_api_actions.py b/apps/api/tests/test_engine_api_actions.py new file mode 100644 index 0000000..b7db104 --- /dev/null +++ b/apps/api/tests/test_engine_api_actions.py @@ -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 diff --git a/apps/api/tests/test_engine_api_team.py b/apps/api/tests/test_engine_api_team.py new file mode 100644 index 0000000..de679e2 --- /dev/null +++ b/apps/api/tests/test_engine_api_team.py @@ -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 diff --git a/apps/api/tests/test_engine_control.py b/apps/api/tests/test_engine_control.py new file mode 100644 index 0000000..c85b487 --- /dev/null +++ b/apps/api/tests/test_engine_control.py @@ -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 diff --git a/apps/api/tests/test_engine_flow.py b/apps/api/tests/test_engine_flow.py index 78e8871..0a42a7b 100644 --- a/apps/api/tests/test_engine_flow.py +++ b/apps/api/tests/test_engine_flow.py @@ -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 merging it * ``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 """ @@ -92,13 +93,19 @@ class _OverdrawnWallet: 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 +class _FundedWallet: + """A wallet with credit left: the org may spend a turn.""" + + balance = 5.0 + + class _Wallets: - def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None: + def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | None) -> None: self._wallet = wallet 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 -async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory): - """A 0.0 balance is every org's starting state, so it must not pause the task. +async def test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent(world, session_factory): + """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 - ``balance <= 0``, which froze every new org before its first turn: usage is - post-paid and nothing debits a wallet. Only an overdrawn wallet (below zero) - 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. + Prepaid billing (decision D-B) reverses the post-paid rule of the earlier plan + (its T3), under which a zero balance was every new org's normal state and did + not pause. Now the platform owner credits an org before any work runs. """ scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")]) 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) 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 scripted.calls == 1 diff --git a/apps/api/tests/test_engine_repo_ui.py b/apps/api/tests/test_engine_repo_ui.py new file mode 100644 index 0000000..c6baca8 --- /dev/null +++ b/apps/api/tests/test_engine_repo_ui.py @@ -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} diff --git a/apps/api/tests/test_engine_step_event.py b/apps/api/tests/test_engine_step_event.py new file mode 100644 index 0000000..585145f --- /dev/null +++ b/apps/api/tests/test_engine_step_event.py @@ -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:`` and ``org:`` +* 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 diff --git a/apps/api/tests/test_engine_ui_fields.py b/apps/api/tests/test_engine_ui_fields.py new file mode 100644 index 0000000..2db633c --- /dev/null +++ b/apps/api/tests/test_engine_ui_fields.py @@ -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 diff --git a/apps/api/tests/test_error_log.py b/apps/api/tests/test_error_log.py index c7dfd01..a99f1ca 100644 --- a/apps/api/tests/test_error_log.py +++ b/apps/api/tests/test_error_log.py @@ -429,25 +429,27 @@ async def test_a_failed_task_run_is_persisted_with_its_tenant( seeded, client: AsyncClient, session_factory, monkeypatch ): """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.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" ) - task_id = ( - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کاری که شکست می‌خورد", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, - ) - ).json()["id"] + task_id = await legacy_task( + session_factory, + org_id, + await _owner_of(session_factory, org_id), + title="کاری که شکست می‌خورد", + project_id=project_id, + assigned_employee_id=employee_id, + ) async def _boom(*args, **kwargs): raise RuntimeError("provider returned a malformed response") diff --git a/apps/api/tests/test_integration_order_route.py b/apps/api/tests/test_integration_order_route.py index 6b37b39..d16506b 100644 --- a/apps/api/tests/test_integration_order_route.py +++ b/apps/api/tests/test_integration_order_route.py @@ -22,6 +22,7 @@ from app.domain.workforce.pipeline import Stage from app.infrastructure.db.models import AiModelRow from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.workforce import TaskRepository +from tests.billing_helpers import fund_org from tests.conftest import auth_header, signup from tests.engine_fakes import ScriptedModel, ScriptedReply 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 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. posted = await client.post( f"/api/organizations/{org_id}/threads/{thread_id}/messages", diff --git a/apps/api/tests/test_integration_thread_project.py b/apps/api/tests/test_integration_thread_project.py index 3dd0f03..5b6d450 100644 --- a/apps/api/tests/test_integration_thread_project.py +++ b/apps/api/tests/test_integration_thread_project.py @@ -16,6 +16,7 @@ from app.application.container_engine import build_scheduler from app.infrastructure.db.models import AiModelRow, OrganizationRow from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.workforce import TaskRepository +from tests.billing_helpers import fund_org from tests.conftest import auth_header from tests.engine_fakes import ScriptedModel, ScriptedReply 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"] 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. created = await client.post( 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 thread_id = created.json()["id"] - # The order goes to the engine's manager. The HTTP message route builds its manager - # without pipeline states (workforce_service._manager_turns), so it cannot show the state. + # The order goes to the engine's manager. The HTTP message route builds its manager with + # 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: owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id _, assistant = await build_scheduler(session).manager.chat( diff --git a/apps/api/tests/test_mcp.py b/apps/api/tests/test_mcp.py index 48ef946..1b4f90a 100644 --- a/apps/api/tests/test_mcp.py +++ b/apps/api/tests/test_mcp.py @@ -36,6 +36,7 @@ from app.core.config import settings from app.core.errors import ValidationError from app.domain.base import new_id, utcnow from app.domain.identity.models import PlatformRole, Role +from app.domain.workforce.pipeline import Stage from app.infrastructure.db.models import ( AuditEventRow, MembershipRow, @@ -49,6 +50,7 @@ from app.infrastructure.db.models_delivery import ( ProductionDeployRow, ) from app.infrastructure.db.models_org import McpTokenRow +from app.infrastructure.repositories.engine import PipelineStateRepository from app.interfaces.mcp.gateway import McpGateway 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( 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") gateway = await _gateway(session_factory) 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.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. other_org, _ = await _org(session_factory, "task-other") other_token = await _token( diff --git a/apps/api/tests/test_model_grants.py b/apps/api/tests/test_model_grants.py index 3b42c68..2992c7e 100644 --- a/apps/api/tests/test_model_grants.py +++ b/apps/api/tests/test_model_grants.py @@ -16,6 +16,7 @@ from httpx import AsyncClient from sqlalchemy import select 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.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow @@ -323,13 +324,18 @@ async def test_policy_defaults_caps_and_audit( session_factory, 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") owner = auth_header(platform_owner_token) fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner) assert fresh.status_code == 200, fresh.text 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["mcp_enabled"] is True 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( 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_b = await _create_org(client, manager_token, "سازمان و", "zeta-org") 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) b = await client.get(f"{POLICIES}/{org_b}", headers=owner) assert a.json()["max_employees"] == 2 - assert b.json()["max_employees"] == 5 + assert b.json()["max_employees"] == 7 diff --git a/apps/api/tests/test_seed.py b/apps/api/tests/test_seed.py index 18a4e77..2407868 100644 --- a/apps/api/tests/test_seed.py +++ b/apps/api/tests/test_seed.py @@ -127,13 +127,18 @@ async def test_platform_owner_is_not_joined_to_any_organization( 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. A seed that satisfies the login test but leaves the catalogue empty would 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( "/api/auth/login", json={ @@ -142,7 +147,8 @@ async def test_seeded_install_can_use_the_product( }, ) 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. templates = await client.get( @@ -177,17 +183,14 @@ async def test_seeded_install_can_use_the_product( ) ).json()["id"] - task_id = ( - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کار seed", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, - ) - ).json()["id"] + task_id = await legacy_task( + session_factory, + org_id, + me["user"]["id"], + title="کار seed", + project_id=project_id, + assigned_employee_id=employee_id, + ) run = await client.post( f"/api/organizations/{org_id}/tasks/{task_id}/runs", diff --git a/apps/api/tests/test_v2_e2e_smoke.py b/apps/api/tests/test_v2_e2e_smoke.py index 239cd8b..e286cbd 100644 --- a/apps/api/tests/test_v2_e2e_smoke.py +++ b/apps/api/tests/test_v2_e2e_smoke.py @@ -38,6 +38,7 @@ from app.infrastructure.repositories.delivery import PipelineRunRepository from app.infrastructure.repositories.engine import PipelineStateRepository from app.infrastructure.repositories.workforce import EmployeeRepository, TaskRepository from app.workers.ai_runtime import AiRuntimeWorker +from tests.billing_helpers import fund_org from tests.conftest import auth_header from tests.engine_fakes import ScriptedModel, ScriptedReply 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"] slug = body["organization"]["slug"] 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: 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"] # 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 - # states (see test_integration_thread_project.py). + # the engine's manager directly, only to read the reply and the states in this session: the + # HTTP message route builds its manager with the pipeline states too + # (test_integration_order_route.py). async with session_factory() as session: await build_scheduler(session).manager.chat( organization_id=org_id, diff --git a/apps/api/tests/test_workers.py b/apps/api/tests/test_workers.py index 1bf3dc4..efc826b 100644 --- a/apps/api/tests/test_workers.py +++ b/apps/api/tests/test_workers.py @@ -22,11 +22,13 @@ from httpx import AsyncClient from app.domain.base import utcnow 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.ai_runtime import AiRuntimeWorker from app.workers.billing import BillingWorker from app.workers.sandbox_gateway import SandboxGatewayWorker from tests.conftest import auth_header, signup +from tests.legacy_tasks import legacy_task @pytest_asyncio.fixture(autouse=True) @@ -89,25 +91,32 @@ async def _make_org_with_employee( 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 -------------------------------------------------------------- # -async def test_ai_runtime_runs_a_task_nobody_clicked(client: AsyncClient): - """A task created without pressing "run" must still be picked up.""" +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. + + 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( client, "worker-runtime@example.com" ) - task_id = ( - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کار بی‌صاحب", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, - ) - ).json()["id"] + task_id = await legacy_task( + session_factory, + org_id, + await _owner_of(session_factory, org_id), + title="کار بی‌صاحب", + project_id=project_id, + assigned_employee_id=employee_id, + ) # Nothing has run it yet. 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" -async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient): - """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( +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. + + 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" ) - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کار یک‌باره", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, + await legacy_task( + session_factory, + org_id, + await _owner_of(session_factory, org_id), + title="کار یک‌باره", + project_id=project_id, + assigned_employee_id=employee_id, ) first = await AiRuntimeWorker().drain() @@ -156,19 +167,21 @@ async def test_ai_runtime_does_not_run_a_task_twice(client: AsyncClient): # -- billing-worker ---------------------------------------------------------- # -async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient): - """Usage in the period must become exactly one draft invoice.""" +async def test_billing_roll_up_creates_a_draft_invoice(client: AsyncClient, session_factory): + """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( client, "worker-billing@example.com" ) - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کار قابل صورتحساب", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, + await legacy_task( + session_factory, + org_id, + await _owner_of(session_factory, org_id), + title="کار قابل صورتحساب", + project_id=project_id, + assigned_employee_id=employee_id, ) 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" -async def test_billing_roll_up_is_idempotent(client: AsyncClient): - """Two passes over the same period must not bill twice.""" +async def test_billing_roll_up_is_idempotent(client: AsyncClient, session_factory): + """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( client, "worker-billing-twice@example.com" ) - await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "کار یک‌بار صورتحساب", - "project_id": project_id, - "assigned_employee_id": employee_id, - }, - headers=headers, + await legacy_task( + session_factory, + org_id, + await _owner_of(session_factory, org_id), + title="کار یک‌بار صورتحساب", + project_id=project_id, + assigned_employee_id=employee_id, ) await AiRuntimeWorker().drain() diff --git a/apps/api/tests/test_workforce.py b/apps/api/tests/test_workforce.py index 6dd3b55..69f2c64 100644 --- a/apps/api/tests/test_workforce.py +++ b/apps/api/tests/test_workforce.py @@ -17,6 +17,7 @@ import pytest_asyncio from httpx import AsyncClient from tests.conftest import auth_header, signup +from tests.legacy_tasks import legacy_task @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): - """Every run must be reconstructable from its steps.""" - token = (await signup(client, "wf-run@example.com"))["access_token"] +async def test_starting_a_run_produces_a_timeline(client: AsyncClient, session_factory): + """Every run must be reconstructable from its steps. + + 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) employee = await _employee(client, token, org_id) - task = await client.post( - f"/api/organizations/{org_id}/tasks", - json={ - "title": "افزودن تست برای ماژول پرداخت", - "description": "پوشش تست واحد برای محاسبهٔ تخفیف", - "assigned_employee_id": employee["id"], - }, - headers=auth_header(token), + task_id = await legacy_task( + session_factory, + org_id, + signed["user"]["id"], + title="افزودن تست برای ماژول پرداخت", + assigned_employee_id=employee["id"], ) - assert task.status_code == 201, task.text - task_id = task.json()["id"] run = await client.post( 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): - token = (await signup(client, "wf-runsecret@example.com"))["access_token"] +async def test_run_payload_never_leaks_model_identity(client: AsyncClient, session_factory): + """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) employee = await _employee(client, token, org_id) - task = await client.post( - f"/api/organizations/{org_id}/tasks", - json={"title": "کار برای بررسی نشت", "assigned_employee_id": employee["id"]}, - headers=auth_header(token), + task_id = await legacy_task( + session_factory, + org_id, + signed["user"]["id"], + title="کار برای بررسی نشت", + assigned_employee_id=employee["id"], ) - task_id = task.json()["id"] run = await client.post( f"/api/organizations/{org_id}/tasks/{task_id}/runs", 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" -async def test_task_detail_lists_its_runs(client: AsyncClient): - token = (await signup(client, "wf-taskdetail@example.com"))["access_token"] +async def test_task_detail_lists_its_runs(client: AsyncClient, session_factory): + """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) employee = await _employee(client, token, org_id) - task = await client.post( - f"/api/organizations/{org_id}/tasks", - json={"title": "کار با چند اجرا", "assigned_employee_id": employee["id"]}, - headers=auth_header(token), + task_id = await legacy_task( + session_factory, + org_id, + signed["user"]["id"], + title="کار با چند اجرا", + assigned_employee_id=employee["id"], ) - task_id = task.json()["id"] run = await client.post( 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 -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 - silently create a second run against a task that is already done.""" - token = (await signup(client, "wf-closedtask@example.com"))["access_token"] + silently create a second run against a task that is already done. + + 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) employee = await _employee(client, token, org_id) - task = await client.post( - f"/api/organizations/{org_id}/tasks", - json={"title": "کار بسته‌شده", "assigned_employee_id": employee["id"]}, - headers=auth_header(token), + task_id = await legacy_task( + session_factory, + org_id, + signed["user"]["id"], + title="کار بسته‌شده", + assigned_employee_id=employee["id"], ) - task_id = task.json()["id"] first = await client.post( f"/api/organizations/{org_id}/tasks/{task_id}/runs", diff --git a/apps/web/src/app/(app)/admin/credits/page.tsx b/apps/web/src/app/(app)/admin/credits/page.tsx new file mode 100644 index 0000000..eaac3af --- /dev/null +++ b/apps/web/src/app/(app)/admin/credits/page.tsx @@ -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> { + return [ + { + key: "when", + header: "زمان", + render: (row) => {dateTime(row.created_at)}, + }, + { + key: "amount", + header: "مبلغ", + render: (row) => ( + {money(row.amount, currency)} + ), + }, + { + key: "reason", + header: "دلیل", + render: (row) => {row.reason}, + }, + { + key: "after", + header: "موجودی پس از ثبت", + render: (row) => ( + + {money(row.balance_after, currency)} + + ), + }, + ]; +} + +export default function AdminCreditsPage() { + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [draft, setDraft] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + const [draftError, setDraftError] = React.useState(null); + const [history, setHistory] = React.useState(null); + const [toasts, setToasts] = React.useState([]); + + 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> = [ + { + key: "name", + header: "نام", + render: (row) => {row.name}, + }, + { + key: "slug", + header: "شناسه", + render: (row) => {row.slug}, + }, + { + key: "balance", + header: "اعتبار", + render: (row) => ( +
+ + {money(row.balance, row.currency)} + + {row.balance <= 0 && بدون اعتبار} +
+ ), + }, + { + key: "paused", + header: "کارهای متوقف", + align: "center", + render: (row) => row.paused_tasks, + }, + { + key: "last", + header: "آخرین افزایش", + render: (row) => ( + {dateTime(row.last_credit_at)} + ), + }, + { + key: "actions", + header: "عملیات", + align: "end", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + 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 ( +
+
+

اعتبار سازمان‌ها

+

+ اعتبار پیش‌پرداخت هر سازمان را این‌جا افزایش می‌دهید. هر افزایش یک ردیف تاریخچه و یک + ردیف ممیزی ثبت می‌کند. سازمانی که موجودی آن به صفر برسد، کار کارمندان هوش مصنوعی‌اش + متوقف می‌شود. +

+
+ + {!loading && !error && ( +
+ + 0 ? "danger" : "neutral"} + /> + +
+ )} + + row.organization_id} + loading={loading} + error={error} + onRetry={() => void load()} + empty={ + + } + /> + + {draft && ( + + + + + ) : ( + <> + + + + ) + } + > + {draft.step === "edit" ? ( +
+ setDraft({ ...draft, amount: event.target.value })} + error={ + amountInvalid + ? `مبلغ باید عددی بیشتر از صفر و حداکثر ${MAX_OWNER_CREDIT.toLocaleString("en-US")} باشد.` + : undefined + } + /> + setDraft({ ...draft, reason: event.target.value })} + hint="این دلیل در تاریخچه و گزارش ممیزی ثبت می‌شود." + error={reasonInvalid ? `دلیل باید حداقل ${REASON_MIN} نویسه باشد.` : undefined} + /> + + ) : ( +
+

+ مبلغ{" "} + + {money(amountValue ?? 0, draft.org.currency)} + {" "} + به اعتبار «{draft.org.name}» افزوده شود؟ +

+

دلیل: {draft.reason.trim()}

+

+ این عملیات در تاریخچهٔ اعتبار و گزارش ممیزی ثبت می‌شود. +

+ {draftError && } +
+ )} +
+ )} + + setHistory(null)}> + {history && ( +
+

{history.org.name}

+
row.id} + loading={history.loading} + error={history.error} + onRetry={() => void openHistory(history.org)} + empty={} + /> + + )} + + + setToasts((current) => current.filter((item) => item.id !== id))} + /> + + ); +} diff --git a/apps/web/src/app/(app)/admin/model-grants/page.tsx b/apps/web/src/app/(app)/admin/model-grants/page.tsx index b4cabc4..ffe5763 100644 --- a/apps/web/src/app/(app)/admin/model-grants/page.tsx +++ b/apps/web/src/app/(app)/admin/model-grants/page.tsx @@ -67,7 +67,7 @@ export default function AdminModelGrantsPage() { const [toasts, setToasts] = React.useState([]); const [budget, setBudget] = React.useState(""); - const [maxEmployees, setMaxEmployees] = React.useState("5"); + const [maxEmployees, setMaxEmployees] = React.useState("7"); const toast = React.useCallback( (title: string, tone: ToastMessage["tone"] = "success", description?: string) => { diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx index ea0e52c..2bc412b 100644 --- a/apps/web/src/app/(app)/billing/page.tsx +++ b/apps/web/src/app/(app)/billing/page.tsx @@ -21,8 +21,8 @@ import { Stat, UsageBar, } from "@agy/ui"; +import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice"; import { ApiError, api } from "@/lib/api"; -import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions"; import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; @@ -53,8 +53,14 @@ function money(value: number, currency = ""): string { 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 ; +} + export default function BillingPage() { - const { activeOrganization } = useSession(); + const { activeOrganization, user } = useSession(); const orgId = activeOrganization?.id ?? null; const [summary, setSummary] = React.useState(null); @@ -189,7 +195,6 @@ export default function BillingPage() { if (error) return ; const currency = summary?.currency ?? ""; - const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING); return (
@@ -201,6 +206,8 @@ export default function BillingPage() {

+ + {summary?.low_balance && (
@@ -223,11 +230,11 @@ export default function BillingPage() {
- {canManage && ( + {user?.is_platform_owner && ( افزایش اعتبار - افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر + افزایش اعتبار دستی توسط مالک سامانه. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر پرداخت آنلاین هم استفاده کنید.
@@ -270,6 +277,15 @@ export default function BillingPage() {
)} + {!user?.is_platform_owner && ( + + + اعتبار سازمان به صورت پیش‌پرداخت توسط مالک سامانه افزوده می‌شود. هر مصرف از همین اعتبار + کم می‌شود. + + + )} + تفکیک بر اساس واحد مصرف {!summary || summary.by_unit.length === 0 ? ( diff --git a/apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx b/apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx new file mode 100644 index 0000000..6f8728e --- /dev/null +++ b/apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx @@ -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 = 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([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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

{empty}

; + return ( +
    + {tasks.map((task) => ( +
  • + + {task.title} + + {meta(task)} +
  • + ))} +
+ ); +} + +export function EngineWidgets({ orgId }: { orgId: string }) { + const engine = useEngineStatus(orgId); + const board = useEngineTasks(orgId); + const { status } = engine; + + if (status === null) { + return ( +
+ {engine.error ? ( + + ) : ( +

در حال دریافت وضعیت کارمندان…

+ )} +
+ ); + } + + 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 ( +
+ + +
+ + {status.balance.toLocaleString("fa-IR")}{" "} + {status.currency} + + } + tone={status.credit_ok ? "neutral" : "danger"} + /> +
+ + {board.error && ( +

+ {board.error} +

+ )} + +
+ + کارهای فعال + کارهایی که هنوز کامل نشده‌اند. برای جزئیات، عنوان کار را بزنید. +
+ {board.loading &&

در حال دریافت…

} + {!board.loading && !board.error && ( + + task.stage && ( + {STAGE_LABELS[task.stage]} + ) + } + /> + )} + {!board.loading && !board.error && activeAll.length > LIST_LIMIT && ( +

+ و {(activeAll.length - LIST_LIMIT).toLocaleString("fa-IR")} کار دیگر +

+ )} +
+
+ + + توزیع مراحل + تعداد کارها در هر ستون تابلوی کارها. +
    + {columns.map((column) => ( +
  • +
    + {column.label} + {column.count.toLocaleString("fa-IR")} +
    +
  • + ))} +
+
+
+ + + آخرین کارهای انجام‌شده + پنج کار آخری که به مرحلهٔ «انجام شد» رسیده‌اند. +
+ {board.loading &&

در حال دریافت…

} + {!board.loading && !board.error && ( + ( + + {new Date(task.updated_at).toLocaleString("fa-IR")} + + )} + /> + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 692cc5e..e141708 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -17,6 +17,7 @@ import { import { ApiError, api } from "@/lib/api"; import { useLiveChannel } from "@/lib/realtime"; import { useSession } from "@/lib/session"; +import { EngineWidgets } from "./_components/engine-widgets"; export default function DashboardPage() { const { activeOrganization, user } = useSession(); @@ -105,6 +106,8 @@ export default function DashboardPage() {

+ +
diff --git a/apps/web/src/app/(app)/manager/_components/decision-banner.tsx b/apps/web/src/app/(app)/manager/_components/decision-banner.tsx new file mode 100644 index 0000000..31150d7 --- /dev/null +++ b/apps/web/src/app/(app)/manager/_components/decision-banner.tsx @@ -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 ( + + در انتظار تصمیم شما +
    + {waiting.map((task) => ( +
  • + +
  • + ))} +
+
+ ); +} + +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(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 ( + + + {task.title} + + +