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
@@ -13,9 +13,10 @@ task — and this service owns three invariants around those calls:
whose key) already exists updates that entry instead of adding a duplicate,
which is also what lets "the port is 8001" correct "the port is 8000".
3. **A reflection can never break the engine.** ``on_task_finished`` asks one
cheap model for what the task taught. If the call fails, the error is
recorded and the function returns: the task is finished, and the org simply
learned nothing this time.
cheap model the organization is granted for what the task taught. If that
cannot happen (no grant, a failing call), the reason is recorded and the
function returns: the task is finished, and the org simply learned nothing
this time.
Nothing here returns a model name, a provider or an infrastructure detail — the
engine's contract is a block of text.
@@ -27,11 +28,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
from app.application.ports.memory import TaskOutcome
from app.application.ports.model_grants import GrantedModel, ModelGrantPort
from app.application.ports.model_provider import ModelResponse
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact_text
from app.domain.ai_control.models import RateUnit, RoutingMode
from app.domain.ai_control.models import AiModel, RateUnit, RoutingMode
from app.domain.base import utcnow
from app.domain.identity.models import ROLE_RANK, Role
from app.domain.memory.models import (
@@ -100,6 +102,7 @@ class MemoryService:
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
provider: ModelProviderPort,
grants: ModelGrantPort | None = None,
) -> None:
self.session = session
self.memories = memories
@@ -108,6 +111,7 @@ class MemoryService:
self.rate_cards = rate_cards
self.ledger = ledger
self.provider = provider
self.grants = grants
# ------------------------------------------------------------------ #
# MemoryPort #
@@ -371,16 +375,8 @@ class MemoryService:
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
"""One cheap completion about the finished task, priced in the ledger."""
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
model = await self._reflection_model(organization_id=organization_id, task=task)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return ""
pool = await self.credentials.list_active_for_provider(model.provider_id)
response = await self.provider.complete(
@@ -406,6 +402,49 @@ class MemoryService:
await self._record_usage(organization_id=organization_id, response=response)
return response.content or ""
async def _reflection_model(self, *, organization_id: str, task: TaskOutcome) -> AiModel | None:
"""The model one reflection runs on, or ``None`` after recording why not.
With a grant port, an organization reflects only on a model its owner
granted it, cheapest tier first. Without one (a direct construction), the
routing table's cheap mode decides, as it always has.
"""
if self.grants is None:
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return model
chosen = _preferred_grant(
await self.grants.granted_models(organization_id=organization_id)
)
if chosen is None:
# The owner granted this organization no model, so it learns nothing
# from this task. That is a policy outcome, and it is recorded as one.
await record_error(
kind="memory.reflection_no_grant",
message="no model is granted to this organization",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return None
model = await self.models.get(chosen.ai_model_id)
if model is None or not model.is_active:
await record_error(
kind="memory.reflection_model_unavailable",
message="the granted model is missing or inactive",
organization_id=organization_id,
metadata={"task_id": task.task_id, "ai_model_id": chosen.ai_model_id},
)
return None
return model
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
"""Price a reflection exactly like any other model call."""
for unit, quantity in (
@@ -464,6 +503,20 @@ def _kind_order(kind: MemoryKind) -> int:
return _KIND_ORDER.get(kind, 9)
#: The tiers a reflection prefers, cheapest first. A reflection is one short
#: completion; a strong model would charge more for the same two lessons.
_REFLECTION_TIERS = ("fast", "balanced")
def _preferred_grant(granted: list[GrantedModel]) -> GrantedModel | None:
"""The first granted model of the cheapest preferred tier, else the first one."""
for tier in _REFLECTION_TIERS:
for grant in granted:
if grant.tier == tier:
return grant
return granted[0] if granted else None
def _confidence_for(source: MemorySource, fallback: float) -> float:
"""Reflections get half confidence; a human's entry keeps its own."""
if source is MemorySource.REFLECTION:
@@ -42,6 +42,7 @@ from app.domain.projects.models import (
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
@@ -251,10 +252,9 @@ class OrgSetupService:
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
# The response is read before the hooks run: a failed hook rolls the session
# back, and that expires the project row this method still reads.
response = {
"organization": {
"id": org.id,
"name": org.name,
@@ -268,6 +268,10 @@ class OrgSetupService:
"deploy_target_id": deploy_target_id,
}
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return response
async def _create_single_app(
self,
org_id: str,
@@ -387,42 +391,50 @@ class OrgSetupService:
exists without its grants would be an organization whose AI staff cannot
think. An empty default set is legitimate (the owner granted nothing).
"""
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
from app.application.container_org import build_model_grant_service
service = ModelGrantService(
session=self.session,
grants=OrgModelGrantRepository(self.session),
defaults=DefaultModelGrantRepository(self.session),
policies=OrgPolicyRepository(self.session),
)
service = build_model_grant_service(self.session)
return await service.copy_defaults_into(
organization_id=organization_id, actor_user_id=user_id
)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
"""Run the post-commit hooks. A failing hook is recorded, never raised.
A successful hook is committed before the next one runs, so a later failure
cannot roll back earlier work. A failed hook is rolled back, which leaves the
session usable for the next hook and for the response.
"""
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
await self.session.commit()
except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
await record_error(
kind="wizard.bootstrap_failed",
message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
)
await self.session.rollback()
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
await self.session.commit()
except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
await record_error(
kind="wizard.on_app_created_failed",
message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
)
await self.session.rollback()
# ------------------------------------------------------------------
# App profile editing (post-wizard)
@@ -74,6 +74,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
ArtifactRepository,
@@ -321,6 +322,15 @@ class WorkforceService:
# Threads & messages #
# ------------------------------------------------------------------ #
async def _own_project_or_404(self, *, organization_id: str, project_id: str | None) -> None:
"""A project the caller names must belong to the caller's organization."""
if not project_id:
return
projects = await ProjectRepository(self.threads.session).list_for_org(organization_id)
if project_id not in {project.id for project in projects}:
# Not found rather than forbidden: another tenant's project is not confirmed to exist.
raise NotFoundError("پروژه یافت نشد.")
async def create_thread(
self,
*,
@@ -329,6 +339,7 @@ class WorkforceService:
title: str = "",
project_id: str | None = None,
) -> Thread:
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
return await self.threads.create(
Thread(
organization_id=organization_id,
@@ -381,15 +392,26 @@ class WorkforceService:
def _manager_turns(self):
"""The engine's manager service, wired on this service's session.
Ports that take a session (model grants, memory) are built here rather
than injected: ``container.py`` is a frozen composition root, and the
engine must not widen a constructor it does not own. Sharing the
session is the point — a reply, a task and its usage events belong to
one transaction.
Ports that take a session (model grants, memory, employee profiles,
pipeline states) are built here rather than injected: ``container.py`` is
a frozen composition root, and the engine must not widen a constructor it
does not own. Sharing the session is the point — a reply, a task, its
pipeline state and its usage events belong to one transaction.
The collaborators match ``container_engine._build_manager``. The runner is
left out on purpose: ``chat`` never runs a turn (only triage, final review
and error review do, and the scheduler's manager covers those). Without
``states`` a manager's CREATE_TASK makes a task the engine never owns, and
the legacy drain runs it instead.
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
session = self.tasks.session
return ManagerTurns(
provider=self.provider,
employees=self.employees,
@@ -400,8 +422,10 @@ class WorkforceService:
credentials=self.credentials,
rate_cards=self.rate_cards,
ledger=self.ledger,
grants=build_model_grants(self.tasks.session),
memory=build_memory(self.tasks.session),
grants=build_model_grants(session),
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
)
# ------------------------------------------------------------------ #
@@ -421,6 +445,7 @@ class WorkforceService:
) -> Task:
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
if assigned_employee_id:
# Validates tenant ownership before we ever store the reference.
await self.get_employee_or_404(