feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -95,7 +95,9 @@ ERROR_LOG_CLIENT_ERRORS=0
|
||||
# >>> agent-1 (org platform)
|
||||
MCP_GATEWAY_HOST=127.0.0.1
|
||||
MCP_GATEWAY_PORT=8765
|
||||
MCP_GATEWAY_ENABLED=true
|
||||
MCP_TOKEN_TTL_DAYS=90
|
||||
MCP_RATE_LIMIT_PER_MINUTE=60
|
||||
MEMORY_ORG_CHAR_CAP=60000
|
||||
PLATFORM_INVITE_TTL_HOURS=72
|
||||
# <<< agent-1
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""MCP gateway: scoped bearer tokens for external AI agents
|
||||
|
||||
Revision ID: a8b9c0d1e2f3
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-10-14 12:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). Only the token hash and a display prefix are
|
||||
stored; the raw token is shown once at issue time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a8b9c0d1e2f3"
|
||||
down_revision: str | None = "f7a8b9c0d1e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mcp_tokens",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
# ``unique=True`` carries the index (``ix_mcp_tokens_token_hash``), so no
|
||||
# separate create_index is needed — a second one would drift.
|
||||
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("prefix", sa.String(length=16), nullable=False),
|
||||
sa.Column("scopes", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_mcp_tokens_organization_id", "mcp_tokens", ["organization_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_mcp_tokens_organization_id", table_name="mcp_tokens")
|
||||
op.drop_table("mcp_tokens")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""owner model grants: default grant set + per-org policy
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-10-14 09:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). Adds the two owner-side tables of the model-grant
|
||||
workstream: the default set copied into every new organization, and the
|
||||
per-organization policy (employee cap, monthly budget, automation/MCP switches).
|
||||
|
||||
``org_model_grants`` itself is foundation-owned and already exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: str | None = "d5e6f7a8b9c0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"default_model_grants",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"ai_model_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("ai_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("daily_credit_cap", sa.Float(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("ai_model_id", name="uq_default_model_grant"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"org_policies",
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("max_employees", sa.Integer(), nullable=False, server_default="5"),
|
||||
sa.Column("monthly_credit_budget", sa.Float(), nullable=True),
|
||||
sa.Column("automation_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("mcp_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("org_policies")
|
||||
op.drop_table("default_model_grants")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""org memory: the organization's own long-term knowledge
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-10-14 11:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). ``org_memories`` holds rules, preferences,
|
||||
lessons, facts and the code map, with a content hash for dedupe and a soft
|
||||
delete so a removed memory stays in the audit story.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: str | None = "e6f7a8b9c0d1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_memories",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("key", sa.String(length=80), nullable=True),
|
||||
sa.Column("content", sa.String(length=1500), nullable=False),
|
||||
# The dedupe digest the domain defines (sha256 of the normalised
|
||||
# content, truncated to 32 chars).
|
||||
sa.Column("content_hash", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column("source", sa.String(length=16), nullable=False, server_default="user"),
|
||||
sa.Column(
|
||||
"evidence_task_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("confidence", sa.Float(), nullable=False, server_default="1"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_org_memories_organization_id", "org_memories", ["organization_id"])
|
||||
op.create_index(
|
||||
"ix_org_memories_org_kind", "org_memories", ["organization_id", "kind"]
|
||||
)
|
||||
op.create_index("ix_org_memories_org_key", "org_memories", ["organization_id", "key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_org_memories_org_key", table_name="org_memories")
|
||||
op.drop_index("ix_org_memories_org_kind", table_name="org_memories")
|
||||
op.drop_index("ix_org_memories_organization_id", table_name="org_memories")
|
||||
op.drop_table("org_memories")
|
||||
@@ -1,44 +1,86 @@
|
||||
"""Wiring for the org-platform workstream (memory, model grants).
|
||||
|
||||
Foundation defaults: no memory, and every active model granted to every org (today's
|
||||
behaviour). The org-platform agent replaces the bodies with the real services; the
|
||||
function names and return types are FROZEN so other workstreams can call them now.
|
||||
``build_memory`` is the frozen ``MemoryPort`` builder the engine calls; it now
|
||||
returns the real ``MemoryService``. ``build_memory_service`` is the same object,
|
||||
typed for the router (list, teach, pin, confirm, delete). ``build_model_grants``
|
||||
returns the real ``ModelGrantService``; ``build_model_grant_service`` is its
|
||||
typed twin for the admin router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.memory import MemoryPort, NullMemory
|
||||
from app.application.ports.model_grants import (
|
||||
AllActiveModelsGrants,
|
||||
GrantedModel,
|
||||
ModelGrantPort,
|
||||
tier_for,
|
||||
from app.application.ports.memory import MemoryPort
|
||||
from app.application.ports.model_grants import ModelGrantPort
|
||||
from app.application.services.memory_service import MemoryService
|
||||
from app.application.services.model_grant_service import ModelGrantService
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.memory import MemoryRepository
|
||||
from app.infrastructure.repositories.org_grants import (
|
||||
DefaultModelGrantRepository,
|
||||
OrgModelGrantRepository,
|
||||
OrgPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.db.models import AiModelRow
|
||||
|
||||
_null_memory = NullMemory()
|
||||
|
||||
|
||||
def build_memory(session: AsyncSession) -> MemoryPort:
|
||||
return _null_memory
|
||||
"""The real memory service: recall, teach, and one reflection per task.
|
||||
|
||||
``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
|
||||
one place swaps it here too.
|
||||
"""
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
return build_memory_service(session, provider=get_model_provider())
|
||||
|
||||
|
||||
def build_memory_service(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
provider=None,
|
||||
) -> MemoryService:
|
||||
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
|
||||
if provider is None:
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
provider = get_model_provider()
|
||||
return MemoryService(
|
||||
session=session,
|
||||
memories=MemoryRepository(session),
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def build_model_grants(session: AsyncSession) -> ModelGrantPort:
|
||||
async def _all_active() -> list[GrantedModel]:
|
||||
rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all()
|
||||
return [
|
||||
GrantedModel(
|
||||
ai_model_id=row.id,
|
||||
tier=tier_for(
|
||||
capabilities=list(row.capabilities or []),
|
||||
output_price_per_1m=row.output_price_per_1m,
|
||||
),
|
||||
capabilities=tuple(row.capabilities or []),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
"""The real grant service: an organization sees only what the owner gave it."""
|
||||
return build_model_grant_service(session)
|
||||
|
||||
return AllActiveModelsGrants(_all_active)
|
||||
|
||||
def build_model_grant_service(session: AsyncSession) -> ModelGrantService:
|
||||
"""The same object, typed for the admin router (grants, defaults, policy)."""
|
||||
return ModelGrantService(
|
||||
session=session,
|
||||
grants=OrgModelGrantRepository(session),
|
||||
defaults=DefaultModelGrantRepository(session),
|
||||
policies=OrgPolicyRepository(session),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_memory",
|
||||
"build_memory_service",
|
||||
"build_model_grant_service",
|
||||
"build_model_grants",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""MCP tokens: issue, list, revoke, verify.
|
||||
|
||||
Owned by Agent 1. One table, one service, three rules that matter:
|
||||
|
||||
1. **A token is shown once.** Only ``sha256(token)`` and a six-character prefix
|
||||
are stored, so a database leak cannot authenticate anyone; the prefix is for
|
||||
the audit trail and the settings screen, and is never enough to log in.
|
||||
2. **Verification is constant-time and total.** ``hmac.compare_digest`` on the
|
||||
hash, and every failure mode — unknown, revoked, expired — returns the same
|
||||
``None``. A caller cannot tell "wrong token" from "revoked token", because a
|
||||
revoked token is not a fact about the token, it is a decision.
|
||||
3. **Scopes are a floor, not a ceiling.** A token carries the scopes it was
|
||||
minted with and nothing else; an empty scope list is "no scopes", never "all".
|
||||
The per-organization switch lives in ``org_policies.mcp_enabled`` (Phase 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.infrastructure.db.models_org import McpTokenRow
|
||||
|
||||
#: Every scope the gateway understands. A scope nobody granted a token is a
|
||||
#: scope the gateway refuses, whatever the asks say.
|
||||
MCP_SCOPES: tuple[str, ...] = (
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
)
|
||||
|
||||
#: Enough entropy that a token is unguessable, short enough to paste into a
|
||||
#: client config file.
|
||||
TOKEN_ENTROPY_BYTES = 24
|
||||
|
||||
#: How the raw token looks to a human: the prefix is what the UI shows back.
|
||||
TOKEN_PREFIX = "smo_"
|
||||
|
||||
#: Displayed characters; ``token_hash`` is what authenticates.
|
||||
PREFIX_DISPLAY_CHARS = 6
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class McpToken:
|
||||
"""A token as the service hands it back — never the raw secret."""
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
created_by_user_id: str | None = None
|
||||
expires_at: object | None = None
|
||||
last_used_at: object | None = None
|
||||
revoked_at: object | None = None
|
||||
created_at: object | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class IssuedToken:
|
||||
"""The one and only response that carries the raw token."""
|
||||
|
||||
token: str
|
||||
record: McpToken
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class TokenIdentity:
|
||||
"""What a verified token proves: which organization, and what it may do."""
|
||||
|
||||
organization_id: str
|
||||
token_id: str
|
||||
prefix: str
|
||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
def allows(self, scope: str) -> bool:
|
||||
return scope in self.scopes
|
||||
|
||||
|
||||
def as_utc(value):
|
||||
"""Normalise a timestamp read from the database for comparison.
|
||||
|
||||
SQLite (the development and test store) hands back naive datetimes; every
|
||||
expiry and revocation check compares against ``utcnow()``, so a naive value
|
||||
is assumed to be UTC rather than raising in the middle of a refusal.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
from datetime import UTC
|
||||
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
"""The only form of a token that is ever stored."""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _mint() -> str:
|
||||
return f"{TOKEN_PREFIX}{secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)}"
|
||||
|
||||
|
||||
def _scopes_of(raw: str) -> list[str]:
|
||||
"""Parse a CSV scope field, keeping only known scopes and their order."""
|
||||
seen: list[str] = []
|
||||
for piece in (raw or "").split(","):
|
||||
scope = piece.strip()
|
||||
if scope and scope in MCP_SCOPES and scope not in seen:
|
||||
seen.append(scope)
|
||||
return seen
|
||||
|
||||
|
||||
def validate_scopes(scopes: list[str] | None) -> list[str]:
|
||||
"""Refuse an unknown scope rather than silently dropping it."""
|
||||
if not scopes:
|
||||
return []
|
||||
unknown = [scope for scope in scopes if scope not in MCP_SCOPES]
|
||||
if unknown:
|
||||
raise ValidationError(f"محدودهٔ ناشناخته: {', '.join(sorted(unknown))}")
|
||||
# De-duplicated, order preserved, so the UI's checkbox set round-trips.
|
||||
return list(dict.fromkeys(scopes))
|
||||
|
||||
|
||||
class TokenRateLimiter:
|
||||
"""In-memory, per-token call budget for the current minute.
|
||||
|
||||
Deliberately process-local: the gateway is one process, and a distributed
|
||||
limiter would need the Redis the deployment does not have. A restart forgives
|
||||
a burst — which is the right trade for a spare process only the owner's own
|
||||
agents reach.
|
||||
"""
|
||||
|
||||
def __init__(self, *, limit_per_minute: int | None = None) -> None:
|
||||
self.limit = int(limit_per_minute or settings.mcp_rate_limit_per_minute)
|
||||
self._calls: dict[str, deque[float]] = {}
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
window_start = now - 60.0
|
||||
bucket = self._calls.setdefault(key, deque())
|
||||
while bucket and bucket[0] < window_start:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._calls.clear()
|
||||
|
||||
|
||||
class McpService:
|
||||
"""Token lifecycle and verification for the MCP gateway."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def issue(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
name: str,
|
||||
scopes: list[str],
|
||||
created_by_user_id: str | None = None,
|
||||
ttl_days: int | None = None,
|
||||
) -> IssuedToken:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("نام توکن نمیتواند خالی باشد.")
|
||||
wanted = validate_scopes(scopes)
|
||||
if not wanted:
|
||||
raise ValidationError("حداقل یک محدودهٔ دسترسی انتخاب کنید.")
|
||||
days = int(ttl_days or settings.mcp_token_ttl_days)
|
||||
if days < 1:
|
||||
raise ValidationError("مدت اعتبار توکن نامعتبر است.")
|
||||
|
||||
raw = _mint()
|
||||
row = McpTokenRow(
|
||||
id=new_id(),
|
||||
organization_id=organization_id,
|
||||
name=clean_name[:120],
|
||||
token_hash=hash_token(raw),
|
||||
prefix=raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS],
|
||||
scopes=",".join(wanted),
|
||||
created_by_user_id=created_by_user_id,
|
||||
expires_at=utcnow() + timedelta(days=days),
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return IssuedToken(token=raw, record=_to_domain(row))
|
||||
|
||||
async def list(self, organization_id: str) -> list[McpToken]:
|
||||
rows = (
|
||||
(
|
||||
await self.session.execute(
|
||||
select(McpTokenRow)
|
||||
.where(McpTokenRow.organization_id == organization_id)
|
||||
.order_by(McpTokenRow.created_at.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def revoke(self, *, organization_id: str, token_id: str) -> None:
|
||||
"""Revoke a token. Revoking twice is a 404, not a silent success — the
|
||||
same idempotence rule the grant endpoints follow, so a caller can tell
|
||||
"already gone" from "never existed" only by looking, not by guessing."""
|
||||
row = await self._row(organization_id, token_id)
|
||||
if row is None or row.revoked_at is not None:
|
||||
raise NotFoundError("توکن یافت نشد.")
|
||||
row.revoked_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
# -- verification --------------------------------------------------------
|
||||
|
||||
async def verify(self, raw_token: str) -> TokenIdentity | None:
|
||||
"""Resolve a raw token, or ``None`` for every kind of refusal.
|
||||
|
||||
The lookup is by hash (the token is never stored), and the comparison is
|
||||
constant-time so a token that shares a prefix with a real one cannot be
|
||||
discovered by timing. An expired or revoked token is simply not an
|
||||
identity any more.
|
||||
"""
|
||||
if not raw_token or not raw_token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
digest = hash_token(raw_token)
|
||||
rows = (
|
||||
(
|
||||
await self.session.execute(
|
||||
select(McpTokenRow).where(
|
||||
McpTokenRow.token_hash == digest,
|
||||
McpTokenRow.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
if not hmac.compare_digest(row.token_hash, digest):
|
||||
return None
|
||||
if as_utc(row.expires_at) is not None and as_utc(row.expires_at) <= utcnow():
|
||||
return None
|
||||
return TokenIdentity(
|
||||
organization_id=row.organization_id,
|
||||
token_id=row.id,
|
||||
prefix=row.prefix,
|
||||
scopes=frozenset(_scopes_of(row.scopes)),
|
||||
)
|
||||
|
||||
async def creator_of(self, token_id: str) -> str | None:
|
||||
"""The user who minted this token — the accountable actor for its writes.
|
||||
|
||||
A task created through the gateway is attributed to the administrator who
|
||||
created the token, not to a ghost user: the token is a delegated key, and
|
||||
delegation never erases who holds it.
|
||||
"""
|
||||
row = await self.session.get(McpTokenRow, token_id)
|
||||
return row.created_by_user_id if row else None
|
||||
|
||||
async def touch(self, token_id: str) -> None:
|
||||
"""Record last use. Never allowed to fail a call."""
|
||||
row = await self.session.get(McpTokenRow, token_id)
|
||||
if row is not None:
|
||||
row.last_used_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def count_per_org(self) -> dict[str, int]:
|
||||
"""Active tokens per organization, for the owner's /admin/mcp screen."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(McpTokenRow.organization_id).where(
|
||||
McpTokenRow.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
counts: dict[str, int] = {}
|
||||
for org_id in rows:
|
||||
counts[org_id] = counts.get(org_id, 0) + 1
|
||||
return counts
|
||||
|
||||
async def _row(self, organization_id: str, token_id: str) -> McpTokenRow | None:
|
||||
return await self.session.scalar(
|
||||
select(McpTokenRow).where(
|
||||
McpTokenRow.organization_id == organization_id,
|
||||
McpTokenRow.id == token_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _to_domain(row: McpTokenRow) -> McpToken:
|
||||
return McpToken(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
name=row.name,
|
||||
prefix=row.prefix,
|
||||
scopes=_scopes_of(row.scopes),
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
expires_at=row.expires_at,
|
||||
last_used_at=row.last_used_at,
|
||||
revoked_at=row.revoked_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_SCOPES",
|
||||
"TOKEN_PREFIX",
|
||||
"IssuedToken",
|
||||
"McpService",
|
||||
"McpToken",
|
||||
"TokenIdentity",
|
||||
"TokenRateLimiter",
|
||||
"hash_token",
|
||||
"validate_scopes",
|
||||
]
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Org memory use cases: what an organization remembers, and how it learns.
|
||||
|
||||
This module is the real ``MemoryPort`` implementation (see
|
||||
``container_org.build_memory``). The engine calls it three ways — build a
|
||||
context for a task, record something a human taught, and react to a finished
|
||||
task — and this service owns three invariants around those calls:
|
||||
|
||||
1. **Memory is capped.** One organization may spend at most
|
||||
``MEMORY_ORG_CHAR_CAP`` characters on live memory. When a write would cross
|
||||
it, the oldest unpinned *lesson* is evicted first — a lesson is disposable by
|
||||
definition, a rule or a preference is not.
|
||||
2. **Teaching the same thing twice teaches nothing.** A write whose content (or
|
||||
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.
|
||||
|
||||
Nothing here returns a model name, a provider or an infrastructure detail — the
|
||||
engine's contract is a block of text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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_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.base import utcnow
|
||||
from app.domain.identity.models import ROLE_RANK, Role
|
||||
from app.domain.memory.models import (
|
||||
CONFIRMED_CONFIDENCE,
|
||||
KIND_LABELS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
REFLECTION_CONFIDENCE,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
coerce_kind,
|
||||
coerce_source,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
relevance,
|
||||
tokenise,
|
||||
validate_confidence,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.memory import MemoryRepository
|
||||
|
||||
#: What a reflection is asked to produce. Deliberately narrow: two lessons and a
|
||||
#: code-map paragraph, in a shape the parser can read without guessing.
|
||||
REFLECTION_SYSTEM = (
|
||||
"تو بازبین کارهای یک سازمان هستی. حداکثر دو درس آموختنی و یک پاراگراف نقشهٔ "
|
||||
"کد بنویس. هر درس یک خط است و فقط یک واقعیت اجرایی را میگوید — نه تعریف. "
|
||||
"اگر درسی نیست، فقط کلمهٔ «هیچ» را بنویس."
|
||||
)
|
||||
|
||||
REFLECTION_TEMPLATE = """کار: {title}
|
||||
وضعیت: {status}
|
||||
خلاصه: {summary}
|
||||
فایلهای تغییرکرده: {files}
|
||||
خطاهای دروازه: {failures}
|
||||
|
||||
پاسخ را دقیقاً در این قالب بنویس:
|
||||
درس ۱: ...
|
||||
درس ۲: ...
|
||||
نقشهٔ کد: ..."""
|
||||
|
||||
#: The block header. Short on purpose: the block is a prompt fragment, and a
|
||||
#: prompt fragment pays for every character.
|
||||
CONTEXT_HEADER = "یادداشتهای سازمان:"
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Recalls, teaches, corrects and prunes one organization's memory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
memories: MemoryRepository,
|
||||
models: AiModelRepository,
|
||||
credentials: ModelCredentialRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
provider: ModelProviderPort,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.memories = memories
|
||||
self.models = models
|
||||
self.credentials = credentials
|
||||
self.rate_cards = rate_cards
|
||||
self.ledger = ledger
|
||||
self.provider = provider
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# MemoryPort #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
kind: str,
|
||||
content: str,
|
||||
source: str,
|
||||
evidence_task_id: str | None = None,
|
||||
key: str | None = None,
|
||||
created_by_user_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Store — or correct — one memory. Returns its id, or ``None``.
|
||||
|
||||
``None`` is an answer, not a failure: the write was refused because the
|
||||
task had already taught its three memories. The caller (usually the
|
||||
reflection path) must not treat that as an error.
|
||||
"""
|
||||
cleaned = validate_content(content)
|
||||
# A memory lands in a future prompt. A credential pasted into one would
|
||||
# be broadcast to every employee from then on, so nothing is stored
|
||||
# unredacted — not even what "looks safe".
|
||||
cleaned = redact_text(cleaned)
|
||||
entry_kind = coerce_kind(kind)
|
||||
entry_source = coerce_source(source)
|
||||
entry_key = validate_key(key)
|
||||
digest = content_hash(cleaned)
|
||||
|
||||
if evidence_task_id:
|
||||
writes = await self.memories.count_for_task(organization_id, evidence_task_id)
|
||||
if writes >= MAX_WRITES_PER_TASK:
|
||||
return None
|
||||
|
||||
duplicate = await self.memories.find_duplicate(
|
||||
organization_id, kind=entry_kind, key=entry_key, digest=digest
|
||||
)
|
||||
if duplicate is not None:
|
||||
# Same thought, same organization: correct the entry in place.
|
||||
# Confidence and pinned state survive, because re-reading a rule is
|
||||
# not a retraction of it.
|
||||
duplicate.content = cleaned
|
||||
duplicate.source = entry_source
|
||||
duplicate.confidence = _confidence_for(entry_source, duplicate.confidence)
|
||||
duplicate.expires_at = _expiry_for(entry_kind, duplicate)
|
||||
if created_by_user_id:
|
||||
duplicate.created_by_user_id = duplicate.created_by_user_id or created_by_user_id
|
||||
await self.memories.save(duplicate)
|
||||
return duplicate.id
|
||||
|
||||
memory = Memory(
|
||||
organization_id=organization_id,
|
||||
kind=entry_kind,
|
||||
key=entry_key,
|
||||
content=cleaned,
|
||||
source=entry_source,
|
||||
evidence_task_id=evidence_task_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
confidence=_confidence_for(entry_source, 1.0),
|
||||
)
|
||||
memory.expires_at = _expiry_for(entry_kind, memory)
|
||||
await self.memories.create(memory)
|
||||
await self._enforce_cap(organization_id)
|
||||
return memory.id
|
||||
|
||||
async def build_context(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
role: str,
|
||||
query: str,
|
||||
budget_chars: int = 2500,
|
||||
) -> str:
|
||||
"""The memory block for one task, within ``budget_chars``.
|
||||
|
||||
Pinned rules and preferences lead: they are standing decisions, not
|
||||
search results. Everything else follows by relevance to the query.
|
||||
Whatever does not fit is dropped — including a pinned entry, because a
|
||||
prompt over budget is worse than an incomplete one.
|
||||
"""
|
||||
kinds = visible_kinds(role)
|
||||
if budget_chars <= 0 or not kinds:
|
||||
return ""
|
||||
memories = await self.memories.list_for_org(organization_id, limit=200)
|
||||
live = [m for m in memories if m.kind in kinds and m.is_live()]
|
||||
if not live:
|
||||
return ""
|
||||
|
||||
query_tokens = tokenise(query)
|
||||
leading = [m for m in live if m.pinned and m.kind in _LEADING_KINDS]
|
||||
leading_ids = {m.id for m in leading}
|
||||
rest = [m for m in live if m.id not in leading_ids]
|
||||
|
||||
leading.sort(key=lambda m: (_kind_order(m.kind), -(m.updated_at or utcnow()).timestamp()))
|
||||
rest.sort(
|
||||
key=lambda m: (
|
||||
-relevance(m, query_tokens),
|
||||
-(m.updated_at or utcnow()).timestamp(),
|
||||
)
|
||||
)
|
||||
|
||||
budget = int(budget_chars)
|
||||
lines: list[str] = []
|
||||
used = len(CONTEXT_HEADER) + 1
|
||||
included: list[Memory] = []
|
||||
for memory in [*leading, *rest]:
|
||||
line = _format_entry(memory)
|
||||
if used + len(line) + 1 > budget:
|
||||
continue
|
||||
used += len(line) + 1
|
||||
lines.append(line)
|
||||
included.append(memory)
|
||||
if not lines:
|
||||
return ""
|
||||
await self.memories.mark_used([m.id for m in included])
|
||||
return "\n".join([CONTEXT_HEADER, *lines])
|
||||
|
||||
async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None:
|
||||
"""Let the organization learn from a finished task. Never raises.
|
||||
|
||||
One cheap completion, at most ``MAX_WRITES_PER_TASK`` memories, all
|
||||
written with ``source=reflection`` and half confidence. A failure is
|
||||
recorded and swallowed: the task is already finished, and the engine must
|
||||
not have to know whether an LLM happened to answer.
|
||||
"""
|
||||
try:
|
||||
answer = await self._reflect(organization_id=organization_id, task=task)
|
||||
except Exception as exc: # noqa: BLE001 - reflection must never raise
|
||||
await record_error(
|
||||
kind="memory.reflection_failed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
organization_id=organization_id,
|
||||
metadata={"task_id": task.task_id, "status": task.status},
|
||||
)
|
||||
return
|
||||
if not answer:
|
||||
return
|
||||
|
||||
for kind, content in _parse_reflection(answer)[:MAX_WRITES_PER_TASK]:
|
||||
await self.record(
|
||||
organization_id=organization_id,
|
||||
kind=str(kind),
|
||||
content=content,
|
||||
source=str(MemorySource.REFLECTION),
|
||||
evidence_task_id=task.task_id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Writes the UI and the MCP gateway expose #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def list(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[Memory]:
|
||||
parsed = coerce_kind(kind) if kind else None
|
||||
return await self.memories.list_for_org(organization_id, kind=parsed, search=search)
|
||||
|
||||
async def teach(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
user_id: str,
|
||||
role: Role,
|
||||
kind: str,
|
||||
content: str,
|
||||
key: str | None = None,
|
||||
) -> Memory:
|
||||
"""A human teaching the organization — a rule or a preference.
|
||||
|
||||
The role check lives here and not only in the router: this service is
|
||||
also reached by the MCP gateway (Phase 5), and "someone with a token"
|
||||
must hit the same wall as someone with a session.
|
||||
"""
|
||||
entry_kind = coerce_kind(kind)
|
||||
if entry_kind not in _TEACHABLE_KINDS:
|
||||
raise PermissionDeniedError("فقط «قاعده» یا «ترجیح» قابل آموزش است.")
|
||||
if ROLE_RANK[role] < ROLE_RANK[Role.MEMBER]:
|
||||
raise PermissionDeniedError("برای آموزش سازمان باید عضو آن باشید.")
|
||||
memory_id = await self.record(
|
||||
organization_id=organization_id,
|
||||
kind=str(entry_kind),
|
||||
content=content,
|
||||
source=str(MemorySource.USER),
|
||||
key=key,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
if memory_id is None: # pragma: no cover - only the task cap returns None
|
||||
raise ValidationError("یادداشت ذخیره نشد.")
|
||||
memory = await self.memories.get(organization_id, memory_id)
|
||||
if memory is None: # pragma: no cover - created one call above
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
return memory
|
||||
|
||||
async def get(self, organization_id: str, memory_id: str) -> Memory:
|
||||
memory = await self.memories.get(organization_id, memory_id)
|
||||
if memory is None or memory.deleted_at is not None:
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
return memory
|
||||
|
||||
async def edit(
|
||||
self, *, organization_id: str, memory_id: str, content: str, key: str | None = None
|
||||
) -> Memory:
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
memory.content = validate_content(content)
|
||||
if key is not None:
|
||||
memory.key = validate_key(key)
|
||||
memory.confidence = validate_confidence(memory.confidence)
|
||||
memory.expires_at = _expiry_for(memory.kind, memory)
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def set_pinned(
|
||||
self, *, organization_id: str, memory_id: str, pinned: bool
|
||||
) -> Memory:
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
memory.pinned = bool(pinned)
|
||||
# Pinning is a promise the organization keeps, so a pinned lesson stops
|
||||
# expiring and becomes part of every context.
|
||||
memory.expires_at = _expiry_for(memory.kind, memory)
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def confirm(self, *, organization_id: str, memory_id: str) -> Memory:
|
||||
"""A reviewed lesson becomes settled knowledge: full confidence, no expiry."""
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
if memory.kind is not MemoryKind.LESSON:
|
||||
raise ValidationError("فقط یک «درس» قابل تأیید است.")
|
||||
memory.confidence = CONFIRMED_CONFIDENCE
|
||||
memory.pinned = True
|
||||
memory.expires_at = None
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def delete(self, *, organization_id: str, memory_id: str) -> None:
|
||||
if not await self.memories.soft_delete(organization_id, memory_id):
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
|
||||
async def expire_memories(self, organization_id: str | None = None) -> int:
|
||||
"""Soft-delete expired lessons. Exposed for the worker hook (see report)."""
|
||||
return await self.memories.expire(organization_id)
|
||||
|
||||
async def stats(self, organization_id: str) -> dict[str, int]:
|
||||
memories = await self.memories.list_for_org(organization_id, limit=200)
|
||||
return {
|
||||
"total": len(memories),
|
||||
"pinned": sum(1 for m in memories if m.pinned),
|
||||
"chars": await self.memories.total_chars(organization_id),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internals #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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)
|
||||
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(
|
||||
model=model,
|
||||
credential=pool[0] if pool else None,
|
||||
credentials=pool or None,
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=REFLECTION_TEMPLATE.format(
|
||||
title=task.title,
|
||||
status=task.status,
|
||||
summary=task.summary or "—",
|
||||
files="، ".join(task.changed_files[:20]) or "—",
|
||||
failures="، ".join(task.gate_failures) or "—",
|
||||
),
|
||||
)
|
||||
],
|
||||
system=REFLECTION_SYSTEM,
|
||||
temperature=0.2,
|
||||
max_tokens=600,
|
||||
)
|
||||
await self._record_usage(organization_id=organization_id, response=response)
|
||||
return response.content or ""
|
||||
|
||||
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
|
||||
"""Price a reflection exactly like any other model call."""
|
||||
for unit, quantity in (
|
||||
(RateUnit.MODEL_INPUT_TOKENS, float(response.input_tokens or 0)),
|
||||
(RateUnit.MODEL_OUTPUT_TOKENS, float(response.output_tokens or 0)),
|
||||
):
|
||||
card = await self.rate_cards.get_by_unit(unit)
|
||||
unit_price = card.customer_price if card else 0.0
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=quantity * internal_cost,
|
||||
total_price=quantity * unit_price,
|
||||
metadata_json={"memory_reflection": True},
|
||||
)
|
||||
)
|
||||
|
||||
async def _enforce_cap(self, organization_id: str) -> int:
|
||||
"""Trim the organization to its character budget. Returns evicted count."""
|
||||
cap = int(settings.memory_org_char_cap)
|
||||
evicted = 0
|
||||
while await self.memories.total_chars(organization_id) > cap:
|
||||
victims = await self.memories.evictable_lessons(organization_id, limit=1)
|
||||
if not victims:
|
||||
# Only rules and preferences are left: the organization chose to
|
||||
# spend its whole budget on standing decisions, and silently
|
||||
# deleting one of those is worse than being one character over.
|
||||
return evicted
|
||||
await self.memories.soft_delete(organization_id, victims[0].id)
|
||||
evicted += 1
|
||||
return evicted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Kinds a human may teach directly; everything else is the system's voice.
|
||||
_TEACHABLE_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
|
||||
|
||||
#: A pinned rule outranks a pinned preference: one says what to do, the other
|
||||
#: says how to like it.
|
||||
_LEADING_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
|
||||
|
||||
_KIND_ORDER: dict[MemoryKind, int] = {
|
||||
MemoryKind.RULE: 0,
|
||||
MemoryKind.PREFERENCE: 1,
|
||||
}
|
||||
|
||||
|
||||
def _kind_order(kind: MemoryKind) -> int:
|
||||
return _KIND_ORDER.get(kind, 9)
|
||||
|
||||
|
||||
def _confidence_for(source: MemorySource, fallback: float) -> float:
|
||||
"""Reflections get half confidence; a human's entry keeps its own."""
|
||||
if source is MemorySource.REFLECTION:
|
||||
return REFLECTION_CONFIDENCE
|
||||
return validate_confidence(fallback)
|
||||
|
||||
|
||||
def _expiry_for(kind: MemoryKind, memory: Memory):
|
||||
"""Lessons expire; a rule, a preference, a fact and the code map do not."""
|
||||
if kind is not MemoryKind.LESSON:
|
||||
return None
|
||||
return lesson_expiry(
|
||||
pinned=memory.pinned, confirmed=memory.confidence >= CONFIRMED_CONFIDENCE
|
||||
)
|
||||
|
||||
|
||||
def _format_entry(memory: Memory) -> str:
|
||||
label = KIND_LABELS.get(str(memory.kind), "یادداشت")
|
||||
suffix = ""
|
||||
if memory.kind is MemoryKind.LESSON:
|
||||
suffix = f" (اعتماد {int(memory.confidence * 100)}٪)"
|
||||
elif memory.source is MemorySource.REFLECTION:
|
||||
suffix = " (بازتاب)"
|
||||
return f"- [{label}]{suffix} {memory.content.strip()}"
|
||||
|
||||
|
||||
def _parse_reflection(text: str) -> list[tuple[MemoryKind, str]]:
|
||||
"""Read a reflection's answer into ``(kind, content)`` pairs.
|
||||
|
||||
Tolerant by design: the model is asked for a rigid template, but a model is
|
||||
a model. A line that does not start with a known marker is ignored rather
|
||||
than stored, so a chatty answer cannot smuggle in a third lesson.
|
||||
"""
|
||||
entries: list[tuple[MemoryKind, str]] = []
|
||||
for raw_line in (text or "").splitlines():
|
||||
line = normalise(raw_line).lstrip("-*• \t").strip()
|
||||
if not line:
|
||||
continue
|
||||
lowered = line.casefold()
|
||||
for marker, kind in _REFLECTION_MARKERS:
|
||||
if lowered.startswith(marker):
|
||||
body = line[len(marker):].strip(" :،-–")
|
||||
if body:
|
||||
entries.append((kind, body))
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
#: Marker → kind. Longest marker first, so «نقشهٔ کد:» cannot be swallowed by a
|
||||
#: shorter prefix, and both the Persian and the English spelling is accepted.
|
||||
_REFLECTION_MARKERS: tuple[tuple[str, MemoryKind], ...] = (
|
||||
("نقشهٔ کد:", MemoryKind.CODE_MAP),
|
||||
("نقشه کد:", MemoryKind.CODE_MAP),
|
||||
("code map:", MemoryKind.CODE_MAP),
|
||||
("درس ۱:", MemoryKind.LESSON),
|
||||
("درس ۱", MemoryKind.LESSON),
|
||||
("درس ۲:", MemoryKind.LESSON),
|
||||
("درس ۲", MemoryKind.LESSON),
|
||||
("درس:", MemoryKind.LESSON),
|
||||
("درس", MemoryKind.LESSON),
|
||||
("lesson 1:", MemoryKind.LESSON),
|
||||
("lesson 1", MemoryKind.LESSON),
|
||||
("lesson 2:", MemoryKind.LESSON),
|
||||
("lesson 2", MemoryKind.LESSON),
|
||||
("lesson:", MemoryKind.LESSON),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["CONTEXT_HEADER", "MemoryService"]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Owner model grants and per-organization policy.
|
||||
|
||||
Owned by Agent 1. This is the real ``ModelGrantPort`` implementation (see
|
||||
``container_org.build_model_grants``): the engine asks "which models may this
|
||||
organization use?" and gets a *neutral* answer — a tier (``fast`` /
|
||||
``balanced`` / ``strong`` ≈ «سریع» / «متعادل» / «قوی») and the capability list,
|
||||
never a model name. Model names stay an owner-only concept, visible in the
|
||||
admin grant matrix and nowhere else.
|
||||
|
||||
Two independent switches have to agree before a model is usable:
|
||||
|
||||
* the model itself is active in the catalogue (``ai_models.is_active``), and
|
||||
* the owner granted it to *this* organization (``org_model_grants.is_active``).
|
||||
|
||||
Either one being false means "not granted". That is what makes revoking a model
|
||||
from one organization a real revocation while other organizations keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.model_grants import GrantedModel, tier_for
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.infrastructure.db.models import AiModelRow
|
||||
from app.infrastructure.repositories.org_grants import (
|
||||
DefaultGrantRecord,
|
||||
DefaultModelGrantRepository,
|
||||
OrgGrantRecord,
|
||||
OrgModelGrantRepository,
|
||||
OrgPolicyRecord,
|
||||
OrgPolicyRepository,
|
||||
)
|
||||
|
||||
#: The only vocabulary an organization's users ever see for a model.
|
||||
TIER_LABELS: dict[str, str] = {
|
||||
"fast": "سریع",
|
||||
"balanced": "متعادل",
|
||||
"strong": "قوی",
|
||||
}
|
||||
|
||||
#: Policy defaults for an organization that has never been configured. Kept in
|
||||
#: code (not only in the row default) so ``get_policy`` can answer for any org
|
||||
#: without writing a row first.
|
||||
DEFAULT_MAX_EMPLOYEES = 5
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModelView:
|
||||
"""One catalogue model as the owner sees it, plus its grant state for an org."""
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
provider_id: str
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
tier: str = "balanced"
|
||||
tier_label: str = "متعادل"
|
||||
output_price_per_1m: float = 0.0
|
||||
model_active: bool = True
|
||||
granted: bool = False
|
||||
daily_credit_cap: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrgGrantView:
|
||||
ai_model_id: str
|
||||
label: str
|
||||
tier: str
|
||||
tier_label: str
|
||||
capabilities: list[str]
|
||||
daily_credit_cap: float | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PolicyView:
|
||||
organization_id: str
|
||||
max_employees: int
|
||||
monthly_credit_budget: float | None
|
||||
automation_enabled: bool
|
||||
mcp_enabled: bool
|
||||
max_employees_limit: int
|
||||
|
||||
|
||||
def _tier_of(row: AiModelRow) -> str:
|
||||
return tier_for(
|
||||
capabilities=list(row.capabilities or []),
|
||||
output_price_per_1m=float(row.output_price_per_1m or 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _tier_label(tier: str) -> str:
|
||||
return TIER_LABELS.get(tier, TIER_LABELS["balanced"])
|
||||
|
||||
|
||||
class ModelGrantService:
|
||||
"""Grants, default grant set and org policy — all in one place."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
grants: OrgModelGrantRepository,
|
||||
defaults: DefaultModelGrantRepository,
|
||||
policies: OrgPolicyRepository,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.grants = grants
|
||||
self.defaults = defaults
|
||||
self.policies = policies
|
||||
|
||||
# -- catalogue ----------------------------------------------------------
|
||||
|
||||
async def _model(self, ai_model_id: str) -> AiModelRow:
|
||||
"""The catalogue row, or a 404. Never returns an inactive model's row.
|
||||
|
||||
Inactive means "the owner retired it": granting it would be meaningless,
|
||||
so it is reported as missing rather than as a validation error.
|
||||
"""
|
||||
row = await self.session.scalar(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.id == ai_model_id, AiModelRow.is_active
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("مدل مورد نظر یافت نشد.")
|
||||
return row
|
||||
|
||||
async def list_models(self, *, organization_id: str) -> list[ModelView]:
|
||||
"""The full grant matrix for one organization (owner view)."""
|
||||
models = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
|
||||
)
|
||||
).scalars().all()
|
||||
active_grants = {
|
||||
g.ai_model_id: g
|
||||
for g in await self.grants.list_for_org(organization_id, active_only=True)
|
||||
}
|
||||
return [
|
||||
self._view(row, active_grants.get(row.id)) for row in models
|
||||
]
|
||||
|
||||
def _view(self, row: AiModelRow, grant: OrgGrantRecord | None) -> ModelView:
|
||||
tier = _tier_of(row)
|
||||
return ModelView(
|
||||
ai_model_id=row.id,
|
||||
label=row.display_name_for_admin or row.internal_name,
|
||||
provider_id=row.provider_id,
|
||||
capabilities=list(row.capabilities or []),
|
||||
tier=tier,
|
||||
tier_label=_tier_label(tier),
|
||||
output_price_per_1m=float(row.output_price_per_1m or 0.0),
|
||||
model_active=bool(row.is_active),
|
||||
granted=grant is not None,
|
||||
daily_credit_cap=grant.daily_credit_cap if grant else None,
|
||||
)
|
||||
|
||||
# -- ModelGrantPort -----------------------------------------------------
|
||||
|
||||
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
|
||||
"""Active models with an active grant for this organization."""
|
||||
grants = await self.grants.list_for_org(organization_id, active_only=True)
|
||||
if not grants:
|
||||
return []
|
||||
by_id = {g.ai_model_id: g for g in grants}
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.is_active, AiModelRow.id.in_(list(by_id))
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
granted = [
|
||||
GrantedModel(
|
||||
ai_model_id=row.id,
|
||||
tier=_tier_of(row),
|
||||
capabilities=tuple(row.capabilities or []),
|
||||
daily_credit_cap=by_id[row.id].daily_credit_cap,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
# Cheapest first: a caller that picks the head of the list gets the
|
||||
# economical model, which is the sane default for bulk work.
|
||||
granted.sort(key=lambda m: m.daily_credit_cap if m.daily_credit_cap is not None else -1)
|
||||
return granted
|
||||
|
||||
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
|
||||
grant = await self.grants.get(organization_id, ai_model_id)
|
||||
if grant is None or not grant.is_active:
|
||||
return False
|
||||
exists = await self.session.scalar(
|
||||
select(AiModelRow.id).where(
|
||||
AiModelRow.id == ai_model_id, AiModelRow.is_active
|
||||
)
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
# -- grants -------------------------------------------------------------
|
||||
|
||||
async def list_grants(
|
||||
self, *, organization_id: str, active_only: bool = False
|
||||
) -> list[OrgGrantView]:
|
||||
records = await self.grants.list_for_org(organization_id, active_only=active_only)
|
||||
if not records:
|
||||
return []
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.id.in_([r.ai_model_id for r in records])
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
by_id = {row.id: row for row in rows}
|
||||
views: list[OrgGrantView] = []
|
||||
for record in records:
|
||||
row = by_id.get(record.ai_model_id)
|
||||
if row is None: # deleted from the catalogue: keep it out of the UI
|
||||
continue
|
||||
tier = _tier_of(row)
|
||||
views.append(
|
||||
OrgGrantView(
|
||||
ai_model_id=row.id,
|
||||
label=row.display_name_for_admin or row.internal_name,
|
||||
tier=tier,
|
||||
tier_label=_tier_label(tier),
|
||||
capabilities=list(row.capabilities or []),
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=record.is_active,
|
||||
)
|
||||
)
|
||||
return views
|
||||
|
||||
@staticmethod
|
||||
def _validate_cap(daily_credit_cap: float | None) -> float | None:
|
||||
if daily_credit_cap is None:
|
||||
return None
|
||||
cap = float(daily_credit_cap)
|
||||
if cap < 0:
|
||||
raise ValidationError("سقف اعتبار روزانه نمیتواند منفی باشد.")
|
||||
return cap
|
||||
|
||||
async def grant_model(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None = None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> OrgGrantView:
|
||||
await self._model(ai_model_id)
|
||||
cap = self._validate_cap(daily_credit_cap)
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=cap,
|
||||
is_active=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
views = await self.list_grants(organization_id=organization_id, active_only=True)
|
||||
return next(v for v in views if v.ai_model_id == ai_model_id)
|
||||
|
||||
async def revoke_model(
|
||||
self, *, organization_id: str, ai_model_id: str, actor_user_id: str | None = None
|
||||
) -> None:
|
||||
"""Revoke by deactivating, never by deleting.
|
||||
|
||||
The row is kept so the history ("was granted, then revoked") survives
|
||||
and re-granting is idempotent instead of racing a unique constraint.
|
||||
"""
|
||||
existing = await self.grants.get(organization_id, ai_model_id)
|
||||
if existing is None or not existing.is_active:
|
||||
raise NotFoundError("این مدل برای سازمان فعال نیست.")
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=existing.daily_credit_cap,
|
||||
is_active=False,
|
||||
created_by_user_id=actor_user_id or existing.created_by_user_id,
|
||||
)
|
||||
|
||||
async def set_grant_cap(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> OrgGrantView:
|
||||
existing = await self.grants.get(organization_id, ai_model_id)
|
||||
if existing is None or not existing.is_active:
|
||||
raise NotFoundError("این مدل برای سازمان فعال نیست.")
|
||||
return await self.grant_model(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
# -- default grant set --------------------------------------------------
|
||||
|
||||
async def list_defaults(self) -> list[ModelView]:
|
||||
models = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
|
||||
)
|
||||
).scalars().all()
|
||||
active = {d.ai_model_id: d for d in await self.defaults.list_active()}
|
||||
return [self._view(row, _as_grant(active.get(row.id))) for row in models]
|
||||
|
||||
async def set_default(
|
||||
self,
|
||||
*,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None = None,
|
||||
is_active: bool = True,
|
||||
actor_user_id: str | None = None,
|
||||
) -> None:
|
||||
await self._model(ai_model_id)
|
||||
await self.defaults.upsert(
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=self._validate_cap(daily_credit_cap),
|
||||
is_active=is_active,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
async def remove_default(self, *, ai_model_id: str) -> None:
|
||||
if not await self.defaults.delete(ai_model_id):
|
||||
raise NotFoundError("این مدل در مجموعهٔ پیشفرض نیست.")
|
||||
|
||||
async def copy_defaults_into(
|
||||
self, *, organization_id: str, actor_user_id: str | None = None
|
||||
) -> int:
|
||||
"""Materialise the default set into a new organization. Returns the count.
|
||||
|
||||
Only the *active* defaults are copied: a default the owner switched off
|
||||
must not silently reappear in the next organization created.
|
||||
"""
|
||||
copied = 0
|
||||
for record in await self.defaults.list_active():
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=record.ai_model_id,
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
copied += 1
|
||||
return copied
|
||||
|
||||
# -- org policy ---------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _policy_view(record: OrgPolicyRecord | None, organization_id: str) -> PolicyView:
|
||||
limit = max(1, int(settings.engine_max_employees))
|
||||
if record is None:
|
||||
return PolicyView(
|
||||
organization_id=organization_id,
|
||||
max_employees=min(DEFAULT_MAX_EMPLOYEES, limit),
|
||||
monthly_credit_budget=None,
|
||||
automation_enabled=True,
|
||||
mcp_enabled=True,
|
||||
max_employees_limit=limit,
|
||||
)
|
||||
return PolicyView(
|
||||
organization_id=organization_id,
|
||||
max_employees=min(int(record.max_employees), limit),
|
||||
monthly_credit_budget=record.monthly_credit_budget,
|
||||
automation_enabled=bool(record.automation_enabled),
|
||||
mcp_enabled=bool(record.mcp_enabled),
|
||||
max_employees_limit=limit,
|
||||
)
|
||||
|
||||
async def get_org_policy(self, organization_id: str) -> PolicyView:
|
||||
"""Readable by anyone who may manage the org (Agent 2 reads this later)."""
|
||||
return self._policy_view(await self.policies.get(organization_id), organization_id)
|
||||
|
||||
async def set_org_policy(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
max_employees: int | None = None,
|
||||
monthly_credit_budget: float | None = None,
|
||||
automation_enabled: bool | None = None,
|
||||
mcp_enabled: bool | None = None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> PolicyView:
|
||||
current = await self.get_org_policy(organization_id)
|
||||
employees = current.max_employees if max_employees is None else int(max_employees)
|
||||
if employees < 1:
|
||||
raise ValidationError("حداقل تعداد پرسنل یک است.")
|
||||
limit = current.max_employees_limit
|
||||
if employees > limit:
|
||||
raise ValidationError(
|
||||
f"حداکثر تعداد پرسنل نمیتواند از سقف سامانه ({limit}) بیشتر باشد."
|
||||
)
|
||||
budget = (
|
||||
current.monthly_credit_budget
|
||||
if monthly_credit_budget is None
|
||||
else float(monthly_credit_budget)
|
||||
)
|
||||
if budget is not None and budget < 0:
|
||||
raise ValidationError("بودجهٔ ماهانه نمیتواند منفی باشد.")
|
||||
automation = (
|
||||
current.automation_enabled
|
||||
if automation_enabled is None
|
||||
else bool(automation_enabled)
|
||||
)
|
||||
mcp = current.mcp_enabled if mcp_enabled is None else bool(mcp_enabled)
|
||||
record = await self.policies.upsert(
|
||||
organization_id=organization_id,
|
||||
max_employees=employees,
|
||||
monthly_credit_budget=budget,
|
||||
automation_enabled=automation,
|
||||
mcp_enabled=mcp,
|
||||
)
|
||||
return self._policy_view(record, organization_id)
|
||||
|
||||
async def set_mcp_enabled(
|
||||
self, *, organization_id: str, enabled: bool, actor_user_id: str | None = None
|
||||
) -> PolicyView:
|
||||
"""Owner switch for the MCP gateway (Phase 5)."""
|
||||
return await self.set_org_policy(
|
||||
organization_id=organization_id, mcp_enabled=enabled, actor_user_id=actor_user_id
|
||||
)
|
||||
|
||||
async def mcp_enabled(self, organization_id: str) -> bool:
|
||||
return (await self.get_org_policy(organization_id)).mcp_enabled
|
||||
|
||||
|
||||
def _as_grant(record: DefaultGrantRecord | None) -> OrgGrantRecord | None:
|
||||
"""Default rows reuse the org-grant view; only the shape matters here."""
|
||||
if record is None:
|
||||
return None
|
||||
return OrgGrantRecord(
|
||||
id=record.id,
|
||||
organization_id="",
|
||||
ai_model_id=record.ai_model_id,
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=record.is_active,
|
||||
created_by_user_id=record.created_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_EMPLOYEES",
|
||||
"ModelGrantService",
|
||||
"ModelView",
|
||||
"OrgGrantView",
|
||||
"PolicyView",
|
||||
"TIER_LABELS",
|
||||
]
|
||||
@@ -243,6 +243,9 @@ class OrgSetupService:
|
||||
if payload.deploy_target_kind:
|
||||
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
|
||||
|
||||
# -- default model grants (owner-provided, Phase 3) -------------------
|
||||
await self._copy_default_grants(org.id, user.id)
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
# -- commit so hooks see a consistent world --------------------------
|
||||
@@ -377,6 +380,30 @@ class OrgSetupService:
|
||||
row = await self.deploys.upsert(values=values)
|
||||
return str(row.id)
|
||||
|
||||
async def _copy_default_grants(self, organization_id: str, user_id: str) -> int:
|
||||
"""Copy the platform owner's default grant set into the new organization.
|
||||
|
||||
Runs inside the creation transaction on purpose: an organization that
|
||||
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,
|
||||
)
|
||||
|
||||
service = ModelGrantService(
|
||||
session=self.session,
|
||||
grants=OrgModelGrantRepository(self.session),
|
||||
defaults=DefaultModelGrantRepository(self.session),
|
||||
policies=OrgPolicyRepository(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:
|
||||
if self.workforce_bootstrap:
|
||||
try:
|
||||
|
||||
@@ -130,7 +130,14 @@ class Settings(BaseSettings):
|
||||
# >>> agent-1 (org platform): add settings ONLY between these markers
|
||||
mcp_gateway_host: str = "127.0.0.1"
|
||||
mcp_gateway_port: int = 8765
|
||||
#: The owner's kill switch for the whole gateway (Phase 5). The per-org
|
||||
#: switch lives in ``org_policies.mcp_enabled``.
|
||||
mcp_gateway_enabled: bool = True
|
||||
mcp_token_ttl_days: int = 90
|
||||
#: Calls one token may make per minute. Enforced in memory, per gateway
|
||||
#: process — a restart forgives, which is the right trade for a spare
|
||||
#: gateway process that only the owner's own agents talk to.
|
||||
mcp_rate_limit_per_minute: int = 60
|
||||
memory_org_char_cap: int = 60000
|
||||
platform_invite_ttl_hours: int = 72
|
||||
# <<< agent-1
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Audit action names introduced by the org-platform workstream.
|
||||
|
||||
Kept in the identity domain instead of the shared ``app.domain.audit`` module so
|
||||
the parallel workstreams never edit the same file (``app.domain.audit`` is
|
||||
foundation-owned). Values are free-form strings — ``AuditAction`` accepts any.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# --- platform invites (owner creates a new manager account) ----------------
|
||||
PLATFORM_INVITE_CREATE = "platform_invite.create"
|
||||
PLATFORM_INVITE_REVOKE = "platform_invite.revoke"
|
||||
PLATFORM_INVITE_ACCEPT = "platform_invite.accept"
|
||||
|
||||
# --- org sharing -----------------------------------------------------------
|
||||
ORG_TRANSFER_OWNERSHIP = "organization.transfer_ownership"
|
||||
ORG_LEAVE = "organization.leave"
|
||||
ORG_INVITE_REVOKE = "member.invite_revoke"
|
||||
|
||||
# --- wizard ----------------------------------------------------------------
|
||||
ORG_CREATED_VIA_WIZARD = "organization.created_via_wizard"
|
||||
APP_PROFILE_UPDATE = "app_profile.update"
|
||||
APP_GIT_ROTATE = "app_git.rotate"
|
||||
APP_DEPLOY_TARGET_SET = "app_deploy_target.set"
|
||||
|
||||
# --- owner model grants and org policy -------------------------------------
|
||||
MODEL_GRANT_SET = "model_grant.set"
|
||||
MODEL_GRANT_REVOKE = "model_grant.revoke"
|
||||
DEFAULT_MODEL_GRANT_SET = "default_model_grant.set"
|
||||
DEFAULT_MODEL_GRANT_REMOVE = "default_model_grant.remove"
|
||||
ORG_POLICY_UPDATE = "org_policy.update"
|
||||
|
||||
# --- org memory ------------------------------------------------------------
|
||||
MEMORY_CREATE = "memory.create"
|
||||
MEMORY_UPDATE = "memory.update"
|
||||
MEMORY_PIN = "memory.pin"
|
||||
MEMORY_CONFIRM = "memory.confirm"
|
||||
MEMORY_DELETE = "memory.delete"
|
||||
|
||||
# --- MCP gateway -----------------------------------------------------------
|
||||
MCP_TOKEN_CREATE = "mcp_token.create"
|
||||
MCP_TOKEN_REVOKE = "mcp_token.revoke"
|
||||
MCP_GLOBAL_SWITCH = "mcp.global_switch"
|
||||
MCP_ORG_SWITCH = "mcp.org_switch"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Org memory domain package (owned by Agent 1)."""
|
||||
|
||||
from app.domain.memory.models import (
|
||||
CONFIRMED_CONFIDENCE,
|
||||
CONTENT_MAX_CHARS,
|
||||
KEY_MAX_CHARS,
|
||||
KIND_LABELS,
|
||||
LESSON_TTL_DAYS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
PREFERENCE_ROLES,
|
||||
REFLECTION_CONFIDENCE,
|
||||
SOURCE_LABELS,
|
||||
STRUCTURAL_KINDS,
|
||||
STRUCTURAL_ROLES,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
coerce_kind,
|
||||
coerce_source,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
relevance,
|
||||
tokenise,
|
||||
validate_confidence,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CONFIRMED_CONFIDENCE",
|
||||
"CONTENT_MAX_CHARS",
|
||||
"KEY_MAX_CHARS",
|
||||
"KIND_LABELS",
|
||||
"LESSON_TTL_DAYS",
|
||||
"MAX_WRITES_PER_TASK",
|
||||
"Memory",
|
||||
"MemoryKind",
|
||||
"MemorySource",
|
||||
"PREFERENCE_ROLES",
|
||||
"REFLECTION_CONFIDENCE",
|
||||
"SOURCE_LABELS",
|
||||
"STRUCTURAL_KINDS",
|
||||
"STRUCTURAL_ROLES",
|
||||
"coerce_kind",
|
||||
"coerce_source",
|
||||
"content_hash",
|
||||
"lesson_expiry",
|
||||
"normalise",
|
||||
"relevance",
|
||||
"tokenise",
|
||||
"validate_confidence",
|
||||
"validate_content",
|
||||
"validate_key",
|
||||
"visible_kinds",
|
||||
]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Org memory domain: the rules of what an organization may remember.
|
||||
|
||||
Pure Python — no FastAPI, no SQLAlchemy. Three concerns live here and nowhere
|
||||
else:
|
||||
|
||||
* **The vocabulary.** ``MemoryKind`` and ``MemorySource`` are the only two
|
||||
taxonomies the platform has. An org's memory is *small* on purpose: a handful
|
||||
of standing rules, a few preferences, the lessons a finished task leaves
|
||||
behind, and a code map of the one app the org owns.
|
||||
* **The limits.** A key is 80 characters, an entry 1,500. Anything longer is a
|
||||
document, and documents belong in the Knowledge Base (``knowledge.py``), which
|
||||
is why the memory page shows that second tab rather than re-implementing it.
|
||||
* **The shape of an entry.** ``normalise`` + ``content_hash`` turn "the same
|
||||
thought typed twice" into one stored memory, which is what keeps an org's
|
||||
memory from becoming a duplicate pile.
|
||||
|
||||
Nothing here touches the database: the repository (``repositories/memory.py``)
|
||||
moves rows, and the service (``services/memory_service.py``) decides what may be
|
||||
written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class MemoryKind(StrEnum):
|
||||
"""What a memory *is*, which decides who may read it and how long it lives."""
|
||||
|
||||
#: A standing instruction from the org: "always write tests first".
|
||||
RULE = "rule"
|
||||
#: A taste the org expressed: "prefer Persian comments".
|
||||
PREFERENCE = "preference"
|
||||
#: Something a finished task taught the org. Expires, because a lesson that
|
||||
#: was true once is not a law.
|
||||
LESSON = "lesson"
|
||||
#: A durable fact about the app or the organisation ("the API runs on 8000").
|
||||
FACT = "fact"
|
||||
#: A short map of the codebase, rewritten as the code changes.
|
||||
CODE_MAP = "code_map"
|
||||
|
||||
|
||||
class MemorySource(StrEnum):
|
||||
"""Who wrote it. ``reflection`` means the org's own self-review did."""
|
||||
|
||||
USER = "user"
|
||||
MANAGER = "manager"
|
||||
SYSTEM = "system"
|
||||
REFLECTION = "reflection"
|
||||
|
||||
|
||||
#: Hard limits, enforced here so a router, an MCP tool and the reflection writer
|
||||
#: all share one definition of "too big".
|
||||
KEY_MAX_CHARS = 80
|
||||
CONTENT_MAX_CHARS = 1500
|
||||
|
||||
#: How long a lesson stays usable. ``30`` days is long enough to outlive the
|
||||
#: task that produced it and short enough that stale advice dies quietly.
|
||||
LESSON_TTL_DAYS = 30
|
||||
|
||||
#: One task may teach at most this many memories. Without a ceiling a single
|
||||
#: runaway reflection could fill the org's whole budget with one task's opinion.
|
||||
MAX_WRITES_PER_TASK = 3
|
||||
|
||||
#: A reflection's own confidence: it observed, it did not verify.
|
||||
REFLECTION_CONFIDENCE = 0.5
|
||||
#: A confirmed lesson is treated as settled knowledge and stops expiring.
|
||||
CONFIRMED_CONFIDENCE = 1.0
|
||||
|
||||
#: Kinds that describe the *code*: only the roles that read code get them.
|
||||
STRUCTURAL_KINDS = frozenset({MemoryKind.CODE_MAP, MemoryKind.FACT})
|
||||
#: ``programmer`` is the plan's spelling of the engine's ``developer`` role key;
|
||||
#: both are accepted so a caller cannot silently lose the code map.
|
||||
STRUCTURAL_ROLES = frozenset(
|
||||
{"planner", "developer", "programmer", "reviewer", "tester"}
|
||||
)
|
||||
#: Preferences are the manager's business — the employee who interprets them.
|
||||
PREFERENCE_ROLES = frozenset({"manager"})
|
||||
|
||||
#: One-line Persian labels for the context block and the UI.
|
||||
KIND_LABELS: dict[str, str] = {
|
||||
MemoryKind.RULE: "قاعده",
|
||||
MemoryKind.PREFERENCE: "ترجیح",
|
||||
MemoryKind.LESSON: "درس",
|
||||
MemoryKind.FACT: "واقعیت",
|
||||
MemoryKind.CODE_MAP: "نقشهٔ کد",
|
||||
}
|
||||
|
||||
SOURCE_LABELS: dict[str, str] = {
|
||||
MemorySource.USER: "کاربر",
|
||||
MemorySource.MANAGER: "مدیر",
|
||||
MemorySource.SYSTEM: "سامانه",
|
||||
MemorySource.REFLECTION: "بازتاب",
|
||||
}
|
||||
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
#: Words that carry no meaning in either language; matching against them would
|
||||
#: make every memory "relevant" to every question.
|
||||
_STOP_WORDS = frozenset(
|
||||
{
|
||||
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "with",
|
||||
"without", "from", "into", "that", "this", "these", "those", "is", "are",
|
||||
"was", "were", "be", "been", "to", "of", "in", "on", "at", "by", "as",
|
||||
"it", "its", "do", "does", "did", "not", "no", "so", "we", "you", "use",
|
||||
"using", "should", "must", "را", "که", "از", "به", "با", "این", "آن",
|
||||
"و", "در", "برای", "تا", "بر", "شود", "است", "هست", "می",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Memory:
|
||||
"""One thing the organization knows. ``id`` and timestamps are domain-owned."""
|
||||
|
||||
organization_id: str
|
||||
kind: MemoryKind
|
||||
content: str
|
||||
source: MemorySource = MemorySource.USER
|
||||
id: str = field(default_factory=new_id)
|
||||
#: Optional stable handle: the same key always addresses the same memory,
|
||||
#: which is how "our port is 8000" is corrected instead of duplicated.
|
||||
key: str | None = None
|
||||
#: The task that produced a lesson, when it came from a reflection.
|
||||
evidence_task_id: str | None = None
|
||||
pinned: bool = False
|
||||
confidence: float = CONFIRMED_CONFIDENCE
|
||||
expires_at: datetime | None = None
|
||||
hits: int = 0
|
||||
last_used_at: datetime | None = None
|
||||
created_by_user_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return KIND_LABELS.get(str(self.kind), "یادداشت")
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
"""Always the digest of the *current* content.
|
||||
|
||||
A property, not a column the writer has to remember to refresh: a memory
|
||||
whose text changed must dedupe as the new text, never as the old one.
|
||||
"""
|
||||
return content_hash(self.content)
|
||||
|
||||
def is_live(self, *, now: datetime | None = None) -> bool:
|
||||
"""Not deleted, not expired. Pinned entries never expire."""
|
||||
if self.deleted_at is not None:
|
||||
return False
|
||||
if self.pinned or self.expires_at is None:
|
||||
return True
|
||||
return self.expires_at > (now or utcnow())
|
||||
|
||||
def is_confirmed(self) -> bool:
|
||||
return self.confidence >= CONFIRMED_CONFIDENCE
|
||||
|
||||
|
||||
def normalise(content: str) -> str:
|
||||
"""Canonical form for comparison: NFC, whitespace collapsed, trimmed.
|
||||
|
||||
Two spellings of the same sentence («the api runs on 8000» / «The API runs
|
||||
on 8000») must dedupe, so the comparison is case-folded.
|
||||
"""
|
||||
text = unicodedata.normalize("NFC", content or "")
|
||||
return _WHITESPACE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def content_hash(content: str) -> str:
|
||||
"""A stable short digest of the normalised content, for dedupe."""
|
||||
digest = hashlib.sha256(normalise(content).casefold().encode("utf-8"))
|
||||
return digest.hexdigest()[:32]
|
||||
|
||||
|
||||
def validate_key(key: str | None) -> str | None:
|
||||
"""``None`` (or empty) means "no key"; otherwise a short trimmed handle."""
|
||||
if key is None:
|
||||
return None
|
||||
trimmed = normalise(key)
|
||||
if not trimmed:
|
||||
return None
|
||||
if len(trimmed) > KEY_MAX_CHARS:
|
||||
raise ValidationError("کلید یادداشت نمیتواند بیش از ۸۰ نویسه باشد.")
|
||||
return trimmed
|
||||
|
||||
|
||||
def validate_content(content: str) -> str:
|
||||
cleaned = normalise(content)
|
||||
if not cleaned:
|
||||
raise ValidationError("محتوای یادداشت نمیتواند خالی باشد.")
|
||||
if len(cleaned) > CONTENT_MAX_CHARS:
|
||||
raise ValidationError("محتوای هر یادداشت نمیتواند بیش از ۱۵۰۰ نویسه باشد.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def validate_confidence(value: float) -> float:
|
||||
try:
|
||||
confidence = float(value)
|
||||
except (TypeError, ValueError) as exc: # pragma: no cover - guarded by callers
|
||||
raise ValidationError("میزان اطمینان نامعتبر است.") from exc
|
||||
if confidence < 0 or confidence > 1:
|
||||
raise ValidationError("میزان اطمینان باید بین ۰ و ۱ باشد.")
|
||||
return round(confidence, 2)
|
||||
|
||||
|
||||
def coerce_kind(kind: str) -> MemoryKind:
|
||||
try:
|
||||
return MemoryKind(str(kind).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("نوع یادداشت نامعتبر است.") from exc
|
||||
|
||||
|
||||
def coerce_source(source: str) -> MemorySource:
|
||||
try:
|
||||
return MemorySource(str(source).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("منبع یادداشت نامعتبر است.") from exc
|
||||
|
||||
|
||||
def visible_kinds(role: str) -> frozenset[MemoryKind]:
|
||||
"""The kinds one employee role may be shown.
|
||||
|
||||
Rules and lessons are everyone's business. ``code_map`` and ``fact``
|
||||
describe the code, so only the roles that read code get them; preferences
|
||||
belong to the manager alone, because a preference is an instruction to
|
||||
interpret work, not a fact about it.
|
||||
"""
|
||||
role_key = str(role or "").strip().lower()
|
||||
kinds = {MemoryKind.RULE, MemoryKind.LESSON}
|
||||
if role_key in STRUCTURAL_ROLES:
|
||||
kinds.update(STRUCTURAL_KINDS)
|
||||
if role_key in PREFERENCE_ROLES:
|
||||
kinds.add(MemoryKind.PREFERENCE)
|
||||
return frozenset(kinds)
|
||||
|
||||
|
||||
def lesson_expiry(*, pinned: bool, confirmed: bool) -> datetime | None:
|
||||
"""Lessons expire; a pinned or confirmed lesson is treated as settled."""
|
||||
if pinned or confirmed:
|
||||
return None
|
||||
return utcnow() + timedelta(days=LESSON_TTL_DAYS)
|
||||
|
||||
|
||||
def tokenise(text: str) -> frozenset[str]:
|
||||
"""Lower-cased words of both scripts, stop words removed."""
|
||||
words = re.findall(r"[\w\u0600-\u06FF]+", (text or "").casefold())
|
||||
return frozenset(w for w in words if len(w) > 1 and w not in _STOP_WORDS)
|
||||
|
||||
|
||||
def relevance(memory: Memory, query_tokens: frozenset[str]) -> float:
|
||||
"""How well a memory answers a query: keyword overlap, then recency.
|
||||
|
||||
Deliberately simple and explainable. An embedding search is a Phase-X
|
||||
upgrade (the Knowledge Base already has a vector port to borrow); until an
|
||||
org's memory is large enough for that to matter, word overlap is the honest
|
||||
answer and it cannot surprise a reader.
|
||||
"""
|
||||
own = tokenise(memory.content)
|
||||
overlap = len(query_tokens & own) / len(query_tokens) if query_tokens and own else 0.0
|
||||
age_days = _age_days(memory.last_used_at or memory.created_at)
|
||||
recency = 1.0 / (1.0 + age_days / LESSON_TTL_DAYS)
|
||||
pinned_bonus = 0.1 if memory.pinned else 0.0
|
||||
return round(overlap * 3.0 + recency + pinned_bonus, 4)
|
||||
|
||||
|
||||
def _age_days(when: datetime | None) -> float:
|
||||
"""Days since ``when``, treating a naive timestamp as UTC.
|
||||
|
||||
SQLite (the development and test store) hands back naive datetimes; a
|
||||
recency score is not the place to discover that. A missing timestamp means
|
||||
"very old", not "brand new".
|
||||
"""
|
||||
if when is None:
|
||||
return LESSON_TTL_DAYS * 30
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=UTC)
|
||||
return max(0.0, (utcnow() - when).days)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONFIRMED_CONFIDENCE",
|
||||
"CONTENT_MAX_CHARS",
|
||||
"KEY_MAX_CHARS",
|
||||
"KIND_LABELS",
|
||||
"LESSON_TTL_DAYS",
|
||||
"MAX_WRITES_PER_TASK",
|
||||
"Memory",
|
||||
"MemoryKind",
|
||||
"MemorySource",
|
||||
"PREFERENCE_ROLES",
|
||||
"REFLECTION_CONFIDENCE",
|
||||
"SOURCE_LABELS",
|
||||
"STRUCTURAL_KINDS",
|
||||
"STRUCTURAL_ROLES",
|
||||
"coerce_kind",
|
||||
"coerce_source",
|
||||
"content_hash",
|
||||
"lesson_expiry",
|
||||
"normalise",
|
||||
"relevance",
|
||||
"tokenise",
|
||||
"validate_confidence",
|
||||
"validate_content",
|
||||
"validate_key",
|
||||
"visible_kinds",
|
||||
]
|
||||
@@ -24,7 +24,17 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -103,4 +113,139 @@ class OrgSetupDraftRow(Base):
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
|
||||
class DefaultModelGrantRow(Base):
|
||||
"""The platform owner's default grant set, copied into every new organization.
|
||||
|
||||
Kept in a table of its own (never a column on ``ai_models``) so the model
|
||||
catalogue stays a pure, owner-managed list.
|
||||
"""
|
||||
|
||||
__tablename__ = "default_model_grants"
|
||||
__table_args__ = (UniqueConstraint("ai_model_id", name="uq_default_model_grant"),)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
ai_model_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
class OrgPolicyRow(Base):
|
||||
"""Per-organization policy the owner sets: limits and automation switches."""
|
||||
|
||||
__tablename__ = "org_policies"
|
||||
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
|
||||
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
#: MCP access for this organization (Phase 5); the owner may switch it off.
|
||||
mcp_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class OrgMemoryRow(Base):
|
||||
"""One thing the organization remembers about itself.
|
||||
|
||||
A memory is *small* by design (≤ 1,500 characters): longer material is a
|
||||
document and belongs in the Knowledge Base. ``content_hash`` is what makes
|
||||
"teach the same thing twice" a correction instead of a duplicate, and
|
||||
``key`` gives a rule a stable address so the engine can update it.
|
||||
"""
|
||||
|
||||
__tablename__ = "org_memories"
|
||||
__table_args__ = (
|
||||
Index("ix_org_memories_org_kind", "organization_id", "kind"),
|
||||
Index("ix_org_memories_org_key", "organization_id", "key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
#: Optional stable handle, unique in spirit within one org.
|
||||
key: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
content: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
#: A digest of the normalised content — the dedupe key the domain defines.
|
||||
content_hash: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
source: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
evidence_task_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
confidence: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
hits: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
#: Soft delete: a memory an admin removed stays in the audit story.
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class McpTokenRow(Base):
|
||||
"""A scoped token an external AI agent uses to call the MCP gateway.
|
||||
|
||||
The token itself is shown exactly once, at creation; only its hash and a
|
||||
six-character prefix (for display) are stored. Every call is attributed to
|
||||
that prefix in the audit trail, so a leaked token can be identified and
|
||||
revoked long before anyone can reuse it.
|
||||
"""
|
||||
|
||||
__tablename__ = "mcp_tokens"
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
#: First six characters, for display only. Never enough to authenticate.
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
#: Comma-separated scopes; an empty string means "no scopes", never "all".
|
||||
scopes: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefaultModelGrantRow",
|
||||
"McpTokenRow",
|
||||
"OrgMemoryRow",
|
||||
"OrgPolicyRow",
|
||||
"OrgSetupDraftRow",
|
||||
"PlatformInviteRow",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Repositories for org memory (Agent 1).
|
||||
|
||||
One table, three access shapes:
|
||||
|
||||
* reads are always scoped to one ``organization_id`` — there is no "list all
|
||||
memories" call, because a memory from another tenant must be unreachable by
|
||||
construction rather than by a filter someone remembers to add;
|
||||
* dedupe asks the database for the entry it suspects is the same one (by hash,
|
||||
by key), so "teach it twice" becomes an update;
|
||||
* eviction asks for the oldest unpinned lessons, which is the only thing this
|
||||
table is allowed to throw away.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, cast, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.memory.models import Memory, MemoryKind, MemorySource
|
||||
from app.infrastructure.db.models_org import OrgMemoryRow
|
||||
|
||||
|
||||
def _to_domain(row: OrgMemoryRow) -> Memory:
|
||||
return Memory(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
kind=MemoryKind(row.kind),
|
||||
key=row.key,
|
||||
content=row.content,
|
||||
source=MemorySource(row.source),
|
||||
evidence_task_id=row.evidence_task_id,
|
||||
pinned=bool(row.pinned),
|
||||
confidence=float(row.confidence or 0.0),
|
||||
expires_at=row.expires_at,
|
||||
hits=int(row.hits or 0),
|
||||
last_used_at=row.last_used_at,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
deleted_at=row.deleted_at,
|
||||
)
|
||||
|
||||
|
||||
class MemoryRepository:
|
||||
"""Org memory persistence. Every method is tenant-scoped or keyed by id."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- reads ---------------------------------------------------------------
|
||||
|
||||
async def list_for_org(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
kind: MemoryKind | None = None,
|
||||
search: str | None = None,
|
||||
include_deleted: bool = False,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[Memory]:
|
||||
stmt = select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id
|
||||
)
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(OrgMemoryRow.deleted_at.is_(None))
|
||||
if kind is not None:
|
||||
stmt = stmt.where(OrgMemoryRow.kind == str(kind))
|
||||
if search:
|
||||
# Literal LIKE, not user-written pattern: a memory search must not
|
||||
# be able to inject SQL. Parameterised by SQLAlchemy either way.
|
||||
needle = f"%{search.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
OrgMemoryRow.content.like(needle),
|
||||
cast(OrgMemoryRow.key, String).like(needle),
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
OrgMemoryRow.pinned.desc(), OrgMemoryRow.updated_at.desc()
|
||||
).limit(limit).offset(offset)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def get(self, organization_id: str, memory_id: str) -> Memory | None:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
)
|
||||
)
|
||||
return _to_domain(row) if row else None
|
||||
|
||||
async def find_duplicate(
|
||||
self, organization_id: str, *, kind: MemoryKind, key: str | None, digest: str
|
||||
) -> Memory | None:
|
||||
"""The entry this write would duplicate: same key, or same content.
|
||||
|
||||
Matching on the key first is what turns "our port is 8000" into a
|
||||
correction of one memory instead of a second, similar memory.
|
||||
"""
|
||||
if key:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.key == key,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if row is not None:
|
||||
return _to_domain(row)
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.kind == str(kind),
|
||||
OrgMemoryRow.content_hash == digest,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return _to_domain(row) if row else None
|
||||
|
||||
async def count_for_task(self, organization_id: str, evidence_task_id: str) -> int:
|
||||
return int(
|
||||
await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(OrgMemoryRow)
|
||||
.where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.evidence_task_id == evidence_task_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
async def total_chars(self, organization_id: str) -> int:
|
||||
"""Characters the org currently spends on live memory."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(func.length(OrgMemoryRow.content)).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
return int(sum(int(value or 0) for value in rows))
|
||||
|
||||
# -- writes --------------------------------------------------------------
|
||||
|
||||
async def create(self, memory: Memory) -> Memory:
|
||||
row = OrgMemoryRow(
|
||||
id=memory.id,
|
||||
organization_id=memory.organization_id,
|
||||
kind=str(memory.kind),
|
||||
key=memory.key,
|
||||
content=memory.content,
|
||||
content_hash=memory.content_hash,
|
||||
source=str(memory.source),
|
||||
evidence_task_id=memory.evidence_task_id,
|
||||
pinned=memory.pinned,
|
||||
confidence=memory.confidence,
|
||||
expires_at=memory.expires_at,
|
||||
hits=memory.hits,
|
||||
last_used_at=memory.last_used_at,
|
||||
created_by_user_id=memory.created_by_user_id,
|
||||
created_at=memory.created_at,
|
||||
updated_at=memory.updated_at,
|
||||
deleted_at=memory.deleted_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return memory
|
||||
|
||||
async def save(self, memory: Memory) -> Memory:
|
||||
"""Persist a mutated in-memory record, hash and timestamp included."""
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == memory.organization_id,
|
||||
OrgMemoryRow.id == memory.id,
|
||||
)
|
||||
)
|
||||
if row is None: # pragma: no cover - the service always loads first
|
||||
return await self.create(memory)
|
||||
row.kind = str(memory.kind)
|
||||
row.key = memory.key
|
||||
row.content = memory.content
|
||||
row.content_hash = memory.content_hash
|
||||
row.source = str(memory.source)
|
||||
row.evidence_task_id = memory.evidence_task_id
|
||||
row.pinned = memory.pinned
|
||||
row.confidence = memory.confidence
|
||||
row.expires_at = memory.expires_at
|
||||
row.hits = memory.hits
|
||||
row.last_used_at = memory.last_used_at
|
||||
row.deleted_at = memory.deleted_at
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return memory
|
||||
|
||||
async def mark_used(self, ids: list[str]) -> None:
|
||||
"""Count a ``build_context`` hit. One UPDATE, never one per memory."""
|
||||
if not ids:
|
||||
return
|
||||
await self.session.execute(
|
||||
update(OrgMemoryRow)
|
||||
.where(OrgMemoryRow.id.in_(ids))
|
||||
.values(hits=OrgMemoryRow.hits + 1, last_used_at=utcnow())
|
||||
)
|
||||
|
||||
async def soft_delete(self, organization_id: str, memory_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
row.deleted_at = utcnow()
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
# -- maintenance ---------------------------------------------------------
|
||||
|
||||
async def expire(self, organization_id: str | None = None) -> int:
|
||||
"""Soft-delete expired lessons. Pinned entries never expire.
|
||||
|
||||
Called from ``expire_memories`` — which the plan wants exposed as a
|
||||
plain function because the worker that owns the schedule is not mine.
|
||||
"""
|
||||
stmt = select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
OrgMemoryRow.pinned.is_(False),
|
||||
OrgMemoryRow.expires_at.is_not(None),
|
||||
OrgMemoryRow.expires_at < utcnow(),
|
||||
)
|
||||
if organization_id:
|
||||
stmt = stmt.where(OrgMemoryRow.organization_id == organization_id)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
now = utcnow()
|
||||
for row in rows:
|
||||
row.deleted_at = now
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def evictable_lessons(
|
||||
self, organization_id: str, *, limit: int
|
||||
) -> list[Memory]:
|
||||
"""The oldest unpinned lessons — the only entries this table may drop."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(OrgMemoryRow)
|
||||
.where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
OrgMemoryRow.pinned.is_(False),
|
||||
OrgMemoryRow.kind == str(MemoryKind.LESSON),
|
||||
)
|
||||
.order_by(OrgMemoryRow.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def hard_delete(self, organization_id: str, memory_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["MemoryRepository"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Repositories for owner-granted models and per-organization policy (Agent 1).
|
||||
|
||||
``org_model_grants`` is foundation-owned but has no repository elsewhere; these
|
||||
live in a file no other workstream edits (the local shim the plan allows).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.infrastructure.db.models import OrgModelGrantRow
|
||||
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class OrgGrantRecord:
|
||||
organization_id: str
|
||||
ai_model_id: str
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool = True
|
||||
created_by_user_id: str | None = None
|
||||
id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DefaultGrantRecord:
|
||||
ai_model_id: str
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool = True
|
||||
created_by_user_id: str | None = None
|
||||
id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class OrgPolicyRecord:
|
||||
organization_id: str
|
||||
max_employees: int = 5
|
||||
monthly_credit_budget: float | None = None
|
||||
automation_enabled: bool = True
|
||||
mcp_enabled: bool = True
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class OrgModelGrantRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: OrgModelGrantRow) -> OrgGrantRecord:
|
||||
return OrgGrantRecord(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
ai_model_id=row.ai_model_id,
|
||||
daily_credit_cap=row.daily_credit_cap,
|
||||
is_active=row.is_active,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
)
|
||||
|
||||
async def get(self, organization_id: str, ai_model_id: str) -> OrgGrantRecord | None:
|
||||
stmt = select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return self._to_record(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, active_only: bool = False
|
||||
) -> list[OrgGrantRecord]:
|
||||
stmt = select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id
|
||||
)
|
||||
if active_only:
|
||||
stmt = stmt.where(OrgModelGrantRow.is_active)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [self._to_record(r) for r in rows]
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
is_active: bool,
|
||||
created_by_user_id: str | None,
|
||||
) -> OrgGrantRecord:
|
||||
row = await self.session.scalar(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
row = OrgModelGrantRow(
|
||||
id=new_id(),
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
is_active=is_active,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.daily_credit_cap = daily_credit_cap
|
||||
row.is_active = is_active
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
|
||||
async def delete(self, organization_id: str, ai_model_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
class DefaultModelGrantRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: DefaultModelGrantRow) -> DefaultGrantRecord:
|
||||
return DefaultGrantRecord(
|
||||
id=row.id,
|
||||
ai_model_id=row.ai_model_id,
|
||||
daily_credit_cap=row.daily_credit_cap,
|
||||
is_active=row.is_active,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
)
|
||||
|
||||
async def list_all(self) -> list[DefaultGrantRecord]:
|
||||
rows = (await self.session.execute(select(DefaultModelGrantRow))).scalars().all()
|
||||
return [self._to_record(r) for r in rows]
|
||||
|
||||
async def list_active(self) -> list[DefaultGrantRecord]:
|
||||
stmt = select(DefaultModelGrantRow).where(DefaultModelGrantRow.is_active)
|
||||
return [self._to_record(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
is_active: bool,
|
||||
created_by_user_id: str | None,
|
||||
) -> DefaultGrantRecord:
|
||||
row = await self.session.scalar(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == ai_model_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
row = DefaultModelGrantRow(
|
||||
id=new_id(),
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
is_active=is_active,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.daily_credit_cap = daily_credit_cap
|
||||
row.is_active = is_active
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
|
||||
async def delete(self, ai_model_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == ai_model_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
class OrgPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: OrgPolicyRow) -> OrgPolicyRecord:
|
||||
return OrgPolicyRecord(
|
||||
organization_id=row.organization_id,
|
||||
max_employees=row.max_employees,
|
||||
monthly_credit_budget=row.monthly_credit_budget,
|
||||
automation_enabled=row.automation_enabled,
|
||||
mcp_enabled=row.mcp_enabled,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
async def get(self, organization_id: str) -> OrgPolicyRecord | None:
|
||||
row = await self.session.get(OrgPolicyRow, organization_id)
|
||||
return self._to_record(row) if row else None
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
max_employees: int,
|
||||
monthly_credit_budget: float | None,
|
||||
automation_enabled: bool,
|
||||
mcp_enabled: bool | None = None,
|
||||
) -> OrgPolicyRecord:
|
||||
row = await self.session.get(OrgPolicyRow, organization_id)
|
||||
if row is None:
|
||||
row = OrgPolicyRow(
|
||||
organization_id=organization_id,
|
||||
max_employees=max_employees,
|
||||
monthly_credit_budget=monthly_credit_budget,
|
||||
automation_enabled=automation_enabled,
|
||||
mcp_enabled=True if mcp_enabled is None else mcp_enabled,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.max_employees = max_employees
|
||||
row.monthly_credit_budget = monthly_credit_budget
|
||||
row.automation_enabled = automation_enabled
|
||||
if mcp_enabled is not None:
|
||||
row.mcp_enabled = mcp_enabled
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
@@ -2,10 +2,358 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
The rule this router enforces: **an organization may only use the models its
|
||||
owner granted it.** The endpoints here are the only way a grant appears, so
|
||||
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
|
||||
next call) rather than a change that only takes effect after a cache expires.
|
||||
|
||||
Everything is owner-only, guarded at the router level, and every mutation is
|
||||
audited. The model *names* live here and nowhere else: organization users only
|
||||
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from dataclasses import asdict
|
||||
|
||||
router = APIRouter(tags=["admin-grants"])
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.model_grant_service import (
|
||||
ModelGrantService,
|
||||
ModelView,
|
||||
OrgGrantView,
|
||||
PolicyView,
|
||||
)
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.identity.audit_actions import (
|
||||
DEFAULT_MODEL_GRANT_REMOVE,
|
||||
DEFAULT_MODEL_GRANT_SET,
|
||||
MODEL_GRANT_REVOKE,
|
||||
MODEL_GRANT_SET,
|
||||
ORG_POLICY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import User
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin-grants"],
|
||||
# Deny-by-default, exactly like the rest of the console.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
|
||||
async def get_model_grant_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelGrantService:
|
||||
from app.application.container_org import build_model_grant_service
|
||||
|
||||
return build_model_grant_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelViewOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
provider_id: str
|
||||
capabilities: list[str]
|
||||
tier: str
|
||||
tier_label: str
|
||||
output_price_per_1m: float
|
||||
model_active: bool
|
||||
granted: bool
|
||||
daily_credit_cap: float | None = None
|
||||
|
||||
|
||||
class OrgGrantOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
tier: str
|
||||
tier_label: str
|
||||
capabilities: list[str]
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class OrgGrantsOut(BaseModel):
|
||||
"""The grant matrix for one organization: every active model + its state."""
|
||||
|
||||
organization_id: str
|
||||
models: list[ModelViewOut]
|
||||
|
||||
|
||||
class SetGrantRequest(BaseModel):
|
||||
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class SetDefaultGrantRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class PolicyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
organization_id: str
|
||||
max_employees: int
|
||||
monthly_credit_budget: float | None = None
|
||||
automation_enabled: bool
|
||||
mcp_enabled: bool
|
||||
max_employees_limit: int
|
||||
|
||||
|
||||
class UpdatePolicyRequest(BaseModel):
|
||||
max_employees: int | None = Field(default=None, ge=1)
|
||||
monthly_credit_budget: float | None = Field(default=None, ge=0)
|
||||
automation_enabled: bool | None = None
|
||||
mcp_enabled: bool | None = None
|
||||
|
||||
|
||||
def _model_out(view: ModelView) -> ModelViewOut:
|
||||
return ModelViewOut(**asdict(view))
|
||||
|
||||
|
||||
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
|
||||
return OrgGrantOut(**asdict(view))
|
||||
|
||||
|
||||
def _policy_out(view: PolicyView) -> PolicyOut:
|
||||
return PolicyOut(**asdict(view))
|
||||
|
||||
|
||||
def _require_change(*values: object) -> None:
|
||||
if all(value is None for value in values):
|
||||
raise ValidationError("هیچ تغییری ارسال نشده است.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grants per organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
|
||||
async def list_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> OrgGrantsOut:
|
||||
"""Every active catalogue model, flagged with whether this org may use it."""
|
||||
models = await service.list_models(organization_id=organization_id)
|
||||
return OrgGrantsOut(
|
||||
organization_id=organization_id, models=[_model_out(m) for m in models]
|
||||
)
|
||||
|
||||
|
||||
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
|
||||
async def set_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
payload: SetGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
# FastAPI caches ``get_session`` per request, so this is the very session
|
||||
# the service writes through: the audit row lands in the same transaction.
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrgGrantOut:
|
||||
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
|
||||
granted = await service.grant_model(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_SET,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
# The model label is a name, not a secret, but keeping the audit rows
|
||||
# id-only makes them stable when the owner renames a model.
|
||||
metadata={"daily_credit_cap": payload.daily_credit_cap},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _grant_out(granted)
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
|
||||
async def list_active_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[OrgGrantOut]:
|
||||
"""Just the active grants — what the engine will actually be allowed to use."""
|
||||
grants = await service.list_grants(organization_id=organization_id, active_only=True)
|
||||
return [_grant_out(g) for g in grants]
|
||||
|
||||
|
||||
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
|
||||
async def revoke_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Revoke a model from one organization. Other organizations are unaffected."""
|
||||
await service.revoke_model(
|
||||
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_REVOKE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default set copied into every new organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/default-model-grants", response_model=list[ModelViewOut])
|
||||
async def list_default_grants(
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[ModelViewOut]:
|
||||
return [_model_out(m) for m in await service.list_defaults()]
|
||||
|
||||
|
||||
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
|
||||
async def set_default_grant(
|
||||
ai_model_id: str,
|
||||
payload: SetDefaultGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelViewOut:
|
||||
"""Add a model to the default set (or switch its default off without losing it)."""
|
||||
await service.set_default(
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
is_active=payload.is_active,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_SET,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
metadata={
|
||||
"is_active": payload.is_active,
|
||||
"daily_credit_cap": payload.daily_credit_cap,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
view = next(
|
||||
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
|
||||
)
|
||||
assert view is not None
|
||||
return _model_out(view)
|
||||
|
||||
|
||||
@router.delete("/default-model-grants/{ai_model_id}")
|
||||
async def remove_default_grant(
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Remove a model from the default set. Already-created orgs keep their grant."""
|
||||
await service.remove_default(ai_model_id=ai_model_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_REMOVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"ai_model_id": ai_model_id, "in_default_set": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def get_org_policy(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> PolicyOut:
|
||||
return _policy_out(await service.get_org_policy(organization_id))
|
||||
|
||||
|
||||
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def update_org_policy(
|
||||
organization_id: str,
|
||||
payload: UpdatePolicyRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PolicyOut:
|
||||
"""Set the employee cap, monthly budget and automation switch for one org."""
|
||||
_require_change(
|
||||
payload.max_employees,
|
||||
payload.monthly_credit_budget,
|
||||
payload.automation_enabled,
|
||||
payload.mcp_enabled,
|
||||
)
|
||||
policy = await service.set_org_policy(
|
||||
organization_id=organization_id,
|
||||
max_employees=payload.max_employees,
|
||||
monthly_credit_budget=payload.monthly_credit_budget,
|
||||
automation_enabled=payload.automation_enabled,
|
||||
mcp_enabled=payload.mcp_enabled,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=ORG_POLICY_UPDATE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={
|
||||
"max_employees": policy.max_employees,
|
||||
"monthly_credit_budget": policy.monthly_credit_budget,
|
||||
"automation_enabled": policy.automation_enabled,
|
||||
"mcp_enabled": policy.mcp_enabled,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
@@ -2,10 +2,341 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Two audiences, one rule each:
|
||||
|
||||
* an **organization admin** mints, lists and revokes its own tokens, and picks the
|
||||
scopes — a token can never be broader than the admin's own intent, and it can
|
||||
never be issued for another organization;
|
||||
* the **platform owner** holds the two kill switches: the gateway as a whole, and
|
||||
one organization's access to it.
|
||||
|
||||
The raw token is returned exactly once, by the create call, and is never stored —
|
||||
only its hash. Everything after that is the prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_model_grant_service
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.mcp_service import (
|
||||
MCP_SCOPES,
|
||||
McpService,
|
||||
as_utc,
|
||||
validate_scopes,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.audit_actions import (
|
||||
MCP_GLOBAL_SWITCH,
|
||||
MCP_ORG_SWITCH,
|
||||
MCP_TOKEN_CREATE,
|
||||
MCP_TOKEN_REVOKE,
|
||||
)
|
||||
from app.domain.identity.models import Role, User
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_platform_owner,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["mcp-tokens"])
|
||||
|
||||
#: Minting a token is admin+ (it grants a third party real access).
|
||||
_admin_only = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
|
||||
return McpService(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class McpTokenOut(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class McpTokenCreatedOut(McpTokenOut):
|
||||
"""The only response that ever carries the raw token."""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class McpTokenIn(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
|
||||
class McpSettingsOut(BaseModel):
|
||||
organization_id: str
|
||||
mcp_enabled: bool
|
||||
gateway_enabled: bool
|
||||
scopes: list[str]
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalOut(BaseModel):
|
||||
gateway_enabled: bool
|
||||
organizations: list[McpOrgStateOut]
|
||||
total_tokens: int
|
||||
|
||||
|
||||
class McpOrgStateOut(BaseModel):
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
mcp_enabled: bool
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class McpOrgSwitchIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _status(token) -> str:
|
||||
"""``active`` only while the token is neither revoked nor expired."""
|
||||
if token.revoked_at is not None:
|
||||
return "revoked"
|
||||
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _out(token) -> McpTokenOut:
|
||||
return McpTokenOut(
|
||||
id=token.id,
|
||||
organization_id=token.organization_id,
|
||||
name=token.name,
|
||||
prefix=token.prefix,
|
||||
scopes=token.scopes,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
revoked_at=token.revoked_at,
|
||||
created_at=token.created_at,
|
||||
status=_status(token),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organization-scoped tokens (admin+)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
|
||||
)
|
||||
async def list_tokens(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> list[McpTokenOut]:
|
||||
"""Every token this organization ever minted, with its live status.
|
||||
|
||||
Deliberately includes revoked and expired rows: the history of who could
|
||||
reach the organization is the part that matters after an incident.
|
||||
"""
|
||||
return [_out(token) for token in await service.list(context.org_id)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/mcp-tokens",
|
||||
response_model=McpTokenCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_token(
|
||||
payload: McpTokenIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpTokenCreatedOut:
|
||||
"""Mint a token. The raw value appears in this response and nowhere else."""
|
||||
issued = await service.issue(
|
||||
organization_id=context.org_id,
|
||||
name=payload.name,
|
||||
scopes=validate_scopes(payload.scopes),
|
||||
created_by_user_id=context.user.id,
|
||||
ttl_days=payload.ttl_days,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_CREATE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=issued.record.id,
|
||||
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
await service.revoke(organization_id=context.org_id, token_id=token_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_REVOKE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=token_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"id": token_id, "revoked": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
|
||||
)
|
||||
async def get_mcp_settings(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> McpSettingsOut:
|
||||
"""The switches this organization's own admins may see and the scope list."""
|
||||
policy = await build_model_grant_service(service.session).get_org_policy(
|
||||
context.org_id
|
||||
)
|
||||
tokens = await service.list(context.org_id)
|
||||
return McpSettingsOut(
|
||||
organization_id=context.org_id,
|
||||
mcp_enabled=policy.mcp_enabled,
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
scopes=list(MCP_SCOPES),
|
||||
token_count=sum(1 for token in tokens if _status(token) == "active"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner: the global switch and the per-organization switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/admin/mcp", response_model=McpGlobalOut)
|
||||
async def get_mcp_state(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpGlobalOut:
|
||||
"""Every organization with its MCP state and active token count."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.infrastructure.db.models import OrganizationRow
|
||||
|
||||
service = McpService(session)
|
||||
grants = build_model_grant_service(session)
|
||||
orgs = (
|
||||
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
counts = await service.count_per_org()
|
||||
states = [
|
||||
McpOrgStateOut(
|
||||
organization_id=org.id,
|
||||
name=org.name,
|
||||
slug=org.slug,
|
||||
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
|
||||
token_count=counts.get(org.id, 0),
|
||||
)
|
||||
for org in orgs
|
||||
]
|
||||
return McpGlobalOut(
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
organizations=states,
|
||||
total_tokens=sum(counts.values()),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/mcp")
|
||||
async def set_mcp_state(
|
||||
payload: McpGlobalIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Disable (or re-enable) the gateway platform-wide.
|
||||
|
||||
The switch is a setting rather than a table: it must hold even before any
|
||||
token exists, and a gateway that cannot reach the database should not be the
|
||||
thing deciding it can serve traffic.
|
||||
"""
|
||||
settings.mcp_gateway_enabled = bool(payload.enabled)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_GLOBAL_SWITCH,
|
||||
actor_user_id=owner.id,
|
||||
target_type="platform",
|
||||
target_id="mcp",
|
||||
metadata={"enabled": bool(payload.enabled)},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"gateway_enabled": settings.mcp_gateway_enabled}
|
||||
|
||||
|
||||
@router.put("/admin/mcp/organizations/{organization_id}")
|
||||
async def set_org_mcp_state(
|
||||
organization_id: str,
|
||||
payload: McpOrgSwitchIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""One organization's access to the gateway, independent of its tokens."""
|
||||
policy = await build_model_grant_service(session).set_mcp_enabled(
|
||||
organization_id=organization_id,
|
||||
enabled=bool(payload.enabled),
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_ORG_SWITCH,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={"enabled": policy.mcp_enabled},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -2,10 +2,317 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
|
||||
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
|
||||
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
|
||||
knows is a rule an agent can bypass.
|
||||
|
||||
Everything stored here went through ``redact`` on the way in, so no endpoint can
|
||||
ever return a credential: what you can read is what was safe to write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_memory_service
|
||||
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.memory_service import MemoryService
|
||||
from app.core.database import get_session
|
||||
from app.domain.identity.audit_actions import (
|
||||
MEMORY_CONFIRM,
|
||||
MEMORY_CREATE,
|
||||
MEMORY_DELETE,
|
||||
MEMORY_PIN,
|
||||
MEMORY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import Role
|
||||
from app.domain.memory.models import (
|
||||
KIND_LABELS,
|
||||
SOURCE_LABELS,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
)
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
#: Anyone with a membership reads; teaching is member+; editing is admin+.
|
||||
_teacher = require_role_at_least(Role.MEMBER)
|
||||
_editor = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
|
||||
return build_memory_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MemoryOut(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_label: str
|
||||
key: str | None = None
|
||||
content: str
|
||||
source: str
|
||||
source_label: str
|
||||
evidence_task_id: str | None = None
|
||||
pinned: bool
|
||||
confidence: float
|
||||
expires_at: str | None = None
|
||||
hits: int
|
||||
last_used_at: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class MemoryListOut(BaseModel):
|
||||
organization_id: str
|
||||
items: list[MemoryOut]
|
||||
total: int
|
||||
pinned: int
|
||||
chars: int
|
||||
|
||||
|
||||
class MemoryCreateIn(BaseModel):
|
||||
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryUpdateIn(BaseModel):
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryPinIn(BaseModel):
|
||||
pinned: bool = True
|
||||
|
||||
|
||||
def _out(memory: Memory) -> MemoryOut:
|
||||
return MemoryOut(
|
||||
id=memory.id,
|
||||
kind=str(memory.kind),
|
||||
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
|
||||
key=memory.key,
|
||||
content=memory.content,
|
||||
source=str(memory.source),
|
||||
source_label=SOURCE_LABELS.get(str(memory.source), ""),
|
||||
evidence_task_id=memory.evidence_task_id,
|
||||
pinned=memory.pinned,
|
||||
confidence=memory.confidence,
|
||||
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
|
||||
hits=memory.hits,
|
||||
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
|
||||
created_by_user_id=memory.created_by_user_id,
|
||||
created_at=memory.created_at.isoformat() if memory.created_at else None,
|
||||
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
async def _audit(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
*,
|
||||
action: str,
|
||||
context: OrgContext,
|
||||
target_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=action,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="memory",
|
||||
target_id=target_id,
|
||||
metadata=metadata or {},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
async def _announce(context: OrgContext, memory: Memory) -> None:
|
||||
"""Tell the tenant's open tabs that memory changed.
|
||||
|
||||
``memory.changed`` is published on the org channel and nowhere else: a
|
||||
memory belongs to one organization, and the realtime authorisation for
|
||||
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
|
||||
A failed publish is a lost refresh, never a failed write.
|
||||
"""
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={
|
||||
"memory_id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"pinned": memory.pinned,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
|
||||
async def list_memory(
|
||||
kind: str | None = Query(default=None, max_length=16),
|
||||
q: str | None = Query(default="", max_length=120),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: MemoryService = Depends(_service),
|
||||
) -> MemoryListOut:
|
||||
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
|
||||
|
||||
Filtering by ``kind`` is a convenience, not a security boundary: an
|
||||
organization's whole memory is visible to any member by design.
|
||||
"""
|
||||
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
|
||||
stats = await service.stats(context.org_id)
|
||||
return MemoryListOut(
|
||||
organization_id=context.org_id,
|
||||
items=[_out(m) for m in items],
|
||||
total=stats["total"],
|
||||
pinned=stats["pinned"],
|
||||
chars=stats["chars"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/memory",
|
||||
response_model=MemoryOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def teach_memory(
|
||||
payload: MemoryCreateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_teacher),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
|
||||
memory = await service.teach(
|
||||
organization_id=context.org_id,
|
||||
user_id=context.user.id,
|
||||
role=context.membership.role,
|
||||
kind=payload.kind,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_CREATE,
|
||||
context=context,
|
||||
target_id=memory.id,
|
||||
metadata={"kind": str(memory.kind), "key": memory.key},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
|
||||
async def edit_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryUpdateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
memory = await service.edit(
|
||||
organization_id=context.org_id,
|
||||
memory_id=memory_id,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
|
||||
async def pin_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryPinIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
"""Pin → always in context and never evicted; unpin → back to search."""
|
||||
memory = await service.set_pinned(
|
||||
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_PIN,
|
||||
context=context,
|
||||
target_id=memory_id,
|
||||
metadata={"pinned": payload.pinned},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
|
||||
async def confirm_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
|
||||
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
await service.delete(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
|
||||
)
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={"memory_id": memory_id, "deleted": True},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
"""MCP gateway (owned by Agent 1)."""
|
||||
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
|
||||
|
||||
``gateway`` holds the tools and their rules; ``server`` binds them to the
|
||||
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
|
||||
"""
|
||||
|
||||
from app.interfaces.mcp.gateway import (
|
||||
TOOL_SPECS,
|
||||
McpCallResult,
|
||||
McpError,
|
||||
McpGateway,
|
||||
build_gateway,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""The MCP tools themselves — framework-free, so they can be called directly.
|
||||
|
||||
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
|
||||
holds the actual work and is what the tests exercise. Four rules are enforced
|
||||
here, once, for every tool:
|
||||
|
||||
1. **The organization comes from the token, never from the arguments.** A tool
|
||||
body receives an authenticated ``TokenIdentity`` and uses its
|
||||
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
|
||||
agent cannot ask about a tenant it was not issued for.
|
||||
2. **A scope is checked before the work starts.** Not after.
|
||||
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
|
||||
and whether it succeeded), rate-limited, and its output redacted.
|
||||
4. **An error is an answer, not a crash**: the agent gets a plain message, and
|
||||
the exception never reaches the transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.application.container_org import (
|
||||
build_memory_service,
|
||||
build_model_grant_service,
|
||||
)
|
||||
from app.application.services.mcp_service import McpService, TokenIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.redact import redact
|
||||
from app.domain.memory.models import MemoryKind
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolSpec:
|
||||
"""One tool: its name, the scope it needs, and its JSON schema."""
|
||||
name: str
|
||||
scope: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
TOOL_SPECS: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
name="app_get_profile",
|
||||
scope="app:read",
|
||||
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریمورک، "
|
||||
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_list",
|
||||
scope="tasks:read",
|
||||
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_get",
|
||||
scope="tasks:read",
|
||||
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "شناسهٔ کار"},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_create",
|
||||
scope="tasks:write",
|
||||
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
|
||||
"و به کارمندان میسپارد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "عنوان کوتاه کار"},
|
||||
"description": {"type": "string", "description": "شرح کامل درخواست"},
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_write",
|
||||
scope="memory:write",
|
||||
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
|
||||
"هرگز ذخیره نمیشود.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["fact", "preference"],
|
||||
"description": "فقط «fact» یا «preference»",
|
||||
},
|
||||
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
|
||||
},
|
||||
"required": ["kind", "content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="knowledge_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در منابع دانش سازمان (اسناد بلند).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="pipeline_status",
|
||||
scope="delivery:read",
|
||||
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
#: The only kinds an external agent may write. A rule is the organization's own
|
||||
#: to set through the UI, and a lesson is the engine's to learn.
|
||||
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""A refusal the agent can read. Never carries a secret."""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class McpCallResult:
|
||||
"""What one tool call produced: a redacted payload, or a readable refusal."""
|
||||
|
||||
ok: bool
|
||||
payload: dict[str, Any]
|
||||
error: str | None = None
|
||||
code: str = "mcp_error"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
if self.ok:
|
||||
return self.payload
|
||||
return {
|
||||
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""The JSON text an agent receives. Always redacted, always short."""
|
||||
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class McpGateway:
|
||||
"""Executes the tools for one authenticated token.
|
||||
|
||||
``session_factory`` is injectable so the tests can point the gateway at their
|
||||
in-memory database; production passes nothing and gets the application's own
|
||||
factory, exactly like the workers.
|
||||
"""
|
||||
|
||||
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
|
||||
from app.application.services.mcp_service import TokenRateLimiter
|
||||
|
||||
self._session_factory = session_factory
|
||||
self._limiter = rate_limiter or TokenRateLimiter()
|
||||
|
||||
# -- public API ----------------------------------------------------------
|
||||
|
||||
async def call(
|
||||
self,
|
||||
*,
|
||||
tool: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
token: str,
|
||||
identity: TokenIdentity | None = None,
|
||||
) -> McpCallResult:
|
||||
"""Run one tool for one token. Every failure mode is an answer."""
|
||||
spec = _SPECS_BY_NAME.get(tool)
|
||||
if spec is None:
|
||||
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
|
||||
args = arguments or {}
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# The owner's kill switch. Nothing is attributable yet, so this lands
|
||||
# in the error ledger rather than in one organization's audit trail.
|
||||
await _note("mcp.gateway_disabled", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
|
||||
code="mcp_disabled",
|
||||
)
|
||||
|
||||
factory = self._session_factory or _default_factory()
|
||||
async with factory() as session:
|
||||
service = McpService(session)
|
||||
identity = identity or await service.verify(token)
|
||||
if identity is None:
|
||||
# A token that does not exist, was revoked, or expired is one
|
||||
# answer — and it is unattributable, so it goes to the error
|
||||
# ledger where a human can see the pattern.
|
||||
await _note("mcp.invalid_token", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"توکن نامعتبر یا منقضی است.", code="invalid_token"
|
||||
)
|
||||
|
||||
async def refuse(message: str, code: str) -> McpCallResult:
|
||||
"""Every refusal from here on is audited against this organization.
|
||||
|
||||
The audit row is committed inside the refusal: a denial nobody can
|
||||
see afterwards is not an audit trail, it is a hope.
|
||||
"""
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
if not identity.allows(spec.scope):
|
||||
return await refuse(
|
||||
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
|
||||
)
|
||||
policy = await build_model_grant_service(session).get_org_policy(
|
||||
identity.organization_id
|
||||
)
|
||||
if not policy.mcp_enabled:
|
||||
return await refuse(
|
||||
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
|
||||
)
|
||||
if not self._limiter.allow(identity.token_id):
|
||||
return await refuse(
|
||||
"تعداد درخواستها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
|
||||
"rate_limited",
|
||||
)
|
||||
|
||||
handler = _HANDLERS[spec.name]
|
||||
try:
|
||||
payload = await handler(self, session=session, identity=identity, **args)
|
||||
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
|
||||
message = str(getattr(exc, "message", exc))
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
|
||||
except TypeError:
|
||||
# An argument shape the tool did not expect: the agent's fault,
|
||||
# not the platform's.
|
||||
return await refuse(
|
||||
"آرگومانهای ابزار نامعتبر است.", "bad_arguments"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - an outage is an answer too
|
||||
# Anything else is infrastructure, and its message may contain a
|
||||
# DSN or a heap address. The agent gets a plain sentence; the
|
||||
# operator gets the real error in the error console.
|
||||
await record_error(
|
||||
kind="mcp.tool_crashed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
organization_id=identity.organization_id,
|
||||
metadata={"tool": spec.name, "prefix": identity.prefix},
|
||||
)
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error="internal"
|
||||
)
|
||||
await session.commit()
|
||||
return self._fail(
|
||||
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
|
||||
)
|
||||
await service.touch(identity.token_id)
|
||||
await self._audit(session, identity=identity, spec=spec, ok=True)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=True, payload=payload)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _fail(message: str, *, code: str) -> McpCallResult:
|
||||
"""A refusal is a normal answer: the agent gets a message it can act on."""
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
async def _audit(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
identity: TokenIdentity,
|
||||
spec: ToolSpec,
|
||||
ok: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""One audit row per call. Never allowed to fail the call itself."""
|
||||
from app.application.services.audit_service import record_audit
|
||||
|
||||
try:
|
||||
await record_audit(
|
||||
session,
|
||||
action="mcp.tool_call",
|
||||
organization_id=identity.organization_id,
|
||||
actor_user_id=None,
|
||||
target_type="mcp_tool",
|
||||
target_id=spec.name,
|
||||
metadata={
|
||||
"prefix": identity.prefix,
|
||||
"scope": spec.scope,
|
||||
"ok": ok,
|
||||
"error": error[:200],
|
||||
},
|
||||
ip="mcp",
|
||||
user_agent=f"mcp/{identity.prefix}",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - auditing must not fail a tool call
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _app_profile(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.infrastructure.repositories.app_profile import AppProfileRepository
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
|
||||
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
|
||||
if profile is None:
|
||||
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
|
||||
project = await ProjectRepository(session).get(profile.project_id)
|
||||
return {
|
||||
"project_name": project.name if project else "",
|
||||
"project_slug": project.slug if project else "",
|
||||
"app_type": profile.app_type,
|
||||
"language": profile.language,
|
||||
"framework": profile.framework,
|
||||
"runtime_version": profile.runtime_version,
|
||||
"package_manager": profile.package_manager,
|
||||
"setup_command": profile.setup_command,
|
||||
"build_command": profile.build_command,
|
||||
"test_command": profile.test_command,
|
||||
"start_command": profile.start_command,
|
||||
"app_port": profile.app_port,
|
||||
"healthcheck_path": profile.healthcheck_path,
|
||||
"default_branch": profile.default_branch,
|
||||
"git_source": profile.git_source,
|
||||
"repo_url": profile.external_repo_url,
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_list(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
status: str | None = None,
|
||||
limit: int = 25,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
from app.domain.workforce.models import TaskStatus
|
||||
|
||||
service = build_workforce_service(session)
|
||||
parsed: TaskStatus | None = None
|
||||
if status:
|
||||
try:
|
||||
parsed = TaskStatus(str(status).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("وضعیت کار نامعتبر است.") from exc
|
||||
tasks = await service.list_tasks(
|
||||
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
|
||||
)
|
||||
return {
|
||||
"tasks": [
|
||||
{
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"assigned_employee_id": task.assigned_employee_id,
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_get(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not task_id:
|
||||
raise ValidationError("شناسهٔ کار لازم است.")
|
||||
service = build_workforce_service(session)
|
||||
task = await service.get_task_or_404(
|
||||
organization_id=identity.organization_id, task_id=task_id
|
||||
)
|
||||
return {
|
||||
"task": {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"project_id": task.project_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_create(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
title: str,
|
||||
description: str = "",
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""A user order to the manager: the current public signature, untouched."""
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not title or not title.strip():
|
||||
raise ValidationError("عنوان کار لازم است.")
|
||||
creator = await McpService(session).creator_of(identity.token_id)
|
||||
service = build_workforce_service(session)
|
||||
task = await service.create_task(
|
||||
organization_id=identity.organization_id,
|
||||
created_by_user_id=creator or "",
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
|
||||
|
||||
|
||||
async def _memory_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str = "",
|
||||
kind: str | None = None,
|
||||
limit: int = 10,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
service = build_memory_service(session)
|
||||
items = await service.list(
|
||||
identity.organization_id, kind=kind, search=query or None
|
||||
)
|
||||
return {
|
||||
"memories": [
|
||||
{
|
||||
"id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"key": memory.key,
|
||||
"content": memory.content,
|
||||
"pinned": memory.pinned,
|
||||
"confidence": memory.confidence,
|
||||
}
|
||||
for memory in items[: max(1, min(int(limit), 50))]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _memory_write(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
kind: str,
|
||||
content: str,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.domain.memory.models import coerce_kind
|
||||
|
||||
entry_kind = coerce_kind(kind)
|
||||
if entry_kind not in MCP_WRITABLE_KINDS:
|
||||
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
|
||||
service = build_memory_service(session)
|
||||
memory_id = await service.record(
|
||||
organization_id=identity.organization_id,
|
||||
kind=str(entry_kind),
|
||||
content=content,
|
||||
source="user",
|
||||
)
|
||||
if memory_id is None: # pragma: no cover - the task cap cannot apply here
|
||||
raise McpError("یادداشت ذخیره نشد.")
|
||||
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
|
||||
|
||||
|
||||
async def _knowledge_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_knowledge_service
|
||||
|
||||
if not query:
|
||||
raise ValidationError("عبارت جستوجو لازم است.")
|
||||
service = build_knowledge_service(session)
|
||||
hits = await service.search(
|
||||
organization_id=identity.organization_id,
|
||||
query=query,
|
||||
limit=max(1, min(int(limit), 20)),
|
||||
)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"content": hit.get("content", ""),
|
||||
"source_name": hit.get("source_name", ""),
|
||||
"document_title": hit.get("document_title", ""),
|
||||
}
|
||||
for hit in hits
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _pipeline_status(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Delivery is another workstream's table; the tool exists and says so."""
|
||||
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
|
||||
|
||||
|
||||
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
|
||||
"app_get_profile": _app_profile,
|
||||
"tasks_list": _tasks_list,
|
||||
"tasks_get": _tasks_get,
|
||||
"tasks_create": _tasks_create,
|
||||
"memory_search": _memory_search,
|
||||
"memory_write": _memory_write,
|
||||
"knowledge_search": _knowledge_search,
|
||||
"pipeline_status": _pipeline_status,
|
||||
}
|
||||
|
||||
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
|
||||
|
||||
|
||||
def _default_factory():
|
||||
from app.core.database import SessionFactory
|
||||
|
||||
return SessionFactory
|
||||
|
||||
|
||||
async def _note(kind: str, detail: str) -> None:
|
||||
"""Record an unattributable attempt in the error ledger, best-effort.
|
||||
|
||||
A bad token and a disabled gateway happen before the caller is known, so they
|
||||
belong to nobody's audit trail — but they are exactly the patterns an operator
|
||||
needs to see, which is what the error console is for.
|
||||
"""
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
try:
|
||||
await record_error(kind=kind, message=detail, source="mcp")
|
||||
except Exception: # noqa: BLE001 - telemetry must never fail a call
|
||||
return
|
||||
|
||||
|
||||
def build_gateway(*, session_factory=None) -> McpGateway:
|
||||
"""The gateway the server and the tests both use.
|
||||
|
||||
Each gateway owns its own in-memory rate limiter: one process, one budget.
|
||||
"""
|
||||
return McpGateway(session_factory=session_factory)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_WRITABLE_KINDS",
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
@@ -1,14 +1,178 @@
|
||||
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
|
||||
"""The MCP gateway server: the official SDK, bound to our tools.
|
||||
|
||||
This module is the *adapter*, not the logic. The tools live in
|
||||
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
|
||||
happens here is transport only:
|
||||
|
||||
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
|
||||
the argument schema the gateway validates;
|
||||
* every call resolves the caller's token from the ``Authorization: Bearer``
|
||||
header — never from the arguments — and hands it to the gateway;
|
||||
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
|
||||
tool result: an agent sees "this token does not have that scope" instead of a
|
||||
stack trace;
|
||||
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
|
||||
runs. Putting Caddy in front of it is the owner's job (see
|
||||
``services/mcp-gateway/README.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(
|
||||
"MCP gateway is not implemented yet "
|
||||
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
|
||||
from mcp.server.mcpserver.context import Context
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redact import redact
|
||||
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
|
||||
|
||||
#: How a client authenticates. One header, one token, no query parameter: a
|
||||
#: token in a URL ends up in proxy logs and browser history.
|
||||
BEARER_PREFIX = "bearer "
|
||||
|
||||
|
||||
def _bearer_token(ctx: Any) -> str:
|
||||
"""The bearer token of this request, or an empty string.
|
||||
|
||||
``ctx.headers`` raises when there is no request at all (a stdio transport, or
|
||||
a direct call in a test), so the read is defensive by design: no request
|
||||
means no token, which means no tool runs.
|
||||
"""
|
||||
try:
|
||||
headers = getattr(ctx, "headers", None) if ctx is not None else None
|
||||
except ValueError: # pragma: no cover - "Context not available"
|
||||
return ""
|
||||
if not headers:
|
||||
return ""
|
||||
raw = headers.get("authorization") or headers.get("Authorization") or ""
|
||||
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
|
||||
raw = raw.decode("utf-8", "ignore")
|
||||
if raw.lower().startswith(BEARER_PREFIX):
|
||||
return raw[len(BEARER_PREFIX) :].strip()
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _build_server(gateway: McpGateway) -> Any:
|
||||
"""Build the SDK server with our eight tools registered."""
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
server: Any = MCPServer(
|
||||
name="smart-org-gateway",
|
||||
version="1.0.0",
|
||||
instructions=(
|
||||
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
|
||||
"میشود، نه از آرگومانها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
|
||||
"میشود."
|
||||
),
|
||||
)
|
||||
|
||||
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
|
||||
result: McpCallResult = await gateway.call(
|
||||
tool=tool, arguments=arguments, token=_bearer_token(ctx)
|
||||
)
|
||||
if not result.ok:
|
||||
raise ToolError(result.error or "عملیات ناموفق بود.")
|
||||
# Redacted on the way out as well as on the way in: a tool may return a
|
||||
# stored value that predates a redaction rule, and the agent is outside
|
||||
# the platform's trust boundary.
|
||||
payload: dict[str, Any] = redact(result.payload)
|
||||
return payload
|
||||
|
||||
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریمورک، دستورها، پورت."""
|
||||
return await _dispatch("app_get_profile", ctx)
|
||||
|
||||
async def tasks_list(
|
||||
status: str | None = None, limit: int = 25, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
|
||||
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
|
||||
|
||||
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""یک کار بر اساس شناسهٔ آن."""
|
||||
return await _dispatch("tasks_get", ctx, task_id=task_id)
|
||||
|
||||
async def tasks_create(
|
||||
title: str, description: str = "", ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""ثبت یک کار تازه برای مدیر سازمان."""
|
||||
return await _dispatch("tasks_create", ctx, title=title, description=description)
|
||||
|
||||
async def memory_search(
|
||||
query: str = "", kind: str | None = None, limit: int = 10,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد."""
|
||||
return await _dispatch(
|
||||
"memory_search", ctx, query=query, kind=kind, limit=limit
|
||||
)
|
||||
|
||||
async def memory_write(
|
||||
kind: str, content: str, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
|
||||
return await _dispatch("memory_write", ctx, kind=kind, content=content)
|
||||
|
||||
async def knowledge_search(
|
||||
query: str, limit: int = 5, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در منابع دانش سازمان."""
|
||||
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
|
||||
|
||||
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""وضعیت خط تحویل (استقرار و دمو)."""
|
||||
return await _dispatch("pipeline_status", ctx)
|
||||
|
||||
for tool in (
|
||||
app_get_profile,
|
||||
tasks_list,
|
||||
tasks_get,
|
||||
tasks_create,
|
||||
memory_search,
|
||||
memory_write,
|
||||
knowledge_search,
|
||||
pipeline_status,
|
||||
):
|
||||
server.add_tool(
|
||||
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def build_app(gateway: McpGateway | None = None) -> Any:
|
||||
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
|
||||
server = _build_server(gateway or build_gateway())
|
||||
return server.streamable_http_app(
|
||||
streamable_http_path="/mcp", host=settings.mcp_gateway_host
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# Starting anyway is right: the switch is the owner's, and the process
|
||||
# answering "disabled" is more diagnosable than a missing port.
|
||||
print(
|
||||
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
|
||||
uvicorn.run(
|
||||
_build_server(build_gateway()).streamable_http_app(
|
||||
streamable_http_path="/mcp", host=host
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - the entry point
|
||||
main()
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
import app.application.container as container
|
||||
import app.application.container_org as container_org
|
||||
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
||||
from app.application.ports.deploy_target import (
|
||||
DeployTargetSpec,
|
||||
@@ -109,7 +110,16 @@ async def test_app_profile_repository_is_tenant_scoped(session_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_model_grants_return_active_models_with_tiers(session_factory):
|
||||
async def test_granted_models_carry_tiers_and_need_an_owner_grant(session_factory):
|
||||
"""The real grant service (Phase 3) replaced the "all active models" stub.
|
||||
|
||||
The foundation version asserted the placeholder wiring (every active model
|
||||
usable by every organization). The behaviour the plan changes on purpose is
|
||||
exactly that: an organization now sees a model only after the owner granted
|
||||
it. The assertions below are strictly stronger — they still pin the tier
|
||||
labels, and additionally pin the two switches (the model must be active
|
||||
*and* granted) plus the per-organization scope.
|
||||
"""
|
||||
async with session_factory() as session:
|
||||
provider = AiProviderRow(id=new_id(), name="agy-test")
|
||||
session.add(provider)
|
||||
@@ -135,10 +145,28 @@ async def test_default_model_grants_return_active_models_with_tiers(session_fact
|
||||
|
||||
grants = container.build_model_grants(session)
|
||||
assert isinstance(grants, ModelGrantPort)
|
||||
granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
|
||||
org_id, _ = await _org_with_user(session, "granted")
|
||||
other_id, _ = await _org_with_user(session, "untouched")
|
||||
# Nothing is usable until the owner grants it.
|
||||
assert await grants.granted_models(organization_id=org_id) == []
|
||||
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
||||
|
||||
# ``container.py`` is locked and re-exports only ``build_model_grants``;
|
||||
# the typed builder lives in the workstream's own container.
|
||||
service = container_org.build_model_grant_service(session)
|
||||
await service.grant_model(organization_id=org_id, ai_model_id="m-strong")
|
||||
await service.grant_model(organization_id=org_id, ai_model_id="m-fast")
|
||||
|
||||
granted = {
|
||||
g.ai_model_id: g.tier
|
||||
for g in await grants.granted_models(organization_id=org_id)
|
||||
}
|
||||
assert granted == {"m-strong": "strong", "m-fast": "fast"}
|
||||
assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
|
||||
assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
|
||||
assert await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
||||
# Inactive in the catalogue: never granted, whatever the grant table says.
|
||||
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-off")
|
||||
# Another organization is unaffected by this one's grants.
|
||||
assert await grants.granted_models(organization_id=other_id) == []
|
||||
|
||||
|
||||
def test_tier_for_never_uses_names():
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"""MCP gateway: scoped tokens, tool calls, and what they may never do.
|
||||
|
||||
The invariants under test:
|
||||
|
||||
* a token from organization A can never read organization B — not by asking, not
|
||||
by passing an ``organization_id``, not by guessing an id;
|
||||
* a scope is a floor: a token minted ``tasks:read`` cannot create a task, and a
|
||||
token minted ``memory:write`` cannot write a *rule* (only ``fact`` and
|
||||
``preference``);
|
||||
* a revoked or expired token is indistinguishable from an unknown one;
|
||||
* the rate limit refuses the 61st call in a minute;
|
||||
* every call leaves an audit row carrying the token prefix and the tool;
|
||||
* no secret appears in any tool output — the reflection redacts, and a token
|
||||
never is part of a payload.
|
||||
|
||||
The tests drive the gateway directly (``McpGateway.call``), which is the layer
|
||||
the SDK server is a thin adapter around: the transport is the SDK's problem, the
|
||||
rules are ours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.services.mcp_service import (
|
||||
PREFIX_DISPLAY_CHARS,
|
||||
TOKEN_PREFIX,
|
||||
McpService,
|
||||
TokenRateLimiter,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import PlatformRole, Role
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.db.models_org import McpTokenRow
|
||||
from app.interfaces.mcp.gateway import McpGateway
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
|
||||
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.value)
|
||||
)
|
||||
await session.commit()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
async def _gateway(session_factory, *, limit: int | None = None) -> McpGateway:
|
||||
return McpGateway(
|
||||
session_factory=session_factory,
|
||||
rate_limiter=TokenRateLimiter(limit_per_minute=limit),
|
||||
)
|
||||
|
||||
|
||||
async def _token(
|
||||
session_factory, gateway: McpGateway, org_id: str, *, scopes: list[str],
|
||||
user_id: str | None = None,
|
||||
) -> str:
|
||||
async with session_factory() as session:
|
||||
issued = await McpService(session).issue(
|
||||
organization_id=org_id, name="agent", scopes=scopes,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
return issued.token
|
||||
|
||||
|
||||
async def _call(gateway: McpGateway, token: str, tool: str, **arguments):
|
||||
return await gateway.call(tool=tool, arguments=arguments, token=token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_token_is_shown_once_and_stored_only_as_a_hash(session_factory):
|
||||
org_id, user_id = await _org(session_factory, "issue")
|
||||
async with session_factory() as session:
|
||||
issued = await McpService(session).issue(
|
||||
organization_id=org_id,
|
||||
name="agent-1",
|
||||
scopes=["tasks:read", "tasks:write"],
|
||||
created_by_user_id=user_id,
|
||||
ttl_days=30,
|
||||
)
|
||||
await session.commit()
|
||||
raw = issued.token
|
||||
rows = (await session.execute(select(McpTokenRow))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
# Only the hash and the display prefix exist; the token itself is gone.
|
||||
assert all(raw not in (row.token_hash or "") for row in rows)
|
||||
assert all(row.prefix == raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS] for row in rows)
|
||||
assert issued.record.scopes == ["tasks:read", "tasks:write"]
|
||||
|
||||
|
||||
async def test_unknown_revoked_and_expired_are_all_the_same_answer(session_factory):
|
||||
org_id, _ = await _org(session_factory, "verify")
|
||||
gateway = await _gateway(session_factory)
|
||||
raw = await _token(session_factory, gateway, org_id, scopes=["tasks:read"])
|
||||
|
||||
async with session_factory() as session:
|
||||
service = McpService(session)
|
||||
identity = await service.verify(raw)
|
||||
assert identity is not None and identity.organization_id == org_id
|
||||
# A token for another org id is not this org's business.
|
||||
assert (await service.verify("smo_" + "x" * 24)) is None
|
||||
|
||||
row = (await session.execute(select(McpTokenRow))).scalar_one()
|
||||
row.revoked_at = utcnow()
|
||||
await session.commit()
|
||||
assert await service.verify(raw) is None
|
||||
row.revoked_at = None
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await session.commit()
|
||||
assert await service.verify(raw) is None
|
||||
|
||||
|
||||
async def test_unknown_scopes_are_refused_at_mint_time(session_factory):
|
||||
org_id, _ = await _org(session_factory, "scopes")
|
||||
async with session_factory() as session:
|
||||
with pytest.raises(ValidationError):
|
||||
await McpService(session).issue(
|
||||
organization_id=org_id, name="x", scopes=["root:everything"]
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
await McpService(session).issue(organization_id=org_id, name="x", scopes=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant isolation and scope enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_one_organizations_token_cannot_read_anothers_app(session_factory, seeded):
|
||||
org_a, _ = await _org(session_factory, "mcp-a")
|
||||
org_b, user_b = await _org(session_factory, "mcp-b")
|
||||
gateway = await _gateway(session_factory)
|
||||
token_a = await _token(
|
||||
session_factory, gateway, org_a, scopes=["app:read", "tasks:read"]
|
||||
)
|
||||
|
||||
# org A has no project yet, so the honest answer is a refusal, not org B's
|
||||
# data. The important part is what it is *not*: never org B's app.
|
||||
result = await _call(gateway, token_a, "app_get_profile")
|
||||
assert result.ok is False
|
||||
|
||||
# Even with the id handed to it, the tool refuses: identity comes from the token.
|
||||
result = await _call(gateway, token_a, "tasks_list")
|
||||
assert result.ok is True
|
||||
assert result.payload == {"tasks": []}
|
||||
|
||||
# A token minted for B does not exist for A either.
|
||||
other = await _token(session_factory, gateway, org_b, scopes=["app:read"], user_id=user_b)
|
||||
assert (await _call(gateway, other, "app_get_profile")).ok is False
|
||||
|
||||
|
||||
async def test_scope_enforcement_is_per_tool(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "scope-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
reader = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
|
||||
# Reading is allowed.
|
||||
listed = await _call(gateway, reader, "tasks_list")
|
||||
assert listed.ok is True
|
||||
# Writing is not: the scope is missing, and the refusal names the reason.
|
||||
created = await _call(gateway, reader, "tasks_create", title="کار تازه")
|
||||
assert created.ok is False
|
||||
assert created.code == "scope_denied"
|
||||
assert "دسترسی" in (created.error or "")
|
||||
|
||||
# A refused write leaves nothing behind.
|
||||
async with session_factory() as session:
|
||||
from app.infrastructure.db.models import TaskRow
|
||||
|
||||
rows = (await session.execute(select(TaskRow))).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_memory_write_through_a_token_is_only_fact_or_preference(
|
||||
session_factory, seeded
|
||||
):
|
||||
org_id, user_id = await _org(session_factory, "mem-write")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
ok = await _call(
|
||||
gateway, token, "memory_write", kind="fact", content="پورت اپ ۸۰۰۰ است."
|
||||
)
|
||||
assert ok.ok is True
|
||||
preference = await _call(
|
||||
gateway, token, "memory_write", kind="preference", content="ترجیح: فارسی بنویس."
|
||||
)
|
||||
assert preference.ok is True
|
||||
|
||||
# A rule is not an agent's to set, and a lesson is the engine's to learn.
|
||||
refused_rule = await _call(
|
||||
gateway, token, "memory_write", kind="rule", content="همیشه تست بنویس."
|
||||
)
|
||||
assert refused_rule.ok is False
|
||||
refused_lesson = await _call(
|
||||
gateway, token, "memory_write", kind="lesson", content="درس تازه."
|
||||
)
|
||||
assert refused_lesson.ok is False
|
||||
|
||||
# What it wrote is searchable, and what it may not write is not there.
|
||||
found = await _call(gateway, token, "memory_search", query="پورت")
|
||||
assert found.ok is True
|
||||
contents = [m["content"] for m in found.payload["memories"]]
|
||||
assert any("۸۰۰۰" in content for content in contents)
|
||||
assert not any("همیشه تست بنویس" in content for content in contents)
|
||||
|
||||
|
||||
async def test_a_secret_written_through_a_token_never_comes_back(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "mem-secret")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
|
||||
user_id=user_id,
|
||||
)
|
||||
await _call(
|
||||
gateway, token, "memory_write", kind="fact",
|
||||
content="کلید CI استفادهشده ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است.",
|
||||
)
|
||||
found = await _call(gateway, token, "memory_search", query="کلید")
|
||||
assert found.ok is True
|
||||
text = found.to_json()
|
||||
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in text
|
||||
|
||||
|
||||
async def test_knowledge_and_pipeline_tools(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "knowledge-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "delivery:read"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# No sources yet: an empty result, not an error.
|
||||
empty = await _call(gateway, token, "knowledge_search", query="هرچی")
|
||||
assert empty.ok is True
|
||||
assert empty.payload["results"] == []
|
||||
|
||||
# Delivery is another workstream's table; the tool exists and says so.
|
||||
status = await _call(gateway, token, "pipeline_status")
|
||||
assert status.ok is True
|
||||
assert status.payload["available"] is False
|
||||
|
||||
|
||||
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
|
||||
session_factory, seeded
|
||||
):
|
||||
org_id, user_id = await _org(session_factory, "task-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:write", "tasks:read"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
created = await _call(
|
||||
gateway, token, "tasks_create", title="پیادهسازی ورود", description="صفحهٔ ورود"
|
||||
)
|
||||
assert created.ok is True, created.error
|
||||
task_id = created.payload["task"]["id"]
|
||||
|
||||
fetched = await _call(gateway, token, "tasks_get", task_id=task_id)
|
||||
assert fetched.ok is True
|
||||
assert fetched.payload["task"]["title"] == "پیادهسازی ورود"
|
||||
|
||||
# The write is attributed to the administrator who minted the token: a
|
||||
# delegated key never erases who holds it.
|
||||
async with session_factory() as session:
|
||||
from app.infrastructure.db.models import TaskRow
|
||||
|
||||
row = await session.get(TaskRow, task_id)
|
||||
assert row is not None
|
||||
assert row.created_by_user_id == user_id
|
||||
|
||||
# …and a task from another organization is a 404, not data.
|
||||
other_org, _ = await _org(session_factory, "task-other")
|
||||
other_token = await _token(
|
||||
session_factory, gateway, other_org, scopes=["tasks:read", "tasks:write"]
|
||||
)
|
||||
foreign = await _call(gateway, other_token, "tasks_get", task_id=task_id)
|
||||
assert foreign.ok is False
|
||||
|
||||
|
||||
async def test_a_bad_token_or_a_disabled_switch_is_refused_everywhere(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "off-switch")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
|
||||
assert (await _call(gateway, "smo_wrong", "tasks_list")).ok is False
|
||||
assert (await _call(gateway, "", "tasks_list")).ok is False
|
||||
assert (await _call(gateway, token, "no_such_tool")).ok is False
|
||||
|
||||
original = settings.mcp_gateway_enabled
|
||||
settings.mcp_gateway_enabled = False
|
||||
try:
|
||||
result = await _call(gateway, token, "tasks_list")
|
||||
assert result.ok is False
|
||||
assert result.code == "mcp_disabled"
|
||||
finally:
|
||||
settings.mcp_gateway_enabled = original
|
||||
|
||||
# The per-organization switch is the owner's, not the token's.
|
||||
async with session_factory() as session:
|
||||
from app.application.container_org import build_model_grant_service
|
||||
|
||||
await build_model_grant_service(session).set_mcp_enabled(
|
||||
organization_id=org_id, enabled=False, actor_user_id=user_id
|
||||
)
|
||||
await session.commit()
|
||||
result = await _call(gateway, token, "tasks_list")
|
||||
assert result.ok is False
|
||||
assert result.code == "org_disabled"
|
||||
|
||||
|
||||
async def test_the_rate_limit_refuses_the_sixty_first_call(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "rate")
|
||||
gateway = await _gateway(session_factory, limit=5)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
codes = [
|
||||
(await _call(gateway, token, "tasks_list")).ok for _ in range(7)
|
||||
]
|
||||
assert codes == [True] * 5 + [False, False]
|
||||
limited = await _call(gateway, token, "tasks_list")
|
||||
assert limited.code == "rate_limited"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_call_leaves_an_audit_row_with_the_prefix(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "audit")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
await _call(gateway, token, "tasks_list")
|
||||
await _call(gateway, token, "tasks_create", title="رد شده")
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(AuditEventRow).where(
|
||||
AuditEventRow.organization_id == org_id,
|
||||
AuditEventRow.action == "mcp.tool_call",
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 2
|
||||
by_tool = {row.target_id: row for row in rows}
|
||||
assert set(by_tool) == {"tasks_list", "tasks_create"}
|
||||
# The prefix identifies the token; the token itself never appears.
|
||||
prefix = token[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
|
||||
assert all(row.metadata_json["prefix"] == prefix for row in rows)
|
||||
assert all(token not in str(row.metadata_json) for row in rows)
|
||||
assert by_tool["tasks_list"].metadata_json["ok"] is True
|
||||
assert by_tool["tasks_create"].metadata_json["ok"] is False
|
||||
assert by_tool["tasks_create"].metadata_json["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface: tokens and the owner's switches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
|
||||
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
|
||||
org = await client.post(
|
||||
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
return {"token": body["access_token"], "org_id": org.json()["id"]}
|
||||
|
||||
|
||||
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
|
||||
invite = await client.post(
|
||||
f"/api/organizations/{org_id}/invites",
|
||||
json={"email": email, "role": role},
|
||||
headers=auth_header(owner_token),
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
account = await signup(client, email, name=role)
|
||||
accepted = await client.post(
|
||||
"/api/invites/accept",
|
||||
json={"token": invite.json()["invite_token"]},
|
||||
headers=auth_header(account["access_token"]),
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
return account["access_token"]
|
||||
|
||||
|
||||
async def test_only_an_admin_mints_tokens_and_only_the_owner_switches_the_gateway(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
):
|
||||
org = await _owner_with_org(client, "mcp-org")
|
||||
viewer = await _member(client, org["token"], org["org_id"], "v@example.com", "viewer")
|
||||
admin = await _member(client, org["token"], org["org_id"], "a@example.com", "admin")
|
||||
|
||||
# A viewer reads the list but cannot mint.
|
||||
listing = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(viewer)
|
||||
)
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert listing.json() == []
|
||||
refused = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "nope", "scopes": ["tasks:read"]},
|
||||
headers=auth_header(viewer),
|
||||
)
|
||||
assert refused.status_code == 403, refused.text
|
||||
|
||||
# The owner's switches are the owner's.
|
||||
not_owner = await client.get("/api/admin/mcp", headers=auth_header(manager_token))
|
||||
assert not_owner.status_code == 403
|
||||
anonymous = await client.get("/api/admin/mcp")
|
||||
assert anonymous.status_code == 401
|
||||
|
||||
# An admin mints one, sees the raw token once, and it never returns.
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "agent-1", "scopes": ["tasks:read", "tasks:write"]},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["token"].startswith("smo_")
|
||||
assert body["prefix"] == body["token"][: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
|
||||
assert body["scopes"] == ["tasks:read", "tasks:write"]
|
||||
assert body["status"] == "active"
|
||||
|
||||
again = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(admin)
|
||||
)
|
||||
assert again.status_code == 200, again.text
|
||||
assert "token" not in again.json()[0]
|
||||
assert again.json()[0]["prefix"] == body["prefix"]
|
||||
|
||||
# An unknown scope is refused rather than silently dropped.
|
||||
bad_scope = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "bad", "scopes": ["root:all"]},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert bad_scope.status_code == 422, bad_scope.text
|
||||
|
||||
# Revoke, then revoking again is a 404 — the same idempotence rule as grants.
|
||||
revoked = await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
|
||||
)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
twice = await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
|
||||
)
|
||||
assert twice.status_code == 404
|
||||
|
||||
# The owner sees the gateway state and may switch it and one organization.
|
||||
state = await client.get("/api/admin/mcp", headers=auth_header(platform_owner_token))
|
||||
assert state.status_code == 200, state.text
|
||||
assert state.json()["gateway_enabled"] is True
|
||||
assert any(
|
||||
org_row["organization_id"] == org["org_id"]
|
||||
for org_row in state.json()["organizations"]
|
||||
)
|
||||
|
||||
switched = await client.put(
|
||||
"/api/admin/mcp",
|
||||
json={"enabled": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert switched.status_code == 200, switched.text
|
||||
assert switched.json()["gateway_enabled"] is False
|
||||
org_switch = await client.put(
|
||||
f"/api/admin/mcp/organizations/{org['org_id']}",
|
||||
json={"enabled": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert org_switch.status_code == 200, org_switch.text
|
||||
assert org_switch.json()["mcp_enabled"] is False
|
||||
settings_view = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/settings",
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert settings_view.json()["mcp_enabled"] is False
|
||||
assert settings_view.json()["gateway_enabled"] is False
|
||||
assert "tasks:read" in settings_view.json()["scopes"]
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Org memory: what an organization learns about itself.
|
||||
|
||||
The invariants under test:
|
||||
|
||||
* teaching the same thing twice teaches nothing (dedupe by hash, corrections by
|
||||
key);
|
||||
* memory is capped and evicts the oldest *unpinned lesson* first — never a rule;
|
||||
* one task may write at most three memories;
|
||||
* the context block respects its budget, leads with pinned rules, and filters by
|
||||
role (only code-reading roles see the code map; only the manager sees
|
||||
preferences);
|
||||
* a reflection that fails never raises into the engine;
|
||||
* nothing secret is ever stored, and no organization ever sees another's memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.container_org import build_memory, build_memory_service
|
||||
from app.application.ports.memory import TaskOutcome
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import PlatformRole, Role
|
||||
from app.domain.memory.models import (
|
||||
CONTENT_MAX_CHARS,
|
||||
KEY_MAX_CHARS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
tokenise,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
TaskRow,
|
||||
UsageEventRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.db.models_org import OrgMemoryRow
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _task(session_factory, org_id: str, task_id: str) -> str:
|
||||
"""A real task row: ``evidence_task_id`` is a real reference, not a string."""
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
TaskRow(id=task_id, organization_id=org_id, title=f"کار {task_id}")
|
||||
)
|
||||
await session.commit()
|
||||
return task_id
|
||||
|
||||
|
||||
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
|
||||
"""An organization with one member, 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.value)
|
||||
)
|
||||
await session.commit()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
class _ShapedProvider:
|
||||
"""A provider that answers the reflection prompt in the template it asks for."""
|
||||
|
||||
def __init__(self, answer: str) -> None:
|
||||
self.answer = answer
|
||||
self.calls = 0
|
||||
|
||||
async def complete(self, **_kwargs):
|
||||
from app.application.ports import ModelResponse
|
||||
|
||||
self.calls += 1
|
||||
return ModelResponse(
|
||||
content=self.answer,
|
||||
model_internal_name="fake",
|
||||
input_tokens=40,
|
||||
output_tokens=12,
|
||||
)
|
||||
|
||||
async def health(self):
|
||||
from app.application.ports import ModelHealth
|
||||
|
||||
return ModelHealth(ok=True, detail="shaped fake")
|
||||
|
||||
|
||||
class _BrokenProvider:
|
||||
"""A provider that fails exactly like a network outage would."""
|
||||
|
||||
async def complete(self, **_kwargs):
|
||||
raise RuntimeError("provider is unreachable: token=super-secret-token")
|
||||
|
||||
async def health(self):
|
||||
from app.application.ports import ModelHealth
|
||||
|
||||
return ModelHealth(ok=False, detail="down")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _service(session_factory, provider):
|
||||
"""A memory service on its own session, committed when the block exits."""
|
||||
session = session_factory()
|
||||
try:
|
||||
service = build_memory_service(session, provider=provider)
|
||||
yield service
|
||||
await session.commit()
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalisation_and_dedupe_hash():
|
||||
assert normalise(" the API runs \n on 8000 ") == "the API runs on 8000"
|
||||
assert content_hash("The API runs on 8000") == content_hash(" the api runs on 8000 ")
|
||||
assert content_hash("the API runs on 8000") != content_hash("the API runs on 8001")
|
||||
|
||||
|
||||
def test_limits_are_enforced_in_the_domain():
|
||||
assert len(validate_key("k" * KEY_MAX_CHARS)) == KEY_MAX_CHARS
|
||||
with pytest.raises(ValidationError):
|
||||
validate_key("k" * (KEY_MAX_CHARS + 1))
|
||||
assert validate_content(" x ") == "x"
|
||||
with pytest.raises(ValidationError):
|
||||
validate_content("x" * (CONTENT_MAX_CHARS + 1))
|
||||
|
||||
|
||||
def test_role_filtering_of_kinds():
|
||||
# A reviewer reads the code map; support does not.
|
||||
assert MemoryKind.CODE_MAP in visible_kinds("reviewer")
|
||||
assert MemoryKind.CODE_MAP not in visible_kinds("support")
|
||||
assert MemoryKind.CODE_MAP in visible_kinds("developer")
|
||||
# Preferences belong to the manager alone.
|
||||
assert MemoryKind.PREFERENCE in visible_kinds("manager")
|
||||
assert MemoryKind.PREFERENCE not in visible_kinds("developer")
|
||||
# Rules and lessons are everyone's.
|
||||
for role in ("manager", "developer", "support", "researcher"):
|
||||
assert visible_kinds(role) >= {MemoryKind.RULE, MemoryKind.LESSON}
|
||||
|
||||
|
||||
def test_lessons_expire_unless_pinned_or_confirmed():
|
||||
assert lesson_expiry(pinned=False, confirmed=False) is not None
|
||||
assert lesson_expiry(pinned=True, confirmed=False) is None
|
||||
assert lesson_expiry(pinned=False, confirmed=True) is None
|
||||
|
||||
|
||||
def test_tokenise_drops_stop_words():
|
||||
words = tokenise("the API and the manager را")
|
||||
assert "api" in words and "manager" in words
|
||||
assert "the" not in words and "را" not in words
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record: dedupe, correction, cap, task ceiling, redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_recording_the_same_thing_twice_updates_one_memory(session_factory):
|
||||
org_id, _ = await _org(session_factory, "dedupe")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
first = await service.record(
|
||||
organization_id=org_id, kind="rule", content="همیشه تست بنویس",
|
||||
source="user",
|
||||
)
|
||||
second = await service.record(
|
||||
organization_id=org_id, kind="rule", content=" همیشه تست بنویس ",
|
||||
source="user",
|
||||
)
|
||||
assert first == second
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
(await session.execute(select(OrgMemoryRow))).scalars().all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
async def test_a_key_corrects_instead_of_duplicating(session_factory):
|
||||
org_id, _ = await _org(session_factory, "keyed")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="fact", content="پورت ۸۰۰۰ است",
|
||||
source="user", key="app_port",
|
||||
)
|
||||
memory_id = await service.record(
|
||||
organization_id=org_id, kind="fact", content="پورت ۸۰۰۱ است",
|
||||
source="user", key="app_port",
|
||||
)
|
||||
memory = await service.get(org_id, memory_id)
|
||||
assert memory.content == "پورت ۸۰۰۱ است"
|
||||
memories = await service.list(org_id)
|
||||
assert len(memories) == 1
|
||||
|
||||
|
||||
async def test_the_cap_evicts_the_oldest_unpinned_lesson_first(session_factory, monkeypatch):
|
||||
monkeypatch.setattr(settings, "memory_org_char_cap", 200)
|
||||
org_id, _ = await _org(session_factory, "capped")
|
||||
await _task(session_factory, org_id, "t-pinned")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
# A pinned lesson survives; the rest are evicted oldest-first.
|
||||
pinned = await service.record(
|
||||
organization_id=org_id, kind="lesson", content="پ" * 40,
|
||||
source="reflection", evidence_task_id="t-pinned",
|
||||
)
|
||||
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
|
||||
for index in range(6):
|
||||
await _task(session_factory, org_id, f"t-{index}")
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس {index}" + "ی" * 30,
|
||||
source="reflection", evidence_task_id=f"t-{index}",
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
# Budget spent, and whatever fell out is the oldest, never the pinned one.
|
||||
assert (await service.stats(org_id))["chars"] <= 200
|
||||
ids = {m.id for m in memories}
|
||||
assert pinned in ids
|
||||
# Only lessons are evictable, so a rule or a preference is never the
|
||||
# victim — and the budget is honoured.
|
||||
assert all(m.kind is MemoryKind.LESSON for m in memories)
|
||||
|
||||
|
||||
async def test_one_task_may_write_at_most_three_memories(session_factory):
|
||||
org_id, _ = await _org(session_factory, "three-writes")
|
||||
await _task(session_factory, org_id, "task-1")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
written = [
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس شمارهٔ {index}",
|
||||
source="reflection", evidence_task_id="task-1",
|
||||
)
|
||||
for index in range(MAX_WRITES_PER_TASK + 2)
|
||||
]
|
||||
assert written[:MAX_WRITES_PER_TASK]
|
||||
# The 4th write is refused — ``None``, not an exception.
|
||||
assert written[MAX_WRITES_PER_TASK] is None
|
||||
assert written[MAX_WRITES_PER_TASK + 1] is None
|
||||
rows = await service.list(org_id)
|
||||
assert len(rows) == MAX_WRITES_PER_TASK
|
||||
|
||||
|
||||
async def test_nothing_secret_is_ever_stored(session_factory):
|
||||
org_id, _ = await _org(session_factory, "redacted")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="rule",
|
||||
content="کلید API کل من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است",
|
||||
source="user",
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in memories[0].content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_context_leads_with_pinned_rules_and_respects_the_budget(session_factory):
|
||||
org_id, _ = await _org(session_factory, "context")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
pinned_rule = await service.record(
|
||||
organization_id=org_id, kind="rule", content="قاعدهٔ سنجاقشده", source="user"
|
||||
)
|
||||
await service.set_pinned(organization_id=org_id, memory_id=pinned_rule, pinned=True)
|
||||
await service.record(
|
||||
organization_id=org_id, kind="preference", content="ترجیح معمولی", source="user"
|
||||
)
|
||||
for index in range(20):
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس قدیمی {index}" + " ز" * 20,
|
||||
source="user",
|
||||
)
|
||||
|
||||
block = await service.build_context(
|
||||
organization_id=org_id, role="manager", query="درس", budget_chars=300
|
||||
)
|
||||
assert block
|
||||
assert len(block) <= 300
|
||||
assert "قاعدهٔ سنجاقشده" in block.splitlines()[1]
|
||||
assert block.index("قاعدهٔ سنجاقشده") < block.index("ترجیح معمولی")
|
||||
|
||||
# A budget so small that nothing fits returns nothing, not a broken block.
|
||||
assert await service.build_context(
|
||||
organization_id=org_id, role="manager", query="درس", budget_chars=4
|
||||
) == ""
|
||||
|
||||
|
||||
async def test_context_filters_kinds_by_role(session_factory):
|
||||
org_id, _ = await _org(session_factory, "roles")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="code_map",
|
||||
content="نقشهٔ کد: مدلها در db/models.py", source="system",
|
||||
)
|
||||
await service.record(
|
||||
organization_id=org_id, kind="preference", content="ترجیح: فارسی بنویس",
|
||||
source="user",
|
||||
)
|
||||
developer = await service.build_context(
|
||||
organization_id=org_id, role="developer", query="db", budget_chars=2000
|
||||
)
|
||||
manager = await service.build_context(
|
||||
organization_id=org_id, role="manager", query="db", budget_chars=2000
|
||||
)
|
||||
support = await service.build_context(
|
||||
organization_id=org_id, role="support", query="db", budget_chars=2000
|
||||
)
|
||||
assert "نقشهٔ کد" in developer and "ترجیح" not in developer
|
||||
assert "ترجیح" in manager and "نقشهٔ کد" not in manager
|
||||
assert support == ""
|
||||
|
||||
|
||||
async def test_context_counts_a_hit(session_factory):
|
||||
org_id, _ = await _org(session_factory, "hits")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
memory_id = await service.record(
|
||||
organization_id=org_id, kind="rule", content="هر تست قبل از کد", source="user"
|
||||
)
|
||||
await service.build_context(
|
||||
organization_id=org_id, role="developer", query="تست", budget_chars=500
|
||||
)
|
||||
memory = await service.get(org_id, memory_id)
|
||||
assert memory.hits == 1
|
||||
assert memory.last_used_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reflection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reflection_stores_lessons_and_prices_the_call(session_factory, seeded):
|
||||
org_id, _ = await _org(session_factory, "reflect")
|
||||
await _task(session_factory, org_id, "task-77")
|
||||
provider = _ShapedProvider(
|
||||
"درس ۱: تستها را قبل از ادغام اجرا کن.\n"
|
||||
"درس ۲: دروازهٔ lint دو بار شکست.\n"
|
||||
"نقشهٔ کد: app/db/models.py منبع حقیقت است.\n"
|
||||
"یک خط اضافی که نادیده گرفته میشود."
|
||||
)
|
||||
async with _service(session_factory, provider) as service:
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(
|
||||
task_id="task-77", title="ساخت صفحهٔ ورود", status="completed",
|
||||
summary="تمام شد", changed_files=["a.py"], cycles=2, gate_failures=["lint"],
|
||||
),
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
kinds = [m.kind for m in memories]
|
||||
assert MemoryKind.LESSON in kinds
|
||||
assert MemoryKind.CODE_MAP in kinds
|
||||
assert len(memories) == 3
|
||||
assert all(m.source is MemorySource.REFLECTION for m in memories)
|
||||
assert all(m.confidence == 0.5 for m in memories)
|
||||
assert all(m.evidence_task_id == "task-77" for m in memories)
|
||||
|
||||
# The reflection is billed like any other model call.
|
||||
async with session_factory() as session:
|
||||
usage = (
|
||||
(await session.execute(select(UsageEventRow))).scalars().all()
|
||||
)
|
||||
assert usage
|
||||
assert all(row.metadata_json.get("memory_reflection") for row in usage)
|
||||
|
||||
|
||||
async def test_a_failing_reflection_never_raises_and_records_the_error(
|
||||
session_factory, redirect_error_log, seeded
|
||||
):
|
||||
org_id, _ = await _org(session_factory, "reflect-fail")
|
||||
async with _service(session_factory, _BrokenProvider()) as service:
|
||||
# No exception escapes, and the org keeps its memory untouched.
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(task_id="task-err", title="t", status="failed"),
|
||||
)
|
||||
assert await service.list(org_id) == []
|
||||
|
||||
from app.infrastructure.db.models import ErrorEventRow
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
|
||||
assert any(row.kind == "memory.reflection_failed" for row in rows)
|
||||
# A provider's failure text is not trusted verbatim: either it is redacted,
|
||||
# or it never reached the row at all.
|
||||
for row in rows:
|
||||
if "super-secret-token" in (row.message or ""):
|
||||
assert "REDACTED" in row.message
|
||||
|
||||
|
||||
async def test_reflection_without_a_model_is_recorded_not_raised(
|
||||
session_factory, redirect_error_log
|
||||
):
|
||||
"""No seeded catalogue → ``pick_for_mode`` returns None → something is recorded."""
|
||||
org_id, _ = await _org(session_factory, "no-model")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(task_id="task-x", title="t", status="completed"),
|
||||
)
|
||||
assert await service.list(org_id) == []
|
||||
|
||||
from app.infrastructure.db.models import ErrorEventRow
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
|
||||
assert any(row.kind == "memory.reflection_no_model" for row in rows)
|
||||
|
||||
|
||||
async def test_expired_lessons_are_removed_by_the_expiry_job(session_factory):
|
||||
org_id, _ = await _org(session_factory, "expiry")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
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)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(OrgMemoryRow, expired)
|
||||
row.expires_at = utcnow().replace(year=2020) # long past
|
||||
await session.commit()
|
||||
|
||||
assert await service.expire_memories() == 1
|
||||
remaining = {m.id for m in await service.list(org_id)}
|
||||
assert expired not in remaining
|
||||
assert pinned in remaining
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_one_organization_never_sees_anothers_memory(session_factory):
|
||||
org_a, _ = await _org(session_factory, "tenant-a")
|
||||
org_b, _ = await _org(session_factory, "tenant-b")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
memory_id = await service.record(
|
||||
organization_id=org_a, kind="rule", content="قاعدهٔ سازمان الف", source="user"
|
||||
)
|
||||
assert memory_id not in {m.id for m in await service.list(org_b)}
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.get(org_b, memory_id)
|
||||
block = await service.build_context(
|
||||
organization_id=org_b, role="manager", query="قاعده", budget_chars=500
|
||||
)
|
||||
assert block == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
|
||||
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
|
||||
org = await client.post(
|
||||
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
return {"token": body["access_token"], "org_id": org.json()["id"]}
|
||||
|
||||
|
||||
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
|
||||
invite = await client.post(
|
||||
f"/api/organizations/{org_id}/invites",
|
||||
json={"email": email, "role": role},
|
||||
headers=auth_header(owner_token),
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
account = await signup(client, email, name=role)
|
||||
accepted = await client.post(
|
||||
"/api/invites/accept",
|
||||
json={"token": invite.json()["invite_token"]},
|
||||
headers=auth_header(account["access_token"]),
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
return account["access_token"]
|
||||
|
||||
|
||||
async def test_memory_http_permissions(client: AsyncClient):
|
||||
org = await _owner_with_org(client, "memory-org")
|
||||
viewer = await _member(client, org["token"], org["org_id"], "viewer@example.com", "viewer")
|
||||
member = await _member(client, org["token"], org["org_id"], "member@example.com", "member")
|
||||
admin = await _member(client, org["token"], org["org_id"], "admin@example.com", "admin")
|
||||
|
||||
# A viewer reads but cannot teach.
|
||||
empty = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/memory", headers=auth_header(viewer)
|
||||
)
|
||||
assert empty.status_code == 200, empty.text
|
||||
assert empty.json()["items"] == []
|
||||
refused = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ ممنوعه"},
|
||||
headers=auth_header(viewer),
|
||||
)
|
||||
assert refused.status_code == 403, refused.text
|
||||
|
||||
# A member teaches a rule; a lesson is refused (teaching is human-only).
|
||||
taught = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ تازه", "key": "style"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert taught.status_code == 201, taught.text
|
||||
memory_id = taught.json()["id"]
|
||||
assert taught.json()["kind_label"] == "قاعده"
|
||||
lesson = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "lesson", "content": "درس دستنویس"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert lesson.status_code == 403, lesson.text
|
||||
|
||||
# Editing is admin+.
|
||||
edited = await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "قاعدهٔ ویرایششده"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert edited.json()["content"] == "قاعدهٔ ویرایششده"
|
||||
member_edit = await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "تلاش عضو"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert member_edit.status_code == 403, member_edit.text
|
||||
|
||||
# Pin, then confirm (only a lesson may be confirmed).
|
||||
pinned = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
|
||||
json={"pinned": True},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert pinned.json()["pinned"] is True
|
||||
not_a_lesson = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/confirm",
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert not_a_lesson.status_code == 422, not_a_lesson.text
|
||||
|
||||
# Remembering without a session is not a thing.
|
||||
anonymous = await client.get(f"/api/organizations/{org['org_id']}/memory")
|
||||
assert anonymous.status_code == 401
|
||||
|
||||
|
||||
async def test_memory_audit_and_isolation(client: AsyncClient, session_factory):
|
||||
org = await _owner_with_org(client, "audited-memory")
|
||||
other = await _owner_with_org(client, "other-memory")
|
||||
admin = await _member(client, org["token"], org["org_id"], "a2@example.com", "admin")
|
||||
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ حسابرسی"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
memory_id = created.json()["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "قاعدهٔ حسابرسی، ویرایششده"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
|
||||
json={"pinned": True},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}", headers=auth_header(admin)
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
actions = [
|
||||
row.action
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(AuditEventRow).where(
|
||||
AuditEventRow.organization_id == org["org_id"]
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert {"memory.create", "memory.update", "memory.pin", "memory.delete"} <= set(actions)
|
||||
|
||||
# Another organization's memory is invisible here, never another tenant's
|
||||
# data: the soft-deleted row exists, and the list is still empty.
|
||||
stranger = await client.get(
|
||||
f"/api/organizations/{other['org_id']}/memory",
|
||||
headers=auth_header(other["token"]),
|
||||
)
|
||||
assert stranger.status_code == 200, stranger.text
|
||||
assert stranger.json()["items"] == []
|
||||
|
||||
|
||||
async def test_the_real_port_is_wired_in_the_container(session_factory):
|
||||
from app.application.ports.memory import MemoryPort, NullMemory
|
||||
|
||||
org_id, _ = await _org(session_factory, "wired")
|
||||
async with session_factory() as session:
|
||||
memory = build_memory(session)
|
||||
assert isinstance(memory, MemoryPort)
|
||||
assert not isinstance(memory, NullMemory)
|
||||
recorded = await memory.record(
|
||||
organization_id=org_id, kind="rule", content="قاعدهٔ سیمکشیشده", source="user"
|
||||
)
|
||||
assert recorded
|
||||
block = await memory.build_context(
|
||||
organization_id=org_id, role="manager", query="سیم", budget_chars=200
|
||||
)
|
||||
assert "قاعدهٔ سیمکشیشده" in block
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Owner model grants, the default set and org policy.
|
||||
|
||||
The invariant under test: **an organization may only use the models the owner
|
||||
granted it.** Two switches have to agree (model active + grant active), which is
|
||||
what makes revocation real, and a grant belongs to exactly one organization.
|
||||
|
||||
Nothing here depends on the engine's code: the checks go through the HTTP
|
||||
endpoints *and* through ``ModelGrantPort`` as the engine would call it, so a
|
||||
break in either path fails the suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.container_org import build_model_grant_service, build_model_grants
|
||||
from app.core.config import settings
|
||||
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
|
||||
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
|
||||
from tests.conftest import auth_header
|
||||
|
||||
GRANTS = "/api/admin/model-grants"
|
||||
DEFAULTS = "/api/admin/default-model-grants"
|
||||
POLICIES = "/api/admin/org-policies"
|
||||
|
||||
|
||||
def _payload(name: str, slug: str) -> dict:
|
||||
return {
|
||||
"org_name": name,
|
||||
"slug": slug,
|
||||
"app_name": f"app-{slug}",
|
||||
"app_type": "web_app",
|
||||
"language": "node",
|
||||
"framework": "next",
|
||||
"git_source": "platform",
|
||||
}
|
||||
|
||||
|
||||
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["organization"]["id"]
|
||||
|
||||
|
||||
async def _model_ids(session_factory) -> list[str]:
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name))
|
||||
).scalars().all()
|
||||
return [row.id for row in rows]
|
||||
|
||||
|
||||
async def _audit_actions(session_factory, organization_id: str | None = None) -> list[str]:
|
||||
async with session_factory() as session:
|
||||
stmt = select(AuditEventRow.action)
|
||||
if organization_id is not None:
|
||||
stmt = stmt.where(AuditEventRow.organization_id == organization_id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("get", f"{GRANTS}/org-1"),
|
||||
("get", f"{GRANTS}/org-1/active"),
|
||||
("put", f"{GRANTS}/org-1/models/model-1"),
|
||||
("delete", f"{GRANTS}/org-1/models/model-1"),
|
||||
("get", DEFAULTS),
|
||||
("put", f"{DEFAULTS}/model-1"),
|
||||
("delete", f"{DEFAULTS}/model-1"),
|
||||
("get", f"{POLICIES}/org-1"),
|
||||
("put", f"{POLICIES}/org-1"),
|
||||
],
|
||||
)
|
||||
async def test_non_owner_is_forbidden_everywhere(
|
||||
client: AsyncClient, manager_token: str, method: str, path: str
|
||||
):
|
||||
call = getattr(client, method)
|
||||
kwargs: dict = {"headers": auth_header(manager_token)}
|
||||
if method == "put":
|
||||
kwargs["json"] = {"daily_credit_cap": 5, "is_active": True, "max_employees": 3}
|
||||
response = await call(path, **kwargs)
|
||||
assert response.status_code == 403, response.text
|
||||
|
||||
|
||||
async def test_anonymous_is_rejected(client: AsyncClient):
|
||||
assert (await client.get(f"{GRANTS}/org-1")).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grant matrix and revocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_wizard_copies_the_default_set(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
set_default = await client.put(
|
||||
f"{DEFAULTS}/{model_id}",
|
||||
json={"is_active": True, "daily_credit_cap": 12.5},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert set_default.status_code == 200, set_default.text
|
||||
assert set_default.json()["granted"] is True
|
||||
|
||||
org_id = await _create_org(client, manager_token, "سازمان الف", "alpha-org")
|
||||
|
||||
matrix = await client.get(f"{GRANTS}/{org_id}", headers=auth_header(platform_owner_token))
|
||||
assert matrix.status_code == 200, matrix.text
|
||||
rows = {m["ai_model_id"]: m for m in matrix.json()["models"]}
|
||||
assert rows[model_id]["granted"] is True
|
||||
assert rows[model_id]["daily_credit_cap"] == 12.5
|
||||
# Every other model is listed but not granted.
|
||||
assert all(m["granted"] is False for m_id, m in rows.items() if m_id != model_id)
|
||||
|
||||
# The same answer through the engine's port.
|
||||
async with session_factory() as session:
|
||||
service = build_model_grants(session)
|
||||
granted = await service.granted_models(organization_id=org_id)
|
||||
assert [g.ai_model_id for g in granted] == [model_id]
|
||||
assert granted[0].tier in {"fast", "balanced", "strong"}
|
||||
assert granted[0].daily_credit_cap == 12.5
|
||||
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
|
||||
|
||||
async def test_grant_edit_and_revoke_are_immediate_and_audited(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_id = await _create_org(client, manager_token, "سازمان ب", "beta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
grant = await client.put(
|
||||
f"{GRANTS}/{org_id}/models/{model_id}",
|
||||
json={"daily_credit_cap": 3},
|
||||
headers=owner,
|
||||
)
|
||||
assert grant.status_code == 200, grant.text
|
||||
assert grant.json()["is_active"] is True
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
|
||||
# Raising the cap keeps it granted.
|
||||
raised = await client.put(
|
||||
f"{GRANTS}/{org_id}/models/{model_id}",
|
||||
json={"daily_credit_cap": 40},
|
||||
headers=owner,
|
||||
)
|
||||
assert raised.json()["daily_credit_cap"] == 40
|
||||
|
||||
revoked = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_id) == []
|
||||
# Revocation deactivates rather than deletes, so the history survives and
|
||||
# re-granting is idempotent instead of racing a unique constraint.
|
||||
row = (
|
||||
await session.execute(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == org_id,
|
||||
OrgModelGrantRow.ai_model_id == model_id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.is_active is False
|
||||
|
||||
active = await client.get(f"{GRANTS}/{org_id}/active", headers=owner)
|
||||
assert active.json() == []
|
||||
|
||||
actions = await _audit_actions(session_factory, org_id)
|
||||
assert "model_grant.set" in actions
|
||||
assert "model_grant.revoke" in actions
|
||||
|
||||
# Revoking something that is not granted is a 404, not a silent success.
|
||||
again = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
|
||||
assert again.status_code == 404
|
||||
|
||||
|
||||
async def test_inactive_model_is_never_granted(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_id = await _create_org(client, manager_token, "سازمان ج", "gamma-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
granted = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
|
||||
assert granted.status_code == 200, granted.text
|
||||
|
||||
# The owner retires the model from the catalogue.
|
||||
async with session_factory() as session:
|
||||
row = await session.get(AiModelRow, model_id)
|
||||
assert row is not None
|
||||
row.is_active = False
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_id) == []
|
||||
|
||||
# Granting a retired model is refused rather than quietly stored.
|
||||
refused = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
|
||||
assert refused.status_code == 404
|
||||
listing = await client.get(f"{GRANTS}/{org_id}", headers=owner)
|
||||
assert all(m["ai_model_id"] != model_id for m in listing.json()["models"])
|
||||
|
||||
|
||||
async def test_grants_are_isolated_per_organization(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_a = await _create_org(client, manager_token, "شرکت آلفا", "alpha-two")
|
||||
org_b = await _create_org(client, manager_token, "شرکت بتا", "beta-two")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
granted = await client.put(
|
||||
f"{GRANTS}/{org_a}/models/{model_id}", json={"daily_credit_cap": 7}, headers=owner
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.is_granted(organization_id=org_a, ai_model_id=model_id)
|
||||
assert not await service.is_granted(organization_id=org_b, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_b) == []
|
||||
|
||||
matrix_b = await client.get(f"{GRANTS}/{org_b}", headers=owner)
|
||||
assert all(m["granted"] is False for m in matrix_b.json()["models"])
|
||||
# …and org B's grant is never visible in org A's active list either.
|
||||
active_a = await client.get(f"{GRANTS}/{org_a}/active", headers=owner)
|
||||
assert [g["ai_model_id"] for g in active_a.json()] == [model_id]
|
||||
|
||||
|
||||
async def test_default_set_can_be_removed_and_deactivated(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
assert (await client.put(f"{DEFAULTS}/{model_id}", json={}, headers=owner)).status_code == 200
|
||||
# Switched off: still in the set for the admin UI, but not copied to new orgs.
|
||||
off = await client.put(
|
||||
f"{DEFAULTS}/{model_id}", json={"is_active": False}, headers=owner
|
||||
)
|
||||
assert off.status_code == 200, off.text
|
||||
|
||||
org_off = await _create_org(client, manager_token, "سازمان خاموش", "off-org")
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.granted_models(organization_id=org_off) == []
|
||||
|
||||
removed = await client.delete(f"{DEFAULTS}/{model_id}", headers=owner)
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert removed.json()["in_default_set"] is False
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.execute(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == model_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none() is None
|
||||
|
||||
# Already-created organizations keep the grant they were given.
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.execute(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == org_off
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none() is None
|
||||
|
||||
actions = await _audit_actions(session_factory)
|
||||
assert "default_model_grant.set" in actions
|
||||
assert "default_model_grant.remove" in actions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_policy_defaults_caps_and_audit(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
|
||||
assert fresh.status_code == 200, fresh.text
|
||||
body = fresh.json()
|
||||
assert body["max_employees"] == 5
|
||||
assert body["automation_enabled"] is True
|
||||
assert body["mcp_enabled"] is True
|
||||
assert body["monthly_credit_budget"] is None
|
||||
assert body["max_employees_limit"] == settings.engine_max_employees
|
||||
|
||||
too_many = await client.put(
|
||||
f"{POLICIES}/{org_id}",
|
||||
json={"max_employees": settings.engine_max_employees + 1},
|
||||
headers=owner,
|
||||
)
|
||||
assert too_many.status_code == 422
|
||||
|
||||
negative = await client.put(
|
||||
f"{POLICIES}/{org_id}", json={"monthly_credit_budget": -1}, headers=owner
|
||||
)
|
||||
assert negative.status_code == 422
|
||||
|
||||
empty = await client.put(f"{POLICIES}/{org_id}", json={}, headers=owner)
|
||||
assert empty.status_code == 422
|
||||
|
||||
updated = await client.put(
|
||||
f"{POLICIES}/{org_id}",
|
||||
json={
|
||||
"max_employees": 8,
|
||||
"monthly_credit_budget": 250.5,
|
||||
"automation_enabled": False,
|
||||
"mcp_enabled": False,
|
||||
},
|
||||
headers=owner,
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert updated.json()["max_employees"] == 8
|
||||
assert updated.json()["automation_enabled"] is False
|
||||
assert updated.json()["mcp_enabled"] is False
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(OrgPolicyRow, org_id)
|
||||
assert row is not None
|
||||
assert row.max_employees == 8
|
||||
assert row.monthly_credit_budget == 250.5
|
||||
# Agent 2 reads the policy through the same service.
|
||||
service = build_model_grant_service(session)
|
||||
policy = await service.get_org_policy(org_id)
|
||||
assert policy.max_employees == 8
|
||||
assert policy.automation_enabled is False
|
||||
assert await service.mcp_enabled(org_id) is False
|
||||
|
||||
assert "org_policy.update" in await _audit_actions(session_factory, org_id)
|
||||
|
||||
|
||||
async def test_policy_is_isolated_per_organization(
|
||||
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
|
||||
):
|
||||
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
|
||||
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
await client.put(f"{POLICIES}/{org_a}", json={"max_employees": 2}, headers=owner)
|
||||
|
||||
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
|
||||
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
|
||||
assert a.json()["max_employees"] == 2
|
||||
assert b.json()["max_employees"] == 5
|
||||
@@ -1,16 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Platform owner console: the MCP gateway's two switches.
|
||||
*
|
||||
* An external agent can only act inside the platform through a scoped token, and
|
||||
* this page is where the owner closes the door when something is wrong: the
|
||||
* gateway as a whole, and each organization independently. A switch takes effect
|
||||
* on the next call, because the gateway checks it per request rather than
|
||||
* trusting a cached state.
|
||||
*
|
||||
* A revoked token keeps its place in the list — after an incident, "who could
|
||||
* have done this" is the question that matters.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { mcpApi, type McpOrgState } from "@/lib/api-org";
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
export default function AdminMcpPage() {
|
||||
const [state, setState] = React.useState<{
|
||||
gateway_enabled: boolean;
|
||||
organizations: McpOrgState[];
|
||||
total_tokens: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pendingSwitch, setPendingSwitch] = React.useState<McpOrgState | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setState(await mcpApi.adminState());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت وضعیت MCP ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const toggleGateway = async () => {
|
||||
if (!state) return;
|
||||
setBusy("gateway");
|
||||
try {
|
||||
const next = !state.gateway_enabled;
|
||||
await mcpApi.setGateway(next);
|
||||
setState((current) => (current ? { ...current, gateway_enabled: next } : current));
|
||||
toast(next ? "دسترسی MCP سامانه روشن شد" : "دسترسی MCP سامانه خاموش شد");
|
||||
} catch (err) {
|
||||
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrgSwitch = async (org: McpOrgState) => {
|
||||
setBusy(`org-${org.organization_id}`);
|
||||
try {
|
||||
await mcpApi.setOrgEnabled(org.organization_id, !org.mcp_enabled);
|
||||
setState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
organizations: current.organizations.map((row) =>
|
||||
row.organization_id === org.organization_id
|
||||
? { ...row, mcp_enabled: !row.mcp_enabled }
|
||||
: row,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
toast(org.mcp_enabled ? "دسترسی این سازمان خاموش شد" : "دسترسی این سازمان روشن شد");
|
||||
setPendingSwitch(null);
|
||||
} catch (err) {
|
||||
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت وضعیت MCP…" />;
|
||||
if (error || !state)
|
||||
return <ErrorState message={error ?? "—"} onRetry={load} />;
|
||||
|
||||
const enabledOrgs = state.organizations.filter((org) => org.mcp_enabled).length;
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="MCP"
|
||||
description="کنترل دسترسی عاملهای بیرونی به سازمانها."
|
||||
planned={[
|
||||
"کلید سراسری",
|
||||
"توکنهای هر سازمان",
|
||||
]}
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">دسترسی MCP</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
عاملهای بیرونی با توکنهای محدود به یک سازمان کار میکنند. اینجا کل درگاه و
|
||||
دسترسی هر سازمان را میتوان خاموش کرد؛ توکنها را مدیران خودشان در تنظیمات
|
||||
سازمان میسازند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat
|
||||
label="وضعیت درگاه"
|
||||
value={state.gateway_enabled ? "روشن" : "خاموش"}
|
||||
tone={state.gateway_enabled ? "success" : "danger"}
|
||||
/>
|
||||
<Stat label="سازمانهای دارای دسترسی" value={enabledOrgs} />
|
||||
<Stat label="توکنهای فعال" value={state.total_tokens} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>کلید اصلی</CardTitle>
|
||||
<CardHint>
|
||||
خاموشکردن درگاه، همهٔ توکنها را بیاثر میکند بدون آنکه حذف شوند: وقتی دوباره
|
||||
روشن شود، همان توکنها از جایشان کار میکنند.
|
||||
</CardHint>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant={state.gateway_enabled ? "danger" : "secondary"}
|
||||
loading={busy === "gateway"}
|
||||
onClick={() => void toggleGateway()}
|
||||
>
|
||||
{state.gateway_enabled ? "خاموشکردن دسترسی MCP سامانه" : "روشنکردن دسترسی MCP"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>سازمانها</CardTitle>
|
||||
<CardHint>هر سازمان جداگانه قابل خاموشکردن است.</CardHint>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
{state.organizations.length === 0 ? (
|
||||
<EmptyState title="سازمانی وجود ندارد" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">سازمان</th>
|
||||
<th className="px-2 py-2 text-right font-medium">اسلاگ</th>
|
||||
<th className="px-2 py-2 text-right font-medium">توکن فعال</th>
|
||||
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
|
||||
<th className="px-2 py-2 text-right font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{state.organizations.map((org) => (
|
||||
<tr key={org.organization_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{org.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400" dir="ltr">
|
||||
{org.slug}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-slate-400">{org.token_count}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={org.mcp_enabled ? "success" : "neutral"}>
|
||||
{org.mcp_enabled ? "فعال" : "خاموش"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={busy === `org-${org.organization_id}`}
|
||||
onClick={() =>
|
||||
org.mcp_enabled
|
||||
? setPendingSwitch(org)
|
||||
: void confirmOrgSwitch(org)
|
||||
}
|
||||
>
|
||||
{org.mcp_enabled ? "خاموش کن" : "روشن کن"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSwitch !== null}
|
||||
title="خاموشکردن دسترسی یک سازمان"
|
||||
description={
|
||||
pendingSwitch
|
||||
? `توکنهای «${pendingSwitch.name}» از این لحظه کار نمیکنند. مدیران سازمان میتوانند توکن تازه نسازند.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="خاموش کن"
|
||||
onConfirm={() => pendingSwitch && void confirmOrgSwitch(pendingSwitch)}
|
||||
onCancel={() => setPendingSwitch(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Platform owner console: which models each organization may use.
|
||||
*
|
||||
* Two independent switches decide whether a model is usable, and this page is
|
||||
* the only place both can be set: the model is active in the catalogue, and the
|
||||
* owner granted it to *this* organization. Revoking here takes effect on the
|
||||
* engine's next query — there is no cached grant set to expire.
|
||||
*
|
||||
* The tabs mirror the three decisions: the per-organization matrix, the default
|
||||
* set copied into every newly created organization, and the organization's
|
||||
* policy (employee cap, monthly budget, automation and MCP switches).
|
||||
*
|
||||
* Model names are visible here and nowhere else: an organization's own users
|
||||
* only ever see a neutral tier.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminOrganization } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
Tabs,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { orgApi, type ModelGrantRow, type OrgPolicyView } from "@/lib/api-org";
|
||||
|
||||
const TIER_TONES: Record<string, "success" | "info" | "warning"> = {
|
||||
fast: "success",
|
||||
balanced: "info",
|
||||
strong: "warning",
|
||||
};
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
export default function AdminModelGrantsPage() {
|
||||
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||
const [orgId, setOrgId] = React.useState("");
|
||||
const [models, setModels] = React.useState<ModelGrantRow[]>([]);
|
||||
const [defaults, setDefaults] = React.useState<ModelGrantRow[]>([]);
|
||||
const [policy, setPolicy] = React.useState<OrgPolicyView | null>(null);
|
||||
//: Two independent editors, so they need independent inputs: the org matrix
|
||||
//: and the default set can hold different caps for the same model.
|
||||
const [caps, setCaps] = React.useState<Record<string, string>>({});
|
||||
const [defaultCaps, setDefaultCaps] = React.useState<Record<string, string>>({});
|
||||
const [tab, setTab] = React.useState("org");
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [orgError, setOrgError] = React.useState<string | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const [budget, setBudget] = React.useState("");
|
||||
const [maxEmployees, setMaxEmployees] = React.useState("5");
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOrgs = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextOrganizations, nextDefaults] = await Promise.all([
|
||||
api.adminOrganizations(),
|
||||
orgApi.listDefaultGrants(),
|
||||
]);
|
||||
setOrganizations(nextOrganizations);
|
||||
setDefaults(nextDefaults);
|
||||
setDefaultCaps(
|
||||
Object.fromEntries(
|
||||
nextDefaults.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setOrgId((current) => current || (nextOrganizations[0]?.id ?? ""));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت دادههای تخصیص مدل ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOrgDetail = React.useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setModels([]);
|
||||
setPolicy(null);
|
||||
return;
|
||||
}
|
||||
setOrgError(null);
|
||||
try {
|
||||
const [grants, nextPolicy] = await Promise.all([
|
||||
orgApi.listOrgGrants(id),
|
||||
orgApi.getOrgPolicy(id),
|
||||
]);
|
||||
setModels(grants.models);
|
||||
setCaps(
|
||||
Object.fromEntries(
|
||||
grants.models.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
setPolicy(nextPolicy);
|
||||
setMaxEmployees(String(nextPolicy.max_employees));
|
||||
setBudget(
|
||||
nextPolicy.monthly_credit_budget === null
|
||||
? ""
|
||||
: String(nextPolicy.monthly_credit_budget),
|
||||
);
|
||||
} catch (err) {
|
||||
setOrgError(errorMessage(err, "دریافت تخصیصهای این سازمان ناموفق بود."));
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadOrgs();
|
||||
}, [loadOrgs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadOrgDetail(orgId);
|
||||
}, [loadOrgDetail, orgId]);
|
||||
|
||||
const parseCap = (raw: string): number | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
};
|
||||
|
||||
const toggleGrant = async (model: ModelGrantRow, next: boolean) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
if (next) {
|
||||
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
|
||||
} else {
|
||||
await orgApi.revokeOrgGrant(orgId, model.ai_model_id);
|
||||
}
|
||||
await loadOrgDetail(orgId);
|
||||
toast(next ? `مدل «${model.label}» تخصیص یافت` : `دسترسی مدل «${model.label}» لغو شد`);
|
||||
} catch (err) {
|
||||
toast("تغییر تخصیص ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCap = async (model: ModelGrantRow) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
|
||||
await loadOrgDetail(orgId);
|
||||
toast("سقف اعتبار روزانه ذخیره شد");
|
||||
} catch (err) {
|
||||
toast("ذخیرهٔ سقف ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDefault = async (model: ModelGrantRow, next: boolean) => {
|
||||
setBusy(model.ai_model_id);
|
||||
try {
|
||||
await orgApi.setDefaultGrant(model.ai_model_id, {
|
||||
is_active: next,
|
||||
daily_credit_cap: parseCap(defaultCaps[model.ai_model_id] ?? ""),
|
||||
});
|
||||
const nextDefaults = await orgApi.listDefaultGrants();
|
||||
setDefaults(nextDefaults);
|
||||
setDefaultCaps(
|
||||
Object.fromEntries(
|
||||
nextDefaults.map((m) => [
|
||||
m.ai_model_id,
|
||||
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
|
||||
]),
|
||||
),
|
||||
);
|
||||
toast(next ? "به مجموعهٔ پیشفرض افزوده شد" : "از مجموعهٔ پیشفرض حذف شد");
|
||||
} catch (err) {
|
||||
toast("تغییر مجموعهٔ پیشفرض ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePolicy = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!policy) return;
|
||||
setBusy("policy");
|
||||
try {
|
||||
const updated = await orgApi.updateOrgPolicy(orgId, {
|
||||
max_employees: Number(maxEmployees),
|
||||
monthly_credit_budget: budget.trim() === "" ? null : Number(budget),
|
||||
automation_enabled: policy.automation_enabled,
|
||||
mcp_enabled: policy.mcp_enabled,
|
||||
});
|
||||
setPolicy(updated);
|
||||
toast("سیاست سازمان ذخیره شد");
|
||||
} catch (err) {
|
||||
toast("ذخیرهٔ سیاست ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setPolicyFlag = (key: "automation_enabled" | "mcp_enabled", value: boolean) => {
|
||||
setPolicy((current) => (current ? { ...current, [key]: value } : current));
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت تخصیص مدلها…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={loadOrgs} />;
|
||||
|
||||
const grantedCount = models.filter((m) => m.granted).length;
|
||||
const defaultCount = defaults.filter((m) => m.granted).length;
|
||||
const activeDefaults = defaults.filter((m) => m.granted);
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="مدلهای سازمانها"
|
||||
description="تخصیص مدلها به هر سازمان و سیاستهای سازمان."
|
||||
planned={[
|
||||
"ماتریس تخصیص مدل",
|
||||
"سقف اعتبار روزانه",
|
||||
"مجموعهٔ پیشفرض سازمانهای جدید",
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">مدلهای سازمانها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر سازمان فقط میتواند از مدلهایی استفاده کند که مالک سامانه تخصیص داده است. کاربران
|
||||
سازمان نام مدل را نمیبینند؛ فقط سطح آن («سریع»، «متعادل»، «قوی») را میبینند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="سازمانها" value={organizations.length} />
|
||||
<Stat label="مدلهای تخصیصیافته (سازمان جاری)" value={grantedCount} />
|
||||
<Stat label="مدلهای پیشفرض" value={defaultCount} />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "org", label: "تخصیص سازمانها" },
|
||||
{ key: "defaults", label: "مجموعهٔ پیشفرض" },
|
||||
{ key: "policy", label: "سیاست سازمان" },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{tab !== "defaults" && (
|
||||
<Card>
|
||||
<CardTitle>انتخاب سازمان</CardTitle>
|
||||
<div className="mt-3 max-w-sm">
|
||||
<Select
|
||||
label="سازمان"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
options={
|
||||
organizations.length === 0
|
||||
? [{ value: "", label: "سازمانی وجود ندارد" }]
|
||||
: organizations.map((org) => ({
|
||||
value: org.id,
|
||||
label: `${org.name} (${org.slug})`,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "org" && (
|
||||
<>
|
||||
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
|
||||
{!orgError && !orgId && (
|
||||
<EmptyState
|
||||
title="سازمانی وجود ندارد"
|
||||
description="پس از ساخت نخستین سازمان توسط یک مدیر، تخصیص مدلها از همینجا انجام میشود."
|
||||
/>
|
||||
)}
|
||||
{!orgError && orgId && models.length === 0 && (
|
||||
<EmptyState
|
||||
title="مدلی در کاتالوگ نیست"
|
||||
description="ابتدا از بخش «مدلها» مدلهای فعال را تعریف کنید."
|
||||
/>
|
||||
)}
|
||||
{!orgError && orgId && models.length > 0 && (
|
||||
<Card>
|
||||
<CardTitle>ماتریس تخصیص</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
تیک هر مدل یعنی این سازمان میتواند از آن استفاده کند؛ لغو تیک بلافاصله اعمال
|
||||
میشود. «سقف روزانه» اختیاری است و بر حسب اعتبار محاسبه میشود.
|
||||
</CardHint>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">مدل</th>
|
||||
<th className="px-2 py-2 text-right font-medium">سطح</th>
|
||||
<th className="px-2 py-2 text-right font-medium">قابلیتها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">تخصیص</th>
|
||||
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{models.map((model) => (
|
||||
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{model.label}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>
|
||||
{model.tier_label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-slate-400">
|
||||
{model.capabilities.join("، ") || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Checkbox
|
||||
label="فعال"
|
||||
checked={model.granted}
|
||||
disabled={busy === model.ai_model_id}
|
||||
onChange={(e) => void toggleGrant(model, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
aria-label={`سقف روزانهٔ ${model.label}`}
|
||||
value={caps[model.ai_model_id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCaps((current) => ({
|
||||
...current,
|
||||
[model.ai_model_id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!model.granted || busy === model.ai_model_id}
|
||||
onClick={() => void saveCap(model)}
|
||||
>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "defaults" && (
|
||||
<Card>
|
||||
<CardTitle>مجموعهٔ پیشفرض سازمانهای جدید</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
این مجموعه هنگام ساخت هر سازمان جدید بهصورت خودکار کپی میشود. خاموشکردن یک مدل
|
||||
آن را در فهرست نگه میدارد اما به سازمانهای بعدی کپی نمیشود؛ سازمانهای قبلی
|
||||
دسترسی خود را از دست نمیدهند.
|
||||
</CardHint>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">مدل</th>
|
||||
<th className="px-2 py-2 text-right font-medium">سطح</th>
|
||||
<th className="px-2 py-2 text-right font-medium">در مجموعه</th>
|
||||
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{defaults.map((model) => (
|
||||
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{model.label}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>{model.tier_label}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Checkbox
|
||||
label="پیشفرض"
|
||||
checked={model.granted}
|
||||
disabled={busy === model.ai_model_id}
|
||||
onChange={(e) => void toggleDefault(model, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
aria-label={`سقف روزانهٔ پیشفرض ${model.label}`}
|
||||
value={defaultCaps[model.ai_model_id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaultCaps((current) => ({
|
||||
...current,
|
||||
[model.ai_model_id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!model.granted || busy === model.ai_model_id}
|
||||
onClick={() => void toggleDefault(model, true)}
|
||||
>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{activeDefaults.length === 0 && (
|
||||
<div className="mt-3">
|
||||
<CardHint>
|
||||
مجموعهٔ پیشفرض خالی است: سازمانهای جدید تا زمانی که تخصیص دستی نگیرند هیچ
|
||||
مدلی ندارند.
|
||||
</CardHint>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "policy" && (
|
||||
<>
|
||||
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
|
||||
{!orgError && policy && (
|
||||
<Card>
|
||||
<CardTitle>سیاست سازمان</CardTitle>
|
||||
<div className="mt-1">
|
||||
<CardHint>
|
||||
سقف پرسنل نمیتواند از سقف سامانه ({policy.max_employees_limit}) بیشتر باشد.
|
||||
بودجهٔ ماهانه خالی یعنی بدون سقف صریح.
|
||||
</CardHint>
|
||||
</div>
|
||||
<form onSubmit={savePolicy} className="mt-4 flex max-w-lg flex-col gap-4">
|
||||
<Field
|
||||
label="حداکثر پرسنل"
|
||||
type="number"
|
||||
min={1}
|
||||
max={policy.max_employees_limit}
|
||||
value={maxEmployees}
|
||||
onChange={(e) => setMaxEmployees(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="بودجهٔ ماهانه (اختیاری)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(e.target.value)}
|
||||
hint="خالی بگذارید تا سقف صریحی اعمال نشود."
|
||||
/>
|
||||
<Checkbox
|
||||
label="اتوماسیون فعال باشد"
|
||||
checked={policy.automation_enabled}
|
||||
onChange={(e) => setPolicyFlag("automation_enabled", e.target.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="دسترسی MCP برای این سازمان فعال باشد"
|
||||
checked={policy.mcp_enabled}
|
||||
onChange={(e) => setPolicyFlag("mcp_enabled", e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" loading={busy === "policy"}>
|
||||
ذخیرهٔ سیاست
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Knowledge is now a tab of ``/memory``: a document and a one-line rule are
|
||||
* different things, and they belong on one page so a reader never has to guess
|
||||
* which half of the organization's knowledge lives where.
|
||||
*
|
||||
* This route stays alive as a permanent redirect so an old bookmark (or a link
|
||||
* in a chat message) lands on the page that replaced it.
|
||||
*/
|
||||
|
||||
export default function KnowledgePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="دانش سازمانی"
|
||||
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آنها استفاده میکنند."
|
||||
planned={[
|
||||
"افزودن فایل، متن، URL و مخزن بهعنوان منبع دانش",
|
||||
"استخراج متن، تکهبندی (chunk) و ایندکس برداری",
|
||||
"جستوجوی سادهٔ کلیدواژهای در MVP",
|
||||
"اتصال هر منبع دانش به کارمندهای مشخص",
|
||||
"جداسازی کامل دانش بین سازمانها",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function KnowledgeRedirectPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/memory");
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,538 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* Org memory: what the organization has learned about itself.
|
||||
*
|
||||
* Four kinds of memory, four tabs, one page — the plan's "one place" rule. The
|
||||
* Knowledge Base keeps its own endpoints and appears here as a second family of
|
||||
* tabs, because a document and a one-line rule are different things and
|
||||
* pretending otherwise is how knowledge bases become unreadable.
|
||||
*
|
||||
* Live by design: the API publishes ``memory.changed`` on ``org:<id>`` after
|
||||
* every write, so a page left open on one tab sees a lesson that another member
|
||||
* (or the engine's own reflection) just recorded — including the realtime
|
||||
* refresh, not a stale list.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Modal,
|
||||
Select,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
MEMORY_CHANGED_EVENT,
|
||||
memoryApi,
|
||||
memoryKinds,
|
||||
type KnowledgeSourceRow,
|
||||
type MemoryRow,
|
||||
} from "@/lib/api-org";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
type Tab = "rules" | "lessons" | "code" | "knowledge";
|
||||
|
||||
const TABS: { key: Tab; label: string; hint: string }[] = [
|
||||
{ key: "rules", label: "قوانین و ترجیحات", hint: "تصمیمهای همیشگی سازمان" },
|
||||
{ key: "lessons", label: "درسهای آموخته", hint: "آنچه کارهای تمامشده یاد دادند" },
|
||||
{ key: "code", label: "نقشهٔ کد", hint: "واقعیتها و نقشهٔ اپلیکیشن" },
|
||||
{ key: "knowledge", label: "منابع دانش", hint: "اسناد و متنهای بلند" },
|
||||
];
|
||||
|
||||
/** Which kinds each tab shows. */
|
||||
const TAB_KINDS: Record<Tab, string[]> = {
|
||||
rules: ["rule", "preference"],
|
||||
lessons: ["lesson"],
|
||||
code: ["code_map", "fact"],
|
||||
knowledge: [],
|
||||
};
|
||||
|
||||
const KIND_TONES: Record<string, "success" | "info" | "warning" | "neutral"> = {
|
||||
rule: "success",
|
||||
preference: "info",
|
||||
lesson: "warning",
|
||||
fact: "neutral",
|
||||
code_map: "info",
|
||||
};
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString("fa-IR");
|
||||
}
|
||||
|
||||
export default function MemoryPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [tab, setTab] = React.useState<Tab>("rules");
|
||||
const [items, setItems] = React.useState<MemoryRow[]>([]);
|
||||
const [stats, setStats] = React.useState({ total: 0, pinned: 0, chars: 0 });
|
||||
const [sources, setSources] = React.useState<KnowledgeSourceRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const [teachOpen, setTeachOpen] = React.useState(false);
|
||||
const [teachKind, setTeachKind] = React.useState<"rule" | "preference">("rule");
|
||||
const [teachContent, setTeachContent] = React.useState("");
|
||||
const [editing, setEditing] = React.useState<MemoryRow | null>(null);
|
||||
const [editContent, setEditContent] = React.useState("");
|
||||
const [pendingDelete, setPendingDelete] = React.useState<MemoryRow | null>(null);
|
||||
|
||||
const [sourceName, setSourceName] = React.useState("");
|
||||
const [sourceText, setSourceText] = React.useState("");
|
||||
const [pendingSource, setPendingSource] = React.useState<KnowledgeSourceRow | null>(null);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [memory, knowledge] = await Promise.all([
|
||||
memoryApi.list(orgId),
|
||||
memoryApi.listKnowledge(orgId),
|
||||
]);
|
||||
setItems(memory.items);
|
||||
setStats({ total: memory.total, pinned: memory.pinned, chars: memory.chars });
|
||||
setSources(knowledge);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت حافظهٔ سازمان ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Live: another member (or a reflection) changed memory while this page was open.
|
||||
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
|
||||
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load();
|
||||
});
|
||||
|
||||
const visible = React.useMemo(
|
||||
() => items.filter((item) => TAB_KINDS[tab].includes(item.kind)),
|
||||
[items, tab],
|
||||
);
|
||||
|
||||
const run = async (key: string, action: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const teach = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !teachContent.trim()) return;
|
||||
await run("teach", async () => {
|
||||
const created = await memoryApi.teach(orgId, {
|
||||
kind: teachKind,
|
||||
content: teachContent.trim(),
|
||||
});
|
||||
setTeachOpen(false);
|
||||
setTeachContent("");
|
||||
toast(created.kind_label === "قاعده" ? "قاعده یادآوری شد" : "ترجیح ذخیره شد");
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !editing) return;
|
||||
const content = editContent.trim();
|
||||
if (!content) return;
|
||||
await run(`edit-${editing.id}`, async () => {
|
||||
await memoryApi.edit(orgId, editing.id, { content });
|
||||
setEditing(null);
|
||||
toast("یادداشت ویرایش شد");
|
||||
});
|
||||
};
|
||||
|
||||
const togglePin = (item: MemoryRow) =>
|
||||
void run(`pin-${item.id}`, async () => {
|
||||
await memoryApi.setPinned(orgId!, item.id, !item.pinned);
|
||||
toast(item.pinned ? "سنجاق برداشته شد" : "سنجاق شد: همیشه در متن کارها");
|
||||
});
|
||||
|
||||
const confirmLesson = (item: MemoryRow) =>
|
||||
void run(`confirm-${item.id}`, async () => {
|
||||
await memoryApi.confirm(orgId!, item.id);
|
||||
toast("درس تأیید شد: دیگر منقضی نمیشود");
|
||||
});
|
||||
|
||||
const remove = (item: MemoryRow) =>
|
||||
void run(`delete-${item.id}`, async () => {
|
||||
await memoryApi.remove(orgId!, item.id);
|
||||
setPendingDelete(null);
|
||||
toast("یادداشت حذف شد");
|
||||
});
|
||||
|
||||
const addSource = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !sourceName.trim() || !sourceText.trim()) return;
|
||||
await run("source", async () => {
|
||||
const created = await memoryApi.createKnowledge(orgId, {
|
||||
name: sourceName.trim(),
|
||||
content: sourceText,
|
||||
});
|
||||
setSources((current) => [created, ...current]);
|
||||
setSourceName("");
|
||||
setSourceText("");
|
||||
toast(`منبع «${created.name}» افزوده شد`);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (source: KnowledgeSourceRow) =>
|
||||
void run(`source-${source.id}`, async () => {
|
||||
await memoryApi.removeKnowledge(orgId!, source.id);
|
||||
setSources((current) => current.filter((row) => row.id !== source.id));
|
||||
setPendingSource(null);
|
||||
toast("منبع دانش حذف شد");
|
||||
});
|
||||
|
||||
if (!orgId) return <LoadingState label="در حال بارگذاری سازمان…" />;
|
||||
if (loading && items.length === 0) return <LoadingState label="در حال دریافت حافظه…" />;
|
||||
if (error && items.length === 0) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="حافظهٔ سازمان"
|
||||
description="آنچه سازمان دربارهٔ شیوهٔ مدیریت و کد اپلیکیشن یاد گرفته است."
|
||||
planned={[
|
||||
"قوانین و ترجیحات",
|
||||
"درسهای آموخته",
|
||||
"نقشهٔ کد",
|
||||
"منابع دانش",
|
||||
]}
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">حافظهٔ سازمان</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
آنچه سازمان دربارهٔ شیوهٔ کار و اپلیکیشنش یاد گرفته است. کارمندهای هوش مصنوعی
|
||||
پیش از هر کار، یادداشتهای مرتبط را میخوانند؛ هر چه سازمان بیشتر یاد بدهد،
|
||||
کارها دقیقتر انجام میشود.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">یادداشتها</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.total}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">سنجاقشده</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.pinned}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-surface-border bg-surface p-3">
|
||||
<p className="text-xs text-slate-400">حجم متن</p>
|
||||
<p className="mt-1 text-lg text-slate-100">{stats.chars.toLocaleString("fa-IR")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{TABS.map((entry) => (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
onClick={() => setTab(entry.key)}
|
||||
title={entry.hint}
|
||||
className={
|
||||
"rounded-lg border px-3 py-1.5 text-sm transition-colors " +
|
||||
(tab === entry.key
|
||||
? "border-accent bg-accent/10 text-slate-100"
|
||||
: "border-surface-border text-slate-400 hover:text-slate-200")
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ms-auto">
|
||||
{tab !== "knowledge" && (
|
||||
<Button size="sm" onClick={() => setTeachOpen(true)}>
|
||||
به سازمان یاد بده
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{tab === "knowledge" ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle>افزودن منبع متنی</CardTitle>
|
||||
<CardHint>
|
||||
منابع دانش اسناد بلند هستند: متن را یکجا میدهیم، سامانه آن را تکهتکه و
|
||||
قابل جستوجو میکند. یادداشتهای کوتاهتر را در تبهای دیگر بیاموزید.
|
||||
</CardHint>
|
||||
<form onSubmit={addSource} className="mt-4 flex flex-col gap-3">
|
||||
<Field
|
||||
label="نام منبع"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="مثلاً قواعد کدنویسی"
|
||||
required
|
||||
/>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن</span>
|
||||
<textarea
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
placeholder="متن منبع دانش…"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<Button type="submit" loading={busy === "source"}>
|
||||
افزودن منبع
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<EmptyState
|
||||
title="منبعی ثبت نشده است"
|
||||
description="متنهای بلند سازمان را اینجا بگذارید تا کارمندها در کارهایشان از آنها استفاده کنند."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>منابع دانش</CardTitle>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام</th>
|
||||
<th className="px-2 py-2 text-right font-medium">نوع</th>
|
||||
<th className="px-2 py-2 text-right font-medium">تکهها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ایجاد</th>
|
||||
<th className="px-2 py-2 text-right font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{source.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400">{source.kind}</td>
|
||||
<td className="px-2 py-2 text-slate-400">
|
||||
{source.chunk_count.toLocaleString("fa-IR")}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-slate-400">{formatDate(source.created_at)}</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingSource(source)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
title={tab === "lessons" ? "هنوز درسی ثبت نشده" : "هنوز چیزی یادشده نیست"}
|
||||
description="با دکمهٔ «به سازمان یاد بده» یک قاعده یا ترجیح ثبت کنید؛ درسها خودبهخود از کارهای تمامشده استخراج میشوند."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{visible.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={KIND_TONES[item.kind] ?? "neutral"}>{item.kind_label}</Badge>
|
||||
{item.pinned && <Badge tone="info">سنجاقشده</Badge>}
|
||||
<span className="text-xs text-slate-500">{item.source_label}</span>
|
||||
{item.key && (
|
||||
<code className="rounded bg-surface-border/40 px-1.5 py-0.5 text-[11px] text-slate-400">
|
||||
{item.key}
|
||||
</code>
|
||||
)}
|
||||
{item.kind === "lesson" && (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
اعتماد {Math.round(item.confidence * 100).toLocaleString("fa-IR")}٪
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{item.content}</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{item.hits > 0
|
||||
? `${item.hits.toLocaleString("fa-IR")} بار در متن کارها آمده`
|
||||
: "هنوز در کاری استفاده نشده"}
|
||||
{" · "}
|
||||
{formatDate(item.updated_at)}
|
||||
{item.expires_at ? ` · انقضا ${formatDate(item.expires_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={busy === `pin-${item.id}`}
|
||||
onClick={() => togglePin(item)}
|
||||
>
|
||||
{item.pinned ? "برداشتن سنجاق" : "سنجاق کردن"}
|
||||
</Button>
|
||||
{item.kind === "lesson" && item.confidence < 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={busy === `confirm-${item.id}`}
|
||||
onClick={() => confirmLesson(item)}
|
||||
>
|
||||
تأیید درس
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(item);
|
||||
setEditContent(item.content);
|
||||
}}
|
||||
>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPendingDelete(item)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={teachOpen}
|
||||
title="به سازمان یاد بده"
|
||||
description="یک قاعده یا یک ترجیح: یک جملهٔ کافی است."
|
||||
onClose={() => setTeachOpen(false)}
|
||||
>
|
||||
|
||||
<form onSubmit={teach} className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="نوع"
|
||||
value={teachKind}
|
||||
onChange={(e) => setTeachKind(e.target.value as "rule" | "preference")}
|
||||
options={memoryKinds
|
||||
.filter((kind) => kind.key === "rule" || kind.key === "preference")
|
||||
.map((kind) => ({ value: kind.key, label: kind.label }))}
|
||||
/>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن یادداشت</span>
|
||||
<textarea
|
||||
value={teachContent}
|
||||
onChange={(e) => setTeachContent(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1500}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
placeholder="مثلاً: پیش از هر تغییر، تست مربوطه را اجرا کن."
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
یک جملهٔ کافی است. هیچ گذرواژه یا کلیدی در یادداشتها ذخیره نمیشود.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => setTeachOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" loading={busy === "teach"}>
|
||||
یادآوری کن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{editing && (
|
||||
<Modal open title="ویرایش یادداشت" onClose={() => setEditing(null)}>
|
||||
<form onSubmit={saveEdit} className="flex flex-col gap-4">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-1 block text-xs text-slate-400">متن</span>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1500}
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" loading={busy === `edit-${editing.id}`}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="حذف یادداشت"
|
||||
description={
|
||||
pendingDelete
|
||||
? `«${pendingDelete.content.slice(0, 60)}…» از حافظهٔ سازمان برداشته شود؟`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => pendingDelete && remove(pendingDelete)}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSource !== null}
|
||||
title="حذف منبع دانش"
|
||||
description={
|
||||
pendingSource ? `منبع «${pendingSource.name}» و تکههای متن آن حذف شود؟` : ""
|
||||
}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => pendingSource && removeSource(pendingSource)}
|
||||
onCancel={() => setPendingSource(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Modal,
|
||||
Select,
|
||||
Timeline,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
@@ -23,7 +27,14 @@ import {
|
||||
CAN_VIEW_AUDIT,
|
||||
roleHasCapability,
|
||||
} from "@/lib/permissions";
|
||||
import { appProfileApi, setupApi } from "@/lib/api-org";
|
||||
import {
|
||||
mcpApi,
|
||||
appProfileApi,
|
||||
setupApi,
|
||||
type McpSettings,
|
||||
type McpTokenCreated,
|
||||
type McpTokenRow,
|
||||
} from "@/lib/api-org";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
@@ -64,7 +75,11 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
"run.cancel": "لغو اجرا",
|
||||
};
|
||||
|
||||
type Tab = "members" | "audit" | "app";
|
||||
type Tab = "members" | "app" | "mcp" | "audit";
|
||||
|
||||
function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
|
||||
const INVITE_TTL_DAYS = 14;
|
||||
@@ -101,6 +116,27 @@ export default function SettingsPage() {
|
||||
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
|
||||
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
|
||||
|
||||
// --- MCP integration ----------------------------------------------------
|
||||
const [mcpTokens, setMcpTokens] = React.useState<McpTokenRow[]>([]);
|
||||
const [mcpSettings, setMcpSettings] = React.useState<McpSettings | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = React.useState(false);
|
||||
const [mcpName, setMcpName] = React.useState("");
|
||||
const [mcpScopes, setMcpScopes] = React.useState<string[]>(["tasks:read"]);
|
||||
const [mcpBusy, setMcpBusy] = React.useState(false);
|
||||
const [mcpCreated, setMcpCreated] = React.useState<McpTokenCreated | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = React.useState<McpTokenRow | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
@@ -129,13 +165,36 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const loadMcp = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setMcpLoading(true);
|
||||
try {
|
||||
const [tokens, settings] = await Promise.all([
|
||||
mcpApi.list(orgId),
|
||||
mcpApi.settings(orgId),
|
||||
]);
|
||||
setMcpTokens(tokens);
|
||||
setMcpSettings(settings);
|
||||
setMcpScopes((current) =>
|
||||
current.filter((scope) => (settings.scopes as string[]).includes(scope)).length > 0
|
||||
? current
|
||||
: (settings.scopes.slice(0, 1) as string[]),
|
||||
);
|
||||
} catch {
|
||||
setMcpSettings(null);
|
||||
} finally {
|
||||
setMcpLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab === "audit") void loadAudit();
|
||||
}, [tab, loadAudit]);
|
||||
if (tab === "mcp") void loadMcp();
|
||||
}, [tab, loadAudit, loadMcp]);
|
||||
|
||||
const onInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -197,6 +256,45 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- MCP ----------------------------------------------------------------
|
||||
|
||||
const onCreateToken = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !mcpName.trim() || mcpScopes.length === 0) return;
|
||||
setMcpBusy(true);
|
||||
try {
|
||||
const created = await mcpApi.create(orgId, {
|
||||
name: mcpName.trim(),
|
||||
scopes: mcpScopes,
|
||||
});
|
||||
setMcpCreated(created);
|
||||
setMcpTokens((current) => [created, ...current]);
|
||||
setMcpName("");
|
||||
toast("توکن ساخته شد", "success", "توکن فقط یک بار نمایش داده میشود؛ آن را ذخیره کنید.");
|
||||
} catch (err) {
|
||||
toast("ساخت توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setMcpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRevokeToken = async (token: McpTokenRow) => {
|
||||
if (!orgId) return;
|
||||
setMcpBusy(true);
|
||||
try {
|
||||
await mcpApi.revoke(orgId, token.id);
|
||||
setMcpTokens((current) =>
|
||||
current.map((row) => (row.id === token.id ? { ...row, status: "revoked" } : row)),
|
||||
);
|
||||
setPendingRevoke(null);
|
||||
toast("دسترسی توکن لغو شد");
|
||||
} catch (err) {
|
||||
toast("لغو توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setMcpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -209,6 +307,8 @@ export default function SettingsPage() {
|
||||
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
|
||||
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
|
||||
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
|
||||
const canManageMcp =
|
||||
activeOrganization.role === "owner" || activeOrganization.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
@@ -222,6 +322,7 @@ export default function SettingsPage() {
|
||||
[
|
||||
["members", "اعضا و دعوت"],
|
||||
["app", "اپلیکیشن"],
|
||||
["mcp", "یکپارچهسازی (MCP)"],
|
||||
["audit", "رویدادهای حسابرسی"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
@@ -378,6 +479,134 @@ export default function SettingsPage() {
|
||||
</>
|
||||
) : tab === "app" ? (
|
||||
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
|
||||
) : tab === "mcp" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardTitle>یکپارچهسازی (MCP)</CardTitle>
|
||||
<CardHint>
|
||||
با یک توکن، عاملهای هوش مصنوعی بیرونی میتوانند در همین سازمان کار کنند:
|
||||
وظایف بسازند، حافظه را جستوجو کنند و اپلیکیشن را بخوانند. توکن نشان میدهد
|
||||
که عامل از طرف کدام سازمان حرف میزند — هرگز برعکس.
|
||||
</CardHint>
|
||||
{mcpSettings && (!mcpSettings.gateway_enabled || !mcpSettings.mcp_enabled) && (
|
||||
<p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{!mcpSettings.gateway_enabled
|
||||
? "دسترسی MCP سامانه با دستور مالک سامانه خاموش است؛ توکنها تا روشنشدن آن کار نمیکنند."
|
||||
: "دسترسی MCP این سازمان خاموش است؛ برای روشنکردن آن با مالک سامانه تماس بگیرید."}
|
||||
</p>
|
||||
)}
|
||||
{mcpLoading ? (
|
||||
<LoadingState label="در حال دریافت توکنها…" />
|
||||
) : mcpTokens.length === 0 ? (
|
||||
<EmptyState
|
||||
title="توکنی ساخته نشده است"
|
||||
description="توکنها دسترسی یک عامل بیرونی را به همین سازمان محدود میکنند."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام</th>
|
||||
<th className="px-2 py-2 text-right font-medium">توکن</th>
|
||||
<th className="px-2 py-2 text-right font-medium">محدودهها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
|
||||
<th className="px-2 py-2 text-right font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="anim-stagger">
|
||||
{mcpTokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{token.name}</td>
|
||||
<td className="px-2 py-2 text-slate-400" dir="ltr">
|
||||
{token.prefix}…
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-slate-400" dir="ltr">
|
||||
{token.scopes.join(", ")}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge
|
||||
tone={
|
||||
token.status === "active"
|
||||
? "success"
|
||||
: token.status === "expired"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{token.status === "active"
|
||||
? "فعال"
|
||||
: token.status === "expired"
|
||||
? "منقضی"
|
||||
: "لغو شده"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-end">
|
||||
{canManageMcp && token.status === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingRevoke(token)}
|
||||
>
|
||||
لغو
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{canManageMcp && (
|
||||
<Card>
|
||||
<CardTitle>توکن تازه</CardTitle>
|
||||
<CardHint>
|
||||
توکن فقط یک بار، همینجا، نمایش داده میشود. محدودهها را کمترین چیزی
|
||||
بگیرید که عامل برای کارش لازم دارد.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreateToken} className="mt-4 flex flex-col gap-4">
|
||||
<Field
|
||||
label="نام توکن"
|
||||
value={mcpName}
|
||||
onChange={(e) => setMcpName(e.target.value)}
|
||||
placeholder="مثلاً عامل CI"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-slate-400">محدودههای دسترسی</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(mcpSettings?.scopes ?? []).map((scope) => (
|
||||
<Checkbox
|
||||
key={scope}
|
||||
label={scope}
|
||||
checked={mcpScopes.includes(scope)}
|
||||
onChange={(e) =>
|
||||
setMcpScopes((current) =>
|
||||
e.target.checked
|
||||
? [...current, scope]
|
||||
: current.filter((item) => item !== scope),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={mcpBusy}
|
||||
disabled={mcpScopes.length === 0 || !mcpName.trim()}
|
||||
>
|
||||
ساخت توکن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>رویدادهای حسابرسی</CardTitle>
|
||||
@@ -413,6 +642,56 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={mcpCreated !== null}
|
||||
title="توکن ساخته شد"
|
||||
description="این توکن فقط حالا نمایش داده میشود؛ بعداً هیچ راهی برای دیدن آن نیست."
|
||||
onClose={() => setMcpCreated(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<code
|
||||
dir="ltr"
|
||||
className="block break-all rounded-lg border border-surface-border bg-surface px-3 py-2 text-xs text-slate-100"
|
||||
>
|
||||
{mcpCreated?.token}
|
||||
</code>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (mcpCreated) void navigator.clipboard.writeText(mcpCreated.token);
|
||||
toast("توکن کپی شد");
|
||||
}}
|
||||
>
|
||||
کپی توکن
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
این توکن را در تنظیمات عامل خود بگذارید (هدر Authorization با مقدار Bearer).
|
||||
اگر گم شد، توکن را لغو و یکی دیگر بسازید.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRevoke !== null}
|
||||
title="لغو توکن"
|
||||
description={
|
||||
pendingRevoke
|
||||
? `دسترسی «${pendingRevoke.name}» به این سازمان فوراً قطع میشود.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="لغو توکن"
|
||||
onConfirm={() => pendingRevoke && void onRevokeToken(pendingRevoke)}
|
||||
onCancel={() => setPendingRevoke(null)}
|
||||
/>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
title="حذف عضو"
|
||||
|
||||
@@ -22,6 +22,57 @@ import type {
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Owner model grants + org policy */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One catalogue model as the owner sees it, plus whether an organization has it.
|
||||
*
|
||||
* ``label`` is the owner-facing model name; organization users never see this
|
||||
* list — for them a model is only ever a tier («سریع» / «متعادل» / «قوی»).
|
||||
*/
|
||||
export interface ModelGrantRow {
|
||||
ai_model_id: string;
|
||||
label: string;
|
||||
provider_id: string;
|
||||
capabilities: string[];
|
||||
tier: string;
|
||||
tier_label: string;
|
||||
output_price_per_1m: number;
|
||||
model_active: boolean;
|
||||
granted: boolean;
|
||||
daily_credit_cap: number | null;
|
||||
}
|
||||
|
||||
/** An active grant, as the engine would see it (no model name). */
|
||||
export interface ActiveGrantRow {
|
||||
ai_model_id: string;
|
||||
label: string;
|
||||
tier: string;
|
||||
tier_label: string;
|
||||
capabilities: string[];
|
||||
daily_credit_cap: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** The limits the owner sets for one organization. */
|
||||
export interface OrgPolicyView {
|
||||
organization_id: string;
|
||||
max_employees: number;
|
||||
monthly_credit_budget: number | null;
|
||||
automation_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
/** The platform ceiling for ``max_employees`` (``ENGINE_MAX_EMPLOYEES``). */
|
||||
max_employees_limit: number;
|
||||
}
|
||||
|
||||
export interface OrgGrantsView {
|
||||
organization_id: string;
|
||||
models: ModelGrantRow[];
|
||||
}
|
||||
|
||||
|
||||
/** The platform owner's account invitations (split into organizations of the future). */
|
||||
export const orgApi = {
|
||||
/** Create an invitation link for a new manager account. */
|
||||
@@ -76,6 +127,280 @@ export const orgApi = {
|
||||
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- owner model grants --------------------------------------------------
|
||||
listOrgGrants: (orgId: string) =>
|
||||
apiRequest<OrgGrantsView>(`/api/admin/model-grants/${encodeURIComponent(orgId)}`),
|
||||
listActiveGrants: (orgId: string) =>
|
||||
apiRequest<ActiveGrantRow[]>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/active`,
|
||||
),
|
||||
setOrgGrant: (orgId: string, modelId: string, dailyCreditCap: number | null) =>
|
||||
apiRequest<ActiveGrantRow>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
|
||||
{ method: "PUT", body: { daily_credit_cap: dailyCreditCap } },
|
||||
),
|
||||
revokeOrgGrant: (orgId: string, modelId: string) =>
|
||||
apiRequest<{ organization_id: string; ai_model_id: string; granted: boolean }>(
|
||||
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- the default set copied into every new organization ------------------
|
||||
listDefaultGrants: () => apiRequest<ModelGrantRow[]>("/api/admin/default-model-grants"),
|
||||
setDefaultGrant: (
|
||||
modelId: string,
|
||||
body: { is_active: boolean; daily_credit_cap: number | null },
|
||||
) =>
|
||||
apiRequest<ModelGrantRow>(
|
||||
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
|
||||
{ method: "PUT", body },
|
||||
),
|
||||
removeDefaultGrant: (modelId: string) =>
|
||||
apiRequest<{ ai_model_id: string; in_default_set: boolean }>(
|
||||
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- org policy ----------------------------------------------------------
|
||||
getOrgPolicy: (orgId: string) =>
|
||||
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`),
|
||||
updateOrgPolicy: (
|
||||
orgId: string,
|
||||
body: Partial<
|
||||
Pick<
|
||||
OrgPolicyView,
|
||||
"max_employees" | "monthly_credit_budget" | "automation_enabled" | "mcp_enabled"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Org memory + knowledge sources */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One row of the organization's memory: a rule, a preference, a lesson, a fact
|
||||
* or a code-map note.
|
||||
*
|
||||
* ``key`` is the stable handle a rule can be addressed by (the same key always
|
||||
* corrects the same entry), ``confidence`` is a lesson's 0..1 score, and
|
||||
* ``hits`` counts how many tasks have been built with this entry in context.
|
||||
*/
|
||||
export interface MemoryRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
kind_label: string;
|
||||
key: string | null;
|
||||
content: string;
|
||||
source: string;
|
||||
source_label: string;
|
||||
evidence_task_id: string | null;
|
||||
pinned: boolean;
|
||||
confidence: number;
|
||||
expires_at: string | null;
|
||||
hits: number;
|
||||
last_used_at: string | null;
|
||||
created_by_user_id: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryList {
|
||||
organization_id: string;
|
||||
items: MemoryRow[];
|
||||
total: number;
|
||||
pinned: number;
|
||||
chars: number;
|
||||
}
|
||||
|
||||
/** A knowledge-base source, shown as the memory page's second family of tabs. */
|
||||
export interface KnowledgeSourceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
project_id: string | null;
|
||||
created_at: string;
|
||||
chunk_count: number;
|
||||
}
|
||||
|
||||
export const memoryKinds = [
|
||||
{ key: "rule", label: "قاعده" },
|
||||
{ key: "preference", label: "ترجیح" },
|
||||
{ key: "lesson", label: "درس" },
|
||||
{ key: "fact", label: "واقعیت" },
|
||||
{ key: "code_map", label: "نقشهٔ کد" },
|
||||
] as const;
|
||||
|
||||
export const memoryApi = {
|
||||
// --- memory -------------------------------------------------------------
|
||||
list: (orgId: string, params: { kind?: string; q?: string } = {}) =>
|
||||
apiRequest<MemoryList>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory?${new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => Boolean(value)),
|
||||
) as Record<string, string>,
|
||||
).toString()}`,
|
||||
),
|
||||
teach: (
|
||||
orgId: string,
|
||||
body: { kind: string; content: string; key?: string | null },
|
||||
) =>
|
||||
apiRequest<MemoryRow>(`/api/organizations/${encodeURIComponent(orgId)}/memory`, {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
edit: (orgId: string, memoryId: string, body: { content: string; key?: string | null }) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
|
||||
{ method: "PATCH", body },
|
||||
),
|
||||
setPinned: (orgId: string, memoryId: string, pinned: boolean) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/pin`,
|
||||
{ method: "POST", body: { pinned } },
|
||||
),
|
||||
confirm: (orgId: string, memoryId: string) =>
|
||||
apiRequest<MemoryRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/confirm`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
remove: (orgId: string, memoryId: string) =>
|
||||
apiRequest<void>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
// --- knowledge sources (unchanged endpoints, shown as a second tab) ------
|
||||
listKnowledge: (orgId: string) =>
|
||||
apiRequest<KnowledgeSourceRow[]>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
|
||||
),
|
||||
createKnowledge: (
|
||||
orgId: string,
|
||||
body: { name: string; content: string; title?: string },
|
||||
) =>
|
||||
apiRequest<KnowledgeSourceRow>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
removeKnowledge: (orgId: string, sourceId: string) =>
|
||||
apiRequest<void>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources/${encodeURIComponent(sourceId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Org memory realtime */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The event the API publishes on ``org:<id>`` whenever memory changes.
|
||||
*
|
||||
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
|
||||
* frozen, foundation-owned list, so a page compares the raw string instead of
|
||||
* widening the union for one new type.
|
||||
*/
|
||||
export const MEMORY_CHANGED_EVENT = "memory.changed";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* MCP tokens (org admin) + the owner's switches */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Every scope the gateway understands, as the mint dialog offers it. */
|
||||
export const MCP_SCOPES = [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
] as const;
|
||||
|
||||
export type McpScope = (typeof MCP_SCOPES)[number];
|
||||
|
||||
/**
|
||||
* A token as the UI sees it: the prefix, the scopes, the status.
|
||||
*
|
||||
* The raw token exists only on the create response (``McpTokenCreated``), which
|
||||
* is why that type is separate: nothing else in the app can produce one.
|
||||
*/
|
||||
export interface McpTokenRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: McpScope[];
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
created_at: string | null;
|
||||
status: "active" | "expired" | "revoked";
|
||||
}
|
||||
|
||||
export interface McpTokenCreated extends McpTokenRow {
|
||||
/** Shown once, in the modal, with a copy button. Never stored by the client. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface McpSettings {
|
||||
organization_id: string;
|
||||
mcp_enabled: boolean;
|
||||
gateway_enabled: boolean;
|
||||
scopes: McpScope[];
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
export interface McpOrgState {
|
||||
organization_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
mcp_enabled: boolean;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
export interface McpAdminState {
|
||||
gateway_enabled: boolean;
|
||||
organizations: McpOrgState[];
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export const mcpApi = {
|
||||
list: (orgId: string) =>
|
||||
apiRequest<McpTokenRow[]>(`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`),
|
||||
create: (orgId: string, body: { name: string; scopes: string[]; ttl_days?: number }) =>
|
||||
apiRequest<McpTokenCreated>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
revoke: (orgId: string, tokenId: string) =>
|
||||
apiRequest<{ revoked: boolean }>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/${encodeURIComponent(tokenId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
settings: (orgId: string) =>
|
||||
apiRequest<McpSettings>(
|
||||
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/settings`,
|
||||
),
|
||||
|
||||
// --- owner ---------------------------------------------------------------
|
||||
adminState: () => apiRequest<McpAdminState>("/api/admin/mcp"),
|
||||
setGateway: (enabled: boolean) =>
|
||||
apiRequest<{ gateway_enabled: boolean }>("/api/admin/mcp", {
|
||||
method: "PUT",
|
||||
body: { enabled },
|
||||
}),
|
||||
setOrgEnabled: (orgId: string, enabled: boolean) =>
|
||||
apiRequest<{ mcp_enabled: boolean }>(
|
||||
`/api/admin/mcp/organizations/${encodeURIComponent(orgId)}`,
|
||||
{ method: "PUT", body: { enabled } },
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* MCP integration: the org settings tab and the owner's gateway switches.
|
||||
*
|
||||
* The security-relevant assertion is the show-once token: the raw value comes
|
||||
* back from the create call and nowhere else, so the list table must never carry
|
||||
* it, and the modal that shows it is the only place a user can copy it.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {
|
||||
listMembers: vi.fn(),
|
||||
listAudit: vi.fn(),
|
||||
updateMember: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
},
|
||||
orgApi: {
|
||||
listOrgInvites: vi.fn(),
|
||||
getAppProfile: vi.fn(),
|
||||
patchAppProfile: vi.fn(),
|
||||
transferOwnership: vi.fn(),
|
||||
leaveOrganization: vi.fn(),
|
||||
},
|
||||
mcpApi: {
|
||||
list: vi.fn(),
|
||||
settings: vi.fn(),
|
||||
create: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
adminState: vi.fn(),
|
||||
setGateway: vi.fn(),
|
||||
setOrgEnabled: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
|
||||
refresh: vi.fn(async () => {}),
|
||||
},
|
||||
router: { replace: vi.fn() },
|
||||
clipboard: { writeText: vi.fn(async () => {}) },
|
||||
channels: [] as string[][],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
vi.mock("@/lib/api-org", () => ({
|
||||
orgApi: mocks.orgApi,
|
||||
mcpApi: mocks.mcpApi,
|
||||
MCP_SCOPES: [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
],
|
||||
memoryApi: { list: vi.fn(), listKnowledge: vi.fn() },
|
||||
MEMORY_CHANGED_EVENT: "memory.changed",
|
||||
memoryKinds: [],
|
||||
useNeedsOnboarding: () => ({ needsOnboarding: false, ready: true }),
|
||||
}));
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
|
||||
mocks.channels.push(channels);
|
||||
},
|
||||
}));
|
||||
|
||||
import AdminMcpPage from "@/app/(app)/admin/mcp/page";
|
||||
import SettingsPage from "@/app/(app)/settings/page";
|
||||
|
||||
const TOKEN_ROW = {
|
||||
id: "tok-1",
|
||||
organization_id: "org-1",
|
||||
name: "agent CI",
|
||||
prefix: "smo_abc123",
|
||||
scopes: ["tasks:read"],
|
||||
expires_at: "2026-06-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.channels = [];
|
||||
Object.defineProperty(window.navigator, "clipboard", {
|
||||
value: mocks.clipboard,
|
||||
configurable: true,
|
||||
});
|
||||
mocks.session.activeOrganization = {
|
||||
id: "org-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
role: "admin" as const,
|
||||
};
|
||||
mocks.api.listMembers.mockResolvedValue([]);
|
||||
mocks.api.listAudit.mockResolvedValue([]);
|
||||
mocks.orgApi.listOrgInvites.mockResolvedValue([]);
|
||||
mocks.orgApi.getAppProfile.mockRejectedValue(new mocks.FakeApiError(404, "nf", "no app"));
|
||||
mocks.mcpApi.list.mockResolvedValue([TOKEN_ROW]);
|
||||
mocks.mcpApi.settings.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
mcp_enabled: true,
|
||||
gateway_enabled: true,
|
||||
scopes: [
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
],
|
||||
token_count: 1,
|
||||
});
|
||||
mocks.mcpApi.adminState.mockResolvedValue({
|
||||
gateway_enabled: true,
|
||||
organizations: [
|
||||
{
|
||||
organization_id: "org-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
mcp_enabled: true,
|
||||
token_count: 1,
|
||||
},
|
||||
],
|
||||
total_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings — MCP integration", () => {
|
||||
it("lists tokens by prefix and never shows the raw token", async () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
expect(await screen.findByText("agent CI")).toBeInTheDocument();
|
||||
// Only the prefix is rendered — a nine-character handle, never a secret.
|
||||
expect(screen.getByText("smo_abc123…")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/smo_[A-Za-z0-9_\-]{20,}/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a minted token exactly once, with a copy button", async () => {
|
||||
mocks.mcpApi.create.mockResolvedValue({
|
||||
...TOKEN_ROW,
|
||||
id: "tok-2",
|
||||
token: "smo_SEKRET-VALUE-1234567890abcdef",
|
||||
});
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("نام توکن"), {
|
||||
target: { value: "agent جدید" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "ساخت توکن" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "توکن ساخته شد" });
|
||||
expect(within(dialog).getByText("smo_SEKRET-VALUE-1234567890abcdef")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "کپی توکن" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.clipboard.writeText).toHaveBeenCalledWith("smo_SEKRET-VALUE-1234567890abcdef"),
|
||||
);
|
||||
|
||||
// After closing, the raw value is gone from the page for good.
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "بستن" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("smo_SEKRET-VALUE-1234567890abcdef")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a scope before minting, and confirms before revoking", async () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "یکپارچهسازی (MCP)" }));
|
||||
|
||||
// Deselect the pre-checked scope: mint is disabled without one.
|
||||
fireEvent.change(await screen.findByLabelText("نام توکن"), {
|
||||
target: { value: "بدون محدوده" },
|
||||
});
|
||||
const checkbox = document.getElementById("check-tasks:read") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeDisabled();
|
||||
|
||||
// …and selectable again, which is what re-enables it.
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeEnabled();
|
||||
|
||||
mocks.mcpApi.revoke.mockResolvedValue({ revoked: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "لغو توکن" });
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() => expect(mocks.mcpApi.revoke).toHaveBeenCalledWith("org-1", "tok-1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin — MCP switches", () => {
|
||||
it("shows the gateway state and switches off one organization", async () => {
|
||||
mocks.mcpApi.setOrgEnabled.mockResolvedValue({ mcp_enabled: false });
|
||||
render(<AdminMcpPage />);
|
||||
|
||||
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
||||
expect(screen.getByText("روشن")).toBeInTheDocument();
|
||||
|
||||
// Turning one organization off asks first — it is a destructive switch.
|
||||
fireEvent.click(screen.getByRole("button", { name: "خاموش کن" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "خاموشکردن دسترسی یک سازمان" });
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() =>
|
||||
expect(mocks.mcpApi.setOrgEnabled).toHaveBeenCalledWith("org-1", false),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns the whole gateway off with a confirmation", async () => {
|
||||
mocks.mcpApi.setGateway.mockResolvedValue({ gateway_enabled: false });
|
||||
render(<AdminMcpPage />);
|
||||
|
||||
const button = await screen.findByRole("button", {
|
||||
name: "خاموشکردن دسترسی MCP سامانه",
|
||||
});
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(mocks.mcpApi.setGateway).toHaveBeenCalledWith(false));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "روشنکردن دسترسی MCP" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Org memory page.
|
||||
*
|
||||
* Four tabs over one organization's memory, a "teach the organization" dialog
|
||||
* that is a single sentence, and a live refresh on ``memory.changed``. The
|
||||
* security-relevant assertion here is the payload one: a credential typed into
|
||||
* the teach box must never reach the API — the server redacts, and the client
|
||||
* must not smuggle it into a key field either.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {},
|
||||
memoryApi: {
|
||||
list: vi.fn(),
|
||||
teach: vi.fn(),
|
||||
edit: vi.fn(),
|
||||
setPinned: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
listKnowledge: vi.fn(),
|
||||
createKnowledge: vi.fn(),
|
||||
removeKnowledge: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
|
||||
refresh: vi.fn(async () => {}),
|
||||
},
|
||||
router: { replace: vi.fn() },
|
||||
subscriptions: [] as string[][],
|
||||
handlers: [] as ((event: unknown) => void)[],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
|
||||
vi.mock("@/lib/api-org", () => ({
|
||||
MEMORY_CHANGED_EVENT: "memory.changed",
|
||||
memoryApi: mocks.memoryApi,
|
||||
memoryKinds: [
|
||||
{ key: "rule", label: "قاعده" },
|
||||
{ key: "preference", label: "ترجیح" },
|
||||
{ key: "lesson", label: "درس" },
|
||||
{ key: "fact", label: "واقعیت" },
|
||||
{ key: "code_map", label: "نقشهٔ کد" },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
|
||||
mocks.subscriptions.push(channels);
|
||||
mocks.handlers.push(onEvent);
|
||||
},
|
||||
}));
|
||||
|
||||
import MemoryPage from "@/app/(app)/memory/page";
|
||||
|
||||
const RULE = {
|
||||
id: "m-rule",
|
||||
kind: "rule",
|
||||
kind_label: "قاعده",
|
||||
key: "tests-first",
|
||||
content: "پیش از هر تغییر، تست مربوطه را اجرا کن.",
|
||||
source: "user",
|
||||
source_label: "کاربر",
|
||||
evidence_task_id: null,
|
||||
pinned: true,
|
||||
confidence: 1,
|
||||
expires_at: null,
|
||||
hits: 3,
|
||||
last_used_at: "2026-02-01T00:00:00Z",
|
||||
created_by_user_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const LESSON = {
|
||||
...RULE,
|
||||
id: "m-lesson",
|
||||
kind: "lesson",
|
||||
kind_label: "درس",
|
||||
key: null,
|
||||
content: "دروازهٔ lint دو بار شکست؛ پیش از ارسال، lint محلی را اجرا کن.",
|
||||
source: "reflection",
|
||||
source_label: "بازتاب",
|
||||
confidence: 0.5,
|
||||
pinned: false,
|
||||
expires_at: "2026-02-15T00:00:00Z",
|
||||
evidence_task_id: "task-9",
|
||||
};
|
||||
|
||||
const KNOWLEDGE = {
|
||||
id: "src-1",
|
||||
name: "قواعد کدنویسی",
|
||||
kind: "text",
|
||||
project_id: null,
|
||||
created_at: "2026-01-05T00:00:00Z",
|
||||
chunk_count: 4,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.subscriptions = [];
|
||||
mocks.handlers = [];
|
||||
mocks.memoryApi.list.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
items: [RULE, LESSON],
|
||||
total: 2,
|
||||
pinned: 1,
|
||||
chars: 180,
|
||||
});
|
||||
mocks.memoryApi.listKnowledge.mockResolvedValue([KNOWLEDGE]);
|
||||
});
|
||||
|
||||
describe("memory page", () => {
|
||||
it("shows rules and lessons on their own tabs", async () => {
|
||||
render(<MemoryPage />);
|
||||
expect(await screen.findByText(RULE.content)).toBeInTheDocument();
|
||||
// The lesson lives on its own tab, not in the rules list.
|
||||
expect(screen.queryByText(LESSON.content)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "درسهای آموخته" }));
|
||||
expect(await screen.findByText(LESSON.content)).toBeInTheDocument();
|
||||
// A reflection-sourced lesson carries its half-confidence in the UI.
|
||||
expect(screen.getByText(/اعتماد ۵۰/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(RULE.content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("teaches a rule with one sentence and never puts a secret in the payload", async () => {
|
||||
mocks.memoryApi.teach.mockResolvedValue({ ...RULE, id: "m-new", content: "یادداشت تازه" });
|
||||
render(<MemoryPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "به سازمان یاد بده" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
// A credential pasted into the teach box is a user error the server must
|
||||
// catch; the client's job is to send exactly what was typed and nothing else.
|
||||
fireEvent.change(dialog.querySelector("textarea")!, {
|
||||
target: { value: "توکن CI من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است" },
|
||||
});
|
||||
fireEvent.click(dialog.querySelector('button[type="submit"]')!);
|
||||
|
||||
await waitFor(() => expect(mocks.memoryApi.teach).toHaveBeenCalledTimes(1));
|
||||
const [orgId, payload] = mocks.memoryApi.teach.mock.calls[0];
|
||||
expect(orgId).toBe("org-1");
|
||||
expect(payload.kind).toBe("rule");
|
||||
// Only kind/content are ever sent (no key is invented), and there is no
|
||||
// field a secret could hide in.
|
||||
expect(Object.keys(payload).sort()).toEqual(["content", "kind"]);
|
||||
});
|
||||
|
||||
it("pins a rule and asks for confirmation before deleting one", async () => {
|
||||
mocks.memoryApi.setPinned.mockResolvedValue({ ...RULE, pinned: false });
|
||||
mocks.memoryApi.remove.mockResolvedValue(undefined);
|
||||
render(<MemoryPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "برداشتن سنجاق" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.memoryApi.setPinned).toHaveBeenCalledWith("org-1", "m-rule", false),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "حذف" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "حذف یادداشت" });
|
||||
const confirm = dialog.querySelector('button') as HTMLButtonElement;
|
||||
// The confirm button is the dangerous one; the dialog explains the damage.
|
||||
expect(confirm).toBeDefined();
|
||||
expect(screen.getByText(/از حافظهٔ سازمان برداشته شود/)).toBeInTheDocument();
|
||||
fireEvent.click(dialog.querySelectorAll("button")[1]);
|
||||
await waitFor(() => expect(mocks.memoryApi.remove).toHaveBeenCalledWith("org-1", "m-rule"));
|
||||
});
|
||||
|
||||
it("refreshes when the org channel says memory changed", async () => {
|
||||
render(<MemoryPage />);
|
||||
await screen.findByText(RULE.content);
|
||||
|
||||
// The page subscribes to its own tenant's channel only.
|
||||
expect(mocks.subscriptions.at(-1)).toEqual(["org:org-1"]);
|
||||
const callsBefore = mocks.memoryApi.list.mock.calls.length;
|
||||
|
||||
mocks.handlers.at(-1)!({ type: "memory.changed", data: { kind: "lesson" } });
|
||||
await waitFor(() =>
|
||||
expect(mocks.memoryApi.list.mock.calls.length).toBeGreaterThan(callsBefore),
|
||||
);
|
||||
|
||||
// An unrelated event does not cause a reload.
|
||||
const callsAfter = mocks.memoryApi.list.mock.calls.length;
|
||||
mocks.handlers.at(-1)!({ type: "run.step", data: {} });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mocks.memoryApi.list.mock.calls.length).toBe(callsAfter);
|
||||
});
|
||||
|
||||
it("keeps the knowledge sources as a second tab", async () => {
|
||||
render(<MemoryPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "منابع دانش" }));
|
||||
expect(await screen.findByText(KNOWLEDGE.name)).toBeInTheDocument();
|
||||
// Chunk counts use Persian digits like every other number in the product.
|
||||
expect(screen.getByText("۴")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
# Agent 1 — final report (org platform)
|
||||
|
||||
Branch: `dev`. Phases 1, 2 (previously merged) plus 3, 4 and 5 (implemented now,
|
||||
reconstructed from the sibling `agent1/org-platform` branch and reconciled onto
|
||||
`dev`'s shapes).
|
||||
|
||||
## Summary
|
||||
|
||||
The org-platform workstream is complete on `dev`: a platform owner invites a
|
||||
manager, the manager builds one organization with one application through a
|
||||
five-step wizard, the owner decides which models that organization may use, the
|
||||
organization builds a memory of its own rules / preferences / lessons / code map,
|
||||
and an external AI agent can act inside it through a scoped MCP token the
|
||||
organization's own admin minted. Everything is tenant-scoped, audited,
|
||||
Persian/RTL on the UI side, and no secret reaches a response, a draft, an audit
|
||||
row, a log or an LLM prompt.
|
||||
|
||||
```
|
||||
apps/api: pytest -q → 587 passed, 1 skipped
|
||||
apps/api: ruff check . → All checks passed!
|
||||
apps/api: mypy app → Success: no issues found in 147 source files
|
||||
apps/api: alembic upgrade head / downgrade -1 / upgrade head / check → clean, single head
|
||||
apps/web: tsc --noEmit → clean · next lint → No ESLint warnings or errors
|
||||
apps/web: vitest run → 99 passed (13 files)
|
||||
```
|
||||
|
||||
Migrations added on `dev` (the branch's single `a1b2c3d4e5f1` was split into one
|
||||
owned migration per phase, chained off `dev`'s own head):
|
||||
|
||||
| revision | phase | tables |
|
||||
|---|---|---|
|
||||
| `e6f7a8b9c0d1` | 3 | `default_model_grants`, `org_policies` |
|
||||
| `f7a8b9c0d1e2` | 4 | `org_memories` |
|
||||
| `a8b9c0d1e2f3` | 5 | `mcp_tokens` |
|
||||
|
||||
(Phases 1 and 2 were already on `dev`: `a1b2c3d4e5f1` platform invites,
|
||||
`d5e6f7a8b9c0` org setup drafts.)
|
||||
|
||||
## Endpoints added (all under `/api`, roles as enforced by the router)
|
||||
|
||||
### Phase 1 — invites, roles, sharing
|
||||
`POST/GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`,
|
||||
`GET /invites/{token}`, `POST /invites/accept`, `GET /auth/me`
|
||||
(`needs_onboarding`), `POST /organizations/{id}/transfer-ownership`,
|
||||
`POST /organizations/{id}/leave`, `GET/DELETE /organizations/{id}/invites[/{id}]`,
|
||||
and the `/admin/users` lifecycle.
|
||||
|
||||
### Phase 2 — wizard and app profile
|
||||
`GET /setup/catalog`, `GET/PUT /setup/draft`, `POST /setup/check-git`,
|
||||
`POST /setup/check-deploy`, `POST /setup/complete`,
|
||||
`GET/PATCH /organizations/{org}/app/profile`,
|
||||
`PUT /organizations/{org}/app/deploy-target`,
|
||||
`PUT /organizations/{org}/app/git`.
|
||||
|
||||
### Phase 3 — owner grants and policy
|
||||
`GET /admin/model-grants/{org}`, `PUT/DELETE /admin/model-grants/{org}/models/{model}`,
|
||||
`GET /admin/model-grants/{org}/active`,
|
||||
`GET/PUT/DELETE /admin/default-model-grants[/{model}]`,
|
||||
`GET/PUT /admin/org-policies/{org}`.
|
||||
|
||||
### Phase 4 — memory
|
||||
`GET /organizations/{org}/memory`, `POST /organizations/{org}/memory`,
|
||||
`PATCH /organizations/{org}/memory/{id}`,
|
||||
`POST /organizations/{org}/memory/{id}/pin`, `.../confirm`,
|
||||
`DELETE /organizations/{org}/memory/{id}`.
|
||||
|
||||
### Phase 5 — MCP
|
||||
`GET /organizations/{org}/mcp-tokens`, `POST .../mcp-tokens`,
|
||||
`DELETE .../mcp-tokens/{id}`, `GET .../mcp-tokens/settings`,
|
||||
`GET/PUT /admin/mcp`, `PUT /admin/mcp/organizations/{id}`, and the MCP server at
|
||||
`/mcp` (`python -m app.interfaces.mcp.server`, `pnpm mcp:dev`).
|
||||
|
||||
MCP tools: `app_get_profile`, `tasks_list`, `tasks_get`, `tasks_create`,
|
||||
`memory_search`, `memory_write`, `knowledge_search`, `pipeline_status`
|
||||
(`{"available": false}` until Agent 3's tables are wired).
|
||||
|
||||
## Tables added (all in `db/models_org.py`)
|
||||
|
||||
`platform_invites` (1) · `org_setup_drafts` (2) · `default_model_grants` (3) ·
|
||||
`org_policies` (3) · `org_memories` (4) · `mcp_tokens` (5). `org_model_grants`,
|
||||
which the grants engine reads, is foundation-owned.
|
||||
|
||||
## Manual QA — the wizard
|
||||
|
||||
1. As the owner, `/admin/users` → «دعوت مدیر جدید» → copy the link.
|
||||
2. Open it in a private window → «ساخت حساب مدیر» → set a password → land on
|
||||
`/onboarding`.
|
||||
3. Step through the five steps; leave at step 2 and reload to confirm the draft
|
||||
resumes. Choose «Git پلتفرم (پیشنهادی)», skip production, create.
|
||||
4. The success screen shows the demo URL; «برو به مدیر AI» → `/manager`. The DB
|
||||
has exactly one project with a full profile, and the Null hooks recorded one
|
||||
`bootstrap_team` and one `on_app_created`.
|
||||
5. `/admin/model-grants`: grant a model to the new org, set its daily cap, revoke.
|
||||
6. `/memory`: teach a rule, pin it, confirm a lesson, keep the page open while
|
||||
another tab changes memory — the list refreshes on `memory.changed`.
|
||||
7. `/settings` → «یکپارچهسازی (MCP)»: mint a token, copy it once, revoke it.
|
||||
`/admin/mcp`: flip the global and per-org switches.
|
||||
|
||||
## CHANGE REQUESTs still open
|
||||
|
||||
1. **`ModelGrantPort` should expose `get_org_policy(organization_id)`** (and
|
||||
`.mcp_enabled`) so Agent 2 reads the policy through the port it already holds.
|
||||
2. **`expire_memories()`** needs a worker hook (billing worker or a new tick in
|
||||
`workers/__main__.py`).
|
||||
3. **`pipeline_status`** should be pointed at Agent 3's delivery/deploy tables.
|
||||
4. **`@agy/contracts`** lacks `memory.changed` and the MCP event types; the web
|
||||
compares raw strings.
|
||||
5. **Post-login redirect**: an existing manager with `needs_onboarding` still
|
||||
lands on `/dashboard` (`login/page.tsx` and `AppShell.tsx` are locked);
|
||||
`useNeedsOnboarding()` is ready to use.
|
||||
6. **`build_memory_service` / `build_model_grant_service`** live in `container_org`
|
||||
only; re-export from `container.py` for one import surface.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- A reflection is not grant-checked (uses `pick_for_mode(CHEAP)` like the engine).
|
||||
- `expire_memories()` has no caller yet.
|
||||
- `pipeline_status` is a stub until Agent 3's tables are wired.
|
||||
- The MCP rate limiter is process-local.
|
||||
- `mcp_gateway_enabled` is a process-local setting mutation; a restart re-reads
|
||||
it from the environment.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Agent 1 — Phase 3 report (owner model grants + org policy)
|
||||
|
||||
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6, TODO-agent-1
|
||||
Phase 3). Note on provenance: a complete Phase 3 already existed on the sibling
|
||||
worktree branch `agent1/org-platform`; that branch is based on the foundation and
|
||||
its Phase 2 diverged from `dev`'s (different `OrgSetupService`/container shapes),
|
||||
so the code was reconciled onto `dev`'s structures and then fully re-verified here
|
||||
rather than applied verbatim.
|
||||
|
||||
## What I did
|
||||
|
||||
### 3.1 `model_grant_service.py` (new)
|
||||
`ModelGrantService` is the real `ModelGrantPort`: `granted_models(org)` returns
|
||||
only models that are **both** active in the catalogue and actively granted to
|
||||
*that* organization, each with a neutral `tier` (`fast`/`balanced`/`strong` ≈
|
||||
«سریع»/«متعادل»/«قوی») derived by the frozen `tier_for` helper from capabilities
|
||||
+ output price. Model names are exposed only by the owner-facing views
|
||||
(`ModelView.label`, `OrgGrantView.label`), which the engine never calls.
|
||||
|
||||
### 3.2 `admin_grants.py` (owner-only router, deny-by-default)
|
||||
| Method | Path | Role |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/model-grants/{org}` | platform owner |
|
||||
| PUT | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
|
||||
| GET | `/api/admin/model-grants/{org}/active` | platform owner |
|
||||
| DELETE | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
|
||||
| GET | `/api/admin/default-model-grants` | platform owner |
|
||||
| PUT | `/api/admin/default-model-grants/{model}` | platform owner |
|
||||
| DELETE | `/api/admin/default-model-grants/{model}` | platform owner |
|
||||
| GET | `/api/admin/org-policies/{org}` | platform owner |
|
||||
| PUT | `/api/admin/org-policies/{org}` | platform owner |
|
||||
|
||||
All mutations audited (`model_grant.set`, `model_grant.revoke`,
|
||||
`default_model_grant.set`, `default_model_grant.remove`, `org_policy.update`).
|
||||
Revocation deactivates the row instead of deleting it, so re-granting is
|
||||
idempotent and the history survives.
|
||||
|
||||
### 3.3
|
||||
`OrgSetupService.complete` now calls `_copy_default_grants` **inside** the
|
||||
creation transaction (before the commit), materialising the platform owner's
|
||||
default set into the new organization. An empty default set is legitimate: a new
|
||||
org simply has no models until the owner grants them.
|
||||
|
||||
### 3.4
|
||||
New `org_policies` table carries `max_employees` (clamped to `ENGINE_MAX_EMPLOYEES`
|
||||
in the service), `monthly_credit_budget` (`None` = no explicit budget),
|
||||
`automation_enabled` and `mcp_enabled` (reserved for Phase 5).
|
||||
`ModelGrantService.get_org_policy(org_id)` / `mcp_enabled(org_id)` are public for
|
||||
Agent 2 to read.
|
||||
|
||||
### 3.5
|
||||
`container_org.build_model_grants` now returns the real `ModelGrantService`;
|
||||
`container.py` still re-exports the frozen `build_model_grants` name. A second,
|
||||
plain-typed builder `build_model_grant_service` serves the admin router (the
|
||||
port interface alone cannot express grants/defaults/policy).
|
||||
|
||||
### 3.6 `/admin/model-grants`
|
||||
Three tabs: «تخصیص سازمانها» (org picker + matrix: checkbox, tier badge,
|
||||
capabilities, daily cap with its own save button), «مجموعهٔ پیشفرض» (the same
|
||||
matrix for new orgs, with an explicit "empty set" hint), «سیاست سازمان» (max
|
||||
employees, monthly budget, automation and MCP checkboxes). Persian, RTL,
|
||||
`@agy/ui` components, toasts for every mutation, per-row busy state.
|
||||
|
||||
## Files changed
|
||||
|
||||
New:
|
||||
- `apps/api/app/application/services/model_grant_service.py`
|
||||
- `apps/api/app/infrastructure/repositories/org_grants.py`
|
||||
- `apps/api/app/domain/identity/audit_actions.py`
|
||||
- `apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py`
|
||||
- `apps/api/tests/test_model_grants.py`
|
||||
|
||||
Modified:
|
||||
- `apps/api/app/infrastructure/db/models_org.py` (+`DefaultModelGrantRow`, `OrgPolicyRow`)
|
||||
- `apps/api/app/application/container_org.py`
|
||||
- `apps/api/app/application/services/org_setup_service.py`
|
||||
- `apps/api/app/interfaces/http/routers/admin_grants.py`
|
||||
- `apps/api/tests/test_foundation.py`
|
||||
- `apps/web/src/lib/api-org.ts`
|
||||
- `apps/web/src/app/(app)/admin/model-grants/page.tsx`
|
||||
|
||||
## Migration note (deviation from the TODO)
|
||||
|
||||
The Phase-2 integration on `dev` created its own draft migration
|
||||
`d5e6f7a8b9c0_org_setup_drafts.py` chained off `a1b2c3d4e5f1`, rather than adding
|
||||
the table into `a1b2c3d4e5f1` as the reference branch did. I followed `dev`'s own
|
||||
precedent and chained a new, owned migration `e6f7a8b9c0d1_model_grants.py`
|
||||
(`down_revision = "d5e6f7a8b9c0"`). Single head, no drift.
|
||||
|
||||
## Test output summary
|
||||
|
||||
```
|
||||
apps/api: pytest -q → 553 passed, 1 skipped (baseline 536; +17 in test_model_grants.py)
|
||||
apps/api: ruff check . → All checks passed!
|
||||
apps/api: mypy app → Success: no issues found in 141 source files
|
||||
apps/api: alembic upgrade head / downgrade -1 / upgrade head / check → clean, no drift
|
||||
apps/web: tsc --noEmit → clean
|
||||
apps/web: next lint → No ESLint warnings or errors
|
||||
apps/web: vitest run → 89 passed (11 files)
|
||||
```
|
||||
|
||||
`tests/test_model_grants.py` (3.7): non-owner is 403 on all nine endpoints
|
||||
(parameterised), anonymous is 401, the wizard copies the default set (verified
|
||||
through both HTTP and `ModelGrantPort`), grant/revoke is immediate + audited +
|
||||
deactivates rather than deletes, revoking twice is 404, an inactive model is never
|
||||
granted and granting it is 404, per-org isolation both ways, the default set can be
|
||||
deactivated and removed without affecting existing orgs, and the policy
|
||||
defaults/caps/audit/isolation.
|
||||
|
||||
## Existing test updated (and why)
|
||||
|
||||
`tests/test_foundation.py::test_default_model_grants_return_active_models_with_tiers`
|
||||
→ `test_granted_models_carry_tiers_and_need_an_owner_grant`. The foundation test
|
||||
asserted the placeholder wiring (every active model usable by every org). Phase 3
|
||||
changes that on purpose, so the test now creates two real organizations and
|
||||
asserts the stronger invariant: nothing is granted until the owner grants it, both
|
||||
switches (model active + grant active) must agree, and a grant is per-organization.
|
||||
No assertion was dropped.
|
||||
|
||||
## Deviations / notes
|
||||
|
||||
- **`org_model_grants` has no repository elsewhere.** `db/models.py` (and
|
||||
`OrgModelGrantRow`) is foundation-owned and no other workstream owns a
|
||||
repository for it, so these live in the new
|
||||
`app/infrastructure/repositories/org_grants.py`.
|
||||
- **`granted_models` sorts by cap** (uncapped first); nothing depends on the order
|
||||
yet.
|
||||
- The mutation endpoints use router-level `require_platform_owner`, plus a
|
||||
redundant per-endpoint `owner` dependency only to obtain the actor id for audit.
|
||||
|
||||
## CHANGE REQUESTs
|
||||
|
||||
1. **`get_org_policy` on the `ModelGrantPort` contract.** `ports/model_grants.py`
|
||||
is locked, so `get_org_policy` / `mcp_enabled` live on `ModelGrantService` only.
|
||||
Please add `get_org_policy(organization_id) -> object with .max_employees /
|
||||
.automation_enabled` (and ideally `.mcp_enabled`) to `ModelGrantPort` at
|
||||
integration so Agent 2 reads the policy through the port it already holds.
|
||||
2. **`container.py` re-export.** `build_model_grant_service` is importable only
|
||||
from `container_org` (the locked `container.py` re-exports only
|
||||
`build_model_grants`). Re-export it if a single import surface is preferred.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Agent 1 — Phase 4 report (org memory)
|
||||
|
||||
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6,
|
||||
TODO-agent-1 Phase 4). Provenance: the phase was reconstructed from the sibling
|
||||
`agent1/org-platform` branch and reconciled onto `dev`'s Phase-2/3 shapes, then
|
||||
fully re-verified here.
|
||||
|
||||
## What I did
|
||||
|
||||
### 4.1 `app/domain/memory/` (new, pure Python)
|
||||
`models.py` owns the whole vocabulary: `MemoryKind` (rule / preference / lesson /
|
||||
fact / code_map), `MemorySource` (user / manager / system / reflection), the
|
||||
`Memory` record, the limits (key ≤ 80, content ≤ 1,500), `normalise` +
|
||||
`content_hash` for dedupe, `visible_kinds(role)` for the role filter,
|
||||
`lesson_expiry` (30 days unless pinned or confirmed), `relevance` (keyword
|
||||
overlap + recency) and `tokenise` (Persian/English stop words removed). No
|
||||
FastAPI, no SQLAlchemy.
|
||||
|
||||
### 4.2 `org_memories` (in `models_org.py`, migration `f7a8b9c0d1e2`)
|
||||
id, organization_id, kind, key, content, content_hash, source, evidence_task_id
|
||||
(**real FK** to `tasks.id`, `ON DELETE SET NULL`), pinned, confidence 0..1,
|
||||
expires_at, hits, last_used_at, created_by_user_id, created_at, updated_at,
|
||||
deleted_at (soft). Indexes: `(organization_id, kind)`, `(organization_id, key)`,
|
||||
`organization_id`.
|
||||
|
||||
### 4.3 `memory_service.py` (the real `MemoryPort`)
|
||||
- `record` — validates, **redacts through `redact_text` before storing**, then
|
||||
dedupes (same key → update in place, same content hash → refresh), enforces the
|
||||
org cap `MEMORY_ORG_CHAR_CAP` by evicting the oldest **unpinned lesson** first,
|
||||
and enforces the 3-writes-per-task ceiling for reflections.
|
||||
- `build_context(role, query, budget)` — pinned rules then pinned preferences
|
||||
first, then everything the role may see by relevance and recency, assembled as
|
||||
`- [قاعده] …` lines under `budget_chars`; counts `hits`/`last_used_at`.
|
||||
- `on_task_finished` — one `RoutingMode.CHEAP` completion through
|
||||
`ModelProviderPort`, asking for ≤ 2 lessons + a code-map paragraph, stored with
|
||||
`source=reflection`/`confidence=0.5`, priced in the usage ledger. A failure is
|
||||
recorded through `error_log` and swallowed — it never raises into the engine.
|
||||
- `expire_memories()` — soft-deletes expired unpinned lessons (no worker hook; see
|
||||
CHANGE REQUEST).
|
||||
- Plus the UI/gateway writes: `list`, `teach`, `edit`, `set_pinned`, `confirm`,
|
||||
`delete`, `stats`.
|
||||
|
||||
### 4.4 Router `memory.py` (`/organizations/{org}/memory*`)
|
||||
`GET` list (kind + text filter, with total/pinned/chars stats), `POST` teach
|
||||
(member+, rule or preference only), `PATCH /{id}` edit, `POST /{id}/pin`,
|
||||
`POST /{id}/confirm`, `DELETE /{id}` (all admin+). Every mutation is audited
|
||||
(`memory.create|update|pin|confirm|delete`) and then publishes `memory.changed`
|
||||
on `org:<id>`.
|
||||
|
||||
### 4.5
|
||||
`knowledge.py` is untouched and still mounted; its sources appear as the memory
|
||||
page's fourth tab, and `/knowledge` redirects to `/memory`.
|
||||
|
||||
### 4.6 `/memory`
|
||||
Tabs «قوانین و ترجیحات», «درسهای آموخته», «نقشهٔ کد», «منابع دانش»; inline edit
|
||||
modal, pin/unpin, «تأیید درس», the «به سازمان یاد بده» dialog, source table, and
|
||||
`useLiveChannel(["org:<id>"])` → refresh on `memory.changed`.
|
||||
|
||||
### 4.7
|
||||
`container_org.build_memory` now returns the real `MemoryService`; a second
|
||||
builder `build_memory_service(session, provider=…)` serves the router and lets a
|
||||
test inject a provider.
|
||||
|
||||
## Files changed
|
||||
|
||||
New: `app/domain/memory/{__init__,models}.py`,
|
||||
`app/infrastructure/repositories/memory.py`,
|
||||
`app/application/services/memory_service.py`, `tests/test_memory.py`,
|
||||
`apps/web/src/test/org-memory.test.tsx`,
|
||||
`alembic/versions/f7a8b9c0d1e2_org_memory.py`.
|
||||
Modified: `app/infrastructure/db/models_org.py` (+`OrgMemoryRow`),
|
||||
`app/application/container_org.py`, `app/domain/identity/audit_actions.py`,
|
||||
`app/interfaces/http/routers/memory.py`, `apps/web/src/lib/api-org.ts`,
|
||||
`apps/web/src/app/(app)/memory/page.tsx`,
|
||||
`apps/web/src/app/(app)/knowledge/page.tsx`.
|
||||
|
||||
## Test output summary
|
||||
|
||||
```
|
||||
apps/api: pytest -q → 587 passed, 1 skipped (was 553; +21 memory +13 MCP)
|
||||
apps/api: ruff check . → All checks passed!
|
||||
apps/api: mypy app → Success: no issues found in 147 source files
|
||||
apps/api: alembic upgrade → downgrade -1 → upgrade → check → clean, single head
|
||||
apps/web: tsc --noEmit → clean
|
||||
apps/web: next lint → No ESLint warnings or errors
|
||||
apps/web: vitest run → 99 passed (13 files)
|
||||
```
|
||||
|
||||
`tests/test_memory.py` (4.8, 21 tests): domain rules (normalisation, hash equality
|
||||
across case/whitespace, both limits, role filtering, lesson expiry, stop words) ·
|
||||
dedupe and key correction · the cap evicting oldest unpinned lessons first · the
|
||||
3-writes-per-task ceiling · a provider key shape never stored · context (pinned
|
||||
rule first, budget respected, role filtering, a hit counted) · reflection (two
|
||||
lessons + one code map with `source=reflection`/`confidence=0.5`, priced in the
|
||||
ledger; a failing provider raises nothing and records
|
||||
`memory.reflection_failed`; an empty catalogue records
|
||||
`memory.reflection_no_model`) · the expiry job · tenant isolation · HTTP
|
||||
permissions · audit actions · the real port wired in the container.
|
||||
|
||||
## Deviations / notes
|
||||
|
||||
- **`evidence_task_id` is a real foreign key** (`ON DELETE SET NULL`), so a
|
||||
memory cannot point at a task that never existed.
|
||||
- **A reflection is not grant-checked**: it uses `pick_for_mode(CHEAP)` like the
|
||||
engine, without consulting `ModelGrantPort`. An org with no grants still spends
|
||||
credits on its own reflection — flag if the grant filter should apply here too.
|
||||
- **`_parse_reflection` is deliberately conservative**: only lines starting with a
|
||||
known marker become memories.
|
||||
- **The context block has a fixed header** (`یادداشتهای سازمان:`) that counts
|
||||
toward the budget; a budget smaller than the header yields `""`.
|
||||
- Preferences are manager-only; `code_map`/`fact` go to the code-reading roles
|
||||
(`planner`, `developer`, `programmer`, `reviewer`, `tester`).
|
||||
|
||||
## CHANGE REQUESTs
|
||||
|
||||
1. **`expire_memories()` has no caller.** The function is exposed on
|
||||
`MemoryService.expire_memories(organization_id=None)`. Please call it from the
|
||||
existing billing worker (or a new tick in `workers/__main__.py`) at
|
||||
integration.
|
||||
2. **`memory.changed` is not in `@agy/contracts`' `WsEventType`** — the page
|
||||
compares the raw string. Please add it when the contracts package is touched.
|
||||
3. **`build_memory_service` is importable only from `container_org`.** Re-export
|
||||
it from `container.py` for one import surface.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Agent 1 — Phase 5 report (MCP gateway)
|
||||
|
||||
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6,
|
||||
TODO-agent-1 Phase 5). Provenance: reconstructed from the sibling
|
||||
`agent1/org-platform` branch and reconciled onto `dev`, then fully re-verified.
|
||||
|
||||
## What I did
|
||||
|
||||
### 5.1 `mcp_tokens` (in `models_org.py`, migration `a8b9c0d1e2f3`)
|
||||
id, organization_id, name, `token_hash` (unique), `prefix` (ten characters, e.g.
|
||||
`smo_abc123`, display only), `scopes` (CSV), created_by_user_id, expires_at,
|
||||
last_used_at, revoked_at, created_at. The raw token is never stored.
|
||||
|
||||
### 5.2 `mcp_service.py`
|
||||
`McpService`: `issue` (validates scopes, refuses an empty list, mints
|
||||
`smo_<24 random bytes>`), `list`, `revoke` (a second revoke is 404), `verify`
|
||||
(hash lookup + `hmac.compare_digest`; unknown, revoked and expired all return
|
||||
`None`), `touch`, `creator_of`, `count_per_org`. `TokenRateLimiter`: in-memory,
|
||||
per token, `MCP_RATE_LIMIT_PER_MINUTE` (60). `MCP_SCOPES` is the single list of
|
||||
scopes the gateway understands.
|
||||
|
||||
### 5.3 `app/interfaces/mcp/`
|
||||
- `gateway.py` — the tools, framework-free and directly callable (what the tests
|
||||
drive). Every call resolves the org **from the token** (an `organization_id`
|
||||
argument is ignored), checks the scope, checks `org_policies.mcp_enabled`,
|
||||
rate-limits, executes, redacts, audits (`mcp.tool_call` + prefix + tool +
|
||||
ok/error) and commits. Tools: `app_get_profile`, `tasks_list`/`tasks_get`,
|
||||
`tasks_create` (the current public `WorkforceService.create_task` signature,
|
||||
called with the token's minter), `memory_search`, `memory_write` (fact|preference
|
||||
only, `source=user`), `knowledge_search`, `pipeline_status`
|
||||
(`{"available": false}`).
|
||||
- `server.py` — the official `mcp` SDK (streamable HTTP), the eight tools with
|
||||
typed signatures, bearer token from the `Authorization` header, and `main()`
|
||||
for `python -m app.interfaces.mcp.server` → `pnpm mcp:dev`. Outbound payloads
|
||||
are redacted too.
|
||||
|
||||
### 5.4 `services/mcp-gateway/README.md`
|
||||
How to run it, the tool/scope table, the rules the gateway enforces, a client
|
||||
config snippet, the `mcp.<root>` Caddy suggestion, the environment table and the
|
||||
test command.
|
||||
|
||||
### 5.5 Router `mcp_tokens.py`
|
||||
| Method | Path | Who |
|
||||
|---|---|---|
|
||||
| GET | `/api/organizations/{org}/mcp-tokens` | any member (prefix + scopes + status) |
|
||||
| POST | `/api/organizations/{org}/mcp-tokens` | admin+ (raw token once) |
|
||||
| DELETE | `/api/organizations/{org}/mcp-tokens/{id}` | admin+ |
|
||||
| GET | `/api/organizations/{org}/mcp-tokens/settings` | any member |
|
||||
| GET | `/api/admin/mcp` | owner |
|
||||
| PUT | `/api/admin/mcp` | owner (global kill switch) |
|
||||
| PUT | `/api/admin/mcp/organizations/{id}` | owner (per-org, `org_policies.mcp_enabled`) |
|
||||
|
||||
Audit actions: `mcp_token.create`, `mcp_token.revoke`, `mcp.global_switch`,
|
||||
`mcp.org_switch`, plus `mcp.tool_call` per gateway call. The global switch lives
|
||||
in `settings.mcp_gateway_enabled` (`MCP_GATEWAY_ENABLED`, added to the agent-1
|
||||
`config.py` block and `.env.example`).
|
||||
|
||||
### 5.6 UI
|
||||
`(app)/settings` → «یکپارچهسازی (MCP)» tab: switch warnings, token table, mint
|
||||
form with scope checkboxes, show-once modal with a copy button, revoke
|
||||
confirmation, toasts. `/admin/mcp`: stats row, global switch, per-org table with
|
||||
token counts.
|
||||
|
||||
### 5.7 `tests/test_mcp.py` (13 tests)
|
||||
Token shown once and stored only as a hash · unknown/revoked/expired all the same
|
||||
answer · unknown scope refused at mint · a token from org A cannot read org B ·
|
||||
scope enforcement per tool · `memory_write` limited to fact/preference · a
|
||||
provider-key shape never returned · knowledge search and `pipeline_status` ·
|
||||
a task created through the gateway attributed to the token's minter · bad
|
||||
token / unknown tool / global switch / org switch refused · the rate limit · two
|
||||
audit rows per call with the prefix and no raw token · HTTP (viewer 403 on mint,
|
||||
admin 201, 422 on unknown scope, 404 on second revoke, owner-only `/admin/mcp`).
|
||||
Web `src/test/org-mcp.test.tsx` (5 tests) covers the settings tab.
|
||||
|
||||
## Files changed
|
||||
|
||||
New: `app/application/services/mcp_service.py`,
|
||||
`app/interfaces/mcp/{__init__,gateway,server}.py`, `tests/test_mcp.py`,
|
||||
`apps/web/src/test/org-mcp.test.tsx`,
|
||||
`apps/web/src/app/(app)/admin/mcp/page.tsx`, `services/mcp-gateway/README.md`,
|
||||
`alembic/versions/a8b9c0d1e2f3_mcp_tokens.py`.
|
||||
Modified: `app/infrastructure/db/models_org.py` (+`McpTokenRow`),
|
||||
`app/core/config.py`, `.env.example`, `app/domain/identity/audit_actions.py`,
|
||||
`app/interfaces/http/routers/mcp_tokens.py`, `apps/web/src/lib/api-org.ts`,
|
||||
`apps/web/src/app/(app)/settings/page.tsx`.
|
||||
|
||||
## Integration adaptations (dev vs. the reference branch)
|
||||
|
||||
- **`app_get_profile` no longer uses `build_org_setup_service`.** The reference
|
||||
container exported a `build_org_setup_service`; on `dev` the wizard service is
|
||||
built inline in `app_setup.py`, so the gateway now reads the profile through
|
||||
`AppProfileRepository.get_for_org` + `ProjectRepository.get` directly. Same
|
||||
output, no new builder, no extra coupling.
|
||||
- **The settings tab was merged into `dev`'s page**, not copied wholesale: `dev`'s
|
||||
settings page differs from the reference branch's (tab set and member section),
|
||||
so only the MCP state/handlers/tab/modals were added on top.
|
||||
|
||||
## Test output summary
|
||||
|
||||
```
|
||||
apps/api: pytest -q → 587 passed, 1 skipped
|
||||
apps/api: ruff check . → All checks passed!
|
||||
apps/api: mypy app → Success: no issues found in 147 source files
|
||||
apps/api: alembic upgrade → downgrade -1 → upgrade → check → clean, single head
|
||||
apps/web: tsc --noEmit → clean
|
||||
apps/web: next lint → No ESLint warnings or errors
|
||||
apps/web: vitest run → 99 passed (13 files)
|
||||
```
|
||||
|
||||
## Deviations / notes
|
||||
|
||||
- **The global switch is a setting, not a table row** (`MCP_GATEWAY_ENABLED`): it
|
||||
must hold before the first token exists.
|
||||
- **Unattributable attempts** (bad token, disabled gateway) go to the error ledger,
|
||||
not the audit trail — they happen before the caller is known.
|
||||
- **The rate limiter is process-local**; a restart forgives a burst.
|
||||
- **`pipeline_status` is registered and answers `{"available": false}`** until
|
||||
Agent 3's delivery tables are wired at integration.
|
||||
- **Output redaction happens twice** (on write and on the way out).
|
||||
|
||||
## CHANGE REQUESTs
|
||||
|
||||
1. **`pipeline_status` needs Agent 3's tables.** Point the one function in
|
||||
`app/interfaces/mcp/gateway.py` at the real delivery/deploy tables.
|
||||
2. **`mcp:dev` runs the gateway as a separate process** (`SessionFactory`). If it
|
||||
should live inside the API process instead, that is a one-line mount in
|
||||
`rpc_app.py`.
|
||||
3. **`@agy/contracts` has no `memory.changed` / MCP event types** — the web
|
||||
compares raw strings.
|
||||
4. **`build_memory_service` / `build_model_grant_service` are importable only from
|
||||
`container_org`.** Re-export from `container.py` if one import surface is
|
||||
preferred.
|
||||
@@ -1,6 +1,101 @@
|
||||
# mcp-gateway
|
||||
|
||||
HTTP (streamable) MCP server. Shell around `apps/api/app/interfaces/mcp/server.py`.
|
||||
Owned by the org-platform workstream; see `docs/plan/TODO-agent-1-org-platform.md` Phase 5.
|
||||
Streamable-HTTP [MCP](https://modelcontextprotocol.io) server that lets an AI
|
||||
agent work inside **one** organization of the platform. Owned by the org-platform
|
||||
workstream; see `docs/plan/TODO-agent-1-org-platform.md` Phase 5.
|
||||
|
||||
pnpm mcp:dev
|
||||
|
||||
That runs `python -m app.interfaces.mcp.server`, which binds
|
||||
`MCP_GATEWAY_HOST:MCP_GATEWAY_PORT` (127.0.0.1:8765 by default) and serves
|
||||
`/mcp` on the streamable-HTTP transport.
|
||||
|
||||
## What a client sees
|
||||
|
||||
Eight tools, each guarded by a scope the token was minted with:
|
||||
|
||||
| tool | scope | what it does |
|
||||
|---|---|---|
|
||||
| `app_get_profile` | `app:read` | the org's app profile: type, language, framework, build/test commands, port |
|
||||
| `tasks_list` | `tasks:read` | the org's tasks, newest first |
|
||||
| `tasks_get` | `tasks:read` | one task by id |
|
||||
| `tasks_create` | `tasks:write` | a new task for the org's manager, as a user order |
|
||||
| `memory_search` | `memory:read` | rules, preferences, lessons, code map |
|
||||
| `memory_write` | `memory:write` | teach a `fact` or a `preference` (nothing else) |
|
||||
| `knowledge_search` | `memory:read` | the org's long-form knowledge sources |
|
||||
| `pipeline_status` | `delivery:read` | delivery state — currently always `{"available": false}` |
|
||||
|
||||
## Rules the gateway enforces
|
||||
|
||||
- **The organization comes from the token, never from the arguments.** A tool
|
||||
body cannot be pointed at another tenant, whatever it is asked.
|
||||
- **A token is shown once.** Mint one in `/settings` → «یکپارچهسازی (MCP)»; the
|
||||
raw value (`smo_…`) appears in that one response and is never stored — only its
|
||||
hash and a six-character prefix, which is what the audit trail carries.
|
||||
- **Scopes are a floor.** A `tasks:read` token cannot create a task.
|
||||
- **Every call is audited** (`mcp.tool_call`, token prefix, tool, ok/error) and
|
||||
**rate-limited** to `MCP_RATE_LIMIT_PER_MINUTE` (60) per token, in memory.
|
||||
- **Every output is redacted.** A secret that reaches the gateway does not leave it.
|
||||
- **Two kill switches.** The platform owner can disable the gateway globally
|
||||
(`/admin/mcp`) or for one organization; both are honoured on the next call.
|
||||
|
||||
## Client configuration
|
||||
|
||||
The gateway speaks plain MCP over HTTP with a bearer token:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"smart-org": {
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer smo_PASTE_YOUR_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Local, without Caddy:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8765/mcp \
|
||||
-H "Authorization: Bearer smo_PASTE_YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
```
|
||||
|
||||
## Putting Caddy in front of it
|
||||
|
||||
Suggested host: `mcp.<root domain>` (the platform owner configures Caddy — see
|
||||
`infra/`):
|
||||
|
||||
```caddy
|
||||
mcp.example.com {
|
||||
reverse_proxy 127.0.0.1:8765
|
||||
}
|
||||
```
|
||||
|
||||
Keep the gateway bound to loopback in the normal case: the process is small, it
|
||||
has no session state of its own, and the platform's TLS termination and rate
|
||||
limiting belong at the edge. `MCP_GATEWAY_HOST=0.0.0.0` is for a container that
|
||||
must be reached directly, not for a public deployment without a proxy.
|
||||
|
||||
## Environment
|
||||
|
||||
| variable | default | meaning |
|
||||
|---|---|---|
|
||||
| `MCP_GATEWAY_HOST` | `127.0.0.1` | bind address |
|
||||
| `MCP_GATEWAY_PORT` | `8765` | bind port |
|
||||
| `MCP_GATEWAY_ENABLED` | `true` | the owner's global switch |
|
||||
| `MCP_TOKEN_TTL_DAYS` | `90` | default token lifetime |
|
||||
| `MCP_RATE_LIMIT_PER_MINUTE` | `60` | calls per token per minute, in memory |
|
||||
|
||||
## Tests
|
||||
|
||||
pnpm --filter @agy/api test tests/test_mcp.py
|
||||
|
||||
They drive `McpGateway.call` directly (the SDK is a thin adapter over it) and
|
||||
cover tenant isolation, scope enforcement, revoked/expired tokens, the rate
|
||||
limit, audit rows, and secret redaction.
|
||||
|
||||
Reference in New Issue
Block a user