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:
co-authored by
Claude Sonnet 5.5
parent
36564e3f2b
commit
fef5a9deab
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user