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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user