mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-10-19 08:53:50 +00:00
feat(server): add Session execution configuration control plane
Persist versioned provider execution revisions, expose Space project admission transitions, and cover configuration and Session routing behavior.
This commit is contained in:
parent
0aeeb26015
commit
e754d1e32a
9 changed files with 1078 additions and 46 deletions
|
|
@ -0,0 +1,54 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""Add database-owned exact Provider execution references.
|
||||
|
||||
Revision ID: provider_execution_revision
|
||||
Revises: merge_self_hosted_rc_lineages
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
from app.model.provider.execution_revision import (
|
||||
install_execution_revision_triggers,
|
||||
remove_execution_revision_triggers,
|
||||
)
|
||||
|
||||
revision = "provider_execution_revision"
|
||||
down_revision = "merge_self_hosted_rc_lineages"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
op.add_column(
|
||||
"provider",
|
||||
sa.Column("execution_revision", sa.String(32), nullable=False, server_default=sa.text("''")),
|
||||
)
|
||||
# Existing rows get independent non-secret identities, never key digests.
|
||||
for row in connection.execute(sa.text("SELECT id FROM provider")).fetchall():
|
||||
connection.execute(
|
||||
sa.text("UPDATE provider SET execution_revision=:revision WHERE id=:id"),
|
||||
{"revision": uuid4().hex, "id": row[0]},
|
||||
)
|
||||
install_execution_revision_triggers(connection)
|
||||
|
||||
|
||||
def downgrade():
|
||||
remove_execution_revision_triggers(op.get_bind())
|
||||
op.drop_column("provider", "execution_revision")
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""Device/account-authenticated configuration and exact credential projection."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.core.database import session
|
||||
from app.domains.model_provider.service.execution_configuration import ExecutionConfigurationSource
|
||||
from app.domains.remote_control.api.command_control_controller import DevicePrincipal, device_principal_must
|
||||
|
||||
router = APIRouter(prefix="/sync/execution", tags=["Managed Execution Configuration"])
|
||||
Principal = Annotated[DevicePrincipal, Depends(device_principal_must)]
|
||||
Database = Annotated[Session, Depends(session)]
|
||||
|
||||
|
||||
class ExactProviderReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
|
||||
provider_ref: str = Field(pattern=r"^provider:[1-9][0-9]{0,18}:[0-9a-f]{32}$")
|
||||
space_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
session_mode: Literal["single-agent", "workforce"]
|
||||
|
||||
|
||||
def private_response(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
@router.get("/identity")
|
||||
def identity(principal: Principal, db: Database, response: Response):
|
||||
private_response(response)
|
||||
return ExecutionConfigurationSource.identity(db, principal)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/configuration")
|
||||
def configuration(project_id: str, principal: Principal, db: Database, response: Response):
|
||||
private_response(response)
|
||||
return ExecutionConfigurationSource.read(db, principal, project_id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/credentials:resolve")
|
||||
async def resolve(project_id: str, principal: Principal, db: Database, request: Request, response: Response):
|
||||
private_response(response)
|
||||
try:
|
||||
raw = b""
|
||||
async for chunk in request.stream():
|
||||
raw += chunk
|
||||
if len(raw) > 2048:
|
||||
raise ValueError("oversized reference")
|
||||
exact = ExactProviderReference.model_validate_json(raw)
|
||||
except ValueError:
|
||||
# FastAPI's default validation payload may echo arbitrary input,
|
||||
# including an accidentally supplied key. This endpoint never does.
|
||||
raise HTTPException(status_code=422, detail={"code": "invalid_execution_reference"}) from None
|
||||
return await run_in_threadpool(ExecutionConfigurationSource.read, db, principal, project_id, exact=exact)
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""Authenticated projection of existing Project, Space and Provider records.
|
||||
|
||||
This is not a second configuration or credential store. Resolving a pinned
|
||||
provider requires its exact database-owned revision and a currently authorized
|
||||
account/device. Values are returned only by the private credential operation.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import select
|
||||
|
||||
from app.domains.remote_control.service.command_control_service import CommandControlService
|
||||
from app.model.project import Project
|
||||
from app.model.provider.provider import Provider, VaildStatus
|
||||
from app.model.remote_control.command_control import DesktopDevice
|
||||
from app.model.space import Space
|
||||
|
||||
|
||||
def unavailable():
|
||||
raise HTTPException(status_code=409, detail={"code": "execution_configuration_unavailable"})
|
||||
|
||||
|
||||
def provider_projection(provider):
|
||||
"""Project only the supported non-secret schema, never arbitrary JSON."""
|
||||
if (
|
||||
provider.provider_name != "openai"
|
||||
or provider.is_valid != VaildStatus.is_valid
|
||||
or not re.fullmatch(r"[0-9a-f]{32}", provider.execution_revision or "")
|
||||
or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}", provider.model_type or "")
|
||||
or provider.model_type.lower().startswith(("sk-", "sk_"))
|
||||
):
|
||||
unavailable()
|
||||
endpoint = urlsplit(provider.endpoint_url)
|
||||
if (
|
||||
endpoint.scheme != "https"
|
||||
or not endpoint.hostname
|
||||
or endpoint.username
|
||||
or endpoint.password
|
||||
or endpoint.query
|
||||
or endpoint.fragment
|
||||
or len(provider.endpoint_url) > 512
|
||||
):
|
||||
unavailable()
|
||||
config = provider.encrypted_config
|
||||
if config is None:
|
||||
config = {}
|
||||
if not isinstance(config, dict) or set(config) - {"model_config_dict", "api_mode", "timeout", "max_retries"}:
|
||||
unavailable()
|
||||
parameters = config.get("model_config_dict", {})
|
||||
numeric = {
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"seed",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
}
|
||||
if not isinstance(parameters, dict) or set(parameters) - numeric - {"stream", "n", "reasoning_effort"}:
|
||||
unavailable()
|
||||
for key, value in parameters.items():
|
||||
if key in numeric and (type(value) not in (int, float) or not math.isfinite(value)):
|
||||
unavailable()
|
||||
if (
|
||||
parameters.get("stream", False) is not False
|
||||
or type(parameters.get("n", 1)) is not int
|
||||
or parameters.get("n", 1) != 1
|
||||
):
|
||||
unavailable()
|
||||
if "reasoning_effort" in parameters and parameters["reasoning_effort"] not in {
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
}:
|
||||
unavailable()
|
||||
constructor = {key: value for key, value in config.items() if key != "model_config_dict"}
|
||||
timeout = constructor.get("timeout", 60)
|
||||
retries = constructor.get("max_retries", 0)
|
||||
if (
|
||||
constructor.get("api_mode", "chat_completions") != "chat_completions"
|
||||
or type(timeout) not in (int, float)
|
||||
or not math.isfinite(timeout)
|
||||
or not 0 < timeout <= 600
|
||||
or type(retries) is not int
|
||||
or not 0 <= retries <= 3
|
||||
):
|
||||
unavailable()
|
||||
# Never derive a durable fingerprint from a secret, including a secret
|
||||
# accidentally copied into an otherwise non-secret string field.
|
||||
if provider.api_key and any(provider.api_key in value for value in (provider.model_type, provider.endpoint_url)):
|
||||
unavailable()
|
||||
return {
|
||||
"provider_ref": f"provider:{provider.id}:{provider.execution_revision}",
|
||||
"model_platform": provider.provider_name,
|
||||
"model_type": provider.model_type,
|
||||
"api_url": provider.endpoint_url,
|
||||
"model_config_dict": parameters,
|
||||
"extra_params": constructor,
|
||||
}
|
||||
|
||||
|
||||
class ExecutionConfigurationSource:
|
||||
@staticmethod
|
||||
def identity(db, principal):
|
||||
device = CommandControlService.require_device(principal.desktop_instance_id, principal.user_id, db)
|
||||
return {
|
||||
"account_owner_id": str(principal.user_id),
|
||||
"desktop_instance_id": device.id,
|
||||
"device_credential_version": device.credential_version,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def read(cls, db, principal, project_id, *, exact=None):
|
||||
identity = cls.identity(db, principal)
|
||||
owner = identity["account_owner_id"]
|
||||
# First select intent; the joined read below rechecks that intent and
|
||||
# returns current membership, device and provider in one DB snapshot.
|
||||
project = db.exec(select(Project).where(Project.id == project_id, Project.user_id == owner)).first()
|
||||
if project is None or project.status != "active" or project.mode not in {"single-agent", "workforce"}:
|
||||
unavailable()
|
||||
space = db.exec(select(Space).where(Space.id == project.space_id, Space.user_id == owner)).first()
|
||||
if space is None or space.status != "active" or not space.root_path:
|
||||
unavailable()
|
||||
metadata = copy.deepcopy(project.metadata_json or {})
|
||||
if not isinstance(metadata, dict):
|
||||
unavailable()
|
||||
effort = metadata.get("thinkingEffort")
|
||||
if effort is not None and effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
|
||||
unavailable()
|
||||
providers = select(Provider).where(Provider.user_id == principal.user_id, Provider.no_delete())
|
||||
selection = metadata.get("modelSelection")
|
||||
if exact is not None:
|
||||
if exact.space_id != project.space_id or exact.session_mode != project.mode:
|
||||
unavailable()
|
||||
_, provider_id, revision = exact.provider_ref.split(":")
|
||||
providers = providers.where(Provider.id == int(provider_id), Provider.execution_revision == revision)
|
||||
elif selection is not None:
|
||||
if (
|
||||
not isinstance(selection, dict)
|
||||
or set(selection) - {"modelType", "provider_id", "model_platform", "model_type"}
|
||||
or selection.get("modelType") not in {"custom", "local"}
|
||||
or type(selection.get("provider_id")) is not int
|
||||
):
|
||||
unavailable()
|
||||
providers = providers.where(Provider.id == selection["provider_id"])
|
||||
else:
|
||||
providers = providers.where(Provider.prefer == True)
|
||||
candidates = db.exec(
|
||||
select(Project, Space, Provider)
|
||||
.join(Space, Space.id == Project.space_id)
|
||||
.join(Provider, Provider.user_id == principal.user_id)
|
||||
.join(DesktopDevice, DesktopDevice.id == principal.desktop_instance_id)
|
||||
.where(
|
||||
Project.id == project_id,
|
||||
Project.user_id == owner,
|
||||
Project.status == "active",
|
||||
Space.user_id == owner,
|
||||
Space.status == "active",
|
||||
DesktopDevice.user_id == principal.user_id,
|
||||
DesktopDevice.revoked_at.is_(None),
|
||||
DesktopDevice.credential_version == identity["device_credential_version"],
|
||||
Provider.id.in_(providers.with_only_columns(Provider.id)),
|
||||
)
|
||||
.execution_options(populate_existing=True)
|
||||
.with_for_update()
|
||||
).all()
|
||||
if len(candidates) != 1:
|
||||
unavailable()
|
||||
project, space, provider = candidates[0]
|
||||
if not space.root_path or project.mode not in {"single-agent", "workforce"}:
|
||||
unavailable()
|
||||
if exact is not None:
|
||||
if (project.space_id, project.mode) != (exact.space_id, exact.session_mode):
|
||||
unavailable()
|
||||
elif (project.metadata_json or {}) != metadata:
|
||||
unavailable()
|
||||
projection = provider_projection(provider)
|
||||
if exact is None and selection:
|
||||
for field in ("model_platform", "model_type"):
|
||||
if field in selection and selection[field] != projection[field]:
|
||||
unavailable()
|
||||
result = {
|
||||
**identity,
|
||||
"project_id": project.id,
|
||||
"space_id": space.id,
|
||||
"space_source_type": "legacy" if (space.metadata_json or {}).get("legacy") is True else space.source_type,
|
||||
"session_mode": project.mode,
|
||||
"space_root": space.root_path,
|
||||
"thinking_effort": effort,
|
||||
"provider": projection,
|
||||
}
|
||||
if exact is not None:
|
||||
if not isinstance(provider.api_key, str) or not provider.api_key.strip():
|
||||
unavailable()
|
||||
# Never log, hash or persist this response in the Brain.
|
||||
result["api_key"] = provider.api_key
|
||||
return result
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
|
@ -33,6 +35,7 @@ from app.model.project import (
|
|||
ProjectUpdate,
|
||||
ProjectWorkdirMode,
|
||||
)
|
||||
from app.model.project.project import PROJECT_CREATION_INTENT_KEY
|
||||
from app.model.space import Space, SpaceIn, SpaceOut, SpaceSourceType, SpaceStatus, SpaceUpdate
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
|
@ -42,9 +45,7 @@ class SpaceHasProjectsError(ValueError):
|
|||
def __init__(self, project_count: int, projects: list[dict[str, str]]) -> None:
|
||||
self.project_count = project_count
|
||||
self.projects = projects
|
||||
super().__init__(
|
||||
f"Space has {project_count} project(s); archive or delete them first"
|
||||
)
|
||||
super().__init__(f"Space has {project_count} project(s); archive or delete them first")
|
||||
|
||||
|
||||
class ProjectModelAdmissionConflictError(ValueError):
|
||||
|
|
@ -110,7 +111,8 @@ class SpaceService:
|
|||
placeholder_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if not (project.metadata_json or {}).get("nameSource") and SpaceService._project_name_is_placeholder(
|
||||
if not (project.metadata_json or {}).get("nameSource")
|
||||
and SpaceService._project_name_is_placeholder(
|
||||
project.name,
|
||||
project.id,
|
||||
)
|
||||
|
|
@ -223,9 +225,7 @@ class SpaceService:
|
|||
raise ValueError("Invalid Space status")
|
||||
|
||||
@staticmethod
|
||||
def _prepare_space_root(
|
||||
data: SpaceIn, user_id: str, s: Session
|
||||
) -> tuple[str | None, dict | None]:
|
||||
def _prepare_space_root(data: SpaceIn, user_id: str, s: Session) -> tuple[str | None, dict | None]:
|
||||
if data.source_type == SpaceSourceType.FOLDER:
|
||||
root_ref = normalize_folder_root_reference(data.root_path or "")
|
||||
existing_spaces = s.exec(
|
||||
|
|
@ -271,9 +271,7 @@ class SpaceService:
|
|||
raise ValueError("Folder is already bound to another Space")
|
||||
|
||||
@staticmethod
|
||||
def _folder_identity_matches(
|
||||
previous: dict | None, current: dict
|
||||
) -> bool:
|
||||
def _folder_identity_matches(previous: dict | None, current: dict) -> bool:
|
||||
if not previous:
|
||||
return True
|
||||
if previous.get("kind") != current.get("kind"):
|
||||
|
|
@ -335,10 +333,7 @@ class SpaceService:
|
|||
if projects:
|
||||
raise SpaceHasProjectsError(
|
||||
len(projects),
|
||||
[
|
||||
{"id": project.id, "name": project.name}
|
||||
for project in projects[:10]
|
||||
],
|
||||
[{"id": project.id, "name": project.name} for project in projects[:10]],
|
||||
)
|
||||
s.delete(space)
|
||||
s.commit()
|
||||
|
|
@ -392,20 +387,13 @@ class SpaceService:
|
|||
root_ref = normalize_folder_root_reference(root_path)
|
||||
if not force:
|
||||
if root_fingerprint and space.root_fingerprint:
|
||||
if not SpaceService._folder_identity_matches(
|
||||
space.root_fingerprint, root_fingerprint
|
||||
):
|
||||
raise ValueError(
|
||||
"Relocated folder identity does not match this Space"
|
||||
)
|
||||
elif space.root_path and not same_folder_reference(
|
||||
space.root_path, root_ref
|
||||
):
|
||||
if not SpaceService._folder_identity_matches(space.root_fingerprint, root_fingerprint):
|
||||
raise ValueError("Relocated folder identity does not match this Space")
|
||||
elif space.root_path and not same_folder_reference(space.root_path, root_ref):
|
||||
# No fingerprint available on either side: require the caller
|
||||
# to acknowledge the change with force=True.
|
||||
raise ValueError(
|
||||
"Relocated folder identity cannot be verified; "
|
||||
"supply root_fingerprint or use force=True"
|
||||
"Relocated folder identity cannot be verified; supply root_fingerprint or use force=True"
|
||||
)
|
||||
SpaceService._assert_folder_not_bound_to_other_space(
|
||||
user_id=canonical_user_id,
|
||||
|
|
@ -498,8 +486,7 @@ class SpaceService:
|
|||
name=display_name[:255] or f"Project {project_id}",
|
||||
description=description,
|
||||
mode=mode,
|
||||
workdir_mode=workdir_mode
|
||||
or SpaceService._default_project_workdir_mode(space),
|
||||
workdir_mode=workdir_mode or SpaceService._default_project_workdir_mode(space),
|
||||
metadata_json=metadata,
|
||||
)
|
||||
try:
|
||||
|
|
@ -535,16 +522,8 @@ class SpaceService:
|
|||
@staticmethod
|
||||
def list_spaces(user_id: int | str, s: Session) -> list[SpaceOut]:
|
||||
canonical_user_id = SpaceService.canonical_user_id(user_id)
|
||||
spaces = s.exec(
|
||||
select(Space)
|
||||
.where(Space.user_id == canonical_user_id)
|
||||
.order_by(Space.updated_at.desc())
|
||||
).all()
|
||||
legacy_space_ids = [
|
||||
space.id
|
||||
for space in spaces
|
||||
if space.source_type == SpaceSourceType.LEGACY
|
||||
]
|
||||
spaces = s.exec(select(Space).where(Space.user_id == canonical_user_id).order_by(Space.updated_at.desc())).all()
|
||||
legacy_space_ids = [space.id for space in spaces if space.source_type == SpaceSourceType.LEGACY]
|
||||
if legacy_space_ids:
|
||||
legacy_space_ids_with_projects = set(
|
||||
s.exec(
|
||||
|
|
@ -557,8 +536,7 @@ class SpaceService:
|
|||
spaces = [
|
||||
space
|
||||
for space in spaces
|
||||
if space.source_type != SpaceSourceType.LEGACY
|
||||
or space.id in legacy_space_ids_with_projects
|
||||
if space.source_type != SpaceSourceType.LEGACY or space.id in legacy_space_ids_with_projects
|
||||
]
|
||||
return [SpaceOut.from_model(space) for space in spaces]
|
||||
|
||||
|
|
@ -600,6 +578,11 @@ class SpaceService:
|
|||
space = SpaceService._get_owned_space(space_id, canonical_user_id, s)
|
||||
SpaceService._validate_project_payload(data.mode, data.status, data.workdir_mode)
|
||||
|
||||
if PROJECT_CREATION_INTENT_KEY in (data.metadata or {}):
|
||||
raise ValueError("Project creation receipt is server-owned")
|
||||
if data.space_id is not None and data.space_id != space_id:
|
||||
raise ValueError("Project Space differs from the creation target")
|
||||
|
||||
project_id = data.id or f"project_{uuid4().hex}"
|
||||
project = Project(
|
||||
id=project_id,
|
||||
|
|
@ -609,12 +592,70 @@ class SpaceService:
|
|||
description=data.description,
|
||||
mode=data.mode,
|
||||
status=data.status,
|
||||
workdir_mode=data.workdir_mode
|
||||
or SpaceService._default_project_workdir_mode(space),
|
||||
workdir_mode=data.workdir_mode or SpaceService._default_project_workdir_mode(space),
|
||||
metadata_json=data.metadata,
|
||||
)
|
||||
# Compare the original creation intent, not mutable name/settings/status.
|
||||
# The receipt lives in the existing JSON column and is never client-writable.
|
||||
creation_intent = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
**data.model_dump(exclude={"id", "space_id"}),
|
||||
"id": project_id,
|
||||
"user_id": canonical_user_id,
|
||||
"space_id": space_id,
|
||||
"name": project.name,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def recover(existing: Project) -> Project:
|
||||
if existing.user_id != canonical_user_id or existing.space_id != space_id:
|
||||
raise ValueError("Project id already belongs to another creation target")
|
||||
receipt = (existing.metadata_json or {}).get(PROJECT_CREATION_INTENT_KEY)
|
||||
if receipt is not None:
|
||||
matches = receipt == creation_intent
|
||||
else:
|
||||
# Pre-receipt rows retain the strict old collision check. There is
|
||||
# no evidence permitting reconstruction from subsequently edited data.
|
||||
fields = ("name", "description", "mode", "status", "workdir_mode", "metadata_json")
|
||||
matches = all(getattr(existing, field) == getattr(project, field) for field in fields)
|
||||
if not matches:
|
||||
raise ValueError("Project id already exists with a different creation intent")
|
||||
return existing
|
||||
|
||||
existing = s.get(Project, project_id)
|
||||
if existing is not None:
|
||||
return recover(existing)
|
||||
project.metadata_json = {**(data.metadata or {}), PROJECT_CREATION_INTENT_KEY: creation_intent}
|
||||
s.add(project)
|
||||
s.commit()
|
||||
try:
|
||||
s.commit()
|
||||
except IntegrityError as error:
|
||||
s.rollback()
|
||||
# Only the Project identity constraints may be recovered. Foreign
|
||||
# key, other unique/check constraints and unrelated failures still fail.
|
||||
original = error.orig
|
||||
sqlite_duplicate = getattr(original, "sqlite_errorcode", None) in {1555, 2067} and str(original) in {
|
||||
f"UNIQUE constraint failed: {Project.__tablename__}.id",
|
||||
f"UNIQUE constraint failed: {Project.__tablename__}.user_id, {Project.__tablename__}.id",
|
||||
}
|
||||
postgres_duplicate = (
|
||||
getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
|
||||
) == "23505" and getattr(getattr(original, "diag", None), "constraint_name", None) in {
|
||||
Project.__table__.primary_key.name or f"{Project.__tablename__}_pkey",
|
||||
"uix_project_user_id_id",
|
||||
}
|
||||
if not (sqlite_duplicate or postgres_duplicate):
|
||||
raise
|
||||
winner = s.get(Project, project_id)
|
||||
if winner is None:
|
||||
raise
|
||||
return recover(winner)
|
||||
s.refresh(project)
|
||||
return project
|
||||
|
||||
|
|
@ -646,7 +687,7 @@ class SpaceService:
|
|||
) -> Project:
|
||||
canonical_user_id = SpaceService.canonical_user_id(user_id)
|
||||
SpaceService._get_owned_space(space_id, canonical_user_id, s)
|
||||
SpaceService._validate_project_payload(status=data.status, workdir_mode=data.workdir_mode)
|
||||
SpaceService._validate_project_payload(mode=data.mode, status=data.status, workdir_mode=data.workdir_mode)
|
||||
project_scope = (
|
||||
Project.id == project_id,
|
||||
Project.user_id == canonical_user_id,
|
||||
|
|
@ -673,6 +714,8 @@ class SpaceService:
|
|||
current_metadata = project.metadata_json or {}
|
||||
current_revision = current_metadata.get("spaceModelAdmissionRevision")
|
||||
metadata = update_data.pop("metadata", None)
|
||||
if PROJECT_CREATION_INTENT_KEY in (metadata or {}):
|
||||
raise ValueError("Project creation receipt is server-owned")
|
||||
if revision is not None:
|
||||
# Both assignment and cleanup compare the entire receipt generation.
|
||||
# Keep the revision after null so an old request cannot exploit ABA.
|
||||
|
|
@ -753,8 +796,7 @@ class SpaceService:
|
|||
project.workdir_mode = ProjectWorkdirMode.COPY
|
||||
project.metadata_json = {
|
||||
**previous_metadata,
|
||||
"promotedFromSpaceId": previous_metadata.get("promotedFromSpaceId")
|
||||
or previous_space_id,
|
||||
"promotedFromSpaceId": previous_metadata.get("promotedFromSpaceId") or previous_space_id,
|
||||
}
|
||||
s.add(project)
|
||||
s.commit()
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ class Project(AbstractModel, DefaultTimes, table=True):
|
|||
metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON))
|
||||
|
||||
|
||||
# Server-owned receipt in the existing JSON column; no database migration.
|
||||
PROJECT_CREATION_INTENT_KEY = "_eigent_creation_intent_v1"
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
id: str | None = None
|
||||
space_id: str | None = None
|
||||
|
|
@ -85,6 +89,7 @@ class ProjectIn(BaseModel):
|
|||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mode: str | None = None
|
||||
status: str | None = None
|
||||
workdir_mode: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
|
@ -135,6 +140,8 @@ class ProjectOut(BaseModel):
|
|||
@classmethod
|
||||
def from_model(cls, project: Project) -> "ProjectOut":
|
||||
metadata = project.metadata_json if isinstance(project.metadata_json, dict) else None
|
||||
if metadata is not None and PROJECT_CREATION_INTENT_KEY in metadata:
|
||||
metadata = {key: value for key, value in metadata.items() if key != PROJECT_CREATION_INTENT_KEY} or None
|
||||
return cls(
|
||||
id=project.id,
|
||||
user_id=project.user_id,
|
||||
|
|
|
|||
83
server/app/model/provider/execution_revision.py
Normal file
83
server/app/model/provider/execution_revision.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""Database-owned opaque Provider revisions, independent of secret values.
|
||||
|
||||
Triggers cover ORM, bulk and raw SQL writes, including older CRUD clients.
|
||||
Changing preference alone does not invalidate a pinned provider. Rollback also
|
||||
rolls back the revision; deleted/re-created rows receive a fresh identity.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
FIELDS = "user_id, provider_name, model_type, api_key, endpoint_url, encrypted_config, is_vaild, deleted_at"
|
||||
|
||||
|
||||
def install_execution_revision_triggers(connection):
|
||||
dialect = connection.dialect.name
|
||||
if dialect == "sqlite":
|
||||
connection.execute(
|
||||
text("""
|
||||
CREATE TRIGGER provider_execution_revision_insert AFTER INSERT ON provider
|
||||
BEGIN
|
||||
UPDATE provider SET execution_revision = lower(hex(randomblob(16)))
|
||||
WHERE id = NEW.id;
|
||||
END
|
||||
""")
|
||||
)
|
||||
connection.execute(
|
||||
text(f"""
|
||||
CREATE TRIGGER provider_execution_revision_update AFTER UPDATE OF {FIELDS} ON provider
|
||||
BEGIN
|
||||
UPDATE provider SET execution_revision = lower(hex(randomblob(16)))
|
||||
WHERE id = NEW.id;
|
||||
END
|
||||
""")
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
# The repository's server deployment uses PostgreSQL 15.
|
||||
connection.execute(
|
||||
text("""
|
||||
CREATE FUNCTION rotate_provider_execution_revision() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.execution_revision := replace(gen_random_uuid()::text, '-', '');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
""")
|
||||
)
|
||||
connection.execute(
|
||||
text(f"""
|
||||
CREATE TRIGGER provider_execution_revision_write
|
||||
BEFORE INSERT OR UPDATE OF {FIELDS} ON provider
|
||||
FOR EACH ROW EXECUTE FUNCTION rotate_provider_execution_revision()
|
||||
""")
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("Provider execution revisions require PostgreSQL or SQLite")
|
||||
|
||||
|
||||
def remove_execution_revision_triggers(connection):
|
||||
if connection.dialect.name == "sqlite":
|
||||
connection.execute(text("DROP TRIGGER provider_execution_revision_insert"))
|
||||
connection.execute(text("DROP TRIGGER provider_execution_revision_update"))
|
||||
elif connection.dialect.name == "postgresql":
|
||||
connection.execute(text("DROP TRIGGER provider_execution_revision_write ON provider"))
|
||||
connection.execute(text("DROP FUNCTION rotate_provider_execution_revision()"))
|
||||
else:
|
||||
raise RuntimeError("Unsupported Provider revision database")
|
||||
|
||||
|
||||
def after_create(_table, connection, **_kwargs):
|
||||
install_execution_revision_triggers(connection)
|
||||
|
|
@ -16,11 +16,12 @@ from enum import IntEnum
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field as PydanticField, field_validator
|
||||
from sqlalchemy import Boolean, Column, SmallInteger, text
|
||||
from sqlalchemy import Boolean, Column, SmallInteger, String, event, text
|
||||
from sqlalchemy_utils import ChoiceType
|
||||
from sqlmodel import JSON, Field
|
||||
|
||||
from app.model.abstract.model import AbstractModel, DefaultTimes
|
||||
from app.model.provider.execution_revision import after_create
|
||||
|
||||
|
||||
class VaildStatus(IntEnum):
|
||||
|
|
@ -34,6 +35,8 @@ class Provider(AbstractModel, DefaultTimes, table=True):
|
|||
provider_name: str
|
||||
model_type: str
|
||||
api_key: str
|
||||
# Assigned by database triggers. It is random, never a hash of a key/token.
|
||||
execution_revision: str = Field(default="", sa_column=Column(String(32), nullable=False, server_default=text("''")))
|
||||
endpoint_url: str = ""
|
||||
encrypted_config: dict | None = Field(default=None, sa_column=Column(JSON))
|
||||
prefer: bool = Field(default=False, sa_column=Column(Boolean, server_default=text("false")))
|
||||
|
|
@ -101,3 +104,6 @@ class ProviderModelMetadata(BaseModel):
|
|||
model_type=model_type,
|
||||
available=bool(model_type) and is_valid == VaildStatus.is_valid,
|
||||
)
|
||||
|
||||
|
||||
event.listen(Provider.__table__, "after_create", after_create)
|
||||
|
|
|
|||
260
server/tests/test_execution_configuration.py
Normal file
260
server/tests/test_execution_configuration.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""Real JWT/device/Provider/database paths with synthetic SQLite and no Redis I/O."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from app.core.database import session
|
||||
from app.domains.model_provider.api.execution_controller import router
|
||||
from app.domains.model_provider.service import provider_service
|
||||
from app.domains.model_provider.service.provider_service import ProviderService
|
||||
from app.domains.remote_control.api.command_control_controller import router as device_router
|
||||
from app.model.project import Project
|
||||
from app.model.provider.provider import Provider, ProviderIn, ProviderOut, VaildStatus
|
||||
from app.model.remote_control.command_control import DesktopDevice
|
||||
from app.model.space import Space
|
||||
from app.model.user.user import User
|
||||
from app.shared.auth import user_auth
|
||||
from app.shared.exception import TokenException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authority(tmp_path, monkeypatch):
|
||||
engine = create_engine("sqlite:///" + str(tmp_path / "authority.sqlite"), connect_args={"check_same_thread": False})
|
||||
for model in (User, Space, Project, Provider, DesktopDevice):
|
||||
model.__table__.create(engine)
|
||||
with Session(engine) as db:
|
||||
db.add(User(id=1, email="one@example.test"))
|
||||
db.add(User(id=2, email="two@example.test"))
|
||||
db.add(Space(id="space", user_id="1", name="Synthetic", root_path=str(tmp_path)))
|
||||
db.add(Project(id="project", user_id="1", space_id="space", name="Synthetic", mode="single-agent"))
|
||||
db.commit()
|
||||
|
||||
async def blacklist(_jti):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(user_auth, "is_blacklisted", blacklist)
|
||||
monkeypatch.setattr(provider_service, "session_make", lambda: Session(engine))
|
||||
|
||||
def database():
|
||||
with Session(engine) as db:
|
||||
yield db
|
||||
|
||||
app = FastAPI()
|
||||
app.dependency_overrides[session] = database
|
||||
app.include_router(device_router)
|
||||
app.include_router(router)
|
||||
|
||||
@app.exception_handler(TokenException)
|
||||
async def rejected(_request, _exception):
|
||||
return JSONResponse(status_code=401, content={"code": "authentication_required"})
|
||||
|
||||
provider = ProviderService.create(
|
||||
1,
|
||||
ProviderIn(
|
||||
provider_name="openai",
|
||||
model_type="gpt-5",
|
||||
api_key="synthetic-provider-secret",
|
||||
endpoint_url="https://api.openai.com/v1",
|
||||
is_valid=VaildStatus.is_valid,
|
||||
prefer=True,
|
||||
).model_dump(),
|
||||
)["provider"]
|
||||
with TestClient(app) as client:
|
||||
headers = {"Authorization": "Bearer " + user_auth.create_access_token(1), "X-Desktop-Instance-ID": "device"}
|
||||
assert client.post("/sync/devices/register", headers=headers, json={}).status_code == 200
|
||||
yield SimpleNamespace(engine=engine, client=client, headers=headers, provider=provider)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def reference(authority):
|
||||
response = authority.client.get("/sync/execution/projects/project/configuration", headers=authority.headers)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "synthetic-provider-secret" not in response.text
|
||||
config = response.json()
|
||||
return {
|
||||
"provider_ref": config["provider"]["provider_ref"],
|
||||
"space_id": config["space_id"],
|
||||
"session_mode": config["session_mode"],
|
||||
}
|
||||
|
||||
|
||||
def resolve(authority, ref):
|
||||
return authority.client.post(
|
||||
"/sync/execution/projects/project/credentials:resolve", headers=authority.headers, json=ref
|
||||
)
|
||||
|
||||
|
||||
def test_existing_auth_and_exact_reference(authority):
|
||||
ref = reference(authority)
|
||||
result = resolve(authority, ref)
|
||||
assert result.status_code == 200, result.text
|
||||
assert result.json()["api_key"] == "synthetic-provider-secret"
|
||||
assert result.headers["cache-control"] == "no-store"
|
||||
# Legacy DTO remains compatible and never acquires the revision field.
|
||||
assert "execution_revision" not in ProviderOut.model_fields
|
||||
assert authority.client.get("/sync/execution/identity").status_code == 401
|
||||
alien = dict(authority.headers, Authorization="Bearer " + user_auth.create_access_token(2))
|
||||
assert authority.client.get("/sync/execution/identity", headers=alien).status_code in (403, 404)
|
||||
with Session(authority.engine) as db:
|
||||
device = db.get(DesktopDevice, "device")
|
||||
device.revoked_at = datetime.now(UTC)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
assert resolve(authority, ref).status_code in (403, 404, 409)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["update", "invalidate", "delete", "raw", "orm", "owner"])
|
||||
def test_every_provider_writer_invalidates_old_reference(authority, mutation):
|
||||
ref = reference(authority)
|
||||
identity = authority.provider.id
|
||||
if mutation == "update":
|
||||
ProviderService.update(identity, 1, {"api_key": "synthetic-rotated-secret"})
|
||||
elif mutation == "invalidate":
|
||||
ProviderService.invalidate(identity, 1)
|
||||
elif mutation == "delete":
|
||||
ProviderService.delete(identity, 1)
|
||||
else:
|
||||
with Session(authority.engine) as db:
|
||||
if mutation == "raw":
|
||||
db.execute(text("UPDATE provider SET encrypted_config='{}' WHERE id=:id"), {"id": identity})
|
||||
else:
|
||||
provider = db.get(Provider, identity)
|
||||
if mutation == "owner":
|
||||
provider.user_id = 2
|
||||
else:
|
||||
provider.model_type = "gpt-4o"
|
||||
db.add(provider)
|
||||
db.commit()
|
||||
with Session(authority.engine) as db:
|
||||
assert db.get(Provider, identity).execution_revision != ref["provider_ref"].split(":")[-1]
|
||||
assert resolve(authority, ref).status_code == 409
|
||||
|
||||
|
||||
def test_preference_rollback_and_concurrent_writers(authority):
|
||||
ref = reference(authority)
|
||||
ProviderService.set_prefer(authority.provider.id, 1)
|
||||
assert reference(authority) == ref
|
||||
with Session(authority.engine) as db:
|
||||
db.execute(text("UPDATE provider SET api_key='rolled-back'"))
|
||||
changed = db.execute(text("SELECT execution_revision FROM provider")).scalar_one()
|
||||
assert changed != ref["provider_ref"].split(":")[-1]
|
||||
db.rollback()
|
||||
assert resolve(authority, ref).status_code == 200
|
||||
|
||||
def write(index):
|
||||
with authority.engine.begin() as connection:
|
||||
connection.execute(text("UPDATE provider SET api_key=:key"), {"key": "synthetic-" + str(index)})
|
||||
return connection.execute(text("SELECT execution_revision FROM provider")).scalar_one()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
revisions = list(pool.map(write, range(4)))
|
||||
assert len(set(revisions)) == 4
|
||||
assert all(re.fullmatch("[0-9a-f]{32}", value) for value in revisions)
|
||||
assert resolve(authority, ref).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value", [("user_id", "2"), ("space_id", "other"), ("mode", "workforce"), ("status", "archived")]
|
||||
)
|
||||
def test_membership_and_mode_are_revalidated(authority, field, value):
|
||||
ref = reference(authority)
|
||||
with Session(authority.engine) as db:
|
||||
project = db.get(Project, "project")
|
||||
setattr(project, field, value)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
assert resolve(authority, ref).status_code == 409
|
||||
|
||||
|
||||
def test_current_intent_cannot_replace_exact_provider(authority):
|
||||
ref = reference(authority)
|
||||
with Session(authority.engine) as db:
|
||||
project = db.get(Project, "project")
|
||||
project.metadata_json = {
|
||||
"modelSelection": {"modelType": "custom", "provider_id": 999},
|
||||
"thinkingEffort": "high",
|
||||
}
|
||||
db.add(project)
|
||||
db.commit()
|
||||
assert resolve(authority, ref).json()["provider"]["provider_ref"] == ref["provider_ref"]
|
||||
assert (
|
||||
authority.client.get("/sync/execution/projects/project/configuration", headers=authority.headers).status_code
|
||||
== 409
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
{"api_key": "input-secret"},
|
||||
{"model_config_dict": {"n": True}},
|
||||
{"api_mode": "responses"},
|
||||
{"model_config_dict": {"headers": {"Authorization": "input-secret"}}},
|
||||
],
|
||||
)
|
||||
def test_unsupported_configuration_and_input_do_not_echo_secrets(authority, config):
|
||||
ref = reference(authority)
|
||||
invalid = resolve(authority, dict(ref, api_key="input-secret"))
|
||||
assert invalid.status_code == 422 and "input-secret" not in invalid.text
|
||||
ProviderService.update(authority.provider.id, 1, {"encrypted_config": config})
|
||||
result = authority.client.get("/sync/execution/projects/project/configuration", headers=authority.headers)
|
||||
assert result.status_code == 409 and "input-secret" not in result.text
|
||||
|
||||
|
||||
def test_upgrade_existing_rows_downgrade_and_old_insert(tmp_path):
|
||||
engine = create_engine("sqlite:///" + str(tmp_path / "migration.sqlite"))
|
||||
path = Path(__file__).parents[1] / "alembic/versions/2026_09_21_1200-provider_execution_revision.py"
|
||||
spec = importlib.util.spec_from_file_location("execution_migration", path)
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE provider (id INTEGER PRIMARY KEY, user_id INTEGER, provider_name TEXT, model_type TEXT, api_key TEXT, endpoint_url TEXT, encrypted_config TEXT, is_vaild INTEGER, deleted_at TEXT)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text("INSERT INTO provider (id,api_key) VALUES (1,'same-synthetic-secret'),(2,'same-synthetic-secret')")
|
||||
)
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
revisions = connection.execute(text("SELECT execution_revision FROM provider")).scalars().all()
|
||||
assert len(set(revisions)) == 2 and all(re.fullmatch("[0-9a-f]{32}", value) for value in revisions)
|
||||
connection.execute(text("INSERT INTO provider (id,api_key) VALUES (3,'same-synthetic-secret')"))
|
||||
assert (
|
||||
connection.execute(text("SELECT execution_revision FROM provider WHERE id=3")).scalar_one()
|
||||
not in revisions
|
||||
)
|
||||
migration.downgrade()
|
||||
assert "execution_revision" not in {
|
||||
row[1] for row in connection.execute(text("PRAGMA table_info(provider)"))
|
||||
}
|
||||
engine.dispose()
|
||||
290
server/tests/test_session_execution_configuration.py
Normal file
290
server/tests/test_session_execution_configuration.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
"""C6 uses the real authenticated Space API and synthetic SQLite only."""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.domains.space.api.space_controller import router
|
||||
from app.model.project import Project
|
||||
from tests import test_execution_configuration
|
||||
|
||||
authority = test_execution_configuration.authority
|
||||
|
||||
|
||||
def test_session_configuration_patch_is_saved_and_returned(authority):
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
payload = {
|
||||
"mode": "single-agent",
|
||||
"metadata": {
|
||||
"modelSelection": {
|
||||
"modelType": "custom",
|
||||
"provider_id": a.provider.id,
|
||||
"model_platform": "openai",
|
||||
"model_type": "gpt-5",
|
||||
},
|
||||
"thinkingEffort": "high",
|
||||
},
|
||||
}
|
||||
response = a.client.patch("/spaces/space/projects/project", headers=a.headers, json=payload)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["mode"] == "single-agent"
|
||||
metadata = response.json()["metadata"]
|
||||
revision = metadata["spaceModelAdmissionRevision"]
|
||||
assert isinstance(revision, str) and len(revision) == 32
|
||||
assert metadata == {**payload["metadata"], "spaceModelAdmissionRevision": revision}
|
||||
with Session(a.engine) as db:
|
||||
saved = db.get(Project, "project")
|
||||
assert saved.mode == "single-agent" and saved.metadata_json == metadata
|
||||
response = a.client.get("/sync/execution/projects/project/configuration", headers=a.headers)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["thinking_effort"] == "high"
|
||||
assert response.json()["session_mode"] == "single-agent"
|
||||
assert response.json()["space_source_type"] == "blank"
|
||||
bad = a.client.patch("/spaces/space/projects/project", headers=a.headers, json={"mode": "unsupported"})
|
||||
assert bad.status_code == 404
|
||||
with Session(a.engine) as db:
|
||||
assert db.get(Project, "project").mode == "single-agent"
|
||||
|
||||
|
||||
def test_lost_create_ack_reuses_exact_creation_and_rejects_collision(authority):
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
payload = {
|
||||
"id": "c6-session",
|
||||
"name": "Synthetic",
|
||||
"mode": "single-agent",
|
||||
"metadata": {"thinkingEffort": "medium"},
|
||||
}
|
||||
first = a.client.post("/spaces/space/projects", headers=a.headers, json=payload)
|
||||
assert first.status_code == 200, first.text
|
||||
assert a.client.post("/spaces/space/projects", headers=a.headers, json=payload).json() == first.json()
|
||||
assert (
|
||||
a.client.post("/spaces/space/projects", headers=a.headers, json={**payload, "name": "different"}).status_code
|
||||
== 404
|
||||
)
|
||||
with Session(a.engine) as db:
|
||||
assert len(db.exec(select(Project).where(Project.id == "c6-session")).all()) == 1
|
||||
|
||||
|
||||
def test_original_creation_receipt_survives_mutable_settings_and_name(authority):
|
||||
from app.model.project.project import PROJECT_CREATION_INTENT_KEY
|
||||
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
payload = {
|
||||
"id": "receipt-session",
|
||||
"name": "Original draft",
|
||||
"mode": "single-agent",
|
||||
"metadata": {"thinkingEffort": "high"},
|
||||
}
|
||||
first = a.client.post("/spaces/space/projects", headers=a.headers, json=payload)
|
||||
assert first.status_code == 200, first.text
|
||||
assert PROJECT_CREATION_INTENT_KEY not in first.json()["metadata"]
|
||||
change = {"name": "Renamed Session", "status": "archived", "metadata": {"thinkingEffort": "low"}}
|
||||
updated = a.client.patch("/spaces/space/projects/receipt-session", headers=a.headers, json=change)
|
||||
assert updated.status_code == 200, updated.text
|
||||
# An ACK retry returns the current owned row without resetting its settings.
|
||||
retry = a.client.post("/spaces/space/projects", headers=a.headers, json=payload)
|
||||
assert retry.status_code == 200, retry.text
|
||||
assert retry.json() == updated.json()
|
||||
with Session(a.engine) as db:
|
||||
row = db.get(Project, "receipt-session")
|
||||
assert row.metadata_json[PROJECT_CREATION_INTENT_KEY]
|
||||
assert row.name == "Renamed Session" and row.metadata_json["thinkingEffort"] == "low"
|
||||
for metadata in ({PROJECT_CREATION_INTENT_KEY: "forged"}, {PROJECT_CREATION_INTENT_KEY: None}):
|
||||
assert (
|
||||
a.client.patch(
|
||||
"/spaces/space/projects/receipt-session", headers=a.headers, json={"metadata": metadata}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
a.client.post(
|
||||
"/spaces/space/projects", headers=a.headers, json={**payload, "metadata": metadata}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
a.client.post(
|
||||
"/spaces/space/projects", headers=a.headers, json={**payload, "name": "Changed creation"}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_creation_receipt_cannot_recover_another_owner_or_space(authority):
|
||||
from app.model.space import Space
|
||||
from app.shared.auth import user_auth
|
||||
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
payload = {"id": "owned-receipt", "name": "draft", "mode": "single-agent"}
|
||||
assert a.client.post("/spaces/space/projects", headers=a.headers, json=payload).status_code == 200
|
||||
with Session(a.engine) as db:
|
||||
db.add(Space(id="second-space", user_id="1", name="Second"))
|
||||
db.add(Space(id="alien-space", user_id="2", name="Alien"))
|
||||
db.commit()
|
||||
alien = {**a.headers, "Authorization": "Bearer " + user_auth.create_access_token(2)}
|
||||
assert a.client.post("/spaces/second-space/projects", headers=a.headers, json=payload).status_code == 404
|
||||
assert a.client.post("/spaces/alien-space/projects", headers=alien, json=payload).status_code == 404
|
||||
assert (
|
||||
a.client.post(
|
||||
"/spaces/space/projects", headers=a.headers, json={**payload, "space_id": "second-space"}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
with Session(a.engine) as db:
|
||||
row = db.get(Project, payload["id"])
|
||||
assert row.space_id == "space" and row.user_id == "1"
|
||||
|
||||
|
||||
def test_concurrent_identical_creates_converge_through_authenticated_api(authority):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from app.core.database import session
|
||||
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
barrier = Barrier(2)
|
||||
|
||||
class RacingSession(Session):
|
||||
def get(self, entity, ident, *args, **kwargs):
|
||||
result = super().get(entity, ident, *args, **kwargs)
|
||||
if entity is Project and ident == "concurrent-session" and result is None:
|
||||
barrier.wait(timeout=5)
|
||||
return result
|
||||
|
||||
def database():
|
||||
with RacingSession(a.engine) as db:
|
||||
yield db
|
||||
|
||||
a.client.app.dependency_overrides[session] = database
|
||||
payload = {"id": "concurrent-session", "name": "draft", "mode": "single-agent"}
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(
|
||||
pool.map(lambda _: a.client.post("/spaces/space/projects", headers=a.headers, json=payload), range(2))
|
||||
)
|
||||
assert [response.status_code for response in responses] == [200, 200]
|
||||
assert responses[0].json() == responses[1].json()
|
||||
with Session(a.engine) as db:
|
||||
assert len(db.exec(select(Project).where(Project.id == payload["id"])).all()) == 1
|
||||
|
||||
|
||||
def test_concurrent_different_creations_reject_the_losing_intent(authority):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from app.core.database import session
|
||||
|
||||
a = authority
|
||||
a.client.app.include_router(router)
|
||||
barrier = Barrier(2)
|
||||
|
||||
class RacingSession(Session):
|
||||
def get(self, entity, ident, *args, **kwargs):
|
||||
result = super().get(entity, ident, *args, **kwargs)
|
||||
if entity is Project and ident == "conflicting-session" and result is None:
|
||||
barrier.wait(timeout=5)
|
||||
return result
|
||||
|
||||
def database():
|
||||
with RacingSession(a.engine) as db:
|
||||
yield db
|
||||
|
||||
a.client.app.dependency_overrides[session] = database
|
||||
|
||||
def create(name):
|
||||
return a.client.post(
|
||||
"/spaces/space/projects",
|
||||
headers=a.headers,
|
||||
json={"id": "conflicting-session", "name": name, "mode": "single-agent"},
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(pool.map(create, ["First", "Second"]))
|
||||
assert sorted(response.status_code for response in responses) == [200, 404]
|
||||
winner = next(response.json() for response in responses if response.status_code == 200)
|
||||
with Session(a.engine) as db:
|
||||
assert db.get(Project, "conflicting-session").name == winner["name"]
|
||||
|
||||
|
||||
def test_unrelated_integrity_errors_are_not_recovered(authority, monkeypatch):
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.domains.space.service.space_service import SpaceService
|
||||
from app.model.project import ProjectIn
|
||||
|
||||
for code, message in [
|
||||
(2067, "UNIQUE constraint failed: project.name"),
|
||||
(787, "FOREIGN KEY constraint failed"),
|
||||
(1299, "NOT NULL constraint failed: project.user_id"),
|
||||
]:
|
||||
original = sqlite3.IntegrityError(message)
|
||||
original.sqlite_errorcode = code
|
||||
failure = IntegrityError("synthetic statement", {}, original)
|
||||
with Session(authority.engine) as db:
|
||||
|
||||
def fail():
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(db, "commit", fail)
|
||||
with pytest.raises(IntegrityError) as caught:
|
||||
SpaceService.create_project(
|
||||
"space", ProjectIn(id="no-recovery", name="draft", mode="single-agent"), 1, db
|
||||
)
|
||||
assert caught.value is failure
|
||||
with Session(authority.engine) as db:
|
||||
assert db.get(Project, "no-recovery") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("constraint", ["project_pkey", "uix_project_user_id_id", "unrelated_unique"])
|
||||
def test_postgres_identity_diagnostics_are_narrowly_recognized_without_remote_database(authority, constraint):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.domains.space.service.space_service import SpaceService
|
||||
from app.model.project import ProjectIn
|
||||
|
||||
class SyntheticPostgresError(Exception):
|
||||
sqlstate = "23505"
|
||||
diag = SimpleNamespace(constraint_name=constraint)
|
||||
|
||||
class CollisionSession(Session):
|
||||
winner = None
|
||||
|
||||
def commit(self):
|
||||
self.winner = next(row for row in self.new if isinstance(row, Project)).model_copy(deep=True)
|
||||
raise IntegrityError("synthetic insert", {}, SyntheticPostgresError())
|
||||
|
||||
def get(self, entity, ident, *args, **kwargs):
|
||||
if entity is Project and self.winner is not None:
|
||||
return self.winner
|
||||
return super().get(entity, ident, *args, **kwargs)
|
||||
|
||||
with CollisionSession(authority.engine) as db:
|
||||
payload = ProjectIn(id="diagnostic-session", name="draft", mode="single-agent")
|
||||
if constraint == "unrelated_unique":
|
||||
with pytest.raises(IntegrityError):
|
||||
SpaceService.create_project("space", payload, 1, db)
|
||||
else:
|
||||
recovered = SpaceService.create_project("space", payload, 1, db)
|
||||
assert recovered is db.winner and recovered.user_id == "1" and recovered.space_id == "space"
|
||||
Loading…
Add table
Add a link
Reference in a new issue