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
@@ -22,8 +22,13 @@ from app.application.container_delivery import (
|
||||
build_deploy_target_tester,
|
||||
build_repo_workspace,
|
||||
)
|
||||
from app.application.container_engine import build_workforce_bootstrap
|
||||
from app.application.container_org import build_memory, build_model_grants
|
||||
from app.application.container_engine import build_scheduler, build_workforce_bootstrap
|
||||
from app.application.container_org import (
|
||||
build_memory,
|
||||
build_memory_service,
|
||||
build_model_grant_service,
|
||||
build_model_grants,
|
||||
)
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.application.services.project_service import ProjectService
|
||||
@@ -172,8 +177,11 @@ __all__ = [
|
||||
"build_deploy_service",
|
||||
"build_deploy_target_tester",
|
||||
"build_memory",
|
||||
"build_memory_service",
|
||||
"build_model_grant_service",
|
||||
"build_model_grants",
|
||||
"build_repo_workspace",
|
||||
"build_scheduler",
|
||||
"build_workforce_bootstrap",
|
||||
"build_knowledge_service",
|
||||
"build_project_service",
|
||||
|
||||
@@ -13,7 +13,8 @@ from typing import TYPE_CHECKING
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
|
||||
from app.domain.sandbox.models import SessionStatus
|
||||
from app.core.errors import AppError
|
||||
from app.domain.sandbox.models import SessionStatus, WorkspaceStatus
|
||||
from app.domain.workforce.models import Task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -87,11 +88,13 @@ def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
|
||||
class _EngineSandboxSessions:
|
||||
"""The sandbox session the engine's turns run in.
|
||||
|
||||
The engine does not *create* sandboxes: a workspace and a running session
|
||||
belong to the project and are started through the sandbox flow. The engine
|
||||
only reuses the active session of the task's project. When there is none it
|
||||
returns ``""``, and the tests gate treats that as red and sends the work
|
||||
back — never "merge untested because there was no container".
|
||||
The engine reuses the active session of the task's project. When there is
|
||||
none, it starts the project's workspace session itself (see ``_provision``):
|
||||
a fresh organization cannot start a sandbox through the API, because sandbox
|
||||
mutations are owner-only, and without a session every tool turn fails. When
|
||||
the session cannot be started it returns ``""``, and the tests gate treats
|
||||
that as red and sends the work back — never "merge untested because there
|
||||
was no container".
|
||||
"""
|
||||
|
||||
#: A session in one of these states can still run a command.
|
||||
@@ -107,6 +110,7 @@ class _EngineSandboxSessions:
|
||||
self.session = session
|
||||
|
||||
async def for_task(self, task: Task) -> str:
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxSessionRepository,
|
||||
WorkspaceRepository,
|
||||
@@ -114,9 +118,12 @@ class _EngineSandboxSessions:
|
||||
|
||||
if not task.project_id:
|
||||
return ""
|
||||
workspaces = await WorkspaceRepository(self.session).list_for_project(
|
||||
task.project_id
|
||||
)
|
||||
# A task only ever runs in its own organization's project, whatever the row says.
|
||||
own = await ProjectRepository(self.session).list_for_org(task.organization_id)
|
||||
if task.project_id not in {project.id for project in own}:
|
||||
return ""
|
||||
found = await WorkspaceRepository(self.session).list_for_project(task.project_id)
|
||||
workspaces = [w for w in found if w.organization_id == task.organization_id]
|
||||
sessions = SandboxSessionRepository(self.session)
|
||||
for workspace in workspaces:
|
||||
for sandbox_session in await sessions.list_for_workspace(workspace.id):
|
||||
@@ -125,7 +132,66 @@ class _EngineSandboxSessions:
|
||||
and sandbox_session.external_session_id
|
||||
):
|
||||
return sandbox_session.external_session_id
|
||||
return ""
|
||||
return await self._provision(task)
|
||||
|
||||
async def _provision(self, task: Task) -> str:
|
||||
"""Start the project's workspace session and return its id, or ``""``.
|
||||
|
||||
A project keeps one workspace: the newest one that is not destroyed is
|
||||
reused, and only a project with none gets a new workspace, built from the
|
||||
platform template for the configured provider. A failure is recorded and
|
||||
never raised, so the turn runs without a container and the gates say so.
|
||||
"""
|
||||
from app.application.container import build_sandbox_service
|
||||
from app.core.config import settings
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxTemplateRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
|
||||
project_id = task.project_id
|
||||
if not project_id:
|
||||
return ""
|
||||
metadata = {"task_id": task.id, "project_id": project_id}
|
||||
service = build_sandbox_service(self.session)
|
||||
try:
|
||||
found = await WorkspaceRepository(self.session).list_for_project(project_id)
|
||||
workspaces = [w for w in found if w.organization_id == task.organization_id]
|
||||
workspace = next(
|
||||
(w for w in workspaces if w.status != WorkspaceStatus.DESTROYED), None
|
||||
)
|
||||
if workspace is None:
|
||||
templates = await SandboxTemplateRepository(self.session).list()
|
||||
if not templates:
|
||||
await record_error(
|
||||
kind="engine.sandbox_provision_failed",
|
||||
message="no sandbox template is seeded",
|
||||
organization_id=task.organization_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ""
|
||||
preferred = [
|
||||
t for t in templates if str(t.provider) == settings.sandbox_provider
|
||||
]
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=task.organization_id,
|
||||
project_id=project_id,
|
||||
template_id=(preferred or templates)[0].id,
|
||||
)
|
||||
started = await service.start_session(
|
||||
organization_id=task.organization_id, workspace_id=workspace.id
|
||||
)
|
||||
except AppError as exc:
|
||||
await record_error(
|
||||
kind="engine.sandbox_provision_failed",
|
||||
message=type(exc).__name__,
|
||||
exc=exc,
|
||||
organization_id=task.organization_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ""
|
||||
return started.external_session_id or ""
|
||||
|
||||
|
||||
def _build_manager(
|
||||
|
||||
@@ -32,6 +32,9 @@ from app.infrastructure.repositories.org_grants import (
|
||||
def build_memory(session: AsyncSession) -> MemoryPort:
|
||||
"""The real memory service: recall, teach, and one reflection per task.
|
||||
|
||||
The reflection runs only on a model the organization was granted, so the
|
||||
owner's grants decide which model learns on the organization's behalf.
|
||||
|
||||
``get_model_provider`` is resolved from ``container``'s namespace rather
|
||||
than from the registry directly: that module is what the composition root
|
||||
re-exports, so a deployment (or the test suite) that swaps the adapter in
|
||||
@@ -39,15 +42,23 @@ def build_memory(session: AsyncSession) -> MemoryPort:
|
||||
"""
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
return build_memory_service(session, provider=get_model_provider())
|
||||
return build_memory_service(
|
||||
session, provider=get_model_provider(), grants=build_model_grants(session)
|
||||
)
|
||||
|
||||
|
||||
def build_memory_service(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
provider=None,
|
||||
grants: ModelGrantPort | None = None,
|
||||
) -> MemoryService:
|
||||
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
|
||||
"""The same object, typed for the router (list, teach, pin, confirm, delete).
|
||||
|
||||
``grants`` is optional. The router never reflects, and a direct construction
|
||||
without it keeps the routing-table path; the engine's path (``build_memory``)
|
||||
always passes it.
|
||||
"""
|
||||
if provider is None:
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
@@ -60,6 +71,7 @@ def build_memory_service(
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
provider=provider,
|
||||
grants=grants,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -302,11 +302,12 @@ class ManagerTurns:
|
||||
raise ValidationError("این نقش قابل استخدام نیست.")
|
||||
if not tier:
|
||||
raise ValidationError("سطح کارمند را انتخاب کنید.")
|
||||
# The owner's per-org policy may lower the global cap, never raise it.
|
||||
policy = await self.grants.get_org_policy(organization_id)
|
||||
cap = min(settings.engine_max_employees, policy.max_employees)
|
||||
count = await self.employees.count_for_org(organization_id)
|
||||
if count >= settings.engine_max_employees:
|
||||
raise ConflictError(
|
||||
f"سقف تعداد کارمندان ({settings.engine_max_employees}) پر شده است."
|
||||
)
|
||||
if count >= cap:
|
||||
raise ConflictError(f"سقف تعداد کارمندان ({cap}) پر شده است.")
|
||||
granted = await self.grants.granted_models(organization_id=organization_id)
|
||||
# A hire is an explicit request for a tier: no silent downgrade, or the
|
||||
# manager would think it hired a strong employee and got a fast one.
|
||||
@@ -529,12 +530,14 @@ class ManagerTurns:
|
||||
if draft.kind in {k.value for k in TaskKind}
|
||||
else TaskKind.FEATURE
|
||||
)
|
||||
# A thread made by the web UI has no project; the org's project is the fallback.
|
||||
project_id = thread.project_id or await self._project_id_for(organization_id)
|
||||
task = await self.tasks.create(
|
||||
Task(
|
||||
organization_id=organization_id,
|
||||
title=draft.title[:200],
|
||||
description=draft.description,
|
||||
project_id=thread.project_id,
|
||||
project_id=project_id,
|
||||
thread_id=thread.id,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
@@ -544,9 +547,11 @@ class ManagerTurns:
|
||||
TaskPipelineState(
|
||||
task_id=task.id,
|
||||
organization_id=organization_id,
|
||||
project_id=thread.project_id,
|
||||
project_id=project_id,
|
||||
pipeline=resolve_pipeline(
|
||||
manager_choice=draft.pipeline or None, task_kind=kind
|
||||
manager_choice=draft.pipeline or None,
|
||||
task_kind=kind,
|
||||
has_active_reviewer=await self._has_active_reviewer(organization_id),
|
||||
),
|
||||
kind=kind,
|
||||
risk=Risk.MEDIUM,
|
||||
@@ -719,6 +724,7 @@ class ManagerTurns:
|
||||
pipeline = resolve_pipeline(
|
||||
manager_choice=assign.pipeline or None,
|
||||
task_kind=state.kind if state else None,
|
||||
has_active_reviewer=await self._has_active_reviewer(task.organization_id),
|
||||
)
|
||||
first_stage = (
|
||||
Stage.PENDING_ANALYSIS
|
||||
@@ -1067,6 +1073,13 @@ class ManagerTurns:
|
||||
return employee.project_id
|
||||
return None
|
||||
|
||||
async def _has_active_reviewer(self, organization_id: str) -> bool:
|
||||
"""A review stage needs an active reviewer to act on it, or the task dead-ends."""
|
||||
return any(
|
||||
e.role_key is RoleKey.REVIEWER and e.is_available
|
||||
for e in await self.employees.list_for_org(organization_id)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BOOTSTRAP_TIERS",
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.application.engine.runner import TurnRequest, TurnRunner
|
||||
from app.application.engine.state import advance, park_dependents
|
||||
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
||||
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
|
||||
from app.application.ports.model_grants import OrgPolicyView
|
||||
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
|
||||
from app.application.ports.sandbox_provider import SandboxProviderPort
|
||||
from app.core.config import settings
|
||||
@@ -165,6 +166,11 @@ class Scheduler:
|
||||
turn_states = await self.states.list_at_stages(TURN_STAGES, limit=limit)
|
||||
merge_states = await self.states.list_at_stages(MERGE_STAGES, limit=limit)
|
||||
|
||||
# One policy read per organization for the whole tick, shared by both lists.
|
||||
policies: dict[str, OrgPolicyView] = {}
|
||||
turn_states = await self._drop_switched_off(turn_states, policies, report)
|
||||
merge_states = await self._drop_switched_off(merge_states, policies, report)
|
||||
|
||||
selected = self._select(turn_states)
|
||||
report.selected = len(selected)
|
||||
for state in selected:
|
||||
@@ -174,6 +180,30 @@ class Scheduler:
|
||||
await self._guarded(self._merge_step, state, report)
|
||||
return report
|
||||
|
||||
async def _drop_switched_off(
|
||||
self,
|
||||
states: list[TaskPipelineState],
|
||||
policies: dict[str, OrgPolicyView],
|
||||
report: TickReport,
|
||||
) -> list[TaskPipelineState]:
|
||||
"""Keep only the states whose organization has automation switched on.
|
||||
|
||||
The owner's switch stops everything up to the merge. A skipped task keeps
|
||||
its stage, so switching automation back on resumes it where it was.
|
||||
"""
|
||||
kept: list[TaskPipelineState] = []
|
||||
for state in states:
|
||||
if state.organization_id not in policies:
|
||||
policies[state.organization_id] = await self.manager.grants.get_org_policy(
|
||||
state.organization_id
|
||||
)
|
||||
if not policies[state.organization_id].automation_enabled:
|
||||
report.skipped += 1
|
||||
report.note(f"task {state.task_id}: اتوماسیون این سازمان خاموش است")
|
||||
continue
|
||||
kept.append(state)
|
||||
return kept
|
||||
|
||||
async def _guarded(self, step, state: TaskPipelineState, report: TickReport) -> None:
|
||||
"""One task must never stop the tick: catch, record, continue."""
|
||||
try:
|
||||
@@ -590,16 +620,32 @@ class Scheduler:
|
||||
await self._post_note(task, reason)
|
||||
|
||||
async def _budget_reason(self, task: Task) -> str | None:
|
||||
"""Why this task may not spend a turn now, or ``None`` when it may.
|
||||
|
||||
Usage is post-paid: the ledger feeds invoices and nothing debits a wallet
|
||||
from usage. A zero balance is therefore the normal starting state of every
|
||||
org, not "out of money", so only an overdrawn wallet (below zero) pauses
|
||||
work. The owner's spend lever is the org's monthly credit budget.
|
||||
"""
|
||||
if self.wallets is not None:
|
||||
wallet = await self.wallets.get_for_org(task.organization_id)
|
||||
# No wallet means no budget was ever set: a fresh org must not be
|
||||
# frozen out of its first task.
|
||||
if wallet is not None and wallet.balance <= 0:
|
||||
# frozen out of its first task. A zero balance is not a pause either.
|
||||
if wallet is not None and wallet.balance < 0:
|
||||
return BUDGET_PAUSED_MESSAGE
|
||||
if self.spend is not None and settings.engine_max_task_credits > 0:
|
||||
used = await self.spend.spend_for_task(task.id)
|
||||
if used >= settings.engine_max_task_credits:
|
||||
return "سقف اعتبار این کار پر شده است؛ کار متوقف شد."
|
||||
if self.spend is not None:
|
||||
policy = await self.manager.grants.get_org_policy(task.organization_id)
|
||||
budget = policy.monthly_credit_budget
|
||||
if budget is not None:
|
||||
# The month is counted in UTC, the clock the ledger stamps rows with.
|
||||
month_start = utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
spent = await self.spend.spend_for_org_since(task.organization_id, month_start)
|
||||
if spent >= budget:
|
||||
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
|
||||
return None
|
||||
|
||||
async def _retry_or_fail(
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Implemented by the org-platform workstream; used by the engine. FROZEN contract:
|
||||
see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names.
|
||||
|
||||
The contract was extended at integration: ``get_org_policy`` gives the engine the owner's
|
||||
per-organization policy (automation switch, employee cap, monthly budget). The port only
|
||||
reads it; the engine applies its own system ceiling (``settings.engine_max_employees``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,12 +31,48 @@ def tier_for(*, capabilities: list[str] | tuple[str, ...], output_price_per_1m:
|
||||
return "fast" if output_price_per_1m < 2.0 else "balanced"
|
||||
|
||||
|
||||
class OrgPolicyView(Protocol):
|
||||
"""Read-only view of one organization's owner policy.
|
||||
|
||||
Properties rather than attributes, so both a mutable dataclass (``ModelGrantService``'s
|
||||
``PolicyView``) and a frozen one (``DefaultOrgPolicy``) satisfy it.
|
||||
"""
|
||||
|
||||
@property
|
||||
def max_employees(self) -> int: ...
|
||||
|
||||
@property
|
||||
def automation_enabled(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def mcp_enabled(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def monthly_credit_budget(self) -> float | None: ...
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True, kw_only=True)
|
||||
class DefaultOrgPolicy:
|
||||
"""The policy for an organization the owner has not configured.
|
||||
|
||||
``max_employees`` is only the port's default. The engine caps it with
|
||||
``settings.engine_max_employees`` itself, so this module does not import settings.
|
||||
"""
|
||||
|
||||
max_employees: int = 20
|
||||
automation_enabled: bool = True
|
||||
mcp_enabled: bool = True
|
||||
monthly_credit_budget: float | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelGrantPort(Protocol):
|
||||
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ...
|
||||
|
||||
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ...
|
||||
|
||||
async def get_org_policy(self, organization_id: str) -> OrgPolicyView: ...
|
||||
|
||||
|
||||
class AllActiveModelsGrants:
|
||||
"""Today's behaviour: every active model is usable by every organization.
|
||||
@@ -48,3 +88,6 @@ class AllActiveModelsGrants:
|
||||
|
||||
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
|
||||
return any(m.ai_model_id == ai_model_id for m in await self._list_active())
|
||||
|
||||
async def get_org_policy(self, organization_id: str) -> OrgPolicyView:
|
||||
return DefaultOrgPolicy()
|
||||
|
||||
@@ -13,9 +13,10 @@ task — and this service owns three invariants around those calls:
|
||||
whose key) already exists updates that entry instead of adding a duplicate,
|
||||
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