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
+17 -2
View File
@@ -15,12 +15,13 @@ from __future__ import annotations
from dataclasses import dataclass
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 (
SessionInfo,
SnapshotInfo,
WorkspaceInfo,
)
from app.core.config import settings
@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
of id → neutral tier, so a test can have a "strong" and a "fast" model and
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:
self._tiers: dict[str, str] = {
"model-strong": "strong",
@@ -200,6 +208,7 @@ class FakeGrants:
else:
self._tiers = dict.fromkeys(granted, "balanced")
self.checks: list[str] = []
self.policy = policy
@property
def granted(self) -> list[str]:
@@ -215,6 +224,12 @@ class FakeGrants:
self.checks.append(ai_model_id)
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:
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
merging it
* ``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
"""
@@ -85,23 +85,29 @@ class _World:
strong_id: str
class _EmptyWallet:
"""A wallet with no credit, and no way to top it up in a test."""
class _OverdrawnWallet:
"""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
class _Wallets:
def __init__(self, wallet: _EmptyWallet | None) -> None:
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None:
self._wallet = wallet
async def get_for_org(self, organization_id: str):
return self._wallet
class _EmptyWalletRepo(_Wallets):
class _OverdrawnWalletRepo(_Wallets):
def __init__(self) -> None:
super().__init__(_EmptyWallet())
super().__init__(_OverdrawnWallet())
@pytest_asyncio.fixture
@@ -532,7 +538,7 @@ async def test_analysis_only_finishes_without_a_worktree(world, session_factory)
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
):
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,
memory=RecordingMemory(),
delivery=NullDeliveryHooks(),
wallets=_EmptyWalletRepo(),
wallets=_OverdrawnWalletRepo(),
)
await _run(scheduler, session, 1)
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
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):
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
import json
import pytest
from httpx import AsyncClient
from sqlalchemy import select
@@ -38,8 +40,14 @@ from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
ProjectRow,
UserRow,
)
from app.infrastructure.db.models_delivery import (
AppRuntimeRow,
PipelineRunRow,
ProductionDeployRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.interfaces.mcp.gateway import McpGateway
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.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")
assert status.ok is True
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(
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
)