feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)

Engine honours org policy and monthly budget, legacy runner skips engine tasks,
sandbox session provisioned on demand, project fallback for threads, reviewer
fallback, tenant checks on project ids, memory reflection respects grants,
expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 20:57:56 +03:30
co-authored by Claude Sonnet 5.5
parent 36564e3f2b
commit fef5a9deab
43 changed files with 3024 additions and 221 deletions
+32 -12
View File
@@ -238,13 +238,35 @@ web/
| قابلیت | env | مقادیر | | قابلیت | env | مقادیر |
|---|---|---| |---|---|---|
| Git | `GIT_PROVIDER` | `mock` \| `gitea` | | Git | `GIT_PROVIDER` | `mock` \| `gitea` |
| Workspace گیت | `GIT_WORKSPACE` | `fake` (پیش‌فرض، برای تست) \| `local` (git روی host) |
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` | | دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | | دامنهٔ سفارشی | `ALLOW_CUSTOM_DOMAINS` | `false` (پیش‌فرض) \| `true` |
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `docker` \| `daytona` \| `e2b` |
| استقرار روی شبکهٔ خصوصی | `DEPLOY_ALLOW_PRIVATE_HOSTS` | `false` (پیش‌فرض)؛ `true` فقط برای مقصدی در شبکهٔ خصوصی که قابل اعتماد است |
| MCP gateway | `MCP_GATEWAY_ENABLED` | `true` (پیش‌فرض) \| `false` |
| پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` | | پرداخت | `PAYMENT_PROVIDER` | `mock` \| `stripe` |
اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و
adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند. adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند.
با پیش‌فرض‌ها (`mock`، `fake` و `false`) تست‌ها بدون docker، git یا شبکه اجرا می‌شوند.
**حالت واقعی (real mode):** وقتی سامانه باید روی یک host واقعی با docker و caddy اجرا شود، این envها را تنظیم کنید:
```bash
SANDBOX_PROVIDER=docker GIT_WORKSPACE=local DOMAIN_ROUTER=caddy GIT_PROVIDER=gitea
```
نیازمندی‌های host در این حالت:
- `docker` یا `podman` در `PATH` (با `SANDBOX_DOCKER_BIN` انتخاب می‌شود). کد کاربر فقط داخل کانتینرهای سخت‌شده اجرا می‌شود: `--read-only`، کاربر غیر root، `--cap-drop ALL`، و محدودیت CPU، حافظه و PID.
- `git` نسخهٔ 2.20 یا جدیدتر در `PATH`. git را خود پلتفرم اجرا می‌کند؛ hookها غیرفعال‌اند و اعتبارنامه‌ها هرگز در argv یا URL نمی‌آیند.
- `caddy` در `PATH` و این خط در Caddyfile: `import /etc/caddy/agy-preview/*.caddy`. بدون این خط adapter اجرا نمی‌شود، تا یک موفقیت کاذب گزارش نشود.
- اجازهٔ بدون رمز برای `caddy reload --config <file>` به کاربر سرویس (روی systemd با sudoers یا polkit، نه با root).
- پوشه‌های قابل‌نوشتن با فضای کافی زیر `SANDBOX_WORKSPACES_ROOT` و `ARTIFACTS_ROOT` (پیش‌فرض `./.data/…`). پاک‌سازی خودکار `runs/`، `artifacts/` و فایل‌های پشتیبان FTP هنوز پیاده نشده است.
- هدف استقرار `ftps` (TLS صریح) است. هدف `ftp` ساده پذیرفته می‌شود، ولی بدون رمزنگاری است.
- تست‌های یکپارچگی docker و استقرار با `SANDBOX_DOCKER_IT=1` و `DEPLOY_IT=1` فعال می‌شوند و بدون آن‌ها رد (skip) می‌شوند.
### ارائه‌دهندهٔ agy (Google Antigravity) — تنها ارائه‌دهندهٔ مدل ### ارائه‌دهندهٔ agy (Google Antigravity) — تنها ارائه‌دهندهٔ مدل
agy کلید API ندارد؛ **اکانت**‌ها هویت Google هستند و از طریق OAuth وارد agy کلید API ندارد؛ **اکانت**‌ها هویت Google هستند و از طریق OAuth وارد
@@ -269,26 +291,24 @@ refresh tokenها فقط encrypted at rest ذخیره می‌شوند و هیچ
## ۹. Workerهای پس‌زمینه ## ۹. Workerهای پس‌زمینه
سه worker وجود دارد. همه در `apps/api/app/workers/` پیاده شده‌اند و `services/<name>` پنج process پس‌زمینه وجود دارد: چهار worker و یک MCP gateway. همه در `apps/api/app/workers/` پیاده شده‌اند (MCP gateway در `apps/api/app/interfaces/mcp/`) و `services/<name>` پوستهٔ اجرای آن‌هاست:
پوستهٔ اجرای آن‌هاست:
| worker | کار | دستور | | worker | کار | دستور |
|---|---|---| |---|---|---|
| `billing-worker` | جمع‌بندی usage ledger به فاکتور draft (هر دوره یک‌بار، idempotent) | `pnpm worker:billing` | | `billing-worker` | جمع‌بندی usage ledger به فاکتور draft (هر دوره یک‌بار، idempotent)، و در همان پاس حذف نرم درس‌های حافظه‌ای منقضی‌شده (به‌جز موارد pin شده) | `pnpm worker:billing` |
| `ai-runtime` | برداشتن taskهای در صف و اجرای آن‌ها از مسیر run engine | `pnpm worker:ai-runtime` | | `ai-runtime` | برداشتن taskهای در صف و اجرای آن‌ها از مسیر run engine، و یک `Scheduler.tick()` در هر پاس (فاصلهٔ پاس‌ها با `ENGINE_TICK_SECONDS`) | `pnpm worker:ai-runtime` |
| `sandbox-gateway` | متوقف‌کردن sessionهای بی‌استفاده و ثبت audit | `pnpm worker:sandbox-gateway` | | `sandbox-gateway` | متوقف‌کردن sessionهای بی‌استفاده، حذف containerهای یتیم با label `smart-org.*`، و ثبت audit | `pnpm worker:sandbox-gateway` |
| `delivery` | اجرای pipeline runهای در صف (از checkout تا route) و بررسی ریموت‌ها برای pushهای بیرونی هر `DELIVERY_POLL_SECONDS` | `pnpm worker:delivery` |
هر دستور **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای **MCP gateway** یک سرویس مداوم است که با `pnpm mcp:dev` اجرا می‌شود. عامل‌های بیرونی AI با توکن‌های محدود به یک سازمان به آن وصل می‌شوند. جزئیات در `services/mcp-gateway/README.md`.
مستمر `--loop` بدهید:
هر دستور worker **یک پاس** اجرا می‌کند و خارج می‌شود (شکل مناسب cron). برای اجرای مستمر `--loop` بدهید:
```bash ```bash
cd apps/api && .venv/bin/python -m app.workers billing --loop --interval 3600 cd apps/api && .venv/bin/python -m app.workers billing --loop --interval 3600
``` ```
**اصل مهم:** workerها هیچ منطق تجاری ندارند. هر سه از **اصل مهم:** workerها هیچ منطق تجاری ندارند. `ai-runtime`، `sandbox-gateway` و `delivery` سرویس‌های خود را از همان builderهای `app/application/container*.py` می‌سازند که HTTP API می‌سازد، پس آن‌ها و API هرگز نمی‌توانند دربارهٔ اینکه کدام adapter پشت یک port است اختلاف داشته باشند. `billing-worker` مستقیم از repositoryها استفاده می‌کند. جزئیات هر کدام در `services/*/README.md`.
`app/application/container.py` همان سرویس‌هایی را می‌سازند که HTTP API می‌سازد،
پس worker و API هرگز نمی‌توانند دربارهٔ اینکه کدام adapter پشت یک port است اختلاف
داشته باشند. جزئیات هر کدام در `services/*/README.md`.
--- ---
+10 -2
View File
@@ -22,8 +22,13 @@ from app.application.container_delivery import (
build_deploy_target_tester, build_deploy_target_tester,
build_repo_workspace, build_repo_workspace,
) )
from app.application.container_engine import build_workforce_bootstrap from app.application.container_engine import build_scheduler, build_workforce_bootstrap
from app.application.container_org import build_memory, build_model_grants from app.application.container_org import (
build_memory,
build_memory_service,
build_model_grant_service,
build_model_grants,
)
from app.application.services.billing_service import BillingService from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService from app.application.services.project_service import ProjectService
@@ -172,8 +177,11 @@ __all__ = [
"build_deploy_service", "build_deploy_service",
"build_deploy_target_tester", "build_deploy_target_tester",
"build_memory", "build_memory",
"build_memory_service",
"build_model_grant_service",
"build_model_grants", "build_model_grants",
"build_repo_workspace", "build_repo_workspace",
"build_scheduler",
"build_workforce_bootstrap", "build_workforce_bootstrap",
"build_knowledge_service", "build_knowledge_service",
"build_project_service", "build_project_service",
+76 -10
View File
@@ -13,7 +13,8 @@ from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.domain.sandbox.models import SessionStatus from app.core.errors import AppError
from app.domain.sandbox.models import SessionStatus, WorkspaceStatus
from app.domain.workforce.models import Task from app.domain.workforce.models import Task
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -87,11 +88,13 @@ def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
class _EngineSandboxSessions: class _EngineSandboxSessions:
"""The sandbox session the engine's turns run in. """The sandbox session the engine's turns run in.
The engine does not *create* sandboxes: a workspace and a running session The engine reuses the active session of the task's project. When there is
belong to the project and are started through the sandbox flow. The engine none, it starts the project's workspace session itself (see ``_provision``):
only reuses the active session of the task's project. When there is none it a fresh organization cannot start a sandbox through the API, because sandbox
returns ``""``, and the tests gate treats that as red and sends the work mutations are owner-only, and without a session every tool turn fails. When
back — never "merge untested because there was no container". the session cannot be started it returns ``""``, and the tests gate treats
that as red and sends the work back — never "merge untested because there
was no container".
""" """
#: A session in one of these states can still run a command. #: A session in one of these states can still run a command.
@@ -107,6 +110,7 @@ class _EngineSandboxSessions:
self.session = session self.session = session
async def for_task(self, task: Task) -> str: async def for_task(self, task: Task) -> str:
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.sandbox import ( from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository, SandboxSessionRepository,
WorkspaceRepository, WorkspaceRepository,
@@ -114,9 +118,12 @@ class _EngineSandboxSessions:
if not task.project_id: if not task.project_id:
return "" return ""
workspaces = await WorkspaceRepository(self.session).list_for_project( # A task only ever runs in its own organization's project, whatever the row says.
task.project_id own = await ProjectRepository(self.session).list_for_org(task.organization_id)
) if task.project_id not in {project.id for project in own}:
return ""
found = await WorkspaceRepository(self.session).list_for_project(task.project_id)
workspaces = [w for w in found if w.organization_id == task.organization_id]
sessions = SandboxSessionRepository(self.session) sessions = SandboxSessionRepository(self.session)
for workspace in workspaces: for workspace in workspaces:
for sandbox_session in await sessions.list_for_workspace(workspace.id): for sandbox_session in await sessions.list_for_workspace(workspace.id):
@@ -125,7 +132,66 @@ class _EngineSandboxSessions:
and sandbox_session.external_session_id and sandbox_session.external_session_id
): ):
return sandbox_session.external_session_id return sandbox_session.external_session_id
return "" return await self._provision(task)
async def _provision(self, task: Task) -> str:
"""Start the project's workspace session and return its id, or ``""``.
A project keeps one workspace: the newest one that is not destroyed is
reused, and only a project with none gets a new workspace, built from the
platform template for the configured provider. A failure is recorded and
never raised, so the turn runs without a container and the gates say so.
"""
from app.application.container import build_sandbox_service
from app.core.config import settings
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.sandbox import (
SandboxTemplateRepository,
WorkspaceRepository,
)
project_id = task.project_id
if not project_id:
return ""
metadata = {"task_id": task.id, "project_id": project_id}
service = build_sandbox_service(self.session)
try:
found = await WorkspaceRepository(self.session).list_for_project(project_id)
workspaces = [w for w in found if w.organization_id == task.organization_id]
workspace = next(
(w for w in workspaces if w.status != WorkspaceStatus.DESTROYED), None
)
if workspace is None:
templates = await SandboxTemplateRepository(self.session).list()
if not templates:
await record_error(
kind="engine.sandbox_provision_failed",
message="no sandbox template is seeded",
organization_id=task.organization_id,
metadata=metadata,
)
return ""
preferred = [
t for t in templates if str(t.provider) == settings.sandbox_provider
]
workspace = await service.create_workspace(
organization_id=task.organization_id,
project_id=project_id,
template_id=(preferred or templates)[0].id,
)
started = await service.start_session(
organization_id=task.organization_id, workspace_id=workspace.id
)
except AppError as exc:
await record_error(
kind="engine.sandbox_provision_failed",
message=type(exc).__name__,
exc=exc,
organization_id=task.organization_id,
metadata=metadata,
)
return ""
return started.external_session_id or ""
def _build_manager( def _build_manager(
+14 -2
View File
@@ -32,6 +32,9 @@ from app.infrastructure.repositories.org_grants import (
def build_memory(session: AsyncSession) -> MemoryPort: def build_memory(session: AsyncSession) -> MemoryPort:
"""The real memory service: recall, teach, and one reflection per task. """The real memory service: recall, teach, and one reflection per task.
The reflection runs only on a model the organization was granted, so the
owner's grants decide which model learns on the organization's behalf.
``get_model_provider`` is resolved from ``container``'s namespace rather ``get_model_provider`` is resolved from ``container``'s namespace rather
than from the registry directly: that module is what the composition root than from the registry directly: that module is what the composition root
re-exports, so a deployment (or the test suite) that swaps the adapter in re-exports, so a deployment (or the test suite) that swaps the adapter in
@@ -39,15 +42,23 @@ def build_memory(session: AsyncSession) -> MemoryPort:
""" """
from app.application.container import get_model_provider from app.application.container import get_model_provider
return build_memory_service(session, provider=get_model_provider()) return build_memory_service(
session, provider=get_model_provider(), grants=build_model_grants(session)
)
def build_memory_service( def build_memory_service(
session: AsyncSession, session: AsyncSession,
*, *,
provider=None, provider=None,
grants: ModelGrantPort | None = None,
) -> MemoryService: ) -> MemoryService:
"""The same object, typed for the router (list, teach, pin, confirm, delete).""" """The same object, typed for the router (list, teach, pin, confirm, delete).
``grants`` is optional. The router never reflects, and a direct construction
without it keeps the routing-table path; the engine's path (``build_memory``)
always passes it.
"""
if provider is None: if provider is None:
from app.application.container import get_model_provider from app.application.container import get_model_provider
@@ -60,6 +71,7 @@ def build_memory_service(
rate_cards=RateCardRepository(session), rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session), ledger=UsageLedgerRepository(session),
provider=provider, provider=provider,
grants=grants,
) )
+20 -7
View File
@@ -302,11 +302,12 @@ class ManagerTurns:
raise ValidationError("این نقش قابل استخدام نیست.") raise ValidationError("این نقش قابل استخدام نیست.")
if not tier: if not tier:
raise ValidationError("سطح کارمند را انتخاب کنید.") raise ValidationError("سطح کارمند را انتخاب کنید.")
# The owner's per-org policy may lower the global cap, never raise it.
policy = await self.grants.get_org_policy(organization_id)
cap = min(settings.engine_max_employees, policy.max_employees)
count = await self.employees.count_for_org(organization_id) count = await self.employees.count_for_org(organization_id)
if count >= settings.engine_max_employees: if count >= cap:
raise ConflictError( raise ConflictError(f"سقف تعداد کارمندان ({cap}) پر شده است.")
f"سقف تعداد کارمندان ({settings.engine_max_employees}) پر شده است."
)
granted = await self.grants.granted_models(organization_id=organization_id) granted = await self.grants.granted_models(organization_id=organization_id)
# A hire is an explicit request for a tier: no silent downgrade, or the # A hire is an explicit request for a tier: no silent downgrade, or the
# manager would think it hired a strong employee and got a fast one. # manager would think it hired a strong employee and got a fast one.
@@ -529,12 +530,14 @@ class ManagerTurns:
if draft.kind in {k.value for k in TaskKind} if draft.kind in {k.value for k in TaskKind}
else TaskKind.FEATURE else TaskKind.FEATURE
) )
# A thread made by the web UI has no project; the org's project is the fallback.
project_id = thread.project_id or await self._project_id_for(organization_id)
task = await self.tasks.create( task = await self.tasks.create(
Task( Task(
organization_id=organization_id, organization_id=organization_id,
title=draft.title[:200], title=draft.title[:200],
description=draft.description, description=draft.description,
project_id=thread.project_id, project_id=project_id,
thread_id=thread.id, thread_id=thread.id,
created_by_user_id=user_id, created_by_user_id=user_id,
) )
@@ -544,9 +547,11 @@ class ManagerTurns:
TaskPipelineState( TaskPipelineState(
task_id=task.id, task_id=task.id,
organization_id=organization_id, organization_id=organization_id,
project_id=thread.project_id, project_id=project_id,
pipeline=resolve_pipeline( pipeline=resolve_pipeline(
manager_choice=draft.pipeline or None, task_kind=kind manager_choice=draft.pipeline or None,
task_kind=kind,
has_active_reviewer=await self._has_active_reviewer(organization_id),
), ),
kind=kind, kind=kind,
risk=Risk.MEDIUM, risk=Risk.MEDIUM,
@@ -719,6 +724,7 @@ class ManagerTurns:
pipeline = resolve_pipeline( pipeline = resolve_pipeline(
manager_choice=assign.pipeline or None, manager_choice=assign.pipeline or None,
task_kind=state.kind if state else None, task_kind=state.kind if state else None,
has_active_reviewer=await self._has_active_reviewer(task.organization_id),
) )
first_stage = ( first_stage = (
Stage.PENDING_ANALYSIS Stage.PENDING_ANALYSIS
@@ -1067,6 +1073,13 @@ class ManagerTurns:
return employee.project_id return employee.project_id
return None return None
async def _has_active_reviewer(self, organization_id: str) -> bool:
"""A review stage needs an active reviewer to act on it, or the task dead-ends."""
return any(
e.role_key is RoleKey.REVIEWER and e.is_available
for e in await self.employees.list_for_org(organization_id)
)
__all__ = [ __all__ = [
"BOOTSTRAP_TIERS", "BOOTSTRAP_TIERS",
+48 -2
View File
@@ -31,6 +31,7 @@ from app.application.engine.runner import TurnRequest, TurnRunner
from app.application.engine.state import advance, park_dependents from app.application.engine.state import advance, park_dependents
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings from app.core.config import settings
@@ -165,6 +166,11 @@ class Scheduler:
turn_states = await self.states.list_at_stages(TURN_STAGES, limit=limit) turn_states = await self.states.list_at_stages(TURN_STAGES, limit=limit)
merge_states = await self.states.list_at_stages(MERGE_STAGES, limit=limit) merge_states = await self.states.list_at_stages(MERGE_STAGES, limit=limit)
# One policy read per organization for the whole tick, shared by both lists.
policies: dict[str, OrgPolicyView] = {}
turn_states = await self._drop_switched_off(turn_states, policies, report)
merge_states = await self._drop_switched_off(merge_states, policies, report)
selected = self._select(turn_states) selected = self._select(turn_states)
report.selected = len(selected) report.selected = len(selected)
for state in selected: for state in selected:
@@ -174,6 +180,30 @@ class Scheduler:
await self._guarded(self._merge_step, state, report) await self._guarded(self._merge_step, state, report)
return report return report
async def _drop_switched_off(
self,
states: list[TaskPipelineState],
policies: dict[str, OrgPolicyView],
report: TickReport,
) -> list[TaskPipelineState]:
"""Keep only the states whose organization has automation switched on.
The owner's switch stops everything up to the merge. A skipped task keeps
its stage, so switching automation back on resumes it where it was.
"""
kept: list[TaskPipelineState] = []
for state in states:
if state.organization_id not in policies:
policies[state.organization_id] = await self.manager.grants.get_org_policy(
state.organization_id
)
if not policies[state.organization_id].automation_enabled:
report.skipped += 1
report.note(f"task {state.task_id}: اتوماسیون این سازمان خاموش است")
continue
kept.append(state)
return kept
async def _guarded(self, step, state: TaskPipelineState, report: TickReport) -> None: async def _guarded(self, step, state: TaskPipelineState, report: TickReport) -> None:
"""One task must never stop the tick: catch, record, continue.""" """One task must never stop the tick: catch, record, continue."""
try: try:
@@ -590,16 +620,32 @@ class Scheduler:
await self._post_note(task, reason) await self._post_note(task, reason)
async def _budget_reason(self, task: Task) -> str | None: async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may.
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.
"""
if self.wallets is not None: if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id) wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be # No wallet means no budget was ever set: a fresh org must not be
# frozen out of its first task. # 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 not None and wallet.balance < 0:
return BUDGET_PAUSED_MESSAGE return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0: if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id) used = await self.spend.spend_for_task(task.id)
if used >= settings.engine_max_task_credits: if used >= settings.engine_max_task_credits:
return "سقف اعتبار این کار پر شده است؛ کار متوقف شد." return "سقف اعتبار این کار پر شده است؛ کار متوقف شد."
if self.spend is not None:
policy = await self.manager.grants.get_org_policy(task.organization_id)
budget = policy.monthly_credit_budget
if budget is not None:
# The month is counted in UTC, the clock the ledger stamps rows with.
month_start = utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
spent = await self.spend.spend_for_org_since(task.organization_id, month_start)
if spent >= budget:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None return None
async def _retry_or_fail( async def _retry_or_fail(
@@ -2,6 +2,10 @@
Implemented by the org-platform workstream; used by the engine. FROZEN contract: Implemented by the org-platform workstream; used by the engine. FROZEN contract:
see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names. see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names.
The contract was extended at integration: ``get_org_policy`` gives the engine the owner's
per-organization policy (automation switch, employee cap, monthly budget). The port only
reads it; the engine applies its own system ceiling (``settings.engine_max_employees``).
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,12 +31,48 @@ def tier_for(*, capabilities: list[str] | tuple[str, ...], output_price_per_1m:
return "fast" if output_price_per_1m < 2.0 else "balanced" return "fast" if output_price_per_1m < 2.0 else "balanced"
class OrgPolicyView(Protocol):
"""Read-only view of one organization's owner policy.
Properties rather than attributes, so both a mutable dataclass (``ModelGrantService``'s
``PolicyView``) and a frozen one (``DefaultOrgPolicy``) satisfy it.
"""
@property
def max_employees(self) -> int: ...
@property
def automation_enabled(self) -> bool: ...
@property
def mcp_enabled(self) -> bool: ...
@property
def monthly_credit_budget(self) -> float | None: ...
@dataclass(slots=True, frozen=True, kw_only=True)
class DefaultOrgPolicy:
"""The policy for an organization the owner has not configured.
``max_employees`` is only the port's default. The engine caps it with
``settings.engine_max_employees`` itself, so this module does not import settings.
"""
max_employees: int = 20
automation_enabled: bool = True
mcp_enabled: bool = True
monthly_credit_budget: float | None = None
@runtime_checkable @runtime_checkable
class ModelGrantPort(Protocol): class ModelGrantPort(Protocol):
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ... async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ...
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ... async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ...
async def get_org_policy(self, organization_id: str) -> OrgPolicyView: ...
class AllActiveModelsGrants: class AllActiveModelsGrants:
"""Today's behaviour: every active model is usable by every organization. """Today's behaviour: every active model is usable by every organization.
@@ -48,3 +88,6 @@ class AllActiveModelsGrants:
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
return any(m.ai_model_id == ai_model_id for m in await self._list_active()) return any(m.ai_model_id == ai_model_id for m in await self._list_active())
async def get_org_policy(self, organization_id: str) -> OrgPolicyView:
return DefaultOrgPolicy()
@@ -13,9 +13,10 @@ task — and this service owns three invariants around those calls:
whose key) already exists updates that entry instead of adding a duplicate, whose key) already exists updates that entry instead of adding a duplicate,
which is also what lets "the port is 8001" correct "the port is 8000". which is also what lets "the port is 8001" correct "the port is 8000".
3. **A reflection can never break the engine.** ``on_task_finished`` asks one 3. **A reflection can never break the engine.** ``on_task_finished`` asks one
cheap model for what the task taught. If the call fails, the error is cheap model the organization is granted for what the task taught. If that
recorded and the function returns: the task is finished, and the org simply cannot happen (no grant, a failing call), the reason is recorded and the
learned nothing this time. function returns: the task is finished, and the org simply learned nothing
this time.
Nothing here returns a model name, a provider or an infrastructure detail — the Nothing here returns a model name, a provider or an infrastructure detail — the
engine's contract is a block of text. engine's contract is a block of text.
@@ -27,11 +28,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
from app.application.ports.memory import TaskOutcome from app.application.ports.memory import TaskOutcome
from app.application.ports.model_grants import GrantedModel, ModelGrantPort
from app.application.ports.model_provider import ModelResponse from app.application.ports.model_provider import ModelResponse
from app.core.config import settings from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact_text from app.core.redact import redact_text
from app.domain.ai_control.models import RateUnit, RoutingMode from app.domain.ai_control.models import AiModel, RateUnit, RoutingMode
from app.domain.base import utcnow from app.domain.base import utcnow
from app.domain.identity.models import ROLE_RANK, Role from app.domain.identity.models import ROLE_RANK, Role
from app.domain.memory.models import ( from app.domain.memory.models import (
@@ -100,6 +102,7 @@ class MemoryService:
rate_cards: RateCardRepository, rate_cards: RateCardRepository,
ledger: UsageLedgerRepository, ledger: UsageLedgerRepository,
provider: ModelProviderPort, provider: ModelProviderPort,
grants: ModelGrantPort | None = None,
) -> None: ) -> None:
self.session = session self.session = session
self.memories = memories self.memories = memories
@@ -108,6 +111,7 @@ class MemoryService:
self.rate_cards = rate_cards self.rate_cards = rate_cards
self.ledger = ledger self.ledger = ledger
self.provider = provider self.provider = provider
self.grants = grants
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# MemoryPort # # MemoryPort #
@@ -371,16 +375,8 @@ class MemoryService:
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str: async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
"""One cheap completion about the finished task, priced in the ledger.""" """One cheap completion about the finished task, priced in the ledger."""
model = await self.models.pick_for_mode(RoutingMode.CHEAP) model = await self._reflection_model(organization_id=organization_id, task=task)
if model is None: if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return "" return ""
pool = await self.credentials.list_active_for_provider(model.provider_id) pool = await self.credentials.list_active_for_provider(model.provider_id)
response = await self.provider.complete( response = await self.provider.complete(
@@ -406,6 +402,49 @@ class MemoryService:
await self._record_usage(organization_id=organization_id, response=response) await self._record_usage(organization_id=organization_id, response=response)
return response.content or "" return response.content or ""
async def _reflection_model(self, *, organization_id: str, task: TaskOutcome) -> AiModel | None:
"""The model one reflection runs on, or ``None`` after recording why not.
With a grant port, an organization reflects only on a model its owner
granted it, cheapest tier first. Without one (a direct construction), the
routing table's cheap mode decides, as it always has.
"""
if self.grants is None:
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return model
chosen = _preferred_grant(
await self.grants.granted_models(organization_id=organization_id)
)
if chosen is None:
# The owner granted this organization no model, so it learns nothing
# from this task. That is a policy outcome, and it is recorded as one.
await record_error(
kind="memory.reflection_no_grant",
message="no model is granted to this organization",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return None
model = await self.models.get(chosen.ai_model_id)
if model is None or not model.is_active:
await record_error(
kind="memory.reflection_model_unavailable",
message="the granted model is missing or inactive",
organization_id=organization_id,
metadata={"task_id": task.task_id, "ai_model_id": chosen.ai_model_id},
)
return None
return model
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None: async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
"""Price a reflection exactly like any other model call.""" """Price a reflection exactly like any other model call."""
for unit, quantity in ( for unit, quantity in (
@@ -464,6 +503,20 @@ def _kind_order(kind: MemoryKind) -> int:
return _KIND_ORDER.get(kind, 9) return _KIND_ORDER.get(kind, 9)
#: The tiers a reflection prefers, cheapest first. A reflection is one short
#: completion; a strong model would charge more for the same two lessons.
_REFLECTION_TIERS = ("fast", "balanced")
def _preferred_grant(granted: list[GrantedModel]) -> GrantedModel | None:
"""The first granted model of the cheapest preferred tier, else the first one."""
for tier in _REFLECTION_TIERS:
for grant in granted:
if grant.tier == tier:
return grant
return granted[0] if granted else None
def _confidence_for(source: MemorySource, fallback: float) -> float: def _confidence_for(source: MemorySource, fallback: float) -> float:
"""Reflections get half confidence; a human's entry keeps its own.""" """Reflections get half confidence; a human's entry keeps its own."""
if source is MemorySource.REFLECTION: if source is MemorySource.REFLECTION:
@@ -42,6 +42,7 @@ from app.domain.projects.models import (
) )
from app.infrastructure.db.models import CreditWalletRow, ProjectRow from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import ( from app.infrastructure.repositories.projects import (
@@ -251,10 +252,9 @@ class OrgSetupService:
# -- commit so hooks see a consistent world -------------------------- # -- commit so hooks see a consistent world --------------------------
await self.session.commit() await self.session.commit()
# -- post-commit hooks ----------------------------------------------- # The response is read before the hooks run: a failed hook rolls the session
await self._call_hooks(organization_id=org.id, project_id=project_row.id) # back, and that expires the project row this method still reads.
response = {
return {
"organization": { "organization": {
"id": org.id, "id": org.id,
"name": org.name, "name": org.name,
@@ -268,6 +268,10 @@ class OrgSetupService:
"deploy_target_id": deploy_target_id, "deploy_target_id": deploy_target_id,
} }
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return response
async def _create_single_app( async def _create_single_app(
self, self,
org_id: str, org_id: str,
@@ -387,42 +391,50 @@ class OrgSetupService:
exists without its grants would be an organization whose AI staff cannot exists without its grants would be an organization whose AI staff cannot
think. An empty default set is legitimate (the owner granted nothing). think. An empty default set is legitimate (the owner granted nothing).
""" """
from app.application.services.model_grant_service import ModelGrantService from app.application.container_org import build_model_grant_service
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
service = ModelGrantService( service = build_model_grant_service(self.session)
session=self.session,
grants=OrgModelGrantRepository(self.session),
defaults=DefaultModelGrantRepository(self.session),
policies=OrgPolicyRepository(self.session),
)
return await service.copy_defaults_into( return await service.copy_defaults_into(
organization_id=organization_id, actor_user_id=user_id organization_id=organization_id, actor_user_id=user_id
) )
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None: async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
"""Run the post-commit hooks. A failing hook is recorded, never raised.
A successful hook is committed before the next one runs, so a later failure
cannot roll back earlier work. A failed hook is rolled back, which leaves the
session usable for the next hook and for the response.
"""
if self.workforce_bootstrap: if self.workforce_bootstrap:
try: try:
await self.workforce_bootstrap.bootstrap_team( await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id organization_id=organization_id, project_id=project_id
) )
except Exception as exc: # noqa: BLE001 await self.session.commit()
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc) except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
await record_error(
kind="wizard.bootstrap_failed",
message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
)
await self.session.rollback()
if self.delivery_hooks: if self.delivery_hooks:
try: try:
await self.delivery_hooks.on_app_created( await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id organization_id=organization_id, project_id=project_id
) )
except Exception as exc: # noqa: BLE001 await self.session.commit()
logger.warning( except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
"Delivery hook on_app_created failed for %s: %s", await record_error(
organization_id, kind="wizard.on_app_created_failed",
exc, message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
) )
await self.session.rollback()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# App profile editing (post-wizard) # App profile editing (post-wizard)
@@ -74,6 +74,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository, RoutingPolicyRepository,
) )
from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import ( from app.infrastructure.repositories.workforce import (
AgentTemplateRepository, AgentTemplateRepository,
ArtifactRepository, ArtifactRepository,
@@ -321,6 +322,15 @@ class WorkforceService:
# Threads & messages # # Threads & messages #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
async def _own_project_or_404(self, *, organization_id: str, project_id: str | None) -> None:
"""A project the caller names must belong to the caller's organization."""
if not project_id:
return
projects = await ProjectRepository(self.threads.session).list_for_org(organization_id)
if project_id not in {project.id for project in projects}:
# Not found rather than forbidden: another tenant's project is not confirmed to exist.
raise NotFoundError("پروژه یافت نشد.")
async def create_thread( async def create_thread(
self, self,
*, *,
@@ -329,6 +339,7 @@ class WorkforceService:
title: str = "", title: str = "",
project_id: str | None = None, project_id: str | None = None,
) -> Thread: ) -> Thread:
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
return await self.threads.create( return await self.threads.create(
Thread( Thread(
organization_id=organization_id, organization_id=organization_id,
@@ -381,15 +392,26 @@ class WorkforceService:
def _manager_turns(self): def _manager_turns(self):
"""The engine's manager service, wired on this service's session. """The engine's manager service, wired on this service's session.
Ports that take a session (model grants, memory) are built here rather Ports that take a session (model grants, memory, employee profiles,
than injected: ``container.py`` is a frozen composition root, and the pipeline states) are built here rather than injected: ``container.py`` is
engine must not widen a constructor it does not own. Sharing the a frozen composition root, and the engine must not widen a constructor it
session is the point — a reply, a task and its usage events belong to does not own. Sharing the session is the point — a reply, a task, its
one transaction. pipeline state and its usage events belong to one transaction.
The collaborators match ``container_engine._build_manager``. The runner is
left out on purpose: ``chat`` never runs a turn (only triage, final review
and error review do, and the scheduler's manager covers those). Without
``states`` a manager's CREATE_TASK makes a task the engine never owns, and
the legacy drain runs it instead.
""" """
from app.application.container_org import build_memory, build_model_grants from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
session = self.tasks.session
return ManagerTurns( return ManagerTurns(
provider=self.provider, provider=self.provider,
employees=self.employees, employees=self.employees,
@@ -400,8 +422,10 @@ class WorkforceService:
credentials=self.credentials, credentials=self.credentials,
rate_cards=self.rate_cards, rate_cards=self.rate_cards,
ledger=self.ledger, ledger=self.ledger,
grants=build_model_grants(self.tasks.session), grants=build_model_grants(session),
memory=build_memory(self.tasks.session), memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -421,6 +445,7 @@ class WorkforceService:
) -> Task: ) -> Task:
if not title.strip(): if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.") raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
if assigned_employee_id: if assigned_employee_id:
# Validates tenant ownership before we ever store the reference. # Validates tenant ownership before we ever store the reference.
await self.get_employee_or_404( await self.get_employee_or_404(
@@ -14,6 +14,8 @@ cannot both win — the loser sees zero updated rows and gets a
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -489,10 +491,11 @@ class GateResultRepository:
class TaskSpendRepository: class TaskSpendRepository:
"""Per-task spend, read from the usage ledger (never written here). """Spend, read from the usage ledger (never written here).
The scheduler's budget guard needs "how much has this task cost so far", The scheduler's budget guards need "how much has this task cost so far"
which the ledger can answer by joining its events to the task's runs. The (joining the ledger's events to the task's runs) and "how much has this
organization spent since a date" (the owner's monthly credit budget). The
ledger stays the source of truth; this only reads it. ledger stays the source of truth; this only reads it.
""" """
@@ -505,3 +508,11 @@ class TaskSpendRepository:
UsageEventRow.run_id.in_(run_ids) UsageEventRow.run_id.in_(run_ids)
) )
return float((await self.session.execute(stmt)).scalar_one()) return float((await self.session.execute(stmt)).scalar_one())
async def spend_for_org_since(self, organization_id: str, since: datetime) -> float:
"""What the organization's usage has cost since ``since``, over all its tasks."""
stmt = select(func.coalesce(func.sum(UsageEventRow.total_price), 0.0)).where(
UsageEventRow.organization_id == organization_id,
UsageEventRow.created_at >= since,
)
return float((await self.session.execute(stmt)).scalar_one())
@@ -7,7 +7,7 @@ tool calls -> artifacts.
from __future__ import annotations from __future__ import annotations
from sqlalchemy import func, select from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.workforce.models import ( from app.domain.workforce.models import (
@@ -42,6 +42,7 @@ from app.infrastructure.db.models import (
ThreadRow, ThreadRow,
ToolCallRow, ToolCallRow,
) )
from app.infrastructure.db.models_engine import TaskPipelineStateRow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mappers # Mappers
@@ -200,8 +201,10 @@ class EmployeeRepository:
return [_employee(r) for r in (await self.session.execute(stmt)).scalars()] return [_employee(r) for r in (await self.session.execute(stmt)).scalars()]
async def count_for_org(self, organization_id: str) -> int: async def count_for_org(self, organization_id: str) -> int:
"""The headcount the cap counts: everyone on the team except the fired (archived)."""
stmt = select(func.count()).select_from(EmployeeRow).where( stmt = select(func.count()).select_from(EmployeeRow).where(
EmployeeRow.organization_id == organization_id EmployeeRow.organization_id == organization_id,
EmployeeRow.status != str(EmployeeStatus.ARCHIVED),
) )
return int((await self.session.execute(stmt)).scalar_one()) return int((await self.session.execute(stmt)).scalar_one())
@@ -447,10 +450,18 @@ class TaskRepository:
Cross-tenant by design: the worker is a platform component, not a Cross-tenant by design: the worker is a platform component, not a
tenant. Only ``new``/``triaged`` qualify — anything else is either tenant. Only ``new``/``triaged`` qualify — anything else is either
already executing or closed, and re-running it would double-bill. already executing or closed, and re-running it would double-bill.
A task that owns a ``task_pipeline_state`` row belongs to
``Scheduler.tick()``. Running it here would double-bill it and mark it
``done`` behind the engine's back, so those tasks are excluded.
""" """
has_pipeline_state = exists(
select(TaskPipelineStateRow.task_id).where(TaskPipelineStateRow.task_id == TaskRow.id)
)
stmt = ( stmt = (
select(TaskRow) select(TaskRow)
.where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)])) .where(TaskRow.status.in_([str(TaskStatus.NEW), str(TaskStatus.TRIAGED)]))
.where(~has_pipeline_state)
.order_by(TaskRow.created_at.asc()) .order_by(TaskRow.created_at.asc())
.limit(min(limit, 100)) .limit(min(limit, 100))
) )
+47 -3
View File
@@ -152,7 +152,7 @@ TOOL_SPECS: tuple[ToolSpec, ...] = (
ToolSpec( ToolSpec(
name="pipeline_status", name="pipeline_status",
scope="delivery:read", scope="delivery:read",
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.", description="وضعیت خط تحویل (استقرار و دمو).",
parameters={ parameters={
"type": "object", "type": "object",
"properties": {}, "properties": {},
@@ -562,8 +562,52 @@ async def _knowledge_search(
async def _pipeline_status( async def _pipeline_status(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delivery is another workstream's table; the tool exists and says so.""" """The organization's delivery state: demo runtime, last runs, last production deploy.
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
Rule 7: the answer says where things stand and nothing about the infrastructure
under it. No port, container id, deploy-target host or log tail is ever returned.
"""
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.delivery import (
AppRuntimeRepository,
PipelineRunRepository,
ProductionDeployRepository,
)
org_id = identity.organization_id
profile = await AppProfileRepository(session).get_for_org(org_id)
if profile is None:
return {"available": False, "message": "اپلیکیشنی برای این سازمان یافت نشد."}
project_id = profile.project_id
runtime = await AppRuntimeRepository(session).get_for_org(org_id, project_id)
runs = await PipelineRunRepository(session).list_for_project(
organization_id=org_id, project_id=project_id, limit=5
)
deploys = await ProductionDeployRepository(session).list_for_project(
project_id=project_id, limit=1
)
return {
"available": True,
"demo_url": runtime.demo_url if runtime else "",
"runtime_status": runtime.status if runtime else None,
"runs": [
{
"id": run.id,
"status": run.status,
"trigger": run.trigger,
"branch": run.branch,
"sha": run.sha[:12],
"error_summary": run.error_summary,
"created_at": run.created_at.isoformat(),
}
for run in runs
],
"last_production_deploy": (
{"status": deploys[0].status, "created_at": deploys[0].created_at.isoformat()}
if deploys
else None
),
}
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = { _HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
+12 -1
View File
@@ -64,10 +64,21 @@ def _billing_pass(args: argparse.Namespace):
async def one_pass() -> WorkerOutcome: async def one_pass() -> WorkerOutcome:
end = utcnow() end = utcnow()
return await worker.roll_up( rolled = await worker.roll_up(
period_start=end - timedelta(days=max(1, args.period_days)), period_start=end - timedelta(days=max(1, args.period_days)),
period_end=end, period_end=end,
) )
# The same pass also expires stale memory lessons (see BillingWorker.expire_memories).
expired = await worker.expire_memories()
merged = WorkerOutcome(
name=rolled.name,
processed=rolled.processed + expired.processed,
skipped=rolled.skipped + expired.skipped,
failed=rolled.failed + expired.failed,
)
for message in [*rolled.notes, *expired.notes]:
merged.note(message)
return merged
return one_pass return one_pass
+6 -3
View File
@@ -52,7 +52,7 @@ class AiRuntimeWorker:
async with worker_session() as session: async with worker_session() as session:
await self._drain_runs(session, outcome, limit=limit) await self._drain_runs(session, outcome, limit=limit)
await self._tick_engine(session, outcome, limit=limit) await self._tick_engine(session, outcome)
return outcome return outcome
@@ -116,7 +116,7 @@ class AiRuntimeWorker:
message=f"کار «{current.title}» در حال اجراست (اجرا {run.id}).", message=f"کار «{current.title}» در حال اجراست (اجرا {run.id}).",
) )
async def _tick_engine(self, session, outcome: WorkerOutcome, *, limit: int) -> None: async def _tick_engine(self, session, outcome: WorkerOutcome) -> None:
"""One scheduler tick: move every ready task one step. """One scheduler tick: move every ready task one step.
A tick failure is recorded against the worker and the pass ends — the A tick failure is recorded against the worker and the pass ends — the
@@ -125,7 +125,10 @@ class AiRuntimeWorker:
""" """
try: try:
scheduler = build_scheduler(session) scheduler = build_scheduler(session)
report = await scheduler.tick(limit=max(limit, 10)) # A wide read: states that cannot run this pass (automation off, or nobody free
# for their stage) must not fill the window and starve the other organizations.
# Scheduler._select still bounds the real work per organization and per pass.
report = await scheduler.tick(limit=500)
except Exception as exc: # noqa: BLE001 - a worker must survive a bad tick except Exception as exc: # noqa: BLE001 - a worker must survive a bad tick
outcome.failed += 1 outcome.failed += 1
outcome.note(f"engine tick: {type(exc).__name__}: {exc}") outcome.note(f"engine tick: {type(exc).__name__}: {exc}")
+19
View File
@@ -9,6 +9,10 @@ background job's.
The one hard requirement is **idempotency**. A cron job that runs twice for the The one hard requirement is **idempotency**. A cron job that runs twice for the
same period must not bill twice, so a period that already has an invoice is same period must not bill twice, so a period that already has an invoice is
skipped rather than re-created. skipped rather than re-created.
The same pass also expires memory lessons past their date (``expire_memories``).
That is housekeeping, not billing, but this is the one scheduled job every
deployment runs, and expiry needs no model provider.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,6 +24,7 @@ from app.domain.billing.models import Invoice, InvoiceStatus
from app.infrastructure.repositories.audit import UsageLedgerRepository from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import InvoiceRepository from app.infrastructure.repositories.billing import InvoiceRepository
from app.infrastructure.repositories.identity import OrganizationRepository from app.infrastructure.repositories.identity import OrganizationRepository
from app.infrastructure.repositories.memory import MemoryRepository
from app.workers.base import WorkerOutcome, worker_session from app.workers.base import WorkerOutcome, worker_session
#: A period shorter than this is almost certainly a bug (a stray manual run), #: A period shorter than this is almost certainly a bug (a stray manual run),
@@ -99,6 +104,20 @@ class BillingWorker:
return outcome return outcome
async def expire_memories(self) -> WorkerOutcome:
"""Soft-delete organization lessons whose expiry has passed.
Pinned entries never expire, and rules and preferences carry no expiry at
all, so only disposable lessons are touched. The sweep covers every
organization at once, which is what a platform-wide schedule wants.
"""
outcome = WorkerOutcome(name=self.name)
async with worker_session() as session:
expired = await MemoryRepository(session).expire(None)
outcome.processed = expired
outcome.note(f"memory: {expired} expired lesson(s) soft-deleted")
return outcome
@staticmethod @staticmethod
def _naive_utc(value: datetime) -> datetime: def _naive_utc(value: datetime) -> datetime:
"""Normalise a timestamp to naive UTC for comparison. """Normalise a timestamp to naive UTC for comparison.
+17 -2
View File
@@ -15,12 +15,13 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from app.application.ports import ChatMessage, CommandResult, ModelHealth, ModelResponse from app.application.ports import ChatMessage, CommandResult, ModelHealth, ModelResponse
from app.application.ports.model_grants import GrantedModel from app.application.ports.model_grants import DefaultOrgPolicy, GrantedModel, OrgPolicyView
from app.application.ports.sandbox_provider import ( from app.application.ports.sandbox_provider import (
SessionInfo, SessionInfo,
SnapshotInfo, SnapshotInfo,
WorkspaceInfo, WorkspaceInfo,
) )
from app.core.config import settings
@dataclass(slots=True, kw_only=True) @dataclass(slots=True, kw_only=True)
@@ -187,9 +188,16 @@ class FakeGrants:
``granted`` is either a list of model ids (all ``balanced``) or a mapping ``granted`` is either a list of model ids (all ``balanced``) or a mapping
of id → neutral tier, so a test can have a "strong" and a "fast" model and of id → neutral tier, so a test can have a "strong" and a "fast" model and
assert the engine picked the right one. assert the engine picked the right one.
``policy`` is the owner's per-organization policy. ``None`` means the default
policy, built at call time from ``settings.engine_max_employees``.
""" """
def __init__(self, granted: list[str] | dict[str, str] | None = None) -> None: def __init__(
self,
granted: list[str] | dict[str, str] | None = None,
policy: OrgPolicyView | None = None,
) -> None:
if granted is None: if granted is None:
self._tiers: dict[str, str] = { self._tiers: dict[str, str] = {
"model-strong": "strong", "model-strong": "strong",
@@ -200,6 +208,7 @@ class FakeGrants:
else: else:
self._tiers = dict.fromkeys(granted, "balanced") self._tiers = dict.fromkeys(granted, "balanced")
self.checks: list[str] = [] self.checks: list[str] = []
self.policy = policy
@property @property
def granted(self) -> list[str]: def granted(self) -> list[str]:
@@ -215,6 +224,12 @@ class FakeGrants:
self.checks.append(ai_model_id) self.checks.append(ai_model_id)
return ai_model_id in self._tiers return ai_model_id in self._tiers
async def get_org_policy(self, organization_id: str) -> OrgPolicyView:
if self.policy is not None:
return self.policy
# Read at call time: a test may monkeypatch ``settings.engine_max_employees``.
return DefaultOrgPolicy(max_employees=settings.engine_max_employees)
def grant(self, model_id: str, tier: str = "balanced") -> None: def grant(self, model_id: str, tier: str = "balanced") -> None:
self._tiers[model_id] = tier self._tiers[model_id] = tier
+46 -8
View File
@@ -14,7 +14,7 @@ git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
* red tests and a forbidden diff send the work *back* with cycle + 1 instead of * red tests and a forbidden diff send the work *back* with cycle + 1 instead of
merging it merging it
* ``analysis_only`` never creates a worktree * ``analysis_only`` never creates a worktree
* an empty wallet pauses the task before a single turn is spent * an overdrawn wallet pauses the task before a single turn is spent
* a transient provider failure is retried, then handed to the manager * a transient provider failure is retried, then handed to the manager
""" """
@@ -85,23 +85,29 @@ class _World:
strong_id: str strong_id: str
class _EmptyWallet: class _OverdrawnWallet:
"""A wallet with no credit, and no way to top it up in a test.""" """A wallet below zero: the org is overdrawn, so the engine stops before a turn."""
balance = -1.0
class _ZeroWallet:
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org."""
balance = 0.0 balance = 0.0
class _Wallets: class _Wallets:
def __init__(self, wallet: _EmptyWallet | None) -> None: def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None:
self._wallet = wallet self._wallet = wallet
async def get_for_org(self, organization_id: str): async def get_for_org(self, organization_id: str):
return self._wallet return self._wallet
class _EmptyWalletRepo(_Wallets): class _OverdrawnWalletRepo(_Wallets):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(_EmptyWallet()) super().__init__(_OverdrawnWallet())
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -532,7 +538,7 @@ async def test_analysis_only_finishes_without_a_worktree(world, session_factory)
assert _calls(workspace, "commit") == [] assert _calls(workspace, "commit") == []
async def test_an_empty_wallet_pauses_the_task_before_a_turn_is_spent( async def test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent(
world, session_factory world, session_factory
): ):
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")]) scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
@@ -549,7 +555,7 @@ async def test_an_empty_wallet_pauses_the_task_before_a_turn_is_spent(
workspace=workspace, workspace=workspace,
memory=RecordingMemory(), memory=RecordingMemory(),
delivery=NullDeliveryHooks(), delivery=NullDeliveryHooks(),
wallets=_EmptyWalletRepo(), wallets=_OverdrawnWalletRepo(),
) )
await _run(scheduler, session, 1) await _run(scheduler, session, 1)
state = await PipelineStateRepository(session).get(row.id) state = await PipelineStateRepository(session).get(row.id)
@@ -561,6 +567,38 @@ async def test_an_empty_wallet_pauses_the_task_before_a_turn_is_spent(
assert BUDGET_PAUSED_MESSAGE assert BUDGET_PAUSED_MESSAGE
async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
"""A 0.0 balance is every org's starting state, so it must not pause the task.
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.
"""
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(_ZeroWallet()),
)
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
async def test_a_task_waits_for_its_dependencies(world, session_factory): async def test_a_task_waits_for_its_dependencies(world, session_factory):
scripted = ScriptedModel( scripted = ScriptedModel(
[ [
@@ -0,0 +1,70 @@
"""Integration: hiring respects the owner's per-organization employee cap.
The team is capped by two numbers: the owner's ``max_employees`` in the org
policy, and the global ``settings.engine_max_employees``. The lower one wins. The
owner can tighten the cap for their organization, but cannot lift it above the
global limit.
"""
import pytest
from app.application.ports.model_grants import DefaultOrgPolicy
from app.core.config import settings
from app.core.errors import ConflictError
from app.domain.workforce.models import RoleKey
from app.infrastructure.repositories.workforce import EmployeeRepository
from tests.engine_fakes import FakeGrants, ScriptedModel
from tests.test_engine_manager import _manager
from tests.test_engine_manager import world as manager_world
#: The 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 and shadowed).
world = manager_world
async def _hire_tester(manager, world):
return await manager.hire(
organization_id=world.organization_id, role=RoleKey.TESTER, tier="fast"
)
async def test_the_owner_policy_caps_the_team(world, session_factory):
"""An owner's ``max_employees`` of 2 refuses the third hire."""
async with session_factory() as session:
grants = FakeGrants({world.fast_id: "fast"}, policy=DefaultOrgPolicy(max_employees=2))
manager = await _manager(world, session, scripted=ScriptedModel(), grants=grants)
await _hire_tester(manager, world)
await _hire_tester(manager, world)
with pytest.raises(ConflictError, match=r"\(2\)"):
await _hire_tester(manager, world)
async def test_the_global_cap_still_wins_when_it_is_lower(world, session_factory, monkeypatch):
"""The owner can tighten the cap, but a policy of 5 does not lift a global cap of 1."""
monkeypatch.setattr(settings, "engine_max_employees", 1)
async with session_factory() as session:
grants = FakeGrants({world.fast_id: "fast"}, policy=DefaultOrgPolicy(max_employees=5))
manager = await _manager(world, session, scripted=ScriptedModel(), grants=grants)
await _hire_tester(manager, world)
with pytest.raises(ConflictError, match=r"\(1\)"):
await _hire_tester(manager, world)
async def test_a_fired_employee_frees_a_hire_slot(world, session_factory):
"""At the cap, firing one employee archives them, and the next hire succeeds."""
async with session_factory() as session:
grants = FakeGrants({world.fast_id: "fast"}, policy=DefaultOrgPolicy(max_employees=2))
manager = await _manager(world, session, scripted=ScriptedModel(), grants=grants)
first = await _hire_tester(manager, world)
await _hire_tester(manager, world)
with pytest.raises(ConflictError, match=r"\(2\)"):
await _hire_tester(manager, world)
await manager.fire(organization_id=world.organization_id, employee_id=first.id)
await session.commit()
replacement = await _hire_tester(manager, world)
await session.commit()
headcount = await EmployeeRepository(session).count_for_org(world.organization_id)
assert replacement.role_key is RoleKey.TESTER
assert headcount == 2
@@ -0,0 +1,107 @@
"""A manager order sent over the HTTP message route becomes a task the engine owns.
``POST /api/organizations/{org}/threads/{thread}/messages`` is the route the web UI posts
to. ``WorkforceService._manager_turns`` builds the manager behind it, and that manager must
carry the same ``states`` and ``profiles`` as the engine's own builder
(``container_engine._build_manager``). Without them the manager's ``CREATE_TASK`` makes a
task with no ``task_pipeline_state`` row, and ``TaskRepository.list_runnable`` hands that task
to the legacy one-shot drain, which runs it outside the engine.
The test drives the route the way a member would: the platform owner grants the default
models, a manager signs up and signs in through ``POST /api/auth/login``, completes the
wizard, opens a thread without ``project_id``, and posts the order. Only the model is
scripted.
"""
from __future__ import annotations
from sqlalchemy import select
from app.domain.workforce.models import TaskStatus
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.conftest import auth_header, signup
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete
DEFAULTS = "/api/admin/default-model-grants"
EMAIL = "order-owner@example.com"
PASSWORD = "password123"
TITLE = "صفحهٔ ورود"
#: The manager's reply to the order: one task tag, so the order becomes one task.
ORDER_REPLY = (
"باشه، یک کار می‌سازم.\n"
f'[[CREATE_TASK title="{TITLE}" kind="feature"]]'
"ساخت صفحهٔ ورود با اعتبارسنجی[[/CREATE_TASK]]"
)
async def test_a_manager_order_over_http_gets_a_pipeline_state(
client, session_factory, seeded, platform_owner_token, monkeypatch, redirect_error_log
):
scripted = ScriptedModel([ScriptedReply(content=ORDER_REPLY)])
monkeypatch.setattr("app.application.container.get_model_provider", lambda: scripted)
# The platform owner grants one reasoning model and one cheap model to the default set.
async with session_factory() as session:
models = list(
(await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name)))
.scalars()
.all()
)
strong = next(m for m in models if "reasoning" in m.capabilities)
cheap = next(m for m in models if "reasoning" not in m.capabilities)
owner = auth_header(platform_owner_token)
for model in (strong, cheap):
granted = await client.put(
f"{DEFAULTS}/{model.id}", json={"is_active": True}, headers=owner
)
assert granted.status_code == 200, granted.text
# A manager signs up, signs in over HTTP, and completes the wizard as the org's owner.
await signup(client, EMAIL, password=PASSWORD)
login = await client.post("/api/auth/login", json={"email": EMAIL, "password": PASSWORD})
assert login.status_code == 200, login.text
headers = auth_header(login.json()["access_token"])
body = await _complete(client, headers)
org_id = body["organization"]["id"]
project_id = body["project"]["id"]
# The web UI opens the thread without a project_id.
created = await client.post(
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
)
assert created.status_code == 201, created.text
assert created.json()["project_id"] is None
thread_id = created.json()["id"]
# The order goes through the real message route.
posted = await client.post(
f"/api/organizations/{org_id}/threads/{thread_id}/messages",
json={"content": "یک صفحهٔ ورود بساز."},
headers=headers,
)
assert posted.status_code == 201, posted.text
assert f"کار ساخته شد: {TITLE}" in posted.json()["assistant_message"]["content"]
async with session_factory() as session:
tasks = await TaskRepository(session).list_for_org(org_id)
assert len(tasks) == 1
task = tasks[0]
state = await PipelineStateRepository(session).get(task.id)
runnable = await TaskRepository(session).list_runnable(limit=100)
assert task.title == TITLE
assert task.project_id == project_id
# The engine owns the task: it has a pipeline state at the manager's review stage.
assert state is not None
assert state.organization_id == org_id
assert state.project_id == project_id
assert state.stage is Stage.PENDING_MANAGER_REVIEW
# The legacy drain picks up new and triaged tasks. This task is new, so only its
# pipeline state keeps the drain away from it.
assert task.status is TaskStatus.NEW
assert task.id not in {t.id for t in runnable}
+138
View File
@@ -0,0 +1,138 @@
"""Integration: the owner's org policy is honoured by the real scheduler tick.
The tick reads the policy through the manager's grants port, once per
organization, and checks two of its switches before a turn is spent:
* ``automation_enabled`` off stops every turn and every merge. The stage does not
move, so switching automation back on resumes the task where it was (the
owner's human-off switch).
* ``monthly_credit_budget`` is a ceiling on the organization's spend since the
first day of the UTC month. Spend at or above it pauses the task.
With no policy (the default), the tick behaves as the flow tests pin it.
The fixtures and the scheduler wiring come from ``tests.test_engine_flow``; this
module adds the owner's policy and the spend guard on top of them.
"""
from __future__ import annotations
from app.application.engine.scheduler import Scheduler
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.model_grants import DefaultOrgPolicy, OrgPolicyView
from app.application.ports.repo_workspace import FakeRepoWorkspace
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import UsageEventRow
from app.infrastructure.repositories.engine import PipelineStateRepository, TaskSpendRepository
from tests.engine_fakes import (
FakeGrants,
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 _wired(
session, world, *, scripted: ScriptedModel, policy: OrgPolicyView | None
) -> Scheduler:
"""The flow test's scheduler, with the owner's policy and the spend guard wired in."""
scheduler = _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=FakeRepoWorkspace(),
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
)
# The tick reads the owner's policy through the manager's grants port.
scheduler.manager.grants = FakeGrants({world.strong_id: "strong"}, policy=policy)
scheduler.spend = TaskSpendRepository(session)
return scheduler
def _usage(world, total_price: float) -> UsageEventRow:
"""A ledger row for this organization, stamped now (so it falls in this month)."""
return UsageEventRow(
organization_id=world.organization_id,
unit="model_input_tokens",
quantity=1000.0,
unit_price=0.0000004,
total_price=total_price,
)
async def test_automation_off_stops_the_turn_and_moves_no_stage(world, session_factory):
"""The owner's human-off switch: no turn is spent and the task keeps its stage."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = await _wired(
session, world, scripted=scripted, policy=DefaultOrgPolicy(automation_enabled=False)
)
report = await scheduler.tick()
state = await PipelineStateRepository(session).get(row.id)
assert state is not None and state.stage is Stage.PENDING_MANAGER_REVIEW
assert report.skipped == 1
assert scripted.calls == 0
async def test_a_spent_monthly_budget_pauses_before_a_turn_is_spent(world, session_factory):
"""A budget of 0.0 with any spend this month is used up, so the task pauses."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
session.add(_usage(world, total_price=0.25))
await session.commit()
scheduler = await _wired(
session, world, scripted=scripted, policy=DefaultOrgPolicy(monthly_credit_budget=0.0)
)
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_spend_under_the_monthly_budget_does_not_pause(world, session_factory):
"""Spend below the monthly budget leaves the task free to take its turn."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
session.add(_usage(world, total_price=0.25))
await session.commit()
scheduler = await _wired(
session, world, scripted=scripted, policy=DefaultOrgPolicy(monthly_credit_budget=10.0)
)
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
async def test_no_policy_keeps_the_default_behaviour(world, session_factory):
"""With no owner policy the default applies: automation on, no monthly budget."""
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
async with session_factory() as session:
await _team(session, world)
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
await session.commit()
scheduler = await _wired(session, world, scripted=scripted, policy=None)
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
@@ -0,0 +1,233 @@
"""A review pipeline needs an active reviewer, or the task waits forever in a review stage.
``resolve_pipeline`` already falls back from ``plan_code_review_test`` to ``plan_code_test``
when it is told there is no active reviewer. The manager used to leave that flag at its
default, so a refactor in an organization without a reviewer went to a stage nobody could
act on. The manager now looks the reviewer up in the organization, for the chat order and
for triage. The bootstrap team still has no reviewer: a fresh organization gets the plain
pipeline, and only a hired, active reviewer gets the review one.
"""
from __future__ import annotations
import pytest_asyncio
from sqlalchemy import select
from app.application.container_org import build_memory
from app.application.engine.manager import ManagerTurns
from app.application.engine.runner import TurnRunner
from app.core.config import settings
from app.domain.base import new_id
from app.domain.workforce.models import Employee, EmployeeStatus, RoleKey
from app.domain.workforce.pipeline import PipelineKey, Stage, TaskKind, TaskPipelineState
from app.infrastructure.db.models import (
AiModelRow,
OrganizationRow,
RunRow,
TaskRow,
ThreadRow,
UserRow,
)
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
RunRepository,
RunStepRepository,
TaskRepository,
ThreadRepository,
ToolCallRepository,
)
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
#: One refactor, which the task-kind default sends to the review pipeline.
REFACTOR_ORDER = (
'[[CREATE_TASK title="بازنویسی ورود" kind="refactor"]]بازنویسی ماژول ورود[[/CREATE_TASK]]'
)
class _Org:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
@pytest_asyncio.fixture
async def org(session_factory, seeded):
"""An organization and its owner, with one reasoning and one cheap catalog model."""
async with session_factory() as session:
user = UserRow(email=f"rev-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
organization = OrganizationRow(
name="سازمان بازبین", slug=f"rev-{user.id[:6]}", owner_user_id=user.id
)
session.add(organization)
await session.commit()
models = list(
(await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name)))
.scalars()
.all()
)
strong = next(m for m in models if "reasoning" in m.capabilities)
fast = next(m for m in models if "reasoning" not in m.capabilities)
yield _Org(
id=organization.id,
user_id=user.id,
strong_id=strong.id,
fast_id=fast.id,
)
def _grants(org: _Org) -> FakeGrants:
return FakeGrants({org.strong_id: "strong", org.fast_id: "fast"})
def _manager(session, *, scripted, grants) -> ManagerTurns:
"""A manager service with real repositories and a scripted model."""
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,
)
async def _order_pipeline(
org: _Org, session_factory, *, reviewer_status: EmployeeStatus | None
) -> PipelineKey:
"""Bootstrap the team, optionally hire a reviewer, then send one refactor order."""
scripted = ScriptedModel([ScriptedReply(content=REFACTOR_ORDER)])
async with session_factory() as session:
manager = _manager(session, scripted=scripted, grants=_grants(org))
await manager.bootstrap_team(organization_id=org.id, project_id=None)
if reviewer_status is not None:
await EmployeeRepository(session).create(
Employee(
organization_id=org.id,
name="رضا",
role_key=RoleKey.REVIEWER,
status=reviewer_status,
)
)
thread = await ThreadRepository(session).create(
ThreadRow(organization_id=org.id, title="گفتگو")
)
await session.commit()
await manager.chat(
organization_id=org.id,
thread_id=thread.id,
content="این ماژول را بازنویسی کن.",
user_id=org.user_id,
)
await session.commit()
task = (await TaskRepository(session).list_for_org(org.id))[0]
state = await PipelineStateRepository(session).get(task.id)
assert state is not None
assert state.kind is TaskKind.REFACTOR
return state.pipeline
async def test_a_refactor_order_without_a_reviewer_skips_the_review_stage(org, session_factory):
pipeline = await _order_pipeline(org, session_factory, reviewer_status=None)
assert pipeline is PipelineKey.PLAN_CODE_TEST
async def test_a_refactor_order_with_an_active_reviewer_gets_the_review_stage(
org, session_factory
):
pipeline = await _order_pipeline(
org, session_factory, reviewer_status=EmployeeStatus.ACTIVE
)
assert pipeline is PipelineKey.PLAN_CODE_REVIEW_TEST
async def test_a_paused_reviewer_does_not_count(org, session_factory):
pipeline = await _order_pipeline(
org, session_factory, reviewer_status=EmployeeStatus.PAUSED
)
assert pipeline is PipelineKey.PLAN_CODE_TEST
async def _refactor_task(session, organization_id: str) -> tuple[str, str]:
task = TaskRow(organization_id=organization_id, title="بازنویسی")
session.add(task)
await session.flush()
run = RunRow(task_id=task.id)
session.add(run)
await session.flush()
# The state starts on the review pipeline, as a refactor order would leave it, so the
# assertion below can only pass if triage moved it back to the plain pipeline.
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
pipeline=PipelineKey.PLAN_CODE_REVIEW_TEST,
kind=TaskKind.REFACTOR,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
await session.flush()
return task.id, run.id
async def test_triage_of_a_refactor_without_a_reviewer_skips_the_review_stage(
org, session_factory, monkeypatch
):
monkeypatch.setattr(settings, "engine_max_tool_calls_per_turn", 40)
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee=""/]]')])
grants = _grants(org)
async with session_factory() as session:
manager = _manager(session, scripted=scripted, grants=grants)
await manager.bootstrap_team(organization_id=org.id, project_id=None)
manager_row = next(
e
for e in await EmployeeRepository(session).list_for_org(org.id)
if e.role_key is RoleKey.MANAGER
)
task_id, run_id = await _refactor_task(session, org.id)
await session.commit()
manager.runner = TurnRunner(
provider=scripted,
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=build_memory(session),
turns=None,
run_steps=RunStepRepository(session),
tool_calls=ToolCallRepository(session),
runs=RunRepository(session),
states=PipelineStateRepository(session),
)
task = await TaskRepository(session).get(task_id)
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
await session.commit()
state = await PipelineStateRepository(session).get(task_id)
assert outcome.acted is True
assert outcome.pipeline is PipelineKey.PLAN_CODE_TEST
assert state is not None
assert state.pipeline is PipelineKey.PLAN_CODE_TEST
+134
View File
@@ -0,0 +1,134 @@
"""Integration: the legacy one-shot run drain never picks up an engine task.
Engine tasks are created with the coarse status ``new``, exactly like a plain
task. ``AiRuntimeWorker._drain_runs`` reads ``TaskRepository.list_runnable`` and
hands every row to the legacy ``start_run``, which marks the task ``done``. If
that query returned an engine task, a manager order would run once through the
drain and never through ``Scheduler.tick()``, and the task would be billed on the
wrong path. A task that owns a ``task_pipeline_state`` row belongs to the engine,
so the drain has to skip it while still picking up the plain task.
"""
from __future__ import annotations
from app.application.ports.delivery_hooks import NullDeliveryHooks
from app.application.ports.repo_workspace import FakeRepoWorkspace
from app.domain.base import new_id
from app.domain.workforce.pipeline import PipelineKey, Stage, TaskKind, TaskPipelineState
from app.infrastructure.db.models import OrganizationRow, TaskRow, UserRow
from app.infrastructure.repositories.engine import PipelineStateRepository
from app.infrastructure.repositories.workforce import TaskRepository
from app.workers.ai_runtime import AiRuntimeWorker
from tests.engine_fakes import FakeSandbox, RecordingMemory, ScriptedModel, ScriptedReply
from tests.test_engine_flow import _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_drain_skips_a_task_the_engine_owns(session_factory, seeded) -> None:
async with session_factory() as session:
user = UserRow(email=f"drain-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان صف اجرا", slug=f"drain-{user.id[:6]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
plain = TaskRow(
organization_id=org.id, title="کار ساده", created_by_user_id=user.id
)
engine_owned = TaskRow(
organization_id=org.id, title="کار موتور", created_by_user_id=user.id
)
session.add_all([plain, engine_owned])
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=engine_owned.id,
organization_id=org.id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
await session.commit()
runnable = await TaskRepository(session).list_runnable()
assert [task.id for task in runnable] == [plain.id]
async def _stuck_org(session) -> tuple[str, str]:
"""An organization with no employees, so none of its tasks can be staffed."""
user = UserRow(email=f"stuck-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان بدون کارمند", slug=f"stuck-{user.id[:6]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
return org.id, user.id
async def _stuck_task(session, organization_id: str, user_id: str, title: str) -> None:
row = TaskRow(organization_id=organization_id, title=title, created_by_user_id=user_id)
session.add(row)
await session.flush()
await PipelineStateRepository(session).create(
TaskPipelineState(
task_id=row.id,
organization_id=organization_id,
pipeline=PipelineKey.PLAN_CODE_TEST,
kind=TaskKind.FEATURE,
stage=Stage.PENDING_MANAGER_REVIEW,
)
)
await session.flush()
async def test_twelve_stuck_tasks_do_not_starve_another_org_in_one_pass(
world, session_factory, redirect_error_log, monkeypatch
) -> None:
"""The tick reads the oldest states first, so twelve unstaffed tasks come first.
With a read window of ten they would fill it, and the staffed organization's task
would never be read. The tick reads a wide window, and the per-organization cap in
``Scheduler._select`` still lets the staffed task take its turn.
"""
async with session_factory() as session:
stuck_org_id, stuck_user_id = await _stuck_org(session)
for number in range(12):
await _stuck_task(session, stuck_org_id, stuck_user_id, f"کار گیر {number}")
await session.commit()
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee=""/]]')])
async with session_factory() as session:
await _team(session, world)
staffed = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
staffed_id = staffed.id
await session.commit()
monkeypatch.setattr(
"app.workers.ai_runtime.build_scheduler",
lambda session: _scheduler(
session,
world,
scripted=scripted,
sandbox=FakeSandbox(),
workspace=FakeRepoWorkspace(),
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
),
)
await AiRuntimeWorker().drain()
async with session_factory() as session:
state = await PipelineStateRepository(session).get(staffed_id)
assert state is not None and state.stage is Stage.PENDING_ANALYSIS
assert scripted.calls == 1
+134
View File
@@ -0,0 +1,134 @@
"""Integration: the engine starts the project's sandbox session when none is active.
A fresh organization cannot start a sandbox through the API, because sandbox
mutations are owner-only. Before this, the engine only reused a running session,
so every tool turn of a new app failed and the tests gate stayed red. The engine
now starts the project's single workspace session on demand. The tests use the
default mock provider and the seeded sandbox templates.
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_engine import _EngineSandboxSessions
from app.core.errors import ProviderUnavailableError
from app.domain.base import new_id
from app.domain.sandbox.models import SessionStatus
from app.infrastructure.db.models import (
ErrorEventRow,
OrganizationRow,
ProjectRow,
TaskRow,
UserRow,
)
from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository,
WorkspaceRepository,
)
from app.infrastructure.repositories.workforce import TaskRepository
async def _seed_task(session_factory) -> str:
"""One organization with one project and one queued task. Returns the task id."""
async with session_factory() as session:
user = UserRow(email=f"sbx-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان سندباکس", slug=f"sbx-{user.id[:6]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
project = ProjectRow(
id=new_id(), organization_id=org.id, name="app", slug="app", test_command="pytest -q"
)
session.add(project)
await session.flush()
task = TaskRow(
organization_id=org.id,
title="کار سندباکس",
project_id=project.id,
created_by_user_id=user.id,
)
session.add(task)
await session.commit()
return task.id
async def _sandbox_for(session, task_id: str) -> str:
task = await TaskRepository(session).get(task_id)
assert task is not None
return await _EngineSandboxSessions(session).for_task(task)
async def test_a_project_without_a_workspace_gets_one_running_session(session_factory, seeded):
task_id = await _seed_task(session_factory)
async with session_factory() as session:
sandbox_id = await _sandbox_for(session, task_id)
await session.commit()
task = await TaskRepository(session).get(task_id)
assert task is not None and task.project_id is not None
workspaces = await WorkspaceRepository(session).list_for_project(task.project_id)
sessions = await SandboxSessionRepository(session).list_for_workspace(workspaces[0].id)
assert sandbox_id
assert len(workspaces) == 1
assert [s.status for s in sessions] == [SessionStatus.RUNNING]
assert sessions[0].external_session_id == sandbox_id
async def test_a_second_call_reuses_the_running_session(session_factory, seeded):
task_id = await _seed_task(session_factory)
async with session_factory() as session:
first = await _sandbox_for(session, task_id)
second = await _sandbox_for(session, task_id)
await session.commit()
task = await TaskRepository(session).get(task_id)
assert task is not None and task.project_id is not None
workspaces = await WorkspaceRepository(session).list_for_project(task.project_id)
sessions = await SandboxSessionRepository(session).list_for_workspace(workspaces[0].id)
assert first
assert second == first
assert len(workspaces) == 1
assert len(sessions) == 1
async def test_without_a_template_nothing_starts_and_the_reason_is_recorded(
session_factory, redirect_error_log
):
"""No seeded sandbox templates (this test does not use ``seeded``)."""
task_id = await _seed_task(session_factory)
async with session_factory() as session:
sandbox_id = await _sandbox_for(session, task_id)
await session.commit()
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert sandbox_id == ""
assert any(row.kind == "engine.sandbox_provision_failed" for row in rows)
async def test_a_provider_failure_is_recorded_not_raised(
session_factory, seeded, redirect_error_log, monkeypatch
):
class _UnavailableService:
async def create_workspace(self, **_kwargs):
raise ProviderUnavailableError("ساخت محیط اجرای ایزوله ناموفق بود.")
monkeypatch.setattr(
"app.application.container.build_sandbox_service",
lambda _session: _UnavailableService(),
)
task_id = await _seed_task(session_factory)
async with session_factory() as session:
sandbox_id = await _sandbox_for(session, task_id)
await session.commit()
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert sandbox_id == ""
assert any(row.kind == "engine.sandbox_provision_failed" for row in rows)
@@ -0,0 +1,164 @@
"""Integration: a project id from another organization never reaches a thread, a task or a sandbox.
The caller names the project of a thread or a task. The service answers 404 for a project
that is not the caller's organization's, so one tenant cannot put work into another
tenant's project. The engine applies the same rule before it reuses or starts a sandbox,
so a task row that names a foreign project never gets that project's session, and a
workspace of another organization on the same project is ignored.
"""
from __future__ import annotations
from app.application.container_engine import _EngineSandboxSessions
from app.domain.base import new_id
from app.domain.sandbox.models import SandboxSession, SessionStatus, Workspace, WorkspaceStatus
from app.infrastructure.db.models import OrganizationRow, ProjectRow, TaskRow, UserRow
from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository,
SandboxTemplateRepository,
WorkspaceRepository,
)
from app.infrastructure.repositories.workforce import TaskRepository
from tests.conftest import auth_header, signup
from tests.test_org_wizard import _complete
async def _org_with_project(session, *, label: str) -> tuple[str, str, str]:
"""One organization with its owner and one project. Returns (org, owner, project) ids."""
user = UserRow(email=f"{label}-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name=f"سازمان {label}", slug=f"{label}-{user.id[:6]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
project = ProjectRow(
id=new_id(), organization_id=org.id, name="app", slug="app", test_command="pytest -q"
)
session.add(project)
await session.flush()
return org.id, user.id, project.id
async def test_a_thread_or_task_cannot_name_another_orgs_project(client, seeded):
headers_a = auth_header((await signup(client, "tenant-a@example.com"))["access_token"])
headers_b = auth_header((await signup(client, "tenant-b@example.com"))["access_token"])
body_a = await _complete(client, headers_a, {"org_name": "سازمان الف", "slug": "tenant-alpha"})
body_b = await _complete(client, headers_b, {"org_name": "سازمان ب", "slug": "tenant-bravo"})
org_a = body_a["organization"]["id"]
own_project = body_a["project"]["id"]
foreign_project = body_b["project"]["id"]
thread = await client.post(
f"/api/organizations/{org_a}/threads",
json={"title": "گفتگو", "project_id": foreign_project},
headers=headers_a,
)
task = await client.post(
f"/api/organizations/{org_a}/tasks",
json={"title": "کار مشترک", "project_id": foreign_project},
headers=headers_a,
)
own = await client.post(
f"/api/organizations/{org_a}/tasks",
json={"title": "کار خودی", "project_id": own_project},
headers=headers_a,
)
listed = await client.get(f"/api/organizations/{org_a}/tasks", headers=headers_a)
assert thread.status_code == 404, thread.text
assert task.status_code == 404, task.text
assert own.status_code == 201, own.text
# The refused task was never stored: the only task of org A is the one in its own project.
assert [row["project_id"] for row in listed.json()] == [own_project]
async def test_the_engine_never_runs_a_task_in_another_orgs_project(session_factory, seeded):
"""Org B has a running session. A task of org A that names B's project gets none of it."""
async with session_factory() as session:
org_b, _, project_b = await _org_with_project(session, label="sbx-b")
org_a, user_a, _ = await _org_with_project(session, label="sbx-a")
template = (await SandboxTemplateRepository(session).list())[0]
workspace_b = await WorkspaceRepository(session).create(
Workspace(
organization_id=org_b,
project_id=project_b,
template_id=template.id,
status=WorkspaceStatus.READY,
)
)
await SandboxSessionRepository(session).create(
SandboxSession(
workspace_id=workspace_b.id,
external_session_id="sess-b",
status=SessionStatus.RUNNING,
)
)
task = TaskRow(
organization_id=org_a,
title="کار غریبه",
project_id=project_b,
created_by_user_id=user_a,
)
session.add(task)
await session.commit()
task_id = task.id
async with session_factory() as session:
row = await TaskRepository(session).get(task_id)
assert row is not None
sandbox_id = await _EngineSandboxSessions(session).for_task(row)
await session.commit()
workspaces = await WorkspaceRepository(session).list_for_project(project_b)
assert sandbox_id == ""
# Nothing was provisioned under org A's name in org B's project.
assert [w.organization_id for w in workspaces] == [org_b]
async def test_a_workspace_of_another_org_on_the_same_project_is_ignored(session_factory, seeded):
"""A foreign workspace linked to org A's own project is never the one the task uses."""
async with session_factory() as session:
org_a, user_a, project_a = await _org_with_project(session, label="sbx-own")
org_b, _, _ = await _org_with_project(session, label="sbx-other")
template = (await SandboxTemplateRepository(session).list())[0]
foreign = await WorkspaceRepository(session).create(
Workspace(
organization_id=org_b,
project_id=project_a,
template_id=template.id,
status=WorkspaceStatus.READY,
)
)
await SandboxSessionRepository(session).create(
SandboxSession(
workspace_id=foreign.id,
external_session_id="sess-foreign",
status=SessionStatus.RUNNING,
)
)
task = TaskRow(
organization_id=org_a,
title="کار خودی",
project_id=project_a,
created_by_user_id=user_a,
)
session.add(task)
await session.commit()
task_id = task.id
async with session_factory() as session:
row = await TaskRepository(session).get(task_id)
assert row is not None
sandbox_id = await _EngineSandboxSessions(session).for_task(row)
await session.commit()
own = [
w
for w in await WorkspaceRepository(session).list_for_project(project_a)
if w.organization_id == org_a
]
assert sandbox_id
assert sandbox_id != "sess-foreign"
assert len(own) == 1
@@ -0,0 +1,88 @@
"""A manager order from a thread without a project still gets the organization's project.
The web UI creates the manager thread without ``project_id`` (``manager/page.tsx``). Before
this fix such an order made a task with no project, so the engine gave it no worktree and
no sandbox. ``ManagerTurns`` now falls back to the organization's project, for the task and
for its pipeline state. This test drives the path: the owner grants the default models, the
manager completes the wizard over HTTP, a thread is created over HTTP without ``project_id``,
and the order goes to the engine's manager, as in ``test_engine_manager.py``.
"""
from __future__ import annotations
from sqlalchemy import select
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.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
DEFAULTS = "/api/admin/default-model-grants"
#: The manager's chat reply: one task tag, so the order becomes one task.
ORDER_REPLY = (
"باشه، یک کار می‌سازم.\n"
'[[CREATE_TASK title="صفحهٔ ورود" kind="feature"]]'
"ساخت صفحهٔ ورود با اعتبارسنجی[[/CREATE_TASK]]"
)
async def test_an_order_in_a_thread_without_a_project_gets_the_orgs_project(
client, session_factory, seeded, platform_owner_token, monkeypatch
):
scripted = ScriptedModel([ScriptedReply(content=ORDER_REPLY)])
monkeypatch.setattr("app.application.container.get_model_provider", lambda: scripted)
# The platform owner grants one reasoning model and one cheap model to the default set.
async with session_factory() as session:
models = list(
(await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name)))
.scalars()
.all()
)
strong = next(m for m in models if "reasoning" in m.capabilities)
cheap = next(m for m in models if "reasoning" not in m.capabilities)
owner = auth_header(platform_owner_token)
for model in (strong, cheap):
granted = await client.put(
f"{DEFAULTS}/{model.id}", json={"is_active": True}, headers=owner
)
assert granted.status_code == 200, granted.text
# The manager completes the wizard: org, project, team and grants.
_, headers = await _manager(client)
body = await _complete(client, headers)
org_id = body["organization"]["id"]
project_id = body["project"]["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
)
assert created.status_code == 201, created.text
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.
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(
organization_id=org_id,
thread_id=thread_id,
content="یک صفحهٔ ورود بساز.",
user_id=owner_user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(org_id)
states = await PipelineStateRepository(session).list_for_org(org_id)
assert "کار ساخته شد: صفحهٔ ورود" in assistant.content
assert len(tasks) == 1
assert tasks[0].project_id == project_id
assert len(states) == 1
assert states[0].project_id == project_id
+67 -1
View File
@@ -20,6 +20,8 @@ rules are ours.
from __future__ import annotations from __future__ import annotations
import json
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
@@ -38,8 +40,14 @@ from app.infrastructure.db.models import (
AuditEventRow, AuditEventRow,
MembershipRow, MembershipRow,
OrganizationRow, OrganizationRow,
ProjectRow,
UserRow, UserRow,
) )
from app.infrastructure.db.models_delivery import (
AppRuntimeRow,
PipelineRunRow,
ProductionDeployRow,
)
from app.infrastructure.db.models_org import McpTokenRow from app.infrastructure.db.models_org import McpTokenRow
from app.interfaces.mcp.gateway import McpGateway from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup from tests.conftest import auth_header, signup
@@ -273,12 +281,70 @@ async def test_knowledge_and_pipeline_tools(session_factory, seeded):
assert empty.ok is True assert empty.ok is True
assert empty.payload["results"] == [] assert empty.payload["results"] == []
# Delivery is another workstream's table; the tool exists and says so. # No app yet: the honest answer is "not available", never an empty delivery state.
status = await _call(gateway, token, "pipeline_status") status = await _call(gateway, token, "pipeline_status")
assert status.ok is True assert status.ok is True
assert status.payload["available"] is False assert status.payload["available"] is False
async def test_pipeline_status_reports_the_delivery_state_and_no_host_details(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "pipeline-org")
project_id = new_id()
async with session_factory() as session:
session.add(
ProjectRow(
id=project_id, organization_id=org_id, name="اپ", slug="app",
created_by_user_id=user_id,
)
)
await session.flush()
session.add(
AppRuntimeRow(
project_id=project_id, organization_id=org_id,
demo_host="pipeline-org.preview.example", host_port=18080,
demo_container_id="ctr-secret-42", status="live",
)
)
session.add(
PipelineRunRow(
id=new_id(), organization_id=org_id, project_id=project_id,
trigger="engine", branch="dev", sha="0123456789abcdef", status="succeeded",
)
)
session.add(
ProductionDeployRow(
id=new_id(), organization_id=org_id, project_id=project_id,
kind="deploy", host="deploy-host.internal", status="succeeded",
log_tail="token=super-secret-log-line",
)
)
await session.commit()
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["delivery:read"], user_id=user_id
)
status = await _call(gateway, token, "pipeline_status")
assert status.ok is True
payload = status.payload
assert payload["available"] is True
assert payload["demo_url"] == "https://pipeline-org.preview.example"
assert payload["runtime_status"] == "live"
assert [run["status"] for run in payload["runs"]] == ["succeeded"]
assert payload["runs"][0]["sha"] == "0123456789ab"
assert set(payload["last_production_deploy"]) == {"status", "created_at"}
assert payload["last_production_deploy"]["status"] == "succeeded"
# Rule 7: no port, container id, deploy-target host or log tail leaves the gateway.
text = json.dumps(payload, ensure_ascii=False)
for hidden in (
"host_port", "18080", "ctr-secret-42", "deploy-host.internal", "super-secret-log-line",
):
assert hidden not in text
async def test_a_task_created_through_a_token_is_attributed_to_its_minter( async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded session_factory, seeded
): ):
+119
View File
@@ -0,0 +1,119 @@
"""The memory-expiry step of the billing pass removes expired lessons, and nothing else.
The invariant under test: an expired, unpinned lesson is soft-deleted by the worker,
while a pinned lesson whose expiry is also in the past survives. The step needs no
model provider, so the provider used in the setup fails loudly if anything asks it
for a call.
"""
from __future__ import annotations
from argparse import Namespace
import pytest_asyncio
from sqlalchemy import select
import app.infrastructure.error_log as error_log
from app.application.container_org import build_memory_service
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.infrastructure.db.models import MembershipRow, OrganizationRow, UserRow
from app.infrastructure.db.models_org import OrgMemoryRow
from app.workers import base as worker_base
from app.workers.billing import BillingWorker
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(autouse=True)
async def _workers_use_the_test_database(monkeypatch, session_factory):
"""Point the worker session and the error recorder at the in-memory database."""
monkeypatch.setattr(worker_base, "SessionFactory", session_factory)
monkeypatch.setattr(error_log, "SessionFactory", session_factory)
class _NoProviderAllowed:
"""Recording a lesson never calls a model; the expiry step must not either."""
async def complete(self, **_kwargs):
raise AssertionError("memory expiry must not call a model provider")
async def _org(session_factory, slug: str) -> str:
"""An organization with one owner, written directly to the database."""
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
# ---------------------------------------------------------------------------
# Expiry
# ---------------------------------------------------------------------------
async def test_the_expiry_step_removes_only_expired_unpinned_lessons(session_factory):
org_id = await _org(session_factory, "expiry-worker")
async with session_factory() as session:
service = build_memory_service(session, provider=_NoProviderAllowed())
expired = await service.record(
organization_id=org_id, kind="lesson", content="درس منقضی", source="reflection"
)
pinned = await service.record(
organization_id=org_id, kind="lesson", content="درس سنجاق‌شده", source="reflection"
)
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
# Both lessons are past their date; only the unpinned one may be removed.
for memory_id in (expired, pinned):
row = await session.get(OrgMemoryRow, memory_id)
row.expires_at = utcnow().replace(year=2020)
await session.commit()
outcome = await BillingWorker().expire_memories()
assert outcome.processed == 1
assert outcome.failed == 0
async with session_factory() as session:
rows = {row.id: row for row in (await session.execute(select(OrgMemoryRow))).scalars()}
assert rows[expired].deleted_at is not None
assert rows[pinned].deleted_at is None
async def test_the_billing_pass_runs_the_expiry_step_after_the_rollup(session_factory):
from app.workers.__main__ import _billing_pass
org_id = await _org(session_factory, "expiry-pass")
async with session_factory() as session:
service = build_memory_service(session, provider=_NoProviderAllowed())
memory_id = await service.record(
organization_id=org_id, kind="lesson", content="درس قدیمی", source="reflection"
)
row = await session.get(OrgMemoryRow, memory_id)
row.expires_at = utcnow().replace(year=2020)
await session.commit()
# No org has billable usage here, so the rollup contributes nothing and the
# merged outcome is exactly the expiry step's.
outcome = await _billing_pass(Namespace(period_days=30))()
assert outcome.processed == 1
assert any("expired lesson" in note for note in outcome.notes)
async with session_factory() as session:
row = await session.get(OrgMemoryRow, memory_id)
assert row.deleted_at is not None
+152
View File
@@ -0,0 +1,152 @@
"""Org memory reflection runs only on a model the owner granted the organization.
The invariants under test:
* an organization with no granted model learns nothing from a finished task: no
provider call, and the refusal is recorded as ``memory.reflection_no_grant``;
* a granted model is the one that answers, and the cheapest tier wins: the
``fast`` model is chosen over the ``strong`` one, whatever the grant order.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from sqlalchemy import select
from app.application.container_org import (
build_memory_service,
build_model_grant_service,
build_model_grants,
)
from app.application.ports import ModelResponse
from app.application.ports.memory import TaskOutcome
from app.domain.base import new_id
from app.domain.identity.models import PlatformRole, Role
from app.domain.memory.models import MemoryKind
from app.infrastructure.db.models import (
AiModelRow,
ErrorEventRow,
MembershipRow,
OrganizationRow,
TaskRow,
UserRow,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _org(session_factory, slug: str) -> str:
"""An organization with one owner, written directly to the database."""
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
async def _task(session_factory, org_id: str, task_id: str) -> None:
"""A real task row: a memory's evidence is a reference, not a free string."""
async with session_factory() as session:
session.add(TaskRow(id=task_id, organization_id=org_id, title=f"کار {task_id}"))
await session.commit()
async def _grant(session_factory, org_id: str, *internal_names: str) -> None:
"""Grant the named seeded models to the organization, in the order given."""
async with session_factory() as session:
service = build_model_grant_service(session)
for name in internal_names:
row = (
await session.execute(select(AiModelRow).where(AiModelRow.internal_name == name))
).scalar_one()
await service.grant_model(organization_id=org_id, ai_model_id=row.id)
await session.commit()
class _RecordingProvider:
"""Answers every reflection with one lesson, and remembers which model was asked."""
def __init__(self) -> None:
self.asked: list[str] = []
async def complete(self, **kwargs):
model = kwargs["model"]
self.asked.append(model.internal_name)
return ModelResponse(
content="درس ۱: تست‌ها را قبل از ادغام اجرا کن.",
model_internal_name=model.internal_name,
input_tokens=20,
output_tokens=8,
)
@asynccontextmanager
async def _service(session_factory, provider):
"""A memory service wired the way the engine wires it, committed on exit."""
session = session_factory()
try:
service = build_memory_service(
session, provider=provider, grants=build_model_grants(session)
)
yield service
await session.commit()
finally:
await session.close()
def _outcome(task_id: str) -> TaskOutcome:
return TaskOutcome(task_id=task_id, title="ساخت صفحه", status="completed")
# ---------------------------------------------------------------------------
# Reflection and grants
# ---------------------------------------------------------------------------
async def test_an_organization_without_a_grant_learns_nothing(
session_factory, redirect_error_log, seeded
):
org_id = await _org(session_factory, "no-grant")
provider = _RecordingProvider()
async with _service(session_factory, provider) as service:
# The catalogue has models, but the owner granted this organization none.
await service.on_task_finished(organization_id=org_id, task=_outcome("task-no-grant"))
assert await service.list(org_id) == []
assert provider.asked == []
async with session_factory() as session:
kinds = {row.kind for row in (await session.execute(select(ErrorEventRow))).scalars()}
assert "memory.reflection_no_grant" in kinds
async def test_a_granted_model_answers_and_the_cheapest_tier_wins(
session_factory, redirect_error_log, seeded
):
org_id = await _org(session_factory, "granted")
await _task(session_factory, org_id, "task-granted")
# Both models are granted; the reflection must pick the fast one whatever the order.
await _grant(session_factory, org_id, "claude-sonnet-4-5-thinking", "gemini-3-flash")
provider = _RecordingProvider()
async with _service(session_factory, provider) as service:
await service.on_task_finished(organization_id=org_id, task=_outcome("task-granted"))
memories = await service.list(org_id)
assert provider.asked == ["gemini-3-flash"]
assert [m.kind for m in memories] == [MemoryKind.LESSON]
assert all(m.evidence_task_id == "task-granted" for m in memories)
+185
View File
@@ -0,0 +1,185 @@
"""End-to-end smoke for the v2 seams: wizard, team, manager order, engine task, push, run.
Only the model is fake: ``tests.engine_fakes.ScriptedModel`` answers every call, reached
through ``app.application.container.get_model_provider``. Git is the default fake
workspace (``GIT_WORKSPACE=fake``, the ``container_delivery._fake_workspace`` singleton),
and the sandbox and domain router are the mock defaults. Everything else is the real
code path: the owner grants the default models over HTTP, the manager completes the
wizard over HTTP (which runs the real bootstrap and delivery hooks), the manager opens a
thread over HTTP without a project as the web UI does, the order goes through the real
``ManagerTurns.chat``, and every pass is one real ``AiRuntimeWorker.drain()``.
What it proves is that the seams line up. A fresh organization gets its team, its demo
host and its granted models. A thread made without a project still yields a task in the
organization's project, with a pipeline state. The engine starts the project's sandbox
session on its own. The task walks to ``COMPLETED``, and the merge queues a ``dev``
pipeline run that names the pushed commit. No network, docker or real git.
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_delivery import _fake_workspace
from app.application.container_engine import build_scheduler
from app.application.ports.repo_workspace import DiffResult
from app.core.config import settings
from app.domain.workforce.models import RoleKey, TaskStatus
from app.domain.workforce.pipeline import Stage
from app.infrastructure.db.models import (
AiModelRow,
OrganizationRow,
OrgModelGrantRow,
SandboxSessionRow,
WorkspaceRow,
)
from app.infrastructure.db.models_delivery import AppRuntimeRow
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.conftest import auth_header
from tests.engine_fakes import ScriptedModel, ScriptedReply
from tests.test_org_wizard import _complete, _manager
DEFAULTS = "/api/admin/default-model-grants"
#: The manager's chat reply: one task tag, so the order becomes one task.
ORDER_REPLY = (
"باشه، یک کار می‌سازم.\n"
'[[CREATE_TASK title="صفحهٔ ورود" kind="feature"]]'
"ساخت صفحهٔ ورود با اعتبارسنجی[[/CREATE_TASK]]"
)
#: The replies that drive one task from triage to merge, as in test_engine_flow. The
#: pipeline is ``plan_code_test``: the default for a feature, and the one the ASSIGN reply
#: names. It has no review stage, so no review replies are needed.
PIPELINE_REPLIES = [
'[[ASSIGN employee="" pipeline="plan_code_test"/]]',
"[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]",
"[[WORK_REPORT]]پیاده‌سازی شد[[/WORK_REPORT]]",
"[[TEST_PASS]]سبز است[[/TEST_PASS]]",
"[[COMPLETE/]]",
]
async def _sandboxes_of(session_factory, organization_id: str, project_id: str) -> list:
"""The sandbox sessions of one organization's project."""
async with session_factory() as session:
stmt = (
select(SandboxSessionRow)
.join(WorkspaceRow, SandboxSessionRow.workspace_id == WorkspaceRow.id)
.where(
WorkspaceRow.organization_id == organization_id,
WorkspaceRow.project_id == project_id,
)
)
return list((await session.execute(stmt)).scalars().all())
async def test_a_manager_order_ships_through_the_engine(
client, session_factory, seeded, platform_owner_token, monkeypatch, redirect_error_log
):
scripted = ScriptedModel(
[ScriptedReply(content=ORDER_REPLY), *(ScriptedReply(content=r) for r in PIPELINE_REPLIES)]
)
monkeypatch.setattr("app.application.container.get_model_provider", lambda: scripted)
# The platform owner grants one reasoning model and one cheap model to the default set.
async with session_factory() as session:
models = list(
(await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name)))
.scalars()
.all()
)
strong = next(m for m in models if "reasoning" in m.capabilities)
cheap = next(m for m in models if "reasoning" not in m.capabilities)
owner = auth_header(platform_owner_token)
for model in (strong, cheap):
granted = await client.put(
f"{DEFAULTS}/{model.id}", json={"is_active": True}, headers=owner
)
assert granted.status_code == 200, granted.text
# The manager completes the wizard over HTTP: org, project, team, runtime, grants.
_, headers = await _manager(client)
body = await _complete(client, headers)
org_id = body["organization"]["id"]
slug = body["organization"]["slug"]
project_id = body["project"]["id"]
async with session_factory() as session:
roles = {e.role_key for e in await EmployeeRepository(session).list_for_org(org_id)}
runtime = await session.get(AppRuntimeRow, project_id)
grants = (
await session.execute(
select(OrgModelGrantRow).where(OrgModelGrantRow.organization_id == org_id)
)
).scalars().all()
owner_user_id = (await session.get(OrganizationRow, org_id)).owner_user_id
assert {RoleKey.MANAGER, RoleKey.PLANNER, RoleKey.DEVELOPER, RoleKey.TESTER} <= roles
assert runtime is not None
assert runtime.demo_host == f"{slug}.{settings.preview_root_domain}"
assert grants
# The manager opens a thread the way the web UI does: no project_id.
created = await client.post(
f"/api/organizations/{org_id}/threads", json={"title": "گفتگو"}, headers=headers
)
assert created.status_code == 201, created.text
assert created.json()["project_id"] is None
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).
async with session_factory() as session:
await build_scheduler(session).manager.chat(
organization_id=org_id,
thread_id=thread_id,
content="یک صفحهٔ ورود بساز.",
user_id=owner_user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(org_id)
states = await PipelineStateRepository(session).list_for_org(org_id)
assert len(tasks) == 1
assert len(states) == 1
assert tasks[0].project_id == project_id
assert states[0].project_id == project_id
task_id = tasks[0].id
# Nothing has started the project's sandbox yet; the engine must do it.
assert await _sandboxes_of(session_factory, org_id, project_id) == []
# The engine walks the task through its stages. Each pass is one real worker drain (the
# legacy drain, then one Scheduler.tick()); the merge pushes to dev.
_fake_workspace.diffs[task_id] = DiffResult(
changed_files=["app/main.py"], text="--- a\n+++ b\n@@\n+x\n"
)
state = None
for _ in range(10):
await AiRuntimeWorker().drain()
async with session_factory() as session:
state = await PipelineStateRepository(session).get(task_id)
if state is not None and state.stage is Stage.COMPLETED:
break
async with session_factory() as session:
task = await TaskRepository(session).get(task_id)
assert state is not None and state.stage is Stage.COMPLETED
assert task is not None and task.status is TaskStatus.DONE
# The engine started the project's sandbox session on its own; no API call did.
assert await _sandboxes_of(session_factory, org_id, project_id)
pushed_sha = _fake_workspace.heads[(project_id, "dev")]
async with session_factory() as session:
runs = await PipelineRunRepository(session).list_for_project(
organization_id=org_id, project_id=project_id
)
assert any(
run.trigger == "engine" and run.branch == "dev" and run.sha == pushed_sha for run in runs
)
+6 -4
View File
@@ -95,12 +95,14 @@ function payload<T>(event: { data: Record<string, unknown> }): T {
} }
export default function ApplicationPage() { export default function ApplicationPage() {
const { activeOrganization, user } = useSession(); const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null; const orgId = activeOrganization?.id ?? null;
const orgSlug = activeOrganization?.slug ?? ""; const orgSlug = activeOrganization?.slug ?? "";
// Org membership is enough to *see* everything here; the server is the real // Org membership is enough to *see* everything here. The actions (re-run,
// boundary for the actions, which is why a 403 still surfaces as a message. // rollback, production) need the org's admin or owner role, the same rule the
const canManage = Boolean(user); // server applies; the server stays the real boundary, which is why a 403 still
// surfaces as a message.
const canManage = activeOrganization?.role === "owner" || activeOrganization?.role === "admin";
const [tab, setTab] = React.useState<Tab>("pipeline"); const [tab, setTab] = React.useState<Tab>("pipeline");
const [project, setProject] = React.useState<Project | null>(null); const [project, setProject] = React.useState<Project | null>(null);
+1 -1
View File
@@ -136,7 +136,7 @@ export default function MemoryPage() {
// Live: another member (or a reflection) changed memory while this page was open. // Live: another member (or a reflection) changed memory while this page was open.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => { useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load(); if (event.type === MEMORY_CHANGED_EVENT) void load();
}); });
const visible = React.useMemo( const visible = React.useMemo(
@@ -0,0 +1,339 @@
"use client";
/**
* Sharing controls on the settings "members" tab (v2 TODO 1.10): the invitations
* nobody has accepted yet, handing the organization to an active admin, and
* leaving it.
*
* The server is the boundary for every rule: who may list or revoke invitations,
* that only the owner transfers, that the target must be an active admin, that the
* typed slug must match, and that the last owner cannot leave. The UI only hides
* the controls a role can never use, and shows the server's message when a request
* is still refused.
*/
import * as React from "react";
import { useRouter } from "next/navigation";
import type { Member, OrganizationWithRole } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { orgApi } from "@/lib/api-org";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<string, string> = {
owner: "مالک",
admin: "مدیر",
manager: "سرپرست",
member: "عضو",
viewer: "بیننده",
};
interface PendingInvite {
id: string;
email: string;
role: string;
expires_at: string;
accepted_at: string | null;
}
function errorText(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export interface SharingSectionProps {
org: OrganizationWithRole;
members: Member[];
/** Pending invitations are shown to members who can manage members. */
canManageMembers: boolean;
/** Called after a change that makes the parent's member list stale. */
onChanged: () => void;
}
export function SharingSection({
org,
members,
canManageMembers,
onChanged,
}: SharingSectionProps) {
const router = useRouter();
const { organizations, refresh } = useSession();
const isOwner = org.role === "owner";
// --- pending invitations ---------------------------------------------------
const [invites, setInvites] = React.useState<PendingInvite[]>([]);
const [invitesLoading, setInvitesLoading] = React.useState(false);
const [invitesError, setInvitesError] = React.useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = React.useState<PendingInvite | null>(null);
const [revoking, setRevoking] = React.useState(false);
// --- transfer ownership (owner only) ---------------------------------------
const [targetUserId, setTargetUserId] = React.useState("");
const [transferOpen, setTransferOpen] = React.useState(false);
const [slugInput, setSlugInput] = React.useState("");
const [transferring, setTransferring] = React.useState(false);
const [transferError, setTransferError] = React.useState<string | null>(null);
// --- leave -------------------------------------------------------------------
const [leaveOpen, setLeaveOpen] = React.useState(false);
const [leaving, setLeaving] = React.useState(false);
const [leaveError, setLeaveError] = React.useState<string | null>(null);
const admins = members.filter((m) => m.role === "admin" && m.status === "active");
const target = admins.find((m) => m.user_id === targetUserId) ?? null;
// Same comparison the server makes (trimmed, case-insensitive), so the button
// enables exactly when the request would be accepted.
const slugMatches = slugInput.trim().toLowerCase() === org.slug.trim().toLowerCase();
React.useEffect(() => {
if (!canManageMembers) return;
let cancelled = false;
setInvitesLoading(true);
setInvitesError(null);
orgApi
.listOrgInvites(org.id)
.then((rows) => {
if (!cancelled) setInvites(rows.filter((row) => row.accepted_at == null));
})
.catch((err: unknown) => {
if (!cancelled) setInvitesError(errorText(err, "دریافت دعوت‌های در انتظار ناموفق بود."));
})
.finally(() => {
if (!cancelled) setInvitesLoading(false);
});
return () => {
cancelled = true;
};
}, [canManageMembers, org.id]);
const onRevoke = async () => {
const invite = pendingRevoke;
if (!invite) return;
setRevoking(true);
setInvitesError(null);
try {
await orgApi.revokeOrgInvite(org.id, invite.id);
setInvites((current) => current.filter((row) => row.id !== invite.id));
} catch (err) {
setInvitesError(errorText(err, "لغو دعوت ناموفق بود."));
} finally {
setPendingRevoke(null);
setRevoking(false);
}
};
const onTransfer = async () => {
if (!target || !slugMatches) return;
setTransferring(true);
setTransferError(null);
try {
await orgApi.transferOrgOwnership(org.id, {
new_owner_user_id: target.user_id,
confirm: slugInput.trim(),
});
setTransferOpen(false);
setSlugInput("");
setTargetUserId("");
// Our own role just changed (owner to admin), and the session carries it.
await refresh();
onChanged();
} catch (err) {
setTransferError(errorText(err, "انتقال مالکیت ناموفق بود."));
} finally {
setTransferring(false);
}
};
const onLeave = async () => {
setLeaving(true);
setLeaveError(null);
try {
await orgApi.leaveOrganization(org.id);
setLeaveOpen(false);
const remaining = organizations.filter((o) => o.id !== org.id);
await refresh();
// Nothing left to work in: the creation wizard is the only useful next screen.
if (remaining.length === 0) router.replace("/onboarding");
} catch (err) {
setLeaveOpen(false);
setLeaveError(errorText(err, "ترک سازمان ناموفق بود."));
} finally {
setLeaving(false);
}
};
return (
<>
{canManageMembers && (
<Card>
<CardTitle>دعوت‌های در انتظار پذیرش</CardTitle>
<CardHint>
لینک‌هایی که هنوز استفاده نشده‌اند. اگر لینکی به نشانی اشتباهی رفته، آن را لغو کنید.
</CardHint>
<div className="mt-4">
{invitesError ? (
<ErrorState title="دعوت‌ها در دسترس نیست" message={invitesError} />
) : invitesLoading ? (
<LoadingState label="در حال دریافت دعوت‌ها…" />
) : invites.length === 0 ? (
<EmptyState title="دعوت در انتظاری وجود ندارد" />
) : (
<ul className="flex flex-col divide-y divide-surface-border">
{invites.map((invite) => (
<li
key={invite.id}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div className="flex flex-col">
<span className="ltr-inline text-sm text-slate-200">{invite.email}</span>
<span className="text-xs text-slate-500">
معتبر تا {new Date(invite.expires_at).toLocaleDateString("fa-IR")}
</span>
</div>
<div className="flex items-center gap-2">
<Badge tone="neutral">{ROLE_LABELS[invite.role] ?? invite.role}</Badge>
<Button size="sm" variant="ghost" onClick={() => setPendingRevoke(invite)}>
لغو دعوت
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</Card>
)}
{isOwner && (
<Card>
<CardTitle>انتقال مالکیت</CardTitle>
<CardHint>
مالکیت را به یکی از مدیران فعال سازمان بسپارید. پس از انتقال، شما به مدیر سازمان
تبدیل می‌شوید.
</CardHint>
{admins.length === 0 ? (
<p className="mt-4 text-xs text-slate-500">
هنوز مدیر فعالی در سازمان نیست. ابتدا یکی از اعضا را به نقش «مدیر» ببرید.
</p>
) : (
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Select
label="مدیر جدید"
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
options={[
{ value: "", label: "یک مدیر را انتخاب کنید" },
...admins.map((m) => ({ value: m.user_id, label: m.name || m.email })),
]}
/>
</div>
<Button
variant="secondary"
disabled={!target}
onClick={() => {
setSlugInput("");
setTransferError(null);
setTransferOpen(true);
}}
>
ادامه و تأیید
</Button>
</div>
)}
</Card>
)}
<Card>
<CardTitle>ترک سازمان</CardTitle>
<CardHint>
پس از ترک سازمان، دسترسی شما به آن قطع می‌شود. آخرین مالک سازمان نمی‌تواند آن را ترک
کند؛ پیش از آن باید مالکیت را منتقل کنید.
</CardHint>
{leaveError && (
<div className="mt-3">
<ErrorState title="ترک سازمان انجام نشد" message={leaveError} />
</div>
)}
<div className="mt-4">
<Button variant="danger" onClick={() => setLeaveOpen(true)}>
ترک سازمان
</Button>
</div>
</Card>
<ConfirmDialog
open={pendingRevoke !== null}
title="لغو دعوت"
description={
pendingRevoke
? `دعوت «${pendingRevoke.email}» باطل می‌شود و لینک آن دیگر کار نمی‌کند.`
: undefined
}
confirmLabel={revoking ? "در حال لغو…" : "لغو دعوت"}
onConfirm={() => void onRevoke()}
onCancel={() => setPendingRevoke(null)}
/>
<Modal
open={transferOpen}
title="تأیید انتقال مالکیت"
description={
target
? `مالکیت «${org.name}» به «${target.name || target.email}» منتقل می‌شود. برای ادامه، نامک سازمان را تایپ کنید.`
: undefined
}
onClose={() => setTransferOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setTransferOpen(false)}>
انصراف
</Button>
<Button
variant="danger"
loading={transferring}
disabled={!slugMatches}
onClick={() => void onTransfer()}
>
انتقال مالکیت
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<Field
label="نامک سازمان"
dir="ltr"
autoComplete="off"
placeholder={org.slug}
hint={`دقیقاً همین نامک را وارد کنید: ${org.slug}`}
value={slugInput}
onChange={(e) => setSlugInput(e.target.value)}
/>
{transferError && <ErrorState title="انتقال انجام نشد" message={transferError} />}
</div>
</Modal>
<ConfirmDialog
open={leaveOpen}
title="ترک سازمان"
description={`دسترسی شما به «${org.name}» قطع می‌شود.`}
confirmLabel={leaving ? "در حال ترک…" : "ترک سازمان"}
onConfirm={() => void onLeave()}
onCancel={() => setLeaveOpen(false)}
/>
</>
);
}
+8
View File
@@ -36,6 +36,7 @@ import {
type McpTokenRow, type McpTokenRow,
} from "@/lib/api-org"; } from "@/lib/api-org";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
import { SharingSection } from "./_components/sharing-section";
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
owner: "مالک", owner: "مالک",
@@ -476,6 +477,13 @@ export default function SettingsPage() {
)} )}
</Card> </Card>
)} )}
<SharingSection
org={activeOrganization}
members={members}
canManageMembers={canManageMembers}
onChanged={load}
/>
</> </>
) : tab === "app" ? ( ) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} /> <AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
+28 -2
View File
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { fetchNeedsOnboarding } from "@/lib/api-org";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
/** /**
@@ -17,6 +18,24 @@ function nextDestination(fallback: string): string {
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback; return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
} }
/**
* Where a signed-in account lands. An explicit `next` wins, so an invite followed
* before signing in (`/accept-invite?token=...`) is not replaced by the wizard, even
* for an account with no organization yet. Without one, a manager with no
* organization still has to build one in the wizard (v2 D12), so the server's
* `needs_onboarding` decides. If that lookup fails, keep the old landing rather than
* strand the user on the login screen.
*/
async function landingAfterSignIn(): Promise<string> {
const next = nextDestination("");
if (next) return next;
try {
return (await fetchNeedsOnboarding()) ? "/onboarding" : "/dashboard";
} catch {
return "/dashboard";
}
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { login, user, ready } = useSession(); const { login, user, ready } = useSession();
@@ -26,7 +45,14 @@ export default function LoginPage() {
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (ready && user) router.replace(nextDestination("/dashboard")); if (!ready || !user) return;
let cancelled = false;
void landingAfterSignIn().then((destination) => {
if (!cancelled) router.replace(destination);
});
return () => {
cancelled = true;
};
}, [ready, user, router]); }, [ready, user, router]);
const onSubmit = async (event: React.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
@@ -35,7 +61,7 @@ export default function LoginPage() {
setSubmitting(true); setSubmitting(true);
try { try {
await login(email, password); await login(email, password);
router.replace(nextDestination("/dashboard")); router.replace(await landingAfterSignIn());
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود."); setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
} finally { } finally {
+13 -8
View File
@@ -19,8 +19,8 @@ const mocks = vi.hoisted(() => ({
session: { session: {
ready: true, ready: true,
user: null as { id: string; email: string } | null, user: null as { id: string; email: string } | null,
organizations: [] as Array<{ id: string; slug: string }>, organizations: [] as Array<{ id: string; slug: string; role: string }>,
activeOrganization: null as { id: string; slug: string } | null, activeOrganization: null as { id: string; slug: string; role: string } | null,
login: vi.fn(async () => {}), login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(), setActiveOrganization: vi.fn(),
@@ -79,7 +79,7 @@ vi.mock("@/lib/api-delivery", () => ({
import ApplicationPage from "@/app/(app)/app/page"; import ApplicationPage from "@/app/(app)/app/page";
import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console"; import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console";
const ORG = { id: "org-1", slug: "acme" }; const ORG = { id: "org-1", slug: "acme", role: "admin" };
const PROJECT = { const PROJECT = {
id: "proj-1", id: "proj-1",
@@ -143,10 +143,11 @@ const PRODUCTION = {
deploys: [], deploys: [],
}; };
function bootstrap({ asOwner = true } = {}) { function bootstrap({ asOwner = true, role = ORG.role } = {}) {
const org = { ...ORG, role };
mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null; mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null;
mocks.session.organizations = [ORG]; mocks.session.organizations = [org];
mocks.session.activeOrganization = ORG; mocks.session.activeOrganization = org;
mocks.listProjects.mockResolvedValue([PROJECT]); mocks.listProjects.mockResolvedValue([PROJECT]);
mocks.getRuntime.mockResolvedValue(RUNTIME); mocks.getRuntime.mockResolvedValue(RUNTIME);
mocks.listRuns.mockResolvedValue([run()]); mocks.listRuns.mockResolvedValue([run()]);
@@ -279,12 +280,16 @@ describe("the Application page", () => {
} }
}); });
it("hides the deploy button from a user without a session owner", async () => { it("hides the deploy actions from an org member who is not an admin", async () => {
bootstrap({ asOwner: false }); bootstrap({ role: "member" });
render(<ApplicationPage />); render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument()); await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument(); expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument();
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
expect(screen.queryByText("استقرار در production")).not.toBeInTheDocument();
}); });
}); });
@@ -0,0 +1,113 @@
/**
* Post-login landing (v2 D12, TODO 1.9).
*
* A manager who has no organization yet must land in the creation wizard, not on
* an empty dashboard. Everyone else keeps the old destination, and a failed
* `needs_onboarding` lookup must not strand the user on the login screen.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
router: { replace: vi.fn() },
session: {
ready: true,
user: null as { id: string; email: string } | null,
login: vi.fn(async () => {}),
},
fetchNeedsOnboarding: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-org", () => ({
fetchNeedsOnboarding: mocks.fetchNeedsOnboarding,
}));
import LoginPage from "@/app/login/page";
function signIn() {
fireEvent.change(screen.getByLabelText("ایمیل"), {
target: { value: "manager@example.com" },
});
fireEvent.change(screen.getByLabelText("رمز عبور"), {
target: { value: "secret-pass" },
});
fireEvent.click(screen.getByRole("button", { name: "ورود" }));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.session.ready = true;
mocks.session.user = null;
window.history.replaceState({}, "", "/");
});
describe("the post-login landing", () => {
it("sends a manager with no organization to the wizard after signing in", async () => {
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
expect(mocks.session.login).toHaveBeenCalledWith("manager@example.com", "secret-pass");
});
it("keeps the dashboard for an account that already has an organization", async () => {
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(false);
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/dashboard"));
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
it("applies the same rule to an account that is already signed in", async () => {
mocks.session.user = { id: "u-1", email: "manager@example.com" };
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
});
it("falls back to the dashboard when the onboarding lookup fails", async () => {
mocks.fetchNeedsOnboarding.mockRejectedValueOnce(new Error("network down"));
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/dashboard"));
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
it("honours an explicit next, such as a pending invite, over onboarding", async () => {
window.history.replaceState({}, "", "/login?next=/accept-invite?token=x");
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
signIn();
await waitFor(() =>
expect(mocks.router.replace).toHaveBeenCalledWith("/accept-invite?token=x"),
);
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
});
@@ -0,0 +1,243 @@
/**
* Settings → members: the sharing section (v2 TODO 1.10).
*
* The rules that matter in the UI are the ones a role must never see or use: only
* the owner gets the transfer section, and the transfer cannot be sent before the
* organization's slug has been typed (the server checks it again). A server refusal
* is shown as its own message, not as a generic failure.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Member, OrganizationWithRole } from "@agy/contracts";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
router: { replace: vi.fn() },
session: {
organizations: [] as OrganizationWithRole[],
refresh: vi.fn(async () => {}),
},
orgApi: {
listOrgInvites: vi.fn(),
revokeOrgInvite: vi.fn(),
transferOrgOwnership: vi.fn(),
leaveOrganization: vi.fn(),
},
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError }));
vi.mock("@/lib/api-org", () => ({ orgApi: mocks.orgApi }));
import { SharingSection } from "@/app/(app)/settings/_components/sharing-section";
const ORG: OrganizationWithRole = {
id: "org-1",
name: "شرکت نمونه",
slug: "acme",
owner_user_id: "u-owner",
role: "owner",
};
const MEMBERS: Member[] = [
{
id: "m-1",
user_id: "u-owner",
email: "owner@acme.test",
name: "مالک",
role: "owner",
status: "active",
created_at: "2026-10-01T00:00:00Z",
},
{
id: "m-2",
user_id: "u-ali",
email: "ali@acme.test",
name: "علی",
role: "admin",
status: "active",
created_at: "2026-10-02T00:00:00Z",
},
{
id: "m-3",
user_id: "u-sara",
email: "sara@acme.test",
name: "سارا",
role: "member",
status: "active",
created_at: "2026-10-03T00:00:00Z",
},
];
const INVITES = [
{
id: "inv-1",
email: "new.hire@acme.test",
role: "member",
expires_at: "2026-10-30T00:00:00Z",
accepted_at: null,
},
{
id: "inv-2",
email: "joined@acme.test",
role: "member",
expires_at: "2026-10-30T00:00:00Z",
accepted_at: "2026-10-05T00:00:00Z",
},
];
function renderSection(
overrides: Partial<OrganizationWithRole> = {},
canManageMembers = true,
) {
const onChanged = vi.fn();
const org: OrganizationWithRole = { ...ORG, ...overrides };
mocks.session.organizations = [org];
render(
<SharingSection
org={org}
members={MEMBERS}
canManageMembers={canManageMembers}
onChanged={onChanged}
/>,
);
return { onChanged, org };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.session.organizations = [];
mocks.orgApi.listOrgInvites.mockResolvedValue(INVITES);
mocks.orgApi.revokeOrgInvite.mockResolvedValue(undefined);
mocks.orgApi.transferOrgOwnership.mockResolvedValue({
id: "org-1",
name: "شرکت نمونه",
slug: "acme",
owner_user_id: "u-ali",
role: "admin",
});
mocks.orgApi.leaveOrganization.mockResolvedValue(undefined);
});
describe("the sharing section on the members tab", () => {
it("lists only the invitations nobody has accepted yet", async () => {
renderSection();
expect(await screen.findByText("new.hire@acme.test")).toBeInTheDocument();
expect(screen.queryByText("joined@acme.test")).not.toBeInTheDocument();
expect(mocks.orgApi.listOrgInvites).toHaveBeenCalledWith("org-1");
});
it("revokes a pending invitation through the API", async () => {
renderSection();
fireEvent.click(await screen.findByRole("button", { name: "لغو دعوت" }));
const dialog = screen.getByRole("dialog", { name: "لغو دعوت" });
fireEvent.click(within(dialog).getByRole("button", { name: "لغو دعوت" }));
await waitFor(() =>
expect(mocks.orgApi.revokeOrgInvite).toHaveBeenCalledWith("org-1", "inv-1"),
);
await waitFor(() =>
expect(screen.queryByText("new.hire@acme.test")).not.toBeInTheDocument(),
);
});
it("keeps the transfer disabled until the organization slug is typed", async () => {
const { onChanged } = renderSection();
fireEvent.change(screen.getByLabelText("مدیر جدید"), { target: { value: "u-ali" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه و تأیید" }));
const dialog = screen.getByRole("dialog", { name: "تأیید انتقال مالکیت" });
const confirm = within(dialog).getByRole("button", { name: "انتقال مالکیت" });
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: "acme-typo" },
});
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: " ACME " },
});
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
await waitFor(() =>
expect(mocks.orgApi.transferOrgOwnership).toHaveBeenCalledWith("org-1", {
new_owner_user_id: "u-ali",
confirm: "ACME",
}),
);
await waitFor(() => expect(mocks.session.refresh).toHaveBeenCalled());
expect(onChanged).toHaveBeenCalled();
});
it("shows the server's refusal as its own message", async () => {
renderSection();
fireEvent.change(screen.getByLabelText("مدیر جدید"), { target: { value: "u-ali" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه و تأیید" }));
const dialog = screen.getByRole("dialog", { name: "تأیید انتقال مالکیت" });
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: "acme" },
});
mocks.orgApi.transferOrgOwnership.mockRejectedValueOnce(
new mocks.FakeApiError(400, "validation_error", "مالکیت تنها به عضوی با نقش admin منتقل می‌شود."),
);
fireEvent.click(within(dialog).getByRole("button", { name: "انتقال مالکیت" }));
expect(
await screen.findByText("مالکیت تنها به عضوی با نقش admin منتقل می‌شود."),
).toBeInTheDocument();
});
it("shows no transfer section to an admin", async () => {
renderSection({ role: "admin" });
expect(await screen.findByText("new.hire@acme.test")).toBeInTheDocument();
expect(screen.queryByText("انتقال مالکیت")).not.toBeInTheDocument();
expect(screen.queryByLabelText("مدیر جدید")).not.toBeInTheDocument();
});
it("shows a plain member neither the invitations nor the transfer", () => {
renderSection({ role: "member" }, false);
expect(screen.queryByText("دعوت‌های در انتظار پذیرش")).not.toBeInTheDocument();
expect(screen.queryByText("انتقال مالکیت")).not.toBeInTheDocument();
expect(mocks.orgApi.listOrgInvites).not.toHaveBeenCalled();
// Leaving stays available to every member.
expect(screen.getByRole("button", { name: "ترک سازمان" })).toBeInTheDocument();
});
it("sends an account that leaves its only organization to the wizard", async () => {
renderSection({ role: "member" }, false);
fireEvent.click(screen.getByRole("button", { name: "ترک سازمان" }));
const dialog = screen.getByRole("dialog", { name: "ترک سازمان" });
fireEvent.click(within(dialog).getByRole("button", { name: "ترک سازمان" }));
await waitFor(() => expect(mocks.orgApi.leaveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
expect(mocks.session.refresh).toHaveBeenCalled();
});
});
+25
View File
@@ -278,3 +278,28 @@ revision when the branches are merged.
wizard → app created → demo domain → manager order → task runs → tests → push → pipeline → demo live wizard → app created → demo domain → manager order → task runs → tests → push → pipeline → demo live
→ production deploy (to a local SSH/FTP test server). → production deploy (to a local SSH/FTP test server).
5. Update `README.md`, `TODO.md` and the architecture doc, then commit. 5. Update `README.md`, `TODO.md` and the architecture doc, then commit.
## 9. Status (2026-10-14)
Written on 2026-10-14 from the code, an alembic run on a scratch SQLite database, and the statuses in
`todo-finish-plan.md`. A box is ticked only when its task is `status: done` or its check passed.
- [ ] §8.1–8.3 (partly done)
- [x] Alembic: single head `b9c0d1e2f3a4`. `upgrade head`, a downgrade to `d4e5f6a7b8c9` and `upgrade head` run cleanly, and `alembic check` reports no new operations.
- [x] The `agent2/ai-workforce` and `agent3/delivery-infra` branches are contained in `dev`.
- [ ] `agent1/org-platform` (6 commits, `0016bb4`..`051bf1b`) is not an ancestor of `dev`, and its committed tree differs from `dev` in 138 files. Its phases reached `dev` as separate commits (`5095a29`, `5fb85d6`, `dee2f72`). Open: confirm that `dev` has everything from Agent 1, then merge or retire the branch.
- [x] §8.4 done through T1–T26; T27 is this docs pass.
- [x] Double execution fixed (T2): the legacy run drain skips tasks that own a pipeline state.
- [x] Zero-wallet pause fixed (T3): only an overdrawn wallet pauses a task.
- [x] The engine honours the owner's org policy: automation switch, monthly credit budget and `max_employees` (T1, T4, T5).
- [x] A new organization gets a sandbox session that the engine starts on demand (T6).
- [x] End-to-end smoke test `tests/test_v2_e2e_smoke.py` (T7; closer to reality in T26): a thread made without a project, the real `AiRuntimeWorker.drain()`, and a sandbox session that the engine starts. The production-deploy leg is not in it.
- [x] Review fixes from the integration pass: the manager gives a task the org's project when the thread has none (T20); a review pipeline is chosen only with an active reviewer (T21); the scheduler tick reads a wide window, so skipped tasks cannot starve other organizations (T22); archived (fired) employees no longer count toward the hire cap (T23); a project id from another organization gets a 404 on threads and tasks, and the engine never uses another tenant's sandbox (T24); an explicit `next` (a pending invite) beats the onboarding redirect at login (T25).
- [x] Fixed: the HTTP message route `POST /api/organizations/{org}/threads/{thread}/messages` built its manager without pipeline states and employee profiles (`workforce_service._manager_turns`), so a manager order sent through it made tasks the engine never picked up. The manager now gets both, as `container_engine._build_manager` gives them. `tests/test_integration_order_route.py` posts the order over HTTP and checks its pipeline state.
- [ ] The production-deploy leg of the smoke test stays manual and has not been run. See `docs/plan/reports/agent-3-final.md` §6, steps 7–8, which need `DEPLOY_IT=1`.
- [x] Contract extension `ModelGrantPort.get_org_policy` (T1), with `DefaultOrgPolicy` as the default.
- [ ] Left to Agent 2 phase 5 (owned by the live phase-5 session; source: "Left to Agent 2" in `todo-finish-plan.md`):
- Web: `apps/web/src/lib/api-engine.ts`, `(app)/manager/**`, `(app)/tasks/**`, `(app)/workforce/**`, `(app)/dashboard/**`, `(app)/traces/**`, `src/test/engine-*.test.tsx`.
- Contracts: the engine event types in `@agy/contracts` (`task.step`, `task.gate`, `run.log`, `run.tool`, `team.changed`).
- Docs: tick `docs/plan/TODO-agent-2-ai-workforce.md` and write `docs/plan/reports/agent-2-final.md`.
- Open decisions: direct tasks (HTTP `POST /tasks`, MCP `tasks_create`) get no pipeline state and still run through the legacy `start_run`. A `paused` task has no resume path in the API yet.
+28 -28
View File
@@ -6,55 +6,55 @@ file. When this is done, `dev` has a foundation commit and three worktrees exist
Read `docs/plan/PLAN-v2.md` §4–§6 first. Everything here is mechanical; no product logic. Read `docs/plan/PLAN-v2.md` §4–§6 first. Everything here is mechanical; no product logic.
## F1. Environment ## F1. Environment
- [ ] `pnpm install` at root; `pnpm api:venv`; confirm the baseline: `pytest -q`, `ruff`, `mypy`, `pnpm test`, `tsc`. Record the counts. - [ ] `pnpm install` at root; `pnpm api:venv`; confirm the baseline: `pytest -q`, `ruff`, `mypy`, `pnpm test`, `tsc`. Record the counts. — open: apps/api/.venv in the main checkout is incomplete (bin/ is empty, no pyvenv.cfg), so `pnpm api:venv` cannot be confirmed there; the foundation-time counts are not written down anywhere in the repo.
- [ ] Add API deps to `apps/api/pyproject.toml`: `asyncssh`, `aioftp`, `mcp` (official SDK). Install and re-run the baseline. - [x] Add API deps to `apps/api/pyproject.toml`: `asyncssh`, `aioftp`, `mcp` (official SDK). Install and re-run the baseline. (baseline recorded at foundation time)
## F2. DB foundation (`db/models.py` + migration `a0f0e1d2c3b4_v2_foundation.py`, down_revision `d4e5f6a7b8c9`) ## F2. DB foundation (`db/models.py` + migration `a0f0e1d2c3b4_v2_foundation.py`, down_revision `d4e5f6a7b8c9`)
- [ ] `projects`: add app-profile columns (all nullable or defaulted): `app_type`, `language`, `framework`, - [x] `projects`: add app-profile columns (all nullable or defaulted): `app_type`, `language`, `framework`,
`runtime_version`, `package_manager`, `setup_command`, `build_command`, `test_command`, `start_command`, `runtime_version`, `package_manager`, `setup_command`, `build_command`, `test_command`, `start_command`,
`app_port` (int), `healthcheck_path` (default `/`), `git_source` (`platform|external`, default `platform`), `app_port` (int), `healthcheck_path` (default `/`), `git_source` (`platform|external`, default `platform`),
`external_repo_url`, `wizard_completed_at`. `external_repo_url`, `wizard_completed_at`.
- [ ] `projects`: `UniqueConstraint("organization_id", name="uq_project_one_per_org")`. Fix the seed and any test that creates 2 projects in one org. - [x] `projects`: `UniqueConstraint("organization_id", name="uq_project_one_per_org")`. Fix the seed and any test that creates 2 projects in one org.
- [ ] New table `project_external_git` (project_id PK/FK, auth_type `token|ssh_key|none`, username, secret_encrypted, known_hosts, updated_at). - [x] New table `project_external_git` (project_id PK/FK, auth_type `token|ssh_key|none`, username, secret_encrypted, known_hosts, updated_at).
- [ ] New table `deploy_targets` (id, organization_id, project_id, kind `ssh|sftp|ftp|ftps`, host, port, username, - [x] New table `deploy_targets` (id, organization_id, project_id, kind `ssh|sftp|ftp|ftps`, host, port, username,
auth_type `password|key`, secret_encrypted, remote_path, post_deploy_command, host_fingerprint, is_active, auth_type `password|key`, secret_encrypted, remote_path, post_deploy_command, host_fingerprint, is_active,
last_tested_at, last_test_ok, last_test_message, created_at). At most one active target per project. last_tested_at, last_test_ok, last_test_message, created_at). At most one active target per project.
- [ ] New table `org_model_grants` (id, organization_id, ai_model_id, daily_credit_cap nullable, is_active, created_by, created_at; unique org+model). - [x] New table `org_model_grants` (id, organization_id, ai_model_id, daily_credit_cap nullable, is_active, created_by, created_at; unique org+model).
- [ ] At the end of `models.py`, import `models_org`, `models_engine`, `models_delivery` (three empty modules with a docstring naming the owner agent). - [x] At the end of `models.py`, import `models_org`, `models_engine`, `models_delivery` (three empty modules with a docstring naming the owner agent). (empty at foundation time)
- [ ] `alembic upgrade head`, `alembic downgrade -1`, `alembic upgrade head`, `alembic check`, all clean. - [x] `alembic upgrade head`, `alembic downgrade -1`, `alembic upgrade head`, `alembic check`, all clean. (re-run 2026-10-14 at head: upgrade head, downgrade to d4e5f6a7b8c9, upgrade head, check, all clean)
## F3. Contracts (`app/application/ports/`), exactly as in PLAN-v2 §5 ## F3. Contracts (`app/application/ports/`), exactly as in PLAN-v2 §5
- [ ] `repo_workspace.py` (+ dataclasses `WorkspaceRef`, `WorktreeRef`, `DiffResult`, `MergeOutcome`, `PushOutcome`, `RemoteCredentials`, `RemoteCheck`) and an in-memory `FakeRepoWorkspace` for tests. - [x] `repo_workspace.py` (+ dataclasses `WorkspaceRef`, `WorktreeRef`, `DiffResult`, `MergeOutcome`, `PushOutcome`, `RemoteCredentials`, `RemoteCheck`) and an in-memory `FakeRepoWorkspace` for tests.
- [ ] `delivery_hooks.py` + `NullDeliveryHooks` (records calls in a list for tests). - [x] `delivery_hooks.py` + `NullDeliveryHooks` (records calls in a list for tests).
- [ ] `deploy_target.py` (`DeployTargetSpec`, `DeployCheck`) + `FakeDeployTargetTester` (ok unless host contains "fail"). - [x] `deploy_target.py` (`DeployTargetSpec`, `DeployCheck`) + `FakeDeployTargetTester` (ok unless host contains "fail").
- [ ] `memory.py` (`TaskOutcome`) + `NullMemory` (empty context, record returns None). - [x] `memory.py` (`TaskOutcome`) + `NullMemory` (empty context, record returns None).
- [ ] `model_grants.py` (`GrantedModel`) + `AllActiveModelsGrants` (today's behaviour: every active model). - [x] `model_grants.py` (`GrantedModel`) + `AllActiveModelsGrants` (today's behaviour: every active model).
- [ ] `workforce_bootstrap.py` + `NullWorkforceBootstrap`. - [x] `workforce_bootstrap.py` + `NullWorkforceBootstrap`.
- [ ] `app/domain/projects/profile.py`: frozen dataclass `AppProfile` + `AppProfile.from_row(...)`. - [x] `app/domain/projects/profile.py`: frozen dataclass `AppProfile` + `AppProfile.from_row(...)`.
## F4. Wiring stubs ## F4. Wiring stubs
- [ ] `app/application/container_org.py`, `container_engine.py`, `container_delivery.py`: each holds the builder functions for its port(s), returning the Null/Fake implementations for now. `container.py` re-exports them. - [x] `app/application/container_org.py`, `container_engine.py`, `container_delivery.py`: each holds the builder functions for its port(s), returning the Null/Fake implementations for now. `container.py` re-exports them. (foundation stubs; the builders now return the real services)
- [ ] Empty routers (`router = APIRouter()`) registered in `rpc_app.py`: `app_setup`, `memory`, `mcp_tokens`, `admin_grants`, `delivery`, `admin_domains`. - [x] Empty routers (`router = APIRouter()`) registered in `rpc_app.py`: `app_setup`, `memory`, `mcp_tokens`, `admin_grants`, `delivery`, `admin_domains`. (routers now hold their endpoints)
- [ ] `app/workers/delivery.py` stub (one no-op pass) registered in `workers/__main__.py` as `delivery`; `services/delivery-worker/README.md` + `pnpm worker:delivery` script. - [x] `app/workers/delivery.py` stub (one no-op pass) registered in `workers/__main__.py` as `delivery`; `services/delivery-worker/README.md` + `pnpm worker:delivery` script. (the stub is now the real worker pass)
- [ ] `services/mcp-gateway/README.md` placeholder + `pnpm mcp:dev` script (points at `app.interfaces.mcp.server:main`, stub module that raises a clear "not implemented yet"). - [x] `services/mcp-gateway/README.md` placeholder + `pnpm mcp:dev` script (points at `app.interfaces.mcp.server:main`, stub module that raises a clear "not implemented yet"). (the module now runs the real gateway)
- [ ] `config.py` and `.env.example`: three marked blocks `# >>> agent-1 … # <<< agent-1` (likewise 2 and 3). Pre-fill the obvious settings: - [x] `config.py` and `.env.example`: three marked blocks `# >>> agent-1 … # <<< agent-1` (likewise 2 and 3). Pre-fill the obvious settings:
- agent-3: `SANDBOX_DOCKER_BIN=docker`, `SANDBOX_WORKSPACES_ROOT`, `SANDBOX_DEFAULT_CPUS=1`, `SANDBOX_DEFAULT_MEMORY_MB=1024`, `DEMO_PORT_START=8100`, `DEMO_PORT_END=8999`, `DELIVERY_POLL_SECONDS=60`, `DEPLOY_ALLOW_PRIVATE_HOSTS=false`, `ARTIFACTS_ROOT`. - agent-3: `SANDBOX_DOCKER_BIN=docker`, `SANDBOX_WORKSPACES_ROOT`, `SANDBOX_DEFAULT_CPUS=1`, `SANDBOX_DEFAULT_MEMORY_MB=1024`, `DEMO_PORT_START=8100`, `DEMO_PORT_END=8999`, `DELIVERY_POLL_SECONDS=60`, `DEPLOY_ALLOW_PRIVATE_HOSTS=false`, `ARTIFACTS_ROOT`.
- agent-2: `ENGINE_MAX_TOOL_CALLS_PER_TURN=40`, `ENGINE_TURN_TIMEOUT_SECONDS=900`, `ENGINE_MAX_CYCLES=6`, `ENGINE_MAX_EMPLOYEES=20`, `ENGINE_TICK_SECONDS=10`. - agent-2: `ENGINE_MAX_TOOL_CALLS_PER_TURN=40`, `ENGINE_TURN_TIMEOUT_SECONDS=900`, `ENGINE_MAX_CYCLES=6`, `ENGINE_MAX_EMPLOYEES=20`, `ENGINE_TICK_SECONDS=10`.
- agent-1: `MCP_GATEWAY_HOST=127.0.0.1`, `MCP_GATEWAY_PORT=8765`, `MCP_TOKEN_TTL_DAYS=90`, `MEMORY_ORG_CHAR_CAP=60000`, `PLATFORM_INVITE_TTL_HOURS=72`. - agent-1: `MCP_GATEWAY_HOST=127.0.0.1`, `MCP_GATEWAY_PORT=8765`, `MCP_TOKEN_TTL_DAYS=90`, `MEMORY_ORG_CHAR_CAP=60000`, `PLATFORM_INVITE_TTL_HOURS=72`.
## F5. Web stubs ## F5. Web stubs
- [ ] `lib/api-org.ts`, `lib/api-engine.ts`, `lib/api-delivery.ts`: each imports `apiRequest` from `./api` and exports an empty object `orgApi` / `engineApi` / `deliveryApi`. - [x] `lib/api-org.ts`, `lib/api-engine.ts`, `lib/api-delivery.ts`: each imports `apiRequest` from `./api` and exports an empty object `orgApi` / `engineApi` / `deliveryApi`. (empty objects at foundation time)
- [ ] `nav.ts`: rename the projects entry to `{ href: "/app", label: "اپلیکیشن", hint: "دمو، استقرار، Git" }`, rename knowledge to `{ href: "/memory", label: "حافظهٔ سازمان", ... }`; admin entries `/admin/model-grants` (مدل‌های سازمان‌ها), `/admin/domains` (دامنه‌ها), `/admin/sandbox` (سندباکس‌ها), `/admin/mcp` (MCP). - [x] `nav.ts`: rename the projects entry to `{ href: "/app", label: "اپلیکیشن", hint: "دمو، استقرار، Git" }`, rename knowledge to `{ href: "/memory", label: "حافظهٔ سازمان", ... }`; admin entries `/admin/model-grants` (مدل‌های سازمان‌ها), `/admin/domains` (دامنه‌ها), `/admin/sandbox` (سندباکس‌ها), `/admin/mcp` (MCP).
- [ ] Placeholder pages (using `PlaceholderPage`) for `(app)/app`, `(app)/memory`, `(app)/admin/model-grants`, `(app)/admin/domains`, `(app)/admin/sandbox`, `(app)/admin/mcp`. `(app)/projects` keeps working until Agent 3 removes it. - [x] Placeholder pages (using `PlaceholderPage`) for `(app)/app`, `(app)/memory`, `(app)/admin/model-grants`, `(app)/admin/domains`, `(app)/admin/sandbox`, `(app)/admin/mcp`. `(app)/projects` keeps working until Agent 3 removes it. (placeholders at foundation time)
## F6. Finish ## F6. Finish
- [ ] Baseline checks are all green with the same counts as F1 (plus any new tests). - [x] Baseline checks are all green with the same counts as F1 (plus any new tests). (baseline recorded at foundation time)
- [ ] Commit on `dev`: `v2 foundation: shared contracts, app profile schema, wiring stubs`. - [x] Commit on `dev`: `v2 foundation: shared contracts, app profile schema, wiring stubs`. (commit 0cd9194)
- [ ] Create the worktrees from that commit: - [x] Create the worktrees from that commit: (worktrees listed by git worktree list)
```bash ```bash
git worktree add /home/mamad/work/smart-org-wt/agent1 -b agent1/org-platform git worktree add /home/mamad/work/smart-org-wt/agent1 -b agent1/org-platform
git worktree add /home/mamad/work/smart-org-wt/agent2 -b agent2/ai-workforce git worktree add /home/mamad/work/smart-org-wt/agent2 -b agent2/ai-workforce
git worktree add /home/mamad/work/smart-org-wt/agent3 -b agent3/delivery-infra git worktree add /home/mamad/work/smart-org-wt/agent3 -b agent3/delivery-infra
``` ```
In each one: `pnpm install` and `pnpm api:venv` so the agent can run the checks immediately. In each one: `pnpm install` and `pnpm api:venv` so the agent can run the checks immediately.
- [ ] `mkdir docs/plan/reports` (tracked with a `.gitkeep`). - [x] `mkdir docs/plan/reports` (tracked with a `.gitkeep`).
+45 -45
View File
@@ -22,19 +22,19 @@ You implement these contracts: `MemoryPort`, `ModelGrantPort`. Your real classes
## Phase 1 — Platform invites, roles and org sharing ## Phase 1 — Platform invites, roles and org sharing
Backend Backend
- [ ] 1.1 New table `platform_invites` (in `db/models_org.py`): id, token_hash, email (optional), created_by (owner), role (`manager` only for now), expires_at (`PLATFORM_INVITE_TTL_HOURS`), used_at, used_by_user_id, revoked_at. Migration in your file. - [x] 1.1 New table `platform_invites` (in `db/models_org.py`): id, token_hash, email (optional), created_by (owner), role (`manager` only for now), expires_at (`PLATFORM_INVITE_TTL_HOURS`), used_at, used_by_user_id, revoked_at. Migration in your file.
- [ ] 1.2 Owner-only endpoints in `admin_users.py`: `POST /admin/platform-invites` (returns `invite_url` once, token shown once), `GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`. Audit every action. - [x] 1.2 Owner-only endpoints in `admin_users.py`: `POST /admin/platform-invites` (returns `invite_url` once, token shown once), `GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`. Audit every action.
- [ ] 1.3 In `invites.py`, extend `GET /invites/{token}` and `POST /invites/accept` to recognise a platform invite: if no account exists, create a `manager` account with the chosen password; if the email exists, require login (same rule as org invites: an invite never resets a password). Single use, constant-time compare, expired or revoked → the same generic 404. - [x] 1.3 In `invites.py`, extend `GET /invites/{token}` and `POST /invites/accept` to recognise a platform invite: if no account exists, create a `manager` account with the chosen password; if the email exists, require login (same rule as org invites: an invite never resets a password). Single use, constant-time compare, expired or revoked → the same generic 404. (token matched by its hash, as org invites are)
- [ ] 1.4 `GET /auth/me` (you own `auth.py`) gains `needs_onboarding: bool` = platform role is `manager`, the user has zero org memberships, and they are not the owner. - [x] 1.4 `GET /auth/me` (you own `auth.py`) gains `needs_onboarding: bool` = platform role is `manager`, the user has zero org memberships, and they are not the owner.
- [ ] 1.5 Org sharing in `organizations.py`: `POST /organizations/{id}/transfer-ownership` (owner → an existing admin member, in one transaction, audited), `POST /organizations/{id}/leave` (the last owner cannot leave), and `GET /organizations/{id}/invites` + `DELETE .../invites/{invite_id}` (list and revoke pending org invites). Keep the role ladder `owner > admin > manager > member > viewer`. - [x] 1.5 Org sharing in `organizations.py`: `POST /organizations/{id}/transfer-ownership` (owner → an existing admin member, in one transaction, audited), `POST /organizations/{id}/leave` (the last owner cannot leave), and `GET /organizations/{id}/invites` + `DELETE .../invites/{invite_id}` (list and revoke pending org invites). Keep the role ladder `owner > admin > manager > member > viewer`.
- [ ] 1.6 Tests: `tests/test_org_platform_invites.py`, `tests/test_org_sharing.py`. Cover: non-owner cannot create platform invites; token single use; expired/revoked; existing-email path; transfer + leave edge cases; tenant isolation (a member of org A cannot list org B invites). - [x] 1.6 Tests: `tests/test_org_platform_invites.py`, `tests/test_org_sharing.py`. Cover: non-owner cannot create platform invites; token single use; expired/revoked; existing-email path; transfer + leave edge cases; tenant isolation (a member of org A cannot list org B invites). (sharing cases are in tests/test_org_platform_invites.py; tests/test_org_sharing.py was not created)
Frontend Frontend
- [ ] 1.7 `/admin/users`: a "دعوت مدیر جدید" button → modal → shows the link once with a copy button; a list of pending invites with revoke. - [x] 1.7 `/admin/users`: a "دعوت مدیر جدید" button → modal → shows the link once with a copy button; a list of pending invites with revoke.
- [ ] 1.8 `/accept-invite`: handle the platform-invite variant (title "ساخت حساب مدیر"). - [x] 1.8 `/accept-invite`: handle the platform-invite variant (title "ساخت حساب مدیر").
- [ ] 1.9 After login: if `needs_onboarding`, redirect to `/onboarding`. Do this in the `onboarding` page and in a small hook in `lib/api-org.ts`. `AppShell` is locked, so if a redirect there is unavoidable, write a CHANGE REQUEST. - [x] 1.9 After login: if `needs_onboarding`, redirect to `/onboarding`. Do this in the `onboarding` page and in a small hook in `lib/api-org.ts`. `AppShell` is locked, so if a redirect there is unavoidable, write a CHANGE REQUEST. (login redirect in login/page.tsx via fetchNeedsOnboarding in lib/api-org.ts, T12)
- [ ] 1.10 `(app)/settings`: a "اشتراک‌گذاری سازمان" section: members with role selector, invite form (email + role), pending invites with revoke, transfer ownership (confirmation modal that requires typing the org slug), leave org. - [x] 1.10 `(app)/settings`: a "اشتراک‌گذاری سازمان" section: members with role selector, invite form (email + role), pending invites with revoke, transfer ownership (confirmation modal that requires typing the org slug), leave org. (T13: settings/_components/sharing-section.tsx)
- [ ] 1.11 Web tests `src/test/org-sharing.test.tsx`. - [x] 1.11 Web tests `src/test/org-sharing.test.tsx`. (accept-invite tests here; settings sharing tests in org-settings-sharing.test.tsx, T13)
**Phase 1 done when:** the owner creates a link → a new person opens it, sets a password → logs in → lands on `/onboarding`. All checks are green. **Phase 1 done when:** the owner creates a link → a new person opens it, sets a password → logs in → lands on `/onboarding`. All checks are green.
@@ -49,28 +49,28 @@ Backend
- php: laravel, plain - php: laravel, plain
- go: plain - go: plain
- static: html - static: html
Add a function `defaults_for(app_type, language, framework) -> ProfileDefaults`. Unit tests for every entry. Add a function `defaults_for(app_type, language, framework) -> ProfileDefaults`. Unit tests for every entry. — open: defaults_for exists in catalog.py, but no unit test covers the catalog entries; test_org_wizard.py only checks the endpoint and the next.js defaults.
- [ ] 2.2 New table `org_setup_drafts` (user_id, step, data_json **without secrets**, updated_at). Secrets typed during the wizard are held only in the final submit request, never in the draft. - [x] 2.2 New table `org_setup_drafts` (user_id, step, data_json **without secrets**, updated_at). Secrets typed during the wizard are held only in the final submit request, never in the draft.
- [ ] 2.3 `org_setup_service.py` + router `app_setup.py`: - [x] 2.3 `org_setup_service.py` + router `app_setup.py`:
- `GET /setup/catalog`. - `GET /setup/catalog`.
- `GET/PUT /setup/draft`. - `GET/PUT /setup/draft`.
- `POST /setup/check-git`: external URL + credentials → `RepoWorkspacePort.test_remote`. Validate the URL **format** first in your own service: `https://` or `ssh://`/`git@` only, no credentials embedded in the URL, no IP-literal localhost/private hosts. The DNS-resolved SSRF check happens inside `test_remote` (Agent 3); do not import Agent 3's code. - `POST /setup/check-git`: external URL + credentials → `RepoWorkspacePort.test_remote`. Validate the URL **format** first in your own service: `https://` or `ssh://`/`git@` only, no credentials embedded in the URL, no IP-literal localhost/private hosts. The DNS-resolved SSRF check happens inside `test_remote` (Agent 3); do not import Agent 3's code.
- `POST /setup/check-deploy`: → `DeployTargetTester.test_connection`. - `POST /setup/check-deploy`: → `DeployTargetTester.test_connection`.
- `POST /setup/complete`: in ONE transaction, create the org (caller = org owner), the project with its app profile (`wizard_completed_at=now`), the encrypted external git credentials or the platform Gitea repo through the existing `GitProviderPort.ensure_project_repo`, the optional deploy target (encrypted), and the default model grants (Phase 3; until then, none). After commit, call `WorkforceBootstrapPort.bootstrap_team` and `DeliveryHooks.on_app_created`. Each hook is called once; a hook failure is recorded with `error_log` and does not undo the org. - `POST /setup/complete`: in ONE transaction, create the org (caller = org owner), the project with its app profile (`wizard_completed_at=now`), the encrypted external git credentials or the platform Gitea repo through the existing `GitProviderPort.ensure_project_repo`, the optional deploy target (encrypted), and the default model grants (Phase 3; until then, none). After commit, call `WorkforceBootstrapPort.bootstrap_team` and `DeliveryHooks.on_app_created`. Each hook is called once; a hook failure is recorded with `error_log` and does not undo the org.
- Slug is unique (reuse the existing slug rules). Audit `org.created_via_wizard`. - Slug is unique (reuse the existing slug rules). Audit `org.created_via_wizard`. (hook failures use record_error, the T11 change; the wizard audit row is organization.wizard_complete)
- [ ] 2.4 App profile editing for org owner/admin: `GET/PATCH /organizations/{org}/app/profile` (commands, port, healthcheck, framework), `PUT /organizations/{org}/app/deploy-target` (+ test), `PUT /organizations/{org}/app/git` (switch platform ↔ external is NOT allowed after creation; only credential rotation). Secrets are write-only: responses return `has_secret` and masked usernames only. - [x] 2.4 App profile editing for org owner/admin: `GET/PATCH /organizations/{org}/app/profile` (commands, port, healthcheck, framework), `PUT /organizations/{org}/app/deploy-target` (+ test), `PUT /organizations/{org}/app/git` (switch platform ↔ external is NOT allowed after creation; only credential rotation). Secrets are write-only: responses return `has_secret` and masked usernames only.
- [ ] 2.5 Enforce 1 org = 1 app: any other path that creates a second project returns a clear 409 (the DB constraint exists already; make the service error friendly). Note: `projects.py` is Agent 3's file. Do NOT edit it; only your service/router may create the app. - [x] 2.5 Enforce 1 org = 1 app: any other path that creates a second project returns a clear 409 (the DB constraint exists already; make the service error friendly). Note: `projects.py` is Agent 3's file. Do NOT edit it; only your service/router may create the app. (409 test: test_second_app_for_same_org_returns_409)
- [ ] 2.6 Tests `tests/test_org_wizard.py`: happy path platform git; happy path external git with a fake `test_remote`; a deploy target with a failing test does not block completion (it is saved as `last_test_ok=false`) but the user sees the warning; secrets never in any response, draft, audit metadata or log (assert by searching the captured output and DB rows for the plaintext secret); a second app → 409; a user with role `user` cannot complete the wizard; SSRF cases for the git URL. - [ ] 2.6 Tests `tests/test_org_wizard.py`: happy path platform git; happy path external git with a fake `test_remote`; a deploy target with a failing test does not block completion (it is saved as `last_test_ok=false`) but the user sees the warning; secrets never in any response, draft, audit metadata or log (assert by searching the captured output and DB rows for the plaintext secret); a second app → 409; a user with role `user` cannot complete the wizard; SSRF cases for the git URL. — open: the secrets test checks responses, audit and draft; it does not capture log output or scan DB rows for the plaintext secret.
Frontend Frontend
- [ ] 2.7 Rebuild `/onboarding` as a 5-step wizard (stepper, back/next, draft auto-save, refresh resumes): - [ ] 2.7 Rebuild `/onboarding` as a 5-step wizard (stepper, back/next, draft auto-save, refresh resumes): — open: step 5's success screen (onboarding/page.tsx) has no demo URL placeholder; the other steps are in place.
1. Organization: name, slug (auto-suggest). 1. Organization: name, slug (auto-suggest).
2. Application: name, type, language, framework (cards from the catalog), "advanced" accordion with prefilled commands, port and healthcheck. 2. Application: name, type, language, framework (cards from the catalog), "advanced" accordion with prefilled commands, port and healthcheck.
3. Git: "Git پلتفرم (پیشنهادی)" or "Git خودم" (URL + token or SSH key + "بررسی اتصال" button). 3. Git: "Git پلتفرم (پیشنهادی)" or "Git خودم" (URL + token or SSH key + "بررسی اتصال" button).
4. Production (optional, skippable): SSH / SFTP / FTP / FTPS fields + "تست اتصال". Show a clear note that production deploys only happen when they press the button. 4. Production (optional, skippable): SSH / SFTP / FTP / FTPS fields + "تست اتصال". Show a clear note that production deploys only happen when they press the button.
5. Review → create → success screen with the demo URL placeholder and a "برو به مدیر AI" button. 5. Review → create → success screen with the demo URL placeholder and a "برو به مدیر AI" button.
- [ ] 2.8 `(app)/settings` → "اپلیکیشن" tab: edit the profile, rotate git credentials, edit or test the deploy target. - [x] 2.8 `(app)/settings` → "اپلیکیشن" tab: edit the profile, rotate git credentials, edit or test the deploy target.
- [ ] 2.9 Web tests `src/test/org-wizard.test.tsx` (step navigation, validation, secrets never placed in the draft payload). - [ ] 2.9 Web tests `src/test/org-wizard.test.tsx` (step navigation, validation, secrets never placed in the draft payload). — open: there is no validation case; step navigation, slug suggestion and the secret-free draft are covered.
**Phase 2 done when:** a fresh manager completes the wizard in the browser and lands on the dashboard of the new org. The DB has exactly one project with a full profile. The Null hooks recorded one `bootstrap_team` and one `on_app_created` call. **Phase 2 done when:** a fresh manager completes the wizard in the browser and lands on the dashboard of the new org. The DB has exactly one project with a full profile. The Null hooks recorded one `bootstrap_team` and one `on_app_created` call.
@@ -78,49 +78,49 @@ Frontend
## Phase 3 — Owner model grants (employees only use what the owner provides) ## Phase 3 — Owner model grants (employees only use what the owner provides)
- [ ] 3.1 `model_grant_service.py` implementing `ModelGrantPort` over `org_model_grants` + `ai_models` (only active models, only active grants). `GrantedModel.tier` is a neutral label derived from capabilities (`سریع`, `متعادل`, `قوی`). Never the model name. - [x] 3.1 `model_grant_service.py` implementing `ModelGrantPort` over `org_model_grants` + `ai_models` (only active models, only active grants). `GrantedModel.tier` is a neutral label derived from capabilities (`سریع`, `متعادل`, `قوی`). Never the model name.
- [ ] 3.2 Owner endpoints `admin_grants.py`: list grants per org, grant/revoke model, set daily credit cap, and "default grant set for new orgs" (new table `default_model_grants`, or a column in your own table; do NOT edit `ai_models`). Audit all. - [x] 3.2 Owner endpoints `admin_grants.py`: list grants per org, grant/revoke model, set daily credit cap, and "default grant set for new orgs" (new table `default_model_grants`, or a column in your own table; do NOT edit `ai_models`). Audit all.
- [ ] 3.3 Wizard `complete` copies the default grant set into the new org. - [x] 3.3 Wizard `complete` copies the default grant set into the new org.
- [ ] 3.4 Owner org policy (`org_policies` table, yours): max_employees (≤ `ENGINE_MAX_EMPLOYEES`), monthly credit budget, automation_enabled (bool). Endpoints under `admin_grants.py`. Expose `get_org_policy(org_id)` in `model_grant_service.py` for Agent 2 to read later. Write a CHANGE REQUEST so Claude adds it to the `ModelGrantPort` contract at integration. - [x] 3.4 Owner org policy (`org_policies` table, yours): max_employees (≤ `ENGINE_MAX_EMPLOYEES`), monthly credit budget, automation_enabled (bool). Endpoints under `admin_grants.py`. Expose `get_org_policy(org_id)` in `model_grant_service.py` for Agent 2 to read later. Write a CHANGE REQUEST so Claude adds it to the `ModelGrantPort` contract at integration. (the ModelGrantPort contract change is T1: get_org_policy)
- [ ] 3.5 Wire your real `ModelGrantPort` in `container_org.py`. - [x] 3.5 Wire your real `ModelGrantPort` in `container_org.py`.
- [ ] 3.6 UI `/admin/model-grants`: org list → grant matrix (models × checkbox + cap), default set editor, policy editor. Org users see nothing new. - [x] 3.6 UI `/admin/model-grants`: org list → grant matrix (models × checkbox + cap), default set editor, policy editor. Org users see nothing new.
- [ ] 3.7 Tests `tests/test_model_grants.py`: non-owner forbidden everywhere; revoked or inactive model not returned; a grant on an org you query for another org is never returned (isolation); defaults copied on wizard completion. - [x] 3.7 Tests `tests/test_model_grants.py`: non-owner forbidden everywhere; revoked or inactive model not returned; a grant on an org you query for another org is never returned (isolation); defaults copied on wizard completion.
--- ---
## Phase 4 — Org memory (self-improving org) ## Phase 4 — Org memory (self-improving org)
- [ ] 4.1 Domain `app/domain/memory/`: `MemoryKind` (`rule`, `preference`, `lesson`, `fact`, `code_map`), `MemorySource` (`user`, `manager`, `system`, `reflection`), validation (key ≤ 80 chars, content ≤ 1,500 chars), normalisation + content hash for dedupe. - [x] 4.1 Domain `app/domain/memory/`: `MemoryKind` (`rule`, `preference`, `lesson`, `fact`, `code_map`), `MemorySource` (`user`, `manager`, `system`, `reflection`), validation (key ≤ 80 chars, content ≤ 1,500 chars), normalisation + content hash for dedupe.
- [ ] 4.2 Table `org_memories` (in `models_org.py`): id, organization_id, kind, key (nullable), content, source, evidence_task_id, pinned, confidence (0..1), expires_at (lessons: +30 days unless pinned or confirmed), hits, last_used_at, created_by_user_id, created_at, updated_at, deleted_at (soft). Index `(organization_id, kind)`. - [x] 4.2 Table `org_memories` (in `models_org.py`): id, organization_id, kind, key (nullable), content, source, evidence_task_id, pinned, confidence (0..1), expires_at (lessons: +30 days unless pinned or confirmed), hits, last_used_at, created_by_user_id, created_at, updated_at, deleted_at (soft). Index `(organization_id, kind)`.
- [ ] 4.3 `memory_service.py` implementing `MemoryPort`: - [x] 4.3 `memory_service.py` implementing `MemoryPort`:
- `record`: dedupe by hash or same key (update instead of insert), org cap `MEMORY_ORG_CHAR_CAP` (evict oldest unpinned lessons first), max 3 writes per task. - `record`: dedupe by hash or same key (update instead of insert), org cap `MEMORY_ORG_CHAR_CAP` (evict oldest unpinned lessons first), max 3 writes per task.
- `build_context(role, query, budget)`: pinned rules and preferences first, then the top memories by keyword overlap with `query` and recency. Role filter: `code_map` and `fact` only for planner/programmer/reviewer/tester; preferences for the manager. Returns a compact Persian/English block within `budget_chars`; updates `hits/last_used_at`. - `build_context(role, query, budget)`: pinned rules and preferences first, then the top memories by keyword overlap with `query` and recency. Role filter: `code_map` and `fact` only for planner/programmer/reviewer/tester; preferences for the manager. Returns a compact Persian/English block within `budget_chars`; updates `hits/last_used_at`.
- `on_task_finished(TaskOutcome)`: one cheap model call (through the existing ai_control routing in "cheap" mode and `ModelProviderPort`, no new adapter) asking for ≤ 2 lessons + a 1-paragraph code-map delta. It stores them with `source=reflection`, `confidence=0.5`. If the call fails, it records the error and returns (it never raises into the engine). Record usage for that call in the usage ledger like other model calls. - `on_task_finished(TaskOutcome)`: one cheap model call (through the existing ai_control routing in "cheap" mode and `ModelProviderPort`, no new adapter) asking for ≤ 2 lessons + a 1-paragraph code-map delta. It stores them with `source=reflection`, `confidence=0.5`. If the call fails, it records the error and returns (it never raises into the engine). Record usage for that call in the usage ledger like other model calls.
- Expire job: function `expire_memories()` (called from the existing billing worker? NO, that is not your file → expose the function and write a CHANGE REQUEST for a worker hook). - Expire job: function `expire_memories()` (called from the existing billing worker? NO, that is not your file → expose the function and write a CHANGE REQUEST for a worker hook). (expire hook: T9, called from the billing pass)
- [ ] 4.4 Router `memory.py`: list (filter by kind, search), create (users can "teach" a rule or preference), edit, pin/unpin, delete (soft, audited), and a `confirm` action (lesson → confidence 1, no expiry). Roles: viewer can read; member+ can add preferences; admin+ can edit or delete anything. - [x] 4.4 Router `memory.py`: list (filter by kind, search), create (users can "teach" a rule or preference), edit, pin/unpin, delete (soft, audited), and a `confirm` action (lesson → confidence 1, no expiry). Roles: viewer can read; member+ can add preferences; admin+ can edit or delete anything.
- [ ] 4.5 Fold the existing knowledge sources into the same page: keep `knowledge.py` endpoints working and show sources as a second tab. - [x] 4.5 Fold the existing knowledge sources into the same page: keep `knowledge.py` endpoints working and show sources as a second tab.
- [ ] 4.6 UI `/memory` (replace the placeholder; also redirect `/knowledge` → `/memory`): tabs "قوانین و ترجیحات", "درس‌های آموخته", "نقشهٔ کد", "منابع دانش"; inline edit; pin; confirm a lesson; "به سازمان یاد بده" input. Publish `memory.changed` on `org:<id>` and refresh live. - [x] 4.6 UI `/memory` (replace the placeholder; also redirect `/knowledge` → `/memory`): tabs "قوانین و ترجیحات", "درس‌های آموخته", "نقشهٔ کد", "منابع دانش"; inline edit; pin; confirm a lesson; "به سازمان یاد بده" input. Publish `memory.changed` on `org:<id>` and refresh live.
- [ ] 4.7 Wire the real `MemoryPort` in `container_org.py`. - [x] 4.7 Wire the real `MemoryPort` in `container_org.py`.
- [ ] 4.8 Tests `tests/test_memory.py`: dedupe, caps and eviction, TTL, role filtering, budget respected, isolation, reflection failure does not raise, no secrets: run content through `app/core/redact.py` before storing and test it. - [x] 4.8 Tests `tests/test_memory.py`: dedupe, caps and eviction, TTL, role filtering, budget respected, isolation, reflection failure does not raise, no secrets: run content through `app/core/redact.py` before storing and test it.
--- ---
## Phase 5 — MCP gateway (AI agents control our app) ## Phase 5 — MCP gateway (AI agents control our app)
- [ ] 5.1 Table `mcp_tokens` (id, organization_id, name, token_hash, prefix (first 6 chars for display), scopes CSV, created_by, expires_at, last_used_at, revoked_at). Tokens look like `smo_<random>` and are shown once. - [x] 5.1 Table `mcp_tokens` (id, organization_id, name, token_hash, prefix (first 6 chars for display), scopes CSV, created_by, expires_at, last_used_at, revoked_at). Tokens look like `smo_<random>` and are shown once.
- [ ] 5.2 `mcp_service.py`: issue, list, revoke, verify (constant-time; expired or revoked → reject), scope check. - [x] 5.2 `mcp_service.py`: issue, list, revoke, verify (constant-time; expired or revoked → reject), scope check.
- [ ] 5.3 `app/interfaces/mcp/server.py` with the official `mcp` SDK (streamable HTTP, bound to `MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`) and `main()` for `pnpm mcp:dev`. Tools (each resolves the org from the token, never from arguments): - [x] 5.3 `app/interfaces/mcp/server.py` with the official `mcp` SDK (streamable HTTP, bound to `MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`) and `main()` for `pnpm mcp:dev`. Tools (each resolves the org from the token, never from arguments):
- `app_get_profile` (`app:read`) - `app_get_profile` (`app:read`)
- `tasks_list` / `tasks_get` (`tasks:read`) - `tasks_list` / `tasks_get` (`tasks:read`)
- `tasks_create` (`tasks:write`): goes to the manager as a user order through the **current** public `WorkforceService.create_task` signature; read it, do not edit it (Agent 2 owns it; Claude re-wires at integration if it changes) - `tasks_create` (`tasks:write`): goes to the manager as a user order through the **current** public `WorkforceService.create_task` signature; read it, do not edit it (Agent 2 owns it; Claude re-wires at integration if it changes)
- `memory_search` (`memory:read`) / `memory_write` (`memory:write`, kinds `fact|preference` only, `source=user`) - `memory_search` (`memory:read`) / `memory_write` (`memory:write`, kinds `fact|preference` only, `source=user`)
- `knowledge_search` (`memory:read`) - `knowledge_search` (`memory:read`)
- `pipeline_status` (`delivery:read`): register the tool but return `{"available": false}`. Agent 3's tables do not exist in your branch; Claude wires it at integration - `pipeline_status` (`delivery:read`): register the tool but return `{"available": false}`. Agent 3's tables do not exist in your branch; Claude wires it at integration (pipeline_status: T10)
Every call is audited (`mcp.tool_call`, token prefix, tool, ok/error), rate limited (60/min per token, in-memory), and its output passes through `redact.py`. Every call is audited (`mcp.tool_call`, token prefix, tool, ok/error), rate limited (60/min per token, in-memory), and its output passes through `redact.py`.
- [ ] 5.4 `services/mcp-gateway/README.md`: how to run it, the Caddy host suggestion `mcp.<root>` (Agent 3/owner configures it), and a client config snippet. - [x] 5.4 `services/mcp-gateway/README.md`: how to run it, the Caddy host suggestion `mcp.<root>` (Agent 3/owner configures it), and a client config snippet.
- [ ] 5.5 Router `mcp_tokens.py`: org admin+ can create, list and revoke org tokens. Owner endpoint to disable MCP globally or per org (setting stored in your `org_policies`). - [x] 5.5 Router `mcp_tokens.py`: org admin+ can create, list and revoke org tokens. Owner endpoint to disable MCP globally or per org (setting stored in your `org_policies`).
- [ ] 5.6 UI: `(app)/settings` → "یکپارچه‌سازی (MCP)" tab (create with scope checkboxes, show-once modal with a copy button, list, revoke); `/admin/mcp` (global switch, per-org switch, token counts). - [x] 5.6 UI: `(app)/settings` → "یکپارچه‌سازی (MCP)" tab (create with scope checkboxes, show-once modal with a copy button, list, revoke); `/admin/mcp` (global switch, per-org switch, token counts).
- [ ] 5.7 Tests `tests/test_mcp.py`: use the SDK's in-process client or call the tool functions directly. Cover: a token from org A can never read org B; scope enforcement; revoked/expired; rate limit; audit row written; no secret in outputs. - [x] 5.7 Tests `tests/test_mcp.py`: use the SDK's in-process client or call the tool functions directly. Cover: a token from org A can never read org B; scope enforcement; revoked/expired; rate limit; audit row written; no secret in outputs.
--- ---
+31 -31
View File
@@ -31,7 +31,7 @@ for tests).
## Phase 1 — Docker sandbox adapter ## Phase 1 — Docker sandbox adapter
- [ ] 1.1 `adapters/docker_sandbox.py`, `DockerSandboxAdapter` implementing `SandboxProviderPort` with the CLI in `SANDBOX_DOCKER_BIN` (docker or podman, the same flags): - [x] 1.1 `adapters/docker_sandbox.py`, `DockerSandboxAdapter` implementing `SandboxProviderPort` with the CLI in `SANDBOX_DOCKER_BIN` (docker or podman, the same flags):
- `create_workspace`: creates a host dir `SANDBOX_WORKSPACES_ROOT/<org_id>/<project_id>/work` (0700, owned by the platform user). - `create_workspace`: creates a host dir `SANDBOX_WORKSPACES_ROOT/<org_id>/<project_id>/work` (0700, owned by the platform user).
- `start_session`: `docker run -d` the **workspace container**: - `start_session`: `docker run -d` the **workspace container**:
- image from the app profile language: `node:20-bookworm`, `python:3.13-bookworm`, `php:8.3-cli`, `golang:1.23`, fallback `debian:bookworm`; make the mapping a dict in your config block / domain - image from the app profile language: `node:20-bookworm`, `python:3.13-bookworm`, `php:8.3-cli`, `golang:1.23`, fallback `debian:bookworm`; make the mapping a dict in your config block / domain
@@ -44,39 +44,39 @@ for tests).
- `read_file` / `write_file`: through the host bind mount with path safety (`resolve()` stays inside the work dir; reject symlinks that escape). - `read_file` / `write_file`: through the host bind mount with path safety (`resolve()` stays inside the work dir; reject symlinks that escape).
- `pause` / `resume` / `destroy`: stop/start/rm. `snapshot`: `docker commit` (tagged per org) or raise a clear "not supported" error, your choice, documented. - `pause` / `resume` / `destroy`: stop/start/rm. `snapshot`: `docker commit` (tagged per org) or raise a clear "not supported" error, your choice, documented.
- `get_preview_url`: delegated to the domain layer (Phase 4). - `get_preview_url`: delegated to the domain layer (Phase 4).
- [ ] 1.2 `registry.py`: `SANDBOX_PROVIDER=docker` → this adapter. The mock stays the default. - [x] 1.2 `registry.py`: `SANDBOX_PROVIDER=docker` → this adapter. The mock stays the default.
- [ ] 1.3 The `sandbox_gateway` worker: also reap containers with the `smart-org.*` label that have no live session row (orphans), audited. - [x] 1.3 The `sandbox_gateway` worker: also reap containers with the `smart-org.*` label that have no live session row (orphans), audited.
- [ ] 1.4 Hide infra from non-owners: - [x] 1.4 Hide infra from non-owners:
- `sandbox.py` command/file endpoints become **owner-only** (or admin+ of the org with a setting off by default; prefer owner-only). - `sandbox.py` command/file endpoints become **owner-only** (or admin+ of the org with a setting off by default; prefer owner-only).
- Keep the read-only status endpoints for org members. - Keep the read-only status endpoints for org members.
- Update `components/sandbox-panel.tsx` to show only status to non-owners. - Update `components/sandbox-panel.tsx` to show only status to non-owners.
- Update existing tests that assumed member access, and explain each change in your report. - Update existing tests that assumed member access, and explain each change in your report.
- [ ] 1.5 Tests `tests/test_docker_sandbox.py`: unit tests with a **fake process runner** (inject the subprocess function) that assert the exact argv (hardening flags present, no secrets, mount of the work dir only), timeout kill, output cap, path escape refused. Integration tests marked `@pytest.mark.docker`, skipped unless `SANDBOX_DOCKER_IT=1`: real start → exec `echo` → write/read file → destroy. - [ ] 1.5 Tests `tests/test_docker_sandbox.py`: unit tests with a **fake process runner** (inject the subprocess function) that assert the exact argv (hardening flags present, no secrets, mount of the work dir only), timeout kill, output cap, path escape refused. Integration tests marked `@pytest.mark.docker`, skipped unless `SANDBOX_DOCKER_IT=1`: real start → exec `echo` → write/read file → destroy. — open: no `@pytest.mark.docker` sandbox integration test (real start, exec, write/read, destroy) exists in tests/test_docker_sandbox.py; the only docker-marked test is test_delivery.py::test_docker_end_to_end_static_app.
--- ---
## Phase 2 — Git workspaces (`RepoWorkspacePort`, the platform does git, D6) ## Phase 2 — Git workspaces (`RepoWorkspacePort`, the platform does git, D6)
- [ ] 2.1 `adapters/git_workspace.py`, `LocalGitWorkspaceAdapter`. Layout per project under `SANDBOX_WORKSPACES_ROOT/<org>/<project>/`: - [x] 2.1 `adapters/git_workspace.py`, `LocalGitWorkspaceAdapter`. Layout per project under `SANDBOX_WORKSPACES_ROOT/<org>/<project>/`:
- `git/`: a bare-ish gitdir, **never mounted** - `git/`: a bare-ish gitdir, **never mounted**
- `work/`: the main working tree for the dev branch, mounted at `/workspace` - `work/`: the main working tree for the dev branch, mounted at `/workspace`
- `work/.worktrees/<task_id>/`: task worktrees, visible in the container at `/workspace/.worktrees/<task_id>` - `work/.worktrees/<task_id>/`: task worktrees, visible in the container at `/workspace/.worktrees/<task_id>`
- `WorktreeRef.sandbox_path` = that in-container path - `WorktreeRef.sandbox_path` = that in-container path
Note: the `.worktrees` dir must be excluded via `info/exclude` in the gitdir, not the user's `.gitignore`. Note: the `.worktrees` dir must be excluded via `info/exclude` in the gitdir, not the user's `.gitignore`.
- [ ] 2.2 Every git call is `git --git-dir=<gitdir> --work-tree=<path> -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false -c protocol.file.allow=never -c safe.directory=*`, with env `GIT_CONFIG_NOSYSTEM=1`, `HOME=<private dir>`, `GIT_TERMINAL_PROMPT=0`. Never trust the `.git` *file* inside a worktree; always pass explicit dirs. - [x] 2.2 Every git call is `git --git-dir=<gitdir> --work-tree=<path> -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false -c protocol.file.allow=never -c safe.directory=*`, with env `GIT_CONFIG_NOSYSTEM=1`, `HOME=<private dir>`, `GIT_TERMINAL_PROMPT=0`. Never trust the `.git` *file* inside a worktree; always pass explicit dirs.
- Status: `status --porcelain=v1 -uall -z`. - Status: `status --porcelain=v1 -uall -z`.
- One `asyncio.Lock` per project id held for every mutating operation (agybot M14). Remove a stale `index.lock` only if older than 30s. - One `asyncio.Lock` per project id held for every mutating operation (agybot M14). Remove a stale `index.lock` only if older than 30s.
- [ ] 2.3 Remotes: - [ ] 2.3 Remotes: — open: the ssh_key first contact uses StrictHostKeyChecking=accept-new and the key is never stored. test_remote returns no host key (RemoteCheck has no field for it) and nothing writes project_external_git.known_hosts.
- **platform** git: the URL and credentials come from the existing Gitea adapter / `project_git_credentials` (decrypt via the existing crypto helper). - **platform** git: the URL and credentials come from the existing Gitea adapter / `project_git_credentials` (decrypt via the existing crypto helper).
- **external** git: `project_external_git` (token → `GIT_ASKPASS`; ssh_key → temp key file 0600 + `GIT_SSH_COMMAND="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=<per-project known_hosts>"`; store `known_hosts` on the first `test_remote` = TOFU, in the existing column). - **external** git: `project_external_git` (token → `GIT_ASKPASS`; ssh_key → temp key file 0600 + `GIT_SSH_COMMAND="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=<per-project known_hosts>"`; store `known_hosts` on the first `test_remote` = TOFU, in the existing column).
- `test_remote`: `git ls-remote --heads` with a 20s timeout; returns ok, default branch, branch list, and a friendly error (auth failed / not found / unreachable) **without echoing the URL credentials or stderr secrets**. - `test_remote`: `git ls-remote --heads` with a 20s timeout; returns ok, default branch, branch list, and a friendly error (auth failed / not found / unreachable) **without echoing the URL credentials or stderr secrets**.
- SSRF check on external hosts (resolve DNS, reject private, loopback and link-local unless `DEPLOY_ALLOW_PRIVATE_HOSTS`); put the check in `domain/platform/rules.py` (yours). It runs inside `test_remote` and before every deploy connection; Agent 1 only does format validation and relies on your check. - SSRF check on external hosts (resolve DNS, reject private, loopback and link-local unless `DEPLOY_ALLOW_PRIVATE_HOSTS`); put the check in `domain/platform/rules.py` (yours). It runs inside `test_remote` and before every deploy connection; Agent 1 only does format validation and relies on your check.
- [ ] 2.4 Implement every `RepoWorkspacePort` method per PLAN-v2 §5. - [x] 2.4 Implement every `RepoWorkspacePort` method per PLAN-v2 §5.
- `ensure_workspace` clones on first use, fetches afterwards. It creates `dev` from the default branch if missing; for platform repos also push `dev`. - `ensure_workspace` clones on first use, fetches afterwards. It creates `dev` from the default branch if missing; for platform repos also push `dev`.
- `merge_into` does a `--no-ff` merge in the main work tree under the lock and returns the conflict file list on failure, after `merge --abort`. Never leave a half-merge (agybot H3). - `merge_into` does a `--no-ff` merge in the main work tree under the lock and returns the conflict file list on failure, after `merge --abort`. Never leave a half-merge (agybot H3).
- `push` returns ok/rejected (non-fast-forward → fetch + report, never force). - `push` returns ok/rejected (non-fast-forward → fetch + report, never force).
- [ ] 2.5 Wire it in `container_delivery.py` (setting `GIT_WORKSPACE=local|fake`, fake default in tests). - [x] 2.5 Wire it in `container_delivery.py` (setting `GIT_WORKSPACE=local|fake`, fake default in tests).
- [ ] 2.6 Tests `tests/test_git_workspace.py` with **real git** and a local bare repo in `tmp_path` as "remote" (no network): - [x] 2.6 Tests `tests/test_git_workspace.py` with **real git** and a local bare repo in `tmp_path` as "remote" (no network):
- the full flow ensure → worktree → write → diff (new dir appears thanks to `-uall`) → commit → sync → merge → push → head - the full flow ensure → worktree → write → diff (new dir appears thanks to `-uall`) → commit → sync → merge → push → head
- a conflict path leaves a clean tree - a conflict path leaves a clean tree
- hooks are NOT executed (plant a `post-commit` hook that writes a file and assert it is absent) - hooks are NOT executed (plant a `post-commit` hook that writes a file and assert it is absent)
@@ -88,15 +88,15 @@ for tests).
## Phase 3 — Live CI/CD + demo server ## Phase 3 — Live CI/CD + demo server
- [ ] 3.1 Tables (`models_delivery.py`): - [x] 3.1 Tables (`models_delivery.py`):
- `pipeline_runs` (id, org, project, trigger `engine|poll|manual`, branch, sha, status `queued|running|succeeded|failed|cancelled|superseded`, started/finished, error_summary) - `pipeline_runs` (id, org, project, trigger `engine|poll|manual`, branch, sha, status `queued|running|succeeded|failed|cancelled|superseded`, started/finished, error_summary)
- `pipeline_steps` (id, run_id, index, name, status, started/finished, exit_code, log_tail ≤ 64 KB) - `pipeline_steps` (id, run_id, index, name, status, started/finished, exit_code, log_tail ≤ 64 KB)
- `releases` (id, org, project, run_id, sha, artifact_path, created_at, is_demo_live) - `releases` (id, org, project, run_id, sha, artifact_path, created_at, is_demo_live)
- `app_runtimes` (project_id PK, demo_container_id, host_port, demo_host, status, current_release_id, updated_at) - `app_runtimes` (project_id PK, demo_container_id, host_port, demo_host, status, current_release_id, updated_at)
- [ ] 3.2 `delivery_service.py` implementing `DeliveryHooks`: - [x] 3.2 `delivery_service.py` implementing `DeliveryHooks`:
- `on_app_created`: allocate a demo port in `DEMO_PORT_START..END` (reuse the existing preview port rules), claim the demo host `<org-slug>.<PREVIEW_ROOT_DOMAIN>` through the existing `DomainRouterPort`, create `app_runtimes`, `ensure_workspace`, and queue a first pipeline run if the repo has a `dev` head. - `on_app_created`: allocate a demo port in `DEMO_PORT_START..END` (reuse the existing preview port rules), claim the demo host `<org-slug>.<PREVIEW_ROOT_DOMAIN>` through the existing `DomainRouterPort`, create `app_runtimes`, `ensure_workspace`, and queue a first pipeline run if the repo has a `dev` head.
- `on_branch_pushed`: queue a run (one active run per project; a newer queued run supersedes an older queued one). - `on_branch_pushed`: queue a run (one active run per project; a newer queued run supersedes an older queued one).
- [ ] 3.3 `workers/delivery.py`: loop (`--loop`) that executes queued runs and polls remotes every `DELIVERY_POLL_SECONDS` (`head` vs the last run sha) for pushes made outside the engine (users pushing to their own git). - [x] 3.3 `workers/delivery.py`: loop (`--loop`) that executes queued runs and polls remotes every `DELIVERY_POLL_SECONDS` (`head` vs the last run sha) for pushes made outside the engine (users pushing to their own git).
- Steps, each in a **fresh build container** (same image mapping and hardening, work dir = a clean checkout of the sha in a per-run dir): - Steps, each in a **fresh build container** (same image mapping and hardening, work dir = a clean checkout of the sha in a per-run dir):
1. `checkout` 1. `checkout`
2. `setup` (setup_command) 2. `setup` (setup_command)
@@ -107,9 +107,9 @@ for tests).
7. `healthcheck`: HTTP GET `http://127.0.0.1:<host_port><healthcheck_path>` with 20 retries × 3s; any 2xx/3xx = ok 7. `healthcheck`: HTTP GET `http://127.0.0.1:<host_port><healthcheck_path>` with 20 retries × 3s; any 2xx/3xx = ok
8. `route`: `configure_reverse_proxy` for the demo host → `127.0.0.1:<host_port>` 8. `route`: `configure_reverse_proxy` for the demo host → `127.0.0.1:<host_port>`
- **Blue/green:** start the new container on a temporary second port, healthcheck it, then switch the Caddy route, then stop the old one. If any step fails, the old demo keeps running untouched and the run is `failed` with a Persian `error_summary`. - **Blue/green:** start the new container on a temporary second port, healthcheck it, then switch the Caddy route, then stop the old one. If any step fails, the old demo keeps running untouched and the run is `failed` with a Persian `error_summary`.
- [ ] 3.4 Live logs: every step streams output via the `on_output` callback → batched `pipeline.log` events on `org:<id>` (≤ 1 event / 500 ms, ≤ 200 lines), plus `pipeline.step` and `pipeline.status`; `release.live` when switched. Store the step `log_tail` (last 64 KB). Pass all output through `app/core/redact.py` before publishing or storing. - [x] 3.4 Live logs: every step streams output via the `on_output` callback → batched `pipeline.log` events on `org:<id>` (≤ 1 event / 500 ms, ≤ 200 lines), plus `pipeline.step` and `pipeline.status`; `release.live` when switched. Store the step `log_tail` (last 64 KB). Pass all output through `app/core/redact.py` before publishing or storing.
- [ ] 3.5 Router `delivery.py` (org member read, admin+ actions): list runs, run detail with steps and tails, `POST /delivery/runs` (manual re-run of the dev head, admin+), cancel the queued/running run, list releases, rollback the demo to a previous release (admin+, confirm, audit), and the runtime status (demo URL, live release, health). - [x] 3.5 Router `delivery.py` (org member read, admin+ actions): list runs, run detail with steps and tails, `POST /delivery/runs` (manual re-run of the dev head, admin+), cancel the queued/running run, list releases, rollback the demo to a previous release (admin+, confirm, audit), and the runtime status (demo URL, live release, health).
- [ ] 3.6 Tests `tests/test_delivery.py` with the fake runner + `FakeRepoWorkspace` + mock domain router: - [x] 3.6 Tests `tests/test_delivery.py` with the fake runner + `FakeRepoWorkspace` + mock domain router:
- the happy path step sequence and events - the happy path step sequence and events
- a failed build keeps the old demo - a failed build keeps the old demo
- healthcheck failure → no route switch - healthcheck failure → no route switch
@@ -123,50 +123,50 @@ for tests).
## Phase 4 — Domains (Caddy) owned by the platform owner ## Phase 4 — Domains (Caddy) owned by the platform owner
- [ ] 4.1 `admin_domains.py` (owner only): - [x] 4.1 `admin_domains.py` (owner only):
- list all routes (host → org/app → target port → status) - list all routes (host → org/app → target port → status)
- attach an extra host to an org's app: a single label under the root via the existing rules, OR a full custom FQDN when `ALLOW_CUSTOM_DOMAINS=true` (strict FQDN regex, no wildcard, no protected hosts such as `git`, `mcp`, `panel`, `api`, root) - attach an extra host to an org's app: a single label under the root via the existing rules, OR a full custom FQDN when `ALLOW_CUSTOM_DOMAINS=true` (strict FQDN regex, no wildcard, no protected hosts such as `git`, `mcp`, `panel`, `api`, root)
- detach (confirm + audit) - detach (confirm + audit)
- re-sync all routes from the DB to Caddy (idempotent) - re-sync all routes from the DB to Caddy (idempotent)
Users cannot add domains (the existing member-facing preview-domain endpoints in `projects.py` become owner-only, and the tests are updated with reasons). Users cannot add domains (the existing member-facing preview-domain endpoints in `projects.py` become owner-only, and the tests are updated with reasons).
- [ ] 4.2 The Caddy adapter keeps validate → revert → reload. Add a `routes_fingerprint` check so re-sync does nothing when nothing changed. - [x] 4.2 The Caddy adapter keeps validate → revert → reload. Add a `routes_fingerprint` check so re-sync does nothing when nothing changed.
- [ ] 4.3 The demo host is created automatically in `on_app_created` (Phase 3). Record the MCP gateway host suggestion (`mcp.<root>` → `MCP_GATEWAY_PORT`) as a protected system route that the owner can enable from this page. - [ ] 4.3 The demo host is created automatically in `on_app_created` (Phase 3). Record the MCP gateway host suggestion (`mcp.<root>` → `MCP_GATEWAY_PORT`) as a protected system route that the owner can enable from this page. — open: the MCP route is only a read-only suggestion (GET /api/admin/domains/suggested, shown as enabled or disabled on the page). There is no enable action, and validate_extra_host refuses the protected label mcp, so the owner cannot enable it from this page.
- [ ] 4.4 Tests `tests/test_admin_domains.py`: non-owner forbidden; protected hosts refused; invalid FQDNs refused; detach audited; re-sync idempotent (mock router call count). - [x] 4.4 Tests `tests/test_admin_domains.py`: non-owner forbidden; protected hosts refused; invalid FQDNs refused; detach audited; re-sync idempotent (mock router call count).
--- ---
## Phase 5 — Production deploy over SSH / SFTP / FTP / FTPS (always human-triggered) ## Phase 5 — Production deploy over SSH / SFTP / FTP / FTPS (always human-triggered)
- [ ] 5.1 `adapters/deploy_ssh.py` (asyncssh), `DeployTargetTester` + `SshDeployer`: - [x] 5.1 `adapters/deploy_ssh.py` (asyncssh), `DeployTargetTester` + `SshDeployer`:
- Test: connect (15s timeout) → host-key TOFU (store the fingerprint on the first success; afterwards a mismatch = hard fail "کلید میزبان تغییر کرده"), check `remote_path` exists and is writable. - Test: connect (15s timeout) → host-key TOFU (store the fingerprint on the first success; afterwards a mismatch = hard fail "کلید میزبان تغییر کرده"), check `remote_path` exists and is writable.
- Deploy: upload the release artifact → `remote_path/releases/<ts>-<sha7>/` → extract → run `post_deploy_command` with `cwd` there (timeout 600s, streamed output) → atomically switch the `remote_path/current` symlink → keep the last 5 releases. - Deploy: upload the release artifact → `remote_path/releases/<ts>-<sha7>/` → extract → run `post_deploy_command` with `cwd` there (timeout 600s, streamed output) → atomically switch the `remote_path/current` symlink → keep the last 5 releases.
- Rollback = re-point `current` to the previous release + rerun post_deploy. - Rollback = re-point `current` to the previous release + rerun post_deploy.
- [ ] 5.2 `adapters/deploy_ftp.py` (aioftp; FTPS via explicit TLS), `DeployTargetTester` + `FtpDeployer`: - [x] 5.2 `adapters/deploy_ftp.py` (aioftp; FTPS via explicit TLS), `DeployTargetTester` + `FtpDeployer`:
- Test: login, cwd `remote_path`, write and delete a probe file. - Test: login, cwd `remote_path`, write and delete a probe file.
- Deploy: upload the build output (the build dir is set by the framework catalog default or `dist/`, `build/`, `public/`, else the whole artifact) into `remote_path` after downloading a **backup listing** of the files it overwrites into `ARTIFACTS_ROOT/.../ftp-backup-<ts>.tar.gz`. - Deploy: upload the build output (the build dir is set by the framework catalog default or `dist/`, `build/`, `public/`, else the whole artifact) into `remote_path` after downloading a **backup listing** of the files it overwrites into `ARTIFACTS_ROOT/.../ftp-backup-<ts>.tar.gz`.
- Rollback = re-upload that backup. Document the limits of FTP (no atomic switch). - Rollback = re-upload that backup. Document the limits of FTP (no atomic switch).
- [ ] 5.3 SSRF check for every target host (the Phase 2.3 helper). A combined `DeployTargetTester` dispatching on `kind`, wired in `container_delivery.py`. - [x] 5.3 SSRF check for every target host (the Phase 2.3 helper). A combined `DeployTargetTester` dispatching on `kind`, wired in `container_delivery.py`.
- [ ] 5.4 `deploy_service.py` + endpoints in `delivery.py`: - [x] 5.4 `deploy_service.py` + endpoints in `delivery.py`:
- `POST /delivery/production/deploy` with `release_id` (default: the live demo release). Org **owner/admin only**; requires a `confirm: "<org-slug>"` field; one production deploy per project at a time. - `POST /delivery/production/deploy` with `release_id` (default: the live demo release). Org **owner/admin only**; requires a `confirm: "<org-slug>"` field; one production deploy per project at a time.
- Status: `deploy.status` events and a `production_deploys` table (id, release, target, status, started/finished, log_tail, rolled_back_from). - Status: `deploy.status` events and a `production_deploys` table (id, release, target, status, started/finished, log_tail, rolled_back_from).
- `POST /delivery/production/rollback`. - `POST /delivery/production/rollback`.
- Every action audited. Credentials decrypted only inside the adapter call. - Every action audited. Credentials decrypted only inside the adapter call.
- [ ] 5.5 Tests `tests/test_deploy.py`: fake SSH/FTP clients injected. Cover: the release/symlink sequence, keep-5 pruning, rollback, host-key mismatch refused, SSRF refused, a member (non-admin) refused, the confirm slug mismatch refused, no password in logs/events/DB columns other than encrypted ones. Optional integration (`DEPLOY_IT=1`) against a local `sshd`/`pure-ftpd` container. - [x] 5.5 Tests `tests/test_deploy.py`: fake SSH/FTP clients injected. Cover: the release/symlink sequence, keep-5 pruning, rollback, host-key mismatch refused, SSRF refused, a member (non-admin) refused, the confirm slug mismatch refused, no password in logs/events/DB columns other than encrypted ones. Optional integration (`DEPLOY_IT=1`) against a local `sshd`/`pure-ftpd` container.
--- ---
## Phase 6 — UI: the "Application" page (replaces `/projects`) ## Phase 6 — UI: the "Application" page (replaces `/projects`)
- [ ] 6.1 `(app)/app/page.tsx` (+ components under `(app)/app/_components/`): - [x] 6.1 `(app)/app/page.tsx` (+ components under `(app)/app/_components/`):
- **Header:** app name, type/language/framework badges, demo URL (open button), live release sha + time, health dot. - **Header:** app name, type/language/framework badges, demo URL (open button), live release sha + time, health dot.
- **Pipeline runs:** list with status; clicking one opens the run view: a step list with status and duration, and a **live log console** (subscribe `org:<id>`, filter by run id, ANSI colours stripped, auto-scroll with pause, and a "copy" button). - **Pipeline runs:** list with status; clicking one opens the run view: a step list with status and duration, and a **live log console** (subscribe `org:<id>`, filter by run id, ANSI colours stripped, auto-scroll with pause, and a "copy" button).
- **Releases:** history; "rollback demo" (admin+, confirm). - **Releases:** history; "rollback demo" (admin+, confirm).
- **Production:** target summary (kind, host, path, last test result; no secrets), the "استقرار در production" button (admin+, modal requiring the org slug), a deploy live log, production history + rollback. - **Production:** target summary (kind, host, path, last test result; no secrets), the "استقرار در production" button (admin+, modal requiring the org slug), a deploy live log, production history + rollback.
- **Git:** repo link (platform: Gitea URL; external: the URL without credentials), `dev` head sha, last poll time. No branch/PR/port controls for users. - **Git:** repo link (platform: Gitea URL; external: the URL without credentials), `dev` head sha, last poll time. No branch/PR/port controls for users.
- [ ] 6.2 Remove `(app)/projects` (redirect `/projects*` → `/app`) and drop the member-facing port/branch/PR/command controls. Owner-only debug tools move to `/admin/sandbox`. - [x] 6.2 Remove `(app)/projects` (redirect `/projects*` → `/app`) and drop the member-facing port/branch/PR/command controls. Owner-only debug tools move to `/admin/sandbox`.
- [ ] 6.3 `/admin/sandbox` (owner): running containers per org (workspace/demo/build), CPU/memory limits per org (existing `sandbox_resource_limits`), stop/restart a container (confirm + audit), and an image mapping view. `/admin/domains` (owner): the Phase 4 UI. - [ ] 6.3 `/admin/sandbox` (owner): running containers per org (workspace/demo/build), CPU/memory limits per org (existing `sandbox_resource_limits`), stop/restart a container (confirm + audit), and an image mapping view. `/admin/domains` (owner): the Phase 4 UI. — open: no restart action. The backend and the page offer list and stop only (POST /api/admin/sandbox/containers/{id}/stop).
- [ ] 6.4 `lib/api-delivery.ts`: typed client for all of the above. - [ ] 6.4 `lib/api-delivery.ts`: typed client for all of the above. — open: /admin/sandbox calls apiRequest directly, and lib/api-delivery.ts has deliveryApi and domainsApi only, with no sandbox client.
- [ ] 6.5 Web tests `src/test/delivery-*.test.tsx`: the log console batching and auto-scroll, the confirm-slug modal, no secret fields rendered, non-admin sees no deploy button. - [ ] 6.5 Web tests `src/test/delivery-*.test.tsx`: the log console batching and auto-scroll, the confirm-slug modal, no secret fields rendered, non-admin sees no deploy button. — open: no test for log batching or auto-scroll. The non-admin deploy-button case is covered by `hides the deploy actions from an org member who is not an admin` (`apps/web/src/test/delivery-app.test.tsx`, added in T14).
--- ---
+3 -1
View File
@@ -836,7 +836,9 @@ export type WsEventType =
| "pipeline.log" | "pipeline.log"
| "pipeline.status" | "pipeline.status"
| "release.live" | "release.live"
| "deploy.status"; | "deploy.status"
// v2 org platform: org memory changed (taught, edited, or reflected on).
| "memory.changed";
/** One pushed event, as the browser receives it. */ /** One pushed event, as the browser receives it. */
export interface WsEvent<T = Record<string, unknown>> { export interface WsEvent<T = Record<string, unknown>> {