From 132b0e6d74bca39acbd12edf0a98dfe6e7f1dea2 Mon Sep 17 00:00:00 2001 From: Avdpro Pang <38308119+Avdpro@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:03:07 +0800 Subject: [PATCH 01/11] feat(platform): add durable control-plane foundation --- ai2apps/api/__init__.py | 5 + ai2apps/api/chat.py | 379 +++ ai2apps/api/errors.py | 102 + ai2apps/api/event_stream.py | 70 + ai2apps/api/health.py | 90 + ai2apps/api/resources.py | 298 +++ ai2apps/api/services.py | 419 +++ ai2apps/chat/__init__.py | 5 + ai2apps/chat/repository.py | 813 ++++++ ai2apps/config.py | 103 + ai2apps/core/__init__.py | 48 + ai2apps/core/clock.py | 42 + ai2apps/core/errors.py | 38 + ai2apps/core/ids.py | 80 + ai2apps/core/models.py | 68 + ai2apps/events/__init__.py | 6 + ai2apps/events/bus.py | 56 + ai2apps/events/store.py | 144 ++ ai2apps/events/stream.py | 56 + ai2apps/platform_runtime.py | 514 ++++ ai2apps/services/__init__.py | 49 + ai2apps/services/adapters.py | 329 +++ ai2apps/services/models.py | 189 ++ ai2apps/services/registry.py | 554 ++++ ai2apps/services/repository.py | 866 +++++++ ai2apps/storage/__init__.py | 51 + ai2apps/storage/database.py | 243 ++ ai2apps/storage/migrations.py | 2125 ++++++++++++++++ ai2apps/storage/models.py | 154 ++ ai2apps/storage/records.py | 154 ++ ai2apps/storage/repositories/__init__.py | 7 + ai2apps/storage/repositories/apps.py | 246 ++ ai2apps/storage/repositories/messages.py | 273 ++ ai2apps/storage/repositories/sessions.py | 320 +++ docs/ai2apps-backend-development-plan.md | 2040 +++++++++++++++ docs/ai2apps-platform-architecture.md | 2942 ++++++++++++++++++++++ tests/test_ai2apps_chat.py | 322 +++ tests/test_ai2apps_core_contracts.py | 77 + tests/test_ai2apps_event_stream.py | 137 + tests/test_ai2apps_platform_schema.py | 308 +++ tests/test_ai2apps_platform_storage.py | 463 ++++ tests/test_ai2apps_repositories.py | 388 +++ tests/test_ai2apps_services.py | 521 ++++ 43 files changed, 16094 insertions(+) create mode 100644 ai2apps/api/__init__.py create mode 100644 ai2apps/api/chat.py create mode 100644 ai2apps/api/errors.py create mode 100644 ai2apps/api/event_stream.py create mode 100644 ai2apps/api/health.py create mode 100644 ai2apps/api/resources.py create mode 100644 ai2apps/api/services.py create mode 100644 ai2apps/chat/__init__.py create mode 100644 ai2apps/chat/repository.py create mode 100644 ai2apps/config.py create mode 100644 ai2apps/core/__init__.py create mode 100644 ai2apps/core/clock.py create mode 100644 ai2apps/core/errors.py create mode 100644 ai2apps/core/ids.py create mode 100644 ai2apps/core/models.py create mode 100644 ai2apps/events/__init__.py create mode 100644 ai2apps/events/bus.py create mode 100644 ai2apps/events/store.py create mode 100644 ai2apps/events/stream.py create mode 100644 ai2apps/platform_runtime.py create mode 100644 ai2apps/services/__init__.py create mode 100644 ai2apps/services/adapters.py create mode 100644 ai2apps/services/models.py create mode 100644 ai2apps/services/registry.py create mode 100644 ai2apps/services/repository.py create mode 100644 ai2apps/storage/__init__.py create mode 100644 ai2apps/storage/database.py create mode 100644 ai2apps/storage/migrations.py create mode 100644 ai2apps/storage/models.py create mode 100644 ai2apps/storage/records.py create mode 100644 ai2apps/storage/repositories/__init__.py create mode 100644 ai2apps/storage/repositories/apps.py create mode 100644 ai2apps/storage/repositories/messages.py create mode 100644 ai2apps/storage/repositories/sessions.py create mode 100644 docs/ai2apps-backend-development-plan.md create mode 100644 docs/ai2apps-platform-architecture.md create mode 100644 tests/test_ai2apps_chat.py create mode 100644 tests/test_ai2apps_core_contracts.py create mode 100644 tests/test_ai2apps_event_stream.py create mode 100644 tests/test_ai2apps_platform_schema.py create mode 100644 tests/test_ai2apps_platform_storage.py create mode 100644 tests/test_ai2apps_repositories.py create mode 100644 tests/test_ai2apps_services.py diff --git a/ai2apps/api/__init__.py b/ai2apps/api/__init__.py new file mode 100644 index 00000000..9bc5ab0d --- /dev/null +++ b/ai2apps/api/__init__.py @@ -0,0 +1,5 @@ +"""AI2Apps platform API surface.""" + +from .router import create_ai2apps_router + +__all__ = ["create_ai2apps_router"] diff --git a/ai2apps/api/chat.py b/ai2apps/api/chat.py new file mode 100644 index 00000000..d1453af8 --- /dev/null +++ b/ai2apps/api/chat.py @@ -0,0 +1,379 @@ +"""Chat-friendly aliases over the singleton Chat App's generic resources.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, model_validator + +from ai2apps.api.errors import repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.api.resources import _runtime_or_error +from ai2apps.chat import ChatContentRecord, ChatRepository, LegacyChatMessageInput +from ai2apps.core import MessageRole, RepositoryError, SessionStatus +from ai2apps.storage import BuiltinChatRecord, ChatCollectionRecord, ChatThreadRecord + + +class ChatAppResponse(BaseModel): + package_id: str + app_instance_id: str + status: str + selected_thread_id: str | None + collection_revision: int + + @classmethod + def from_record(cls, record: BuiltinChatRecord) -> ChatAppResponse: + return cls( + package_id=record.definition.package_id, + app_instance_id=record.instance.id, + status=record.instance.status.value, + selected_thread_id=record.collection.selected_session_id, + collection_revision=record.collection.revision, + ) + + +class ChatCollectionResponse(BaseModel): + app_instance_id: str + selected_thread_id: str | None + revision: int + + @classmethod + def from_record(cls, record: ChatCollectionRecord) -> ChatCollectionResponse: + return cls( + app_instance_id=record.app_instance_id, + selected_thread_id=record.selected_session_id, + revision=record.revision, + ) + + +class ChatThreadResponse(BaseModel): + id: str + app_instance_id: str + title: str + status: SessionStatus + is_home: bool + pinned: bool + sort_order: int + legacy_thread_id: str | None + revision: int + created_at: datetime + updated_at: datetime + + @classmethod + def from_record(cls, record: ChatThreadRecord) -> ChatThreadResponse: + session = record.session + return cls( + id=session.id, + app_instance_id=session.app_instance_id, + title=session.title, + status=session.status, + is_home=session.is_home, + pinned=record.pinned, + sort_order=record.sort_order, + legacy_thread_id=record.legacy_thread_id, + revision=session.revision, + created_at=session.created_at, + updated_at=session.updated_at, + ) + + +class ChatThreadListResponse(BaseModel): + items: list[ChatThreadResponse] + + +class LegacyChatMessageRequest(BaseModel): + role: MessageRole + content: Any + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ChatThreadCreateRequest(BaseModel): + title: str = "" + pinned: bool = False + legacy_thread_id: str | None = Field(default=None, min_length=1, max_length=512) + session_metadata: dict[str, Any] = Field(default_factory=dict) + legacy_messages: list[LegacyChatMessageRequest] = Field( + default_factory=list, + max_length=10_000, + ) + + @model_validator(mode="after") + def legacy_messages_require_identity(self) -> ChatThreadCreateRequest: + if self.legacy_messages and self.legacy_thread_id is None: + raise ValueError("legacy_messages require legacy_thread_id") + return self + + +class ChatThreadPatchRequest(BaseModel): + expected_revision: int = Field(ge=1) + title: str | None = None + pinned: bool | None = None + + @model_validator(mode="after") + def require_change(self) -> ChatThreadPatchRequest: + if self.title is None and self.pinned is None: + raise ValueError("At least one Chat thread field must change") + return self + + +class ExpectedRevisionRequest(BaseModel): + expected_revision: int = Field(ge=1) + + +class ChatContentRequest(BaseModel): + expected_revision: int = Field(ge=1) + title: str | None = None + session_metadata: dict[str, Any] = Field(default_factory=dict) + messages: list[LegacyChatMessageRequest] = Field(max_length=10_000) + + +class ChatContentResponse(BaseModel): + thread: ChatThreadResponse + session_metadata: dict[str, Any] + messages: list[LegacyChatMessageRequest] + + @classmethod + def from_record(cls, record: ChatContentRecord) -> ChatContentResponse: + return cls( + thread=ChatThreadResponse.from_record(record.thread), + session_metadata=record.metadata, + messages=[ + LegacyChatMessageRequest( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in record.messages + ], + ) + + +def create_chat_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/chat") + + def repository_or_error(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + return ChatRepository(runtime.database, runtime.events) + + @router.get("", response_model=ChatAppResponse) + def get_chat_app(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatAppResponse.from_record(repository.ensure_builtin()) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads", response_model=ChatThreadResponse, status_code=201) + def create_thread( + request: ChatThreadCreateRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record, created = repository.create_thread( + title=request.title, + pinned=request.pinned, + legacy_thread_id=request.legacy_thread_id, + metadata=request.session_metadata, + legacy_messages=tuple( + LegacyChatMessageInput( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in request.legacy_messages + ), + trace_id=x_trace_id, + ) + response = ChatThreadResponse.from_record(record) + if not created: + return JSONResponse( + status_code=200, + content=response.model_dump(mode="json"), + ) + return response + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/threads", response_model=ChatThreadListResponse) + def list_threads( + include_archived: bool = False, + include_deleted: bool = False, + limit: int = Query(default=100, ge=1, le=1_000), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + records = repository.list_threads( + include_archived=include_archived, + include_deleted=include_deleted, + limit=limit, + ) + return ChatThreadListResponse( + items=[ChatThreadResponse.from_record(record) for record in records] + ) + + @router.get("/threads/{thread_id}", response_model=ChatThreadResponse) + def get_thread(thread_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatThreadResponse.from_record(repository.get_thread(thread_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/threads/{thread_id}/content", + response_model=ChatContentResponse, + ) + def get_thread_content(thread_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatContentResponse.from_record(repository.get_content(thread_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.put( + "/threads/{thread_id}/content", + response_model=ChatContentResponse, + ) + def replace_thread_content( + thread_id: str, + request: ChatContentRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.replace_content( + thread_id, + expected_revision=request.expected_revision, + title=request.title, + metadata=request.session_metadata, + messages=tuple( + LegacyChatMessageInput( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in request.messages + ), + trace_id=x_trace_id, + ) + return ChatContentResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.patch("/threads/{thread_id}", response_model=ChatThreadResponse) + def patch_thread( + thread_id: str, + request: ChatThreadPatchRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=request.expected_revision, + title=request.title, + pinned=request.pinned, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/select", response_model=ChatCollectionResponse) + def select_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.select_thread( + thread_id, + expected_revision=request.expected_revision, + trace_id=x_trace_id, + ) + return ChatCollectionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/home", response_model=ChatThreadResponse) + def set_home_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.set_home_thread( + thread_id, + expected_revision=request.expected_revision, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/archive", response_model=ChatThreadResponse) + def archive_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=request.expected_revision, + status=SessionStatus.ARCHIVED, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/threads/{thread_id}", response_model=ChatThreadResponse) + def delete_thread( + thread_id: str, + expected_revision: int = Query(ge=1), + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=expected_revision, + status=SessionStatus.DELETED, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/errors.py b/ai2apps/api/errors.py new file mode 100644 index 00000000..409e9fd7 --- /dev/null +++ b/ai2apps/api/errors.py @@ -0,0 +1,102 @@ +"""Stable error envelope for AI2Apps platform APIs.""" + +from __future__ import annotations + +from typing import Any + +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.core import ( + IdempotencyConflictError, + RepositoryError, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, +) + + +class PlatformError(BaseModel): + """Machine-readable platform error detail.""" + + code: str + message: str + retryable: bool = False + details: dict[str, Any] = Field(default_factory=dict) + + +class PlatformErrorEnvelope(BaseModel): + """Top-level error response shared by AI2Apps resource APIs.""" + + error: PlatformError + + +def platform_error_response( + *, + status_code: int, + code: str, + message: str, + retryable: bool = False, + details: dict[str, Any] | None = None, +) -> JSONResponse: + """Build a JSON response using the stable platform error envelope.""" + + envelope = PlatformErrorEnvelope( + error=PlatformError( + code=code, + message=message, + retryable=retryable, + details=details or {}, + ) + ) + return JSONResponse( + status_code=status_code, + content=envelope.model_dump(mode="json"), + ) + + +def repository_error_response(error: RepositoryError) -> JSONResponse: + """Map typed Repository failures to the stable platform API envelope.""" + + if isinstance(error, ResourceNotFoundError): + return platform_error_response( + status_code=404, + code="not_found", + message=str(error), + details={ + "resource_id": error.resource_id, + "resource_type": error.resource_type, + }, + ) + if isinstance(error, RevisionConflictError): + return platform_error_response( + status_code=409, + code="revision_conflict", + message=str(error), + details={ + "actual_revision": error.actual, + "expected_revision": error.expected, + "resource_id": error.resource_id, + }, + ) + if isinstance(error, IdempotencyConflictError): + return platform_error_response( + status_code=409, + code="idempotency_conflict", + message=str(error), + details={ + "idempotency_key": error.idempotency_key, + "session_id": error.session_id, + }, + ) + if isinstance(error, ResourceConflictError): + return platform_error_response( + status_code=409, + code="resource_conflict", + message=str(error), + ) + return platform_error_response( + status_code=500, + code="repository_error", + message="Platform persistence operation failed.", + ) diff --git a/ai2apps/api/event_stream.py b/ai2apps/api/event_stream.py new file mode 100644 index 00000000..61822031 --- /dev/null +++ b/ai2apps/api/event_stream.py @@ -0,0 +1,70 @@ +"""HTTP transport for replayable AI2Apps platform Events.""" + +from __future__ import annotations + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.events.stream import stream_events + + +def create_event_stream_router( + runtime_provider: PlatformRuntimeProvider, +) -> APIRouter: + router = APIRouter() + + @router.get("/events", response_model=None) + async def events( + after: int | None = Query(default=None, ge=0), + session_id: str | None = None, + app_instance_id: str | None = None, + subject_id: str | None = None, + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + ) -> StreamingResponse | JSONResponse: + cursor = after + if cursor is None and last_event_id is not None: + try: + cursor = int(last_event_id) + except ValueError: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + if cursor < 0: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + runtime = runtime_provider() + if ( + runtime is None + or runtime.events is None + or runtime.notifications is None + ): + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps Event transport is not ready.", + retryable=True, + ) + return StreamingResponse( + stream_events( + runtime.events, + runtime.notifications, + after_sequence=cursor or 0, + session_id=session_id, + app_instance_id=app_instance_id, + subject_id=subject_id, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + return router diff --git a/ai2apps/api/health.py b/ai2apps/api/health.py new file mode 100644 index 00000000..8a73b092 --- /dev/null +++ b/ai2apps/api/health.py @@ -0,0 +1,90 @@ +"""Health contract for the AI2Apps Harness backend.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal + +from fastapi import APIRouter +from pydantic import BaseModel + +from ai2apps import __version__ +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformDatabaseStatus, PlatformRuntime + +PlatformConfigProvider = Callable[[], PlatformConfig] +PlatformRuntimeProvider = Callable[[], PlatformRuntime | None] + + +class RuntimeHealth(BaseModel): + """Runtime adapter attached to the platform API.""" + + provider: str + attached: bool + + +class DatabaseHealth(BaseModel): + """Platform database bootstrap state.""" + + configured: bool + status: Literal["unconfigured", "not_initialized", "ready"] + schema_version: int + target_schema_version: int + filename: str + journal_mode: str | None = None + + +class PlatformHealthResponse(BaseModel): + """Versioned health response for the AI2Apps platform layer.""" + + status: Literal["ok"] + product: Literal["ai2apps"] + version: str + api_version: Literal["v1"] + runtime: RuntimeHealth + database: DatabaseHealth + + +def _unconfigured_platform() -> PlatformConfig: + return PlatformConfig.unconfigured() + + +def create_health_router( + config_provider: PlatformConfigProvider | None = None, + runtime_provider: PlatformRuntimeProvider | None = None, +) -> APIRouter: + """Create the health router without importing the embedded oMLX runtime.""" + + router = APIRouter() + provide_config = config_provider or _unconfigured_platform + + def database_status() -> PlatformDatabaseStatus: + runtime = runtime_provider() if runtime_provider is not None else None + if runtime is not None: + return runtime.database_status + return PlatformRuntime.status_before_start(provide_config()) + + @router.get( + "/health", + response_model=PlatformHealthResponse, + summary="Get AI2Apps platform health", + ) + async def platform_health() -> PlatformHealthResponse: + database = database_status() + return PlatformHealthResponse( + status="ok", + product="ai2apps", + version=__version__, + api_version="v1", + runtime=RuntimeHealth(provider="omlx", attached=True), + database=DatabaseHealth( + configured=database.configured, + status=database.status, + schema_version=database.schema_version, + target_schema_version=database.target_schema_version, + filename=database.filename, + journal_mode=database.journal_mode, + ), + ) + + return router diff --git a/ai2apps/api/resources.py b/ai2apps/api/resources.py new file mode 100644 index 00000000..0eef0a4b --- /dev/null +++ b/ai2apps/api/resources.py @@ -0,0 +1,298 @@ +"""Generic Session, Message, and Event snapshot APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.api.models import ( + EventListResponse, + EventResponse, + MessageCreateRequest, + MessageListResponse, + MessageResponse, + SessionCreateRequest, + SessionListResponse, + SessionPatchRequest, + SessionResponse, +) +from ai2apps.core import ( + RepositoryError, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + format_utc, +) +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.storage import MessagePartInput +from ai2apps.storage.repositories import MessageRepository, SessionRepository + + +def _runtime_or_error( + runtime_provider: PlatformRuntimeProvider, +) -> PlatformRuntime | JSONResponse: + runtime = runtime_provider() + if runtime is None or runtime.database is None or runtime.events is None: + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps platform persistence is not ready.", + retryable=True, + ) + return runtime + + +def _session_defaults(request: SessionCreateRequest): + conversational_embed = request.kind in { + SessionKind.MINI_CHAT, + SessionKind.IN_APP_CHAT, + } + visibility = request.visibility or ( + SessionVisibility.UNLISTED + if conversational_embed + else SessionVisibility.LISTED + ) + retention = request.retention or ( + SessionRetention.TEMPORARY + if conversational_embed + else SessionRetention.DURABLE + ) + return visibility, retention + + +def create_resource_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + @router.post( + "/app-instances/{app_instance_id}/sessions", + response_model=SessionResponse, + status_code=201, + ) + def create_session( + app_instance_id: str, + request: SessionCreateRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + visibility, retention = _session_defaults(request) + try: + record = SessionRepository(runtime.database, runtime.events).create( + app_instance_id=app_instance_id, + title=request.title, + is_home=request.is_home, + session_kind=request.kind, + visibility=visibility, + retention=retention, + expires_at=( + None + if request.expires_at is None + else format_utc(request.expires_at) + ), + metadata=request.metadata, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/app-instances/{app_instance_id}/sessions", + response_model=SessionListResponse, + ) + def list_sessions( + app_instance_id: str, + kind: SessionKind | None = None, + visibility: SessionVisibility | None = None, + include_deleted: bool = False, + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + records = SessionRepository(runtime.database, runtime.events).list_for_instance( + app_instance_id, + include_deleted=include_deleted, + session_kind=kind, + visibility=visibility, + limit=limit, + ) + return SessionListResponse( + items=[SessionResponse.from_record(record) for record in records] + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def get_session(app_instance_id: str, session_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).get( + session_id, + app_instance_id=app_instance_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.patch( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def patch_session( + app_instance_id: str, + session_id: str, + request: SessionPatchRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).update( + session_id, + expected_revision=request.expected_revision, + app_instance_id=app_instance_id, + title=request.title, + status=request.status, + is_home=request.is_home, + visibility=request.visibility, + retention=request.retention, + metadata=request.metadata, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def delete_session( + app_instance_id: str, + session_id: str, + expected_revision: int = Query(ge=1), + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).update( + session_id, + expected_revision=expected_revision, + app_instance_id=app_instance_id, + status=SessionStatus.DELETED, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/sessions/{session_id}/messages", + response_model=MessageResponse, + status_code=201, + ) + def append_message( + session_id: str, + request: MessageCreateRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + if ( + idempotency_key is not None + and request.idempotency_key is not None + and idempotency_key != request.idempotency_key + ): + return platform_error_response( + status_code=400, + code="idempotency_key_mismatch", + message="Header and body idempotency keys must match.", + ) + try: + result = MessageRepository(runtime.database, runtime.events).append( + session_id=session_id, + role=request.role, + status=request.status, + parts=tuple( + MessagePartInput(kind=part.kind, content=part.content) + for part in request.parts + ), + idempotency_key=idempotency_key or request.idempotency_key, + metadata=request.metadata, + trace_id=x_trace_id, + ) + response = MessageResponse.from_record( + result.value, + created=result.created, + ) + if not result.created: + return JSONResponse( + status_code=200, + content=response.model_dump(mode="json"), + ) + return response + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/sessions/{session_id}/messages", + response_model=MessageListResponse, + ) + def list_messages( + session_id: str, + after: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + records = MessageRepository(runtime.database, runtime.events).list_for_session( + session_id, + after_sequence=after, + limit=limit, + ) + return MessageListResponse( + items=[MessageResponse.from_record(record) for record in records] + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/sessions/{session_id}/events", + response_model=EventListResponse, + ) + def list_session_events( + session_id: str, + after: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + events = runtime.events.list_after( + after, + session_id=session_id, + limit=limit, + ) + return EventListResponse( + items=[EventResponse.from_record(event) for event in events] + ) + + return router diff --git a/ai2apps/api/services.py b/ai2apps/api/services.py new file mode 100644 index 00000000..0f2fa111 --- /dev/null +++ b/ai2apps/api/services.py @@ -0,0 +1,419 @@ +"""Service Registry, Tool discovery, lifecycle, and invocation APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Header +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.packages import PackageError +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ( + ServiceDescriptorRecord, + ServiceInstanceRecord, + ServiceInstanceStatus, + ServiceRuntimeMode, + ServiceStatus, + ToolCallContext, + ToolDescriptorRecord, + ToolGatewayError, + ToolInvocationRecord, + ToolInvocationStatus, +) + + +class ServiceInstanceResponse(BaseModel): + id: str + provider_key: str + status: ServiceInstanceStatus + endpoint: str | None + health: dict[str, Any] + last_error: str | None + revision: int + + @classmethod + def from_record(cls, record: ServiceInstanceRecord) -> ServiceInstanceResponse: + return cls(**{name: getattr(record, name) for name in cls.model_fields}) + + +class ServiceResponse(BaseModel): + id: str + service_key: str + package_id: str + package_version: str + display_name: str + runtime_mode: ServiceRuntimeMode + source: str + status: ServiceStatus + capabilities: list[str] + dependencies: list[dict[str, Any]] + config: dict[str, Any] + package_digest: str | None + permissions: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + instance: ServiceInstanceResponse | None = None + + @classmethod + def from_record( + cls, + record: ServiceDescriptorRecord, + instance: ServiceInstanceRecord | None = None, + ) -> ServiceResponse: + return cls( + id=record.id, + service_key=record.service_key, + package_id=record.package_id, + package_version=record.package_version, + display_name=record.display_name, + runtime_mode=record.runtime_mode, + source=record.source, + status=record.status, + capabilities=list(record.capabilities), + dependencies=[ + { + "service_key": dependency.service_key, + "version_spec": dependency.version_spec, + "optional": dependency.optional, + } + for dependency in record.dependencies + ], + config=record.config, + package_digest=record.package_digest, + permissions=record.permissions, + revision=record.revision, + created_at=record.created_at, + updated_at=record.updated_at, + instance=( + None + if instance is None + else ServiceInstanceResponse.from_record(instance) + ), + ) + + +class ServiceListResponse(BaseModel): + items: list[ServiceResponse] + + +class ServiceLifecycleRequest(BaseModel): + expected_revision: int = Field(ge=1) + + +class ToolResponse(BaseModel): + id: str + service_id: str + qualified_name: str + display_name: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + effects: list[str] + required_capabilities: list[str] + capability_rules: list[dict[str, Any]] + retry_policy: dict[str, Any] + timeout_ms: int + + @classmethod + def from_record(cls, record: ToolDescriptorRecord) -> ToolResponse: + return cls( + id=record.id, + service_id=record.service_id, + qualified_name=record.qualified_name, + display_name=record.display_name, + description=record.description, + input_schema=record.input_schema, + output_schema=record.output_schema, + effects=list(record.effects), + required_capabilities=list(record.required_capabilities), + capability_rules=list(record.capability_rules), + retry_policy=record.retry_policy, + timeout_ms=record.timeout_ms, + ) + + +class ToolListResponse(BaseModel): + items: list[ToolResponse] + + +class ToolInvokeRequest(BaseModel): + arguments: dict[str, Any] = Field(default_factory=dict) + session_id: str | None = None + timeout_ms: int | None = Field(default=None, ge=1) + + +class ToolInvokeResponse(BaseModel): + invocation_id: str + tool_id: str + qualified_name: str + provider_key: str + output: dict[str, Any] + duration_ms: int + + +class ToolInvocationResponse(BaseModel): + id: str + tool_id: str + qualified_name: str + provider_key: str + caller_id: str + session_id: str | None + trace_id: str | None + status: ToolInvocationStatus + arguments: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + progress: dict[str, Any] + timeout_ms: int + attempt: int + duration_ms: int | None + revision: int + created_at: datetime + updated_at: datetime + finished_at: datetime | None + + @classmethod + def from_record(cls, record: ToolInvocationRecord) -> ToolInvocationResponse: + return cls(**{name: getattr(record, name) for name in cls.model_fields}) + + +class ToolInvocationListResponse(BaseModel): + items: list[ToolInvocationResponse] + + +def _runtime_or_error( + runtime_provider: PlatformRuntimeProvider, +) -> PlatformRuntime | JSONResponse: + runtime = runtime_provider() + if ( + runtime is None + or runtime.services is None + or runtime.service_registry is None + or runtime.tools is None + ): + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps Service runtime is not ready.", + retryable=True, + ) + return runtime + + +def _gateway_error(error: ToolGatewayError) -> JSONResponse: + status = { + "tool_not_found": 404, + "session_not_found": 404, + "invalid_tool_input": 422, + "invalid_timeout": 422, + "capability_denied": 403, + "tool_disabled": 409, + "service_disabled": 409, + "provider_identity_mismatch": 409, + "invalid_tool_output": 502, + "provider_error": 502, + "provider_unavailable": 503, + "service_unavailable": 503, + "tool_timeout": 504, + }.get(error.code, 500) + return platform_error_response( + status_code=status, + code=error.code, + message=str(error), + retryable=error.retryable, + details=error.details, + ) + + +def create_service_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + @router.get("/services", response_model=ServiceListResponse) + def list_services(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + items = [] + for service in runtime.services.list_services(): + try: + instance = runtime.services.get_instance_for_service(service.id) + except RepositoryError: + instance = None + items.append(ServiceResponse.from_record(service, instance)) + return ServiceListResponse(items=items) + + @router.get("/services/{service_key}", response_model=ServiceResponse) + def get_service(service_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + service = runtime.services.get_service(service_key) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + + async def change_enabled( + service_key: str, request: ServiceLifecycleRequest, enabled: bool + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + managed = ( + runtime.package_manager is not None + and runtime.package_repository is not None + and runtime.package_repository.active(service_key) is not None + ) + if managed: + operation = ( + runtime.package_manager.enable + if enabled + else runtime.package_manager.disable + ) + service = await operation(service_key, request.expected_revision) + else: + service = await runtime.service_registry.set_enabled( + service_key, + expected_revision=request.expected_revision, + enabled=enabled, + ) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + except ToolGatewayError as error: + return _gateway_error(error) + except PackageError as error: + return platform_error_response( + status_code=409, + code=error.code, + message=str(error), + details=error.details, + ) + + @router.post("/services/{service_key}/enable", response_model=ServiceResponse) + async def enable_service(service_key: str, request: ServiceLifecycleRequest): + return await change_enabled(service_key, request, True) + + @router.post("/services/{service_key}/disable", response_model=ServiceResponse) + async def disable_service(service_key: str, request: ServiceLifecycleRequest): + return await change_enabled(service_key, request, False) + + @router.post("/services/{service_key}/restart", response_model=ServiceResponse) + async def restart_service(service_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + if ( + runtime.package_manager is not None + and runtime.package_repository is not None + and runtime.package_repository.active(service_key) is not None + ): + await runtime.package_manager.restart(service_key) + else: + await runtime.service_registry.restart(service_key) + service = runtime.services.get_service(service_key) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + except ToolGatewayError as error: + return _gateway_error(error) + + @router.get("/tools", response_model=ToolListResponse) + def list_tools(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + context = ToolCallContext(caller_id="api:authenticated") + return ToolListResponse( + items=[ + ToolResponse.from_record(tool) + for tool in runtime.tools.list_tools(context) + ] + ) + + @router.get( + "/tool-invocations", response_model=ToolInvocationListResponse + ) + def list_tool_invocations( + session_id: str | None = None, + trace_id: str | None = None, + status: ToolInvocationStatus | None = None, + limit: int = 100, + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + return ToolInvocationListResponse( + items=[ + ToolInvocationResponse.from_record(item) + for item in runtime.services.list_invocations( + session_id=session_id, + trace_id=trace_id, + status=status, + limit=limit, + ) + ] + ) + + @router.get( + "/tool-invocations/{invocation_id}", + response_model=ToolInvocationResponse, + ) + def get_tool_invocation(invocation_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return ToolInvocationResponse.from_record( + runtime.services.get_invocation(invocation_id) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/tools/{qualified_name}/invoke", + response_model=ToolInvokeResponse, + ) + async def invoke_tool( + qualified_name: str, + request: ToolInvokeRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + result = await runtime.tools.execute( + qualified_name, + request.arguments, + context=ToolCallContext( + caller_id="api:authenticated", + session_id=request.session_id, + trace_id=x_trace_id, + ), + timeout_ms=request.timeout_ms, + ) + return ToolInvokeResponse( + invocation_id=result.invocation_id, + tool_id=result.tool_id, + qualified_name=result.qualified_name, + provider_key=result.provider_key, + output=result.output, + duration_ms=result.duration_ms, + ) + except ToolGatewayError as error: + return _gateway_error(error) + + return router diff --git a/ai2apps/chat/__init__.py b/ai2apps/chat/__init__.py new file mode 100644 index 00000000..82ab865d --- /dev/null +++ b/ai2apps/chat/__init__.py @@ -0,0 +1,5 @@ +"""Built-in singleton Chat App backend.""" + +from .repository import ChatContentRecord, ChatRepository, LegacyChatMessageInput + +__all__ = ["ChatContentRecord", "ChatRepository", "LegacyChatMessageInput"] diff --git a/ai2apps/chat/repository.py b/ai2apps/chat/repository.py new file mode 100644 index 00000000..a82181d5 --- /dev/null +++ b/ai2apps/chat/repository.py @@ -0,0 +1,813 @@ +"""Transactional backend for the built-in singleton Chat App.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from typing import Any + +from ai2apps.config import ( + BUILTIN_CHAT_PACKAGE_ID, + BUILTIN_CHAT_PACKAGE_VERSION, + BUILTIN_CHAT_SINGLETON_KEY, +) +from ai2apps.core import ( + EntityIdKind, + MessageRole, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, + SessionStatus, + new_entity_id, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import ( + BuiltinChatRecord, + ChatCollectionRecord, + ChatThreadRecord, + PlatformDatabase, +) +from ai2apps.storage.records import ( + app_definition_from_row, + app_instance_from_row, + canonical_json, + chat_collection_from_row, + chat_thread_from_joined_row, +) + +_THREAD_SELECT = """ + SELECT s.*, + e.pinned AS chat_pinned, + e.sort_order AS chat_sort_order, + e.legacy_thread_id AS chat_legacy_thread_id, + e.created_at AS chat_created_at, + e.updated_at AS chat_updated_at + FROM chat_thread_entries e + JOIN sessions s ON s.id = e.session_id +""" + + +@dataclass(frozen=True, slots=True) +class LegacyChatMessageInput: + """Browser-owned oMLX message accepted by the backend migration seam.""" + + role: MessageRole + content: Any + metadata: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class ChatContentRecord: + thread: ChatThreadRecord + metadata: dict[str, Any] + messages: tuple[LegacyChatMessageInput, ...] + + +class ChatRepository: + """Keep Chat collection state separate from generic Session content.""" + + def __init__(self, database: PlatformDatabase, events: EventStore | None = None): + self.database = database + self.events = events or EventStore(database) + + def ensure_builtin(self, *, trace_id: str | None = None) -> BuiltinChatRecord: + """Idempotently seed and resolve the local-user singleton Chat App.""" + + with self.database.transaction() as connection: + definition = connection.execute( + """ + SELECT * FROM app_definitions + WHERE package_id = ? AND package_version = ? + """, + (BUILTIN_CHAT_PACKAGE_ID, BUILTIN_CHAT_PACKAGE_VERSION), + ).fetchone() + instance = connection.execute( + "SELECT * FROM app_instances WHERE singleton_key = ?", + (BUILTIN_CHAT_SINGLETON_KEY,), + ).fetchone() + collection = ( + None + if instance is None + else connection.execute( + "SELECT * FROM chat_collections WHERE app_instance_id = ?", + (instance["id"],), + ).fetchone() + ) + if definition is not None and ( + definition["instance_mode"] != "singleton" + or definition["singleton_scope"] != "user" + or definition["source"] != "builtin" + ): + raise ResourceConflictError( + "Built-in Chat definition has incompatible policy" + ) + if ( + definition is not None + and instance is not None + and instance["app_definition_id"] != definition["id"] + ): + raise ResourceConflictError( + "Built-in Chat singleton key belongs to another definition" + ) + if definition is not None and instance is not None and collection is not None: + return BuiltinChatRecord( + definition=app_definition_from_row(definition), + instance=app_instance_from_row(instance), + collection=chat_collection_from_row(collection), + ) + + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + definition = connection.execute( + """ + SELECT * FROM app_definitions + WHERE package_id = ? AND package_version = ? + """, + (BUILTIN_CHAT_PACKAGE_ID, BUILTIN_CHAT_PACKAGE_VERSION), + ).fetchone() + if definition is None: + definition_id = new_entity_id(EntityIdKind.APP_DEFINITION) + connection.execute( + """ + INSERT INTO app_definitions( + id, package_id, package_version, display_name, + instance_mode, singleton_scope, source, status, + manifest_json, created_at, updated_at + ) VALUES (?, ?, ?, 'Chat', 'singleton', 'user', + 'builtin', 'enabled', ?, ?, ?) + """, + ( + definition_id, + BUILTIN_CHAT_PACKAGE_ID, + BUILTIN_CHAT_PACKAGE_VERSION, + canonical_json( + { + "entry": "chat", + "instance_mode": "singleton", + "session_kind": "chat_thread", + } + ), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="app.definition.created", + subject_id=definition_id, + trace_id=trace_id, + payload={ + "package_id": BUILTIN_CHAT_PACKAGE_ID, + "package_version": BUILTIN_CHAT_PACKAGE_VERSION, + }, + ) + definition = connection.execute( + "SELECT * FROM app_definitions WHERE id = ?", + (definition_id,), + ).fetchone() + assert definition is not None + if ( + definition["instance_mode"] != "singleton" + or definition["singleton_scope"] != "user" + or definition["source"] != "builtin" + ): + raise ResourceConflictError( + "Built-in Chat definition has incompatible policy" + ) + + instance = connection.execute( + "SELECT * FROM app_instances WHERE singleton_key = ?", + (BUILTIN_CHAT_SINGLETON_KEY,), + ).fetchone() + if instance is None: + instance_id = new_entity_id(EntityIdKind.APP_INSTANCE) + connection.execute( + """ + INSERT INTO app_instances( + id, app_definition_id, singleton_key, status, + state_json, created_at, updated_at + ) VALUES (?, ?, ?, 'active', '{}', ?, ?) + """, + ( + instance_id, + definition["id"], + BUILTIN_CHAT_SINGLETON_KEY, + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="app.instance.created", + subject_id=instance_id, + app_instance_id=instance_id, + trace_id=trace_id, + payload={"app_definition_id": definition["id"]}, + ) + instance = connection.execute( + "SELECT * FROM app_instances WHERE id = ?", (instance_id,) + ).fetchone() + assert instance is not None + elif instance["app_definition_id"] != definition["id"]: + raise ResourceConflictError( + "Built-in Chat singleton key belongs to another definition" + ) + + connection.execute( + """ + INSERT OR IGNORE INTO chat_collections( + app_instance_id, created_at, updated_at + ) VALUES (?, ?, ?) + """, + (instance["id"], now, now), + ) + collection = connection.execute( + "SELECT * FROM chat_collections WHERE app_instance_id = ?", + (instance["id"],), + ).fetchone() + assert collection is not None + return BuiltinChatRecord( + definition=app_definition_from_row(definition), + instance=app_instance_from_row(instance), + collection=chat_collection_from_row(collection), + ) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_collection(self) -> ChatCollectionRecord: + builtin = self.ensure_builtin() + return builtin.collection + + def _thread_row(self, connection, session_id: str): + return connection.execute( + _THREAD_SELECT + " WHERE e.session_id = ?", + (session_id,), + ).fetchone() + + def get_thread(self, session_id: str) -> ChatThreadRecord: + builtin = self.ensure_builtin() + with self.database.transaction() as connection: + row = connection.execute( + _THREAD_SELECT + + " WHERE e.session_id = ? AND e.app_instance_id = ?", + (session_id, builtin.instance.id), + ).fetchone() + if row is None: + raise ResourceNotFoundError("chat_thread", session_id) + return chat_thread_from_joined_row(row) + + def list_threads( + self, + *, + include_archived: bool = False, + include_deleted: bool = False, + limit: int = 100, + ) -> tuple[ChatThreadRecord, ...]: + if not 1 <= limit <= 1_000: + raise ValueError("limit must be between 1 and 1000") + builtin = self.ensure_builtin() + statuses = ["active"] + if include_archived: + statuses.append("archived") + if include_deleted: + statuses.append("deleted") + placeholders = ",".join("?" for _ in statuses) + with self.database.transaction() as connection: + rows = connection.execute( + _THREAD_SELECT + + f""" WHERE e.app_instance_id = ? + AND s.status IN ({placeholders}) + ORDER BY e.pinned DESC, e.sort_order DESC LIMIT ?""", + (builtin.instance.id, *statuses, limit), + ).fetchall() + return tuple(chat_thread_from_joined_row(row) for row in rows) + + def create_thread( + self, + *, + title: str = "", + pinned: bool = False, + legacy_thread_id: str | None = None, + metadata: dict[str, Any] | None = None, + legacy_messages: tuple[LegacyChatMessageInput, ...] = (), + trace_id: str | None = None, + ) -> tuple[ChatThreadRecord, bool]: + builtin = self.ensure_builtin(trace_id=trace_id) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + if legacy_thread_id is not None: + existing = connection.execute( + _THREAD_SELECT + " WHERE e.legacy_thread_id = ?", + (legacy_thread_id,), + ).fetchone() + if existing is not None: + if existing["app_instance_id"] != builtin.instance.id: + raise ResourceConflictError( + "Legacy thread belongs to another Chat collection" + ) + return chat_thread_from_joined_row(existing), False + has_home = connection.execute( + """ + SELECT 1 FROM sessions + WHERE app_instance_id = ? AND is_home = 1 AND status = 'active' + """, + (builtin.instance.id,), + ).fetchone() + order = int( + connection.execute( + """ + SELECT COALESCE(MAX(sort_order), 0) + 1 + FROM chat_thread_entries WHERE app_instance_id = ? + """, + (builtin.instance.id,), + ).fetchone()[0] + ) + session_id = new_entity_id(EntityIdKind.SESSION) + connection.execute( + """ + INSERT INTO sessions( + id, app_instance_id, title, is_home, session_kind, + visibility, retention, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'chat_thread', 'listed', 'durable', + ?, ?, ?) + """, + ( + session_id, + builtin.instance.id, + title, + int(has_home is None), + canonical_json(metadata or {}), + now, + now, + ), + ) + connection.execute( + """ + INSERT INTO chat_thread_entries( + session_id, app_instance_id, pinned, sort_order, + legacy_thread_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + builtin.instance.id, + int(pinned), + order, + legacy_thread_id, + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="session.created", + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={ + "is_home": has_home is None, + "retention": "durable", + "session_kind": "chat_thread", + "title": title, + "visibility": "listed", + }, + ) + for sequence, message in enumerate(legacy_messages, start=1): + message_id = new_entity_id(EntityIdKind.MESSAGE) + part_id = new_entity_id(EntityIdKind.MESSAGE_PART) + connection.execute( + """ + INSERT INTO messages( + id, session_id, sequence, role, status, + metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?) + """, + ( + message_id, + session_id, + sequence, + message.role.value, + canonical_json(message.metadata), + now, + now, + ), + ) + if isinstance(message.content, str): + part_kind = "text" + part_content = {"text": message.content} + else: + part_kind = "openai_content" + part_content = {"content": message.content} + connection.execute( + """ + INSERT INTO message_parts( + id, message_id, position, kind, content_json, created_at + ) VALUES (?, ?, 0, ?, ?, ?) + """, + ( + part_id, + message_id, + part_kind, + canonical_json(part_content), + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="message.created", + subject_id=message_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={ + "legacy_import": True, + "role": message.role.value, + "sequence": sequence, + }, + ) + connection.execute( + """ + UPDATE chat_collections + SET selected_session_id = ?, revision = revision + 1, + updated_at = ? + WHERE app_instance_id = ? + """, + (session_id, now, builtin.instance.id), + ) + self.events.append_in_transaction( + connection, + event_type="chat.thread.created", + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={"legacy_thread_id": legacy_thread_id, "pinned": pinned}, + ) + row = self._thread_row(connection, session_id) + assert row is not None + return chat_thread_from_joined_row(row), True + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def select_thread( + self, + session_id: str, + *, + expected_revision: int, + trace_id: str | None = None, + ) -> ChatCollectionRecord: + builtin = self.ensure_builtin(trace_id=trace_id) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + thread = self._thread_row(connection, session_id) + if ( + thread is None + or thread["app_instance_id"] != builtin.instance.id + ): + raise ResourceNotFoundError("chat_thread", session_id) + if thread["status"] != "active": + raise ResourceConflictError("Only active Chat threads can be selected") + cursor = connection.execute( + """ + UPDATE chat_collections + SET selected_session_id = ?, revision = revision + 1, + updated_at = ? + WHERE app_instance_id = ? AND revision = ? + """, + (session_id, now, builtin.instance.id, expected_revision), + ) + if cursor.rowcount == 0: + current = connection.execute( + "SELECT revision FROM chat_collections WHERE app_instance_id = ?", + (builtin.instance.id,), + ).fetchone() + assert current is not None + raise RevisionConflictError( + builtin.instance.id, + expected_revision, + int(current["revision"]), + ) + self.events.append_in_transaction( + connection, + event_type="chat.thread.selected", + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + ) + row = connection.execute( + "SELECT * FROM chat_collections WHERE app_instance_id = ?", + (builtin.instance.id,), + ).fetchone() + assert row is not None + return chat_collection_from_row(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def set_home_thread( + self, + session_id: str, + *, + expected_revision: int, + trace_id: str | None = None, + ) -> ChatThreadRecord: + builtin = self.ensure_builtin(trace_id=trace_id) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + target = self._thread_row(connection, session_id) + if target is None or target["app_instance_id"] != builtin.instance.id: + raise ResourceNotFoundError("chat_thread", session_id) + if target["status"] != "active": + raise ResourceConflictError("Home Chat thread must be active") + if int(target["revision"]) != expected_revision: + raise RevisionConflictError( + session_id, expected_revision, int(target["revision"]) + ) + old_home = connection.execute( + """ + SELECT id FROM sessions + WHERE app_instance_id = ? AND is_home = 1 AND id != ? + """, + (builtin.instance.id, session_id), + ).fetchone() + if old_home is not None: + connection.execute( + """ + UPDATE sessions SET is_home = 0, revision = revision + 1, + updated_at = ? WHERE id = ? + """, + (now, old_home["id"]), + ) + if not bool(target["is_home"]): + connection.execute( + """ + UPDATE sessions SET is_home = 1, revision = revision + 1, + updated_at = ? WHERE id = ? + """, + (now, session_id), + ) + self.events.append_in_transaction( + connection, + event_type="chat.thread.home_changed", + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={ + "previous_session_id": ( + None if old_home is None else old_home["id"] + ) + }, + ) + row = self._thread_row(connection, session_id) + assert row is not None + return chat_thread_from_joined_row(row) + + def update_thread( + self, + session_id: str, + *, + expected_revision: int, + title: str | None = None, + pinned: bool | None = None, + status: SessionStatus | None = None, + trace_id: str | None = None, + ) -> ChatThreadRecord: + if title is None and pinned is None and status is None: + raise ValueError("At least one Chat thread field must change") + builtin = self.ensure_builtin(trace_id=trace_id) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + current = self._thread_row(connection, session_id) + if ( + current is None + or current["app_instance_id"] != builtin.instance.id + ): + raise ResourceNotFoundError("chat_thread", session_id) + if int(current["revision"]) != expected_revision: + raise RevisionConflictError( + session_id, expected_revision, int(current["revision"]) + ) + fallback_id: str | None = None + leaving_active = status in { + SessionStatus.ARCHIVED, + SessionStatus.DELETED, + } + collection = connection.execute( + "SELECT * FROM chat_collections WHERE app_instance_id = ?", + (builtin.instance.id,), + ).fetchone() + assert collection is not None + if leaving_active: + fallback = connection.execute( + """ + SELECT s.id FROM chat_thread_entries e + JOIN sessions s ON s.id = e.session_id + WHERE e.app_instance_id = ? AND s.status = 'active' + AND s.id != ? + ORDER BY e.pinned DESC, e.sort_order DESC LIMIT 1 + """, + (builtin.instance.id, session_id), + ).fetchone() + fallback_id = None if fallback is None else fallback["id"] + if collection["selected_session_id"] == session_id: + connection.execute( + """ + UPDATE chat_collections + SET selected_session_id = ?, revision = revision + 1, + updated_at = ? WHERE app_instance_id = ? + """, + (fallback_id, now, builtin.instance.id), + ) + if bool(current["is_home"]): + connection.execute( + "UPDATE sessions SET is_home = 0 WHERE id = ?", + (session_id,), + ) + if fallback_id is not None: + connection.execute( + """ + UPDATE sessions + SET is_home = 1, revision = revision + 1, + updated_at = ? WHERE id = ? + """, + (now, fallback_id), + ) + + session_changes: dict[str, object] = {"updated_at": now} + if title is not None: + session_changes["title"] = title + if status is not None: + session_changes["status"] = status.value + if status is SessionStatus.ARCHIVED: + session_changes["archived_at"] = now + session_changes["deleted_at"] = None + elif status is SessionStatus.DELETED: + session_changes["deleted_at"] = now + elif status is SessionStatus.ACTIVE: + session_changes["archived_at"] = None + session_changes["deleted_at"] = None + assignments = [f"{column} = ?" for column in session_changes] + assignments.append("revision = revision + 1") + connection.execute( + f"UPDATE sessions SET {', '.join(assignments)} WHERE id = ?", + (*session_changes.values(), session_id), + ) + if pinned is not None: + connection.execute( + """ + UPDATE chat_thread_entries + SET pinned = ?, updated_at = ? WHERE session_id = ? + """, + (int(pinned), now, session_id), + ) + event_type = ( + "chat.thread.archived" + if status is SessionStatus.ARCHIVED + else "chat.thread.deleted" + if status is SessionStatus.DELETED + else "chat.thread.updated" + ) + self.events.append_in_transaction( + connection, + event_type=event_type, + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={ + "fallback_session_id": fallback_id, + "pinned": pinned, + "title_changed": title is not None, + }, + ) + row = self._thread_row(connection, session_id) + assert row is not None + return chat_thread_from_joined_row(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_content(self, session_id: str) -> ChatContentRecord: + thread = self.get_thread(session_id) + with self.database.transaction() as connection: + rows = connection.execute( + """ + SELECT m.role, m.metadata_json, p.kind, p.content_json + FROM messages m + JOIN message_parts p ON p.message_id = m.id + WHERE m.session_id = ? AND p.position = 0 + ORDER BY m.sequence + """, + (session_id,), + ).fetchall() + messages: list[LegacyChatMessageInput] = [] + for row in rows: + content_data = json.loads(row["content_json"]) + content = ( + content_data.get("text", "") + if row["kind"] == "text" + else content_data.get("content") + ) + messages.append( + LegacyChatMessageInput( + role=MessageRole(row["role"]), + content=content, + metadata=json.loads(row["metadata_json"]), + ) + ) + return ChatContentRecord( + thread=thread, + metadata=thread.session.metadata, + messages=tuple(messages), + ) + + def replace_content( + self, + session_id: str, + *, + expected_revision: int, + title: str | None = None, + metadata: dict[str, Any], + messages: tuple[LegacyChatMessageInput, ...], + trace_id: str | None = None, + ) -> ChatContentRecord: + """Atomically replace one UI snapshot using generic Message resources.""" + + builtin = self.ensure_builtin(trace_id=trace_id) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + current = self._thread_row(connection, session_id) + if current is None or current["app_instance_id"] != builtin.instance.id: + raise ResourceNotFoundError("chat_thread", session_id) + if int(current["revision"]) != expected_revision: + raise RevisionConflictError( + session_id, expected_revision, int(current["revision"]) + ) + connection.execute( + """ + DELETE FROM message_parts WHERE message_id IN ( + SELECT id FROM messages WHERE session_id = ? + ) + """, + (session_id,), + ) + connection.execute("DELETE FROM messages WHERE session_id = ?", (session_id,)) + for sequence, message in enumerate(messages, start=1): + message_id = new_entity_id(EntityIdKind.MESSAGE) + part_id = new_entity_id(EntityIdKind.MESSAGE_PART) + connection.execute( + """ + INSERT INTO messages( + id, session_id, sequence, role, status, metadata_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?) + """, + ( + message_id, + session_id, + sequence, + message.role.value, + canonical_json(message.metadata), + now, + now, + ), + ) + connection.execute( + """ + INSERT INTO message_parts( + id, message_id, position, kind, content_json, created_at + ) VALUES (?, ?, 0, 'chat_ui_content', ?, ?) + """, + ( + part_id, + message_id, + canonical_json({"content": message.content}), + now, + ), + ) + connection.execute( + """ + UPDATE sessions + SET title = COALESCE(?, title), metadata_json = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (title, canonical_json(metadata), now, session_id), + ) + self.events.append_in_transaction( + connection, + event_type="chat.thread.content_replaced", + subject_id=session_id, + app_instance_id=builtin.instance.id, + session_id=session_id, + trace_id=trace_id, + payload={"message_count": len(messages)}, + ) + return self.get_content(session_id) diff --git a/ai2apps/config.py b/ai2apps/config.py new file mode 100644 index 00000000..9d5f67bf --- /dev/null +++ b/ai2apps/config.py @@ -0,0 +1,103 @@ +"""Configuration paths owned by the AI2Apps platform layer.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +PLATFORM_DATABASE_FILENAME = "ai2apps-platform.sqlite3" +PLATFORM_DATABASE_SCHEMA_VERSION = 22 +DEFAULT_SESSION_WORKSPACE_QUOTA_BYTES = 512 * 1024 * 1024 +DEFAULT_RESOURCE_IMPORT_LIMIT_BYTES = 64 * 1024 * 1024 +DEFAULT_WORKSPACE_READ_LIMIT_BYTES = 1024 * 1024 +DEFAULT_SESSION_PROCESS_LIMIT = 4 +DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024 +DEFAULT_PROCESS_MEMORY_LIMIT_BYTES = 1024 * 1024 * 1024 +DEFAULT_PROCESS_WALL_TIME_SECONDS = 300 +DEFAULT_PROCESS_IDLE_TIME_SECONDS = 60 +DEFAULT_PROCESS_CPU_TIME_SECONDS = 120 +DEFAULT_TEMPORARY_SESSION_TTL_SECONDS = 24 * 60 * 60 +DEFAULT_SESSION_RETENTION_INTERVAL_SECONDS = 60.0 +BUILTIN_CHAT_PACKAGE_ID = "ai2apps.general-chat" +BUILTIN_CHAT_PACKAGE_VERSION = "1.0.0" +BUILTIN_CHAT_SINGLETON_KEY = "ai2apps.general-chat:user:local" + + +def resolve_projects_path(base_path: str | Path) -> Path: + """Return the AI2Apps-owned source-project root. + + The server still accepts ``~/.omlx`` as a compatibility data root, but new + Coder source projects must not inherit that legacy product namespace. + Explicit non-legacy base paths remain self-contained. + """ + + override = os.environ.get("AI2APPS_PROJECTS_DIR", "").strip() + if override: + return Path(override).expanduser().resolve() + resolved = Path(base_path).expanduser().resolve() + legacy_default = (Path.home() / ".omlx").resolve() + if resolved == legacy_default: + return (Path.home() / ".ai2apps" / "projects").resolve() + return resolved / "projects" + + +@dataclass(frozen=True, slots=True) +class PlatformPaths: + """Managed paths derived from the installation's existing data root.""" + + base_path: Path + database_path: Path + artifacts_path: Path + sandboxes_path: Path + packages_path: Path + projects_path: Path + documents_path: Path + browsers_path: Path + secrets_path: Path + + @classmethod + def from_base_path(cls, base_path: str | Path) -> PlatformPaths: + """Resolve paths without creating or mutating the filesystem.""" + + resolved = Path(base_path).expanduser().resolve() + platform_root = resolved / "platform" + return cls( + base_path=resolved, + database_path=platform_root / PLATFORM_DATABASE_FILENAME, + artifacts_path=platform_root / "artifacts", + sandboxes_path=platform_root / "sandboxes", + packages_path=platform_root / "packages", + projects_path=resolve_projects_path(resolved), + documents_path=platform_root / "documents", + browsers_path=platform_root / "browsers", + secrets_path=platform_root / "secrets", + ) + + +@dataclass(frozen=True, slots=True) +class PlatformConfig: + """Bootstrap configuration before the platform database is opened.""" + + paths: PlatformPaths | None + database_schema_version: int = PLATFORM_DATABASE_SCHEMA_VERSION + secret_backend: str = "auto" + + @property + def database_filename(self) -> str: + if self.paths is None: + return PLATFORM_DATABASE_FILENAME + return self.paths.database_path.name + + @classmethod + def from_base_path( + cls, base_path: str | Path, *, secret_backend: str = "auto" + ) -> PlatformConfig: + return cls( + paths=PlatformPaths.from_base_path(base_path), + secret_backend=secret_backend, + ) + + @classmethod + def unconfigured(cls) -> PlatformConfig: + return cls(paths=None) diff --git a/ai2apps/core/__init__.py b/ai2apps/core/__init__.py new file mode 100644 index 00000000..255525cd --- /dev/null +++ b/ai2apps/core/__init__.py @@ -0,0 +1,48 @@ +"""Shared value contracts for AI2Apps platform resources.""" + +from .clock import format_utc, parse_utc, utc_now, utc_now_text +from .errors import ( + IdempotencyConflictError, + RepositoryError, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, +) +from .ids import EntityIdKind, new_entity_id, validate_entity_id +from .models import ( + AppDefinitionStatus, + AppInstanceMode, + AppInstanceStatus, + MessageRole, + MessageStatus, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + SingletonScope, +) + +__all__ = [ + "AppDefinitionStatus", + "AppInstanceMode", + "AppInstanceStatus", + "EntityIdKind", + "IdempotencyConflictError", + "MessageRole", + "MessageStatus", + "RepositoryError", + "ResourceConflictError", + "ResourceNotFoundError", + "RevisionConflictError", + "SessionKind", + "SessionRetention", + "SessionStatus", + "SessionVisibility", + "SingletonScope", + "format_utc", + "new_entity_id", + "parse_utc", + "utc_now", + "utc_now_text", + "validate_entity_id", +] diff --git a/ai2apps/core/clock.py b/ai2apps/core/clock.py new file mode 100644 index 00000000..4e58c067 --- /dev/null +++ b/ai2apps/core/clock.py @@ -0,0 +1,42 @@ +"""Canonical UTC timestamp helpers for durable platform records.""" + +from __future__ import annotations + +from datetime import UTC, datetime + + +def utc_now() -> datetime: + """Return an aware UTC datetime.""" + + return datetime.now(UTC) + + +def format_utc(value: datetime) -> str: + """Format an aware datetime as RFC 3339 UTC with microsecond precision.""" + + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("Platform timestamps must be timezone-aware") + return ( + value.astimezone(UTC) + .isoformat(timespec="microseconds") + .replace("+00:00", "Z") + ) + + +def utc_now_text() -> str: + """Return the current time in canonical durable representation.""" + + return format_utc(utc_now()) + + +def parse_utc(value: str) -> datetime: + """Parse an RFC 3339 timestamp and normalize it to aware UTC.""" + + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError(f"Invalid RFC 3339 timestamp: {value!r}") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("Platform timestamps must include a timezone") + return parsed.astimezone(UTC) diff --git a/ai2apps/core/errors.py b/ai2apps/core/errors.py new file mode 100644 index 00000000..b4dd6d4e --- /dev/null +++ b/ai2apps/core/errors.py @@ -0,0 +1,38 @@ +"""Typed failures shared by platform repositories and future API adapters.""" + +from __future__ import annotations + + +class RepositoryError(RuntimeError): + """Base class for expected persistence-layer failures.""" + + +class ResourceNotFoundError(RepositoryError): + def __init__(self, resource_type: str, resource_id: str) -> None: + self.resource_type = resource_type + self.resource_id = resource_id + super().__init__(f"{resource_type} not found: {resource_id}") + + +class RevisionConflictError(RepositoryError): + def __init__(self, resource_id: str, expected: int, actual: int) -> None: + self.resource_id = resource_id + self.expected = expected + self.actual = actual + super().__init__( + f"Revision conflict for {resource_id}: expected {expected}, actual {actual}" + ) + + +class IdempotencyConflictError(RepositoryError): + def __init__(self, session_id: str, idempotency_key: str) -> None: + self.session_id = session_id + self.idempotency_key = idempotency_key + super().__init__( + "Idempotency key was already used for a different message in " + f"{session_id}: {idempotency_key}" + ) + + +class ResourceConflictError(RepositoryError): + """A relational uniqueness or lifecycle rule rejected the operation.""" diff --git a/ai2apps/core/ids.py b/ai2apps/core/ids.py new file mode 100644 index 00000000..d8ef9cb5 --- /dev/null +++ b/ai2apps/core/ids.py @@ -0,0 +1,80 @@ +"""Opaque, prefixed identifiers for durable AI2Apps entities.""" + +from __future__ import annotations + +from enum import StrEnum +from uuid import uuid4 + + +class EntityIdKind(StrEnum): + APP_DEFINITION = "app" + APP_INSTANCE = "appi" + SESSION = "ses" + MESSAGE = "msg" + MESSAGE_PART = "part" + EVENT = "evt" + SERVICE = "svc" + SERVICE_INSTANCE = "svci" + TOOL = "tool" + TOOL_INVOCATION = "tinv" + AGENT_DEFINITION = "agt" + AGENT_RUN = "run" + AGENT_DELEGATION = "dlg" + RUN_STEP = "step" + INTERACTION = "int" + STATUS_LINE = "stl" + CAPABILITY_POLICY = "pol" + GRANT_LEASE = "grant" + CAPABILITY_DECISION = "capd" + CAPABILITY_REQUEST = "capr" + SESSION_SANDBOX = "sbx" + RESOURCE_HANDLE = "res" + ARTIFACT = "art" + ARTIFACT_EXPORT = "exp" + PROCESS_EXECUTION = "proc" + PROCESS_LOG = "plog" + BROKER_REQUEST = "brq" + SERVICE_PACKAGE = "spkg" + PUBLISHER = "pub" + PACKAGE_ATTESTATION = "att" + SERVICE_OPERATION = "sop" + SERVICE_LOG = "slog" + MANAGED_SERVICE_PROCESS = "msp" + INTERACTIVE_PACKAGE = "ipkg" + LOCAL_PATCH = "patch" + EFFECTIVE_DEFINITION = "eff" + APP_MOUNT = "mnt" + APP_STATE_SNAPSHOT = "snap" + INTERACTIVE_OPERATION = "iop" + CODER_PROJECT = "cprj" + CODER_THREAD = "cthr" + ATTACHMENT = "attc" + DOCUMENT_BLOB = "dbl" + DOCUMENT_BLOCK = "dblk" + SECRET = "sec" + + @property + def prefix(self) -> str: + return f"{self.value}_" + + +def new_entity_id(kind: EntityIdKind) -> str: + """Create a lowercase opaque UUID4 identifier with a typed prefix.""" + + return f"{kind.prefix}{uuid4().hex}" + + +def validate_entity_id(value: str, kind: EntityIdKind) -> str: + """Validate the exact prefix and UUID payload used by platform IDs.""" + + prefix = kind.prefix + payload = value.removeprefix(prefix) + if not value.startswith(prefix) or len(payload) != 32: + raise ValueError(f"Expected {kind.value} identifier") + try: + int(payload, 16) + except ValueError as exc: + raise ValueError(f"Expected {kind.value} identifier") from exc + if value != value.lower(): + raise ValueError(f"Expected lowercase {kind.value} identifier") + return value diff --git a/ai2apps/core/models.py b/ai2apps/core/models.py new file mode 100644 index 00000000..bdda79b0 --- /dev/null +++ b/ai2apps/core/models.py @@ -0,0 +1,68 @@ +"""Stable lifecycle vocabularies shared by storage and future APIs.""" + +from enum import StrEnum + + +class AppInstanceMode(StrEnum): + MULTIPLE = "multiple" + SINGLETON = "singleton" + + +class SingletonScope(StrEnum): + SYSTEM = "system" + USER = "user" + SESSION = "session" + + +class AppDefinitionStatus(StrEnum): + ENABLED = "enabled" + DISABLED = "disabled" + + +class AppInstanceStatus(StrEnum): + CREATING = "creating" + ACTIVE = "active" + BACKGROUND = "background" + SUSPENDED = "suspended" + CLOSED = "closed" + DEGRADED = "degraded" + FAILED = "failed" + + +class SessionStatus(StrEnum): + ACTIVE = "active" + ARCHIVED = "archived" + DELETED = "deleted" + + +class SessionKind(StrEnum): + APP = "app" + CHAT_THREAD = "chat_thread" + MINI_CHAT = "mini_chat" + IN_APP_CHAT = "in_app_chat" + AGENT_CHILD = "agent_child" + + +class SessionVisibility(StrEnum): + LISTED = "listed" + UNLISTED = "unlisted" + + +class SessionRetention(StrEnum): + DURABLE = "durable" + TEMPORARY = "temporary" + + +class MessageRole(StrEnum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + TOOL = "tool" + APP = "app" + + +class MessageStatus(StrEnum): + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/ai2apps/events/__init__.py b/ai2apps/events/__init__.py new file mode 100644 index 00000000..cc09281f --- /dev/null +++ b/ai2apps/events/__init__.py @@ -0,0 +1,6 @@ +"""Durable semantic Event storage.""" + +from .bus import EventNotificationBus +from .store import EventStore + +__all__ = ["EventNotificationBus", "EventStore"] diff --git a/ai2apps/events/bus.py b/ai2apps/events/bus.py new file mode 100644 index 00000000..ac082da2 --- /dev/null +++ b/ai2apps/events/bus.py @@ -0,0 +1,56 @@ +"""Thread-safe commit notification bus backed by durable Event replay.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from dataclasses import dataclass +from threading import Lock +from uuid import uuid4 + + +@dataclass(frozen=True, slots=True) +class _Subscriber: + loop: asyncio.AbstractEventLoop + queue: asyncio.Queue[None] + + +class EventNotificationBus: + """Wake subscribers after commit; durable Events remain the source of truth.""" + + def __init__(self) -> None: + self._subscribers: dict[str, _Subscriber] = {} + self._lock = Lock() + + @staticmethod + def _offer(queue: asyncio.Queue[None]) -> None: + if not queue.full(): + queue.put_nowait(None) + + def notify(self) -> None: + with self._lock: + subscribers = tuple(self._subscribers.values()) + for subscriber in subscribers: + subscriber.loop.call_soon_threadsafe(self._offer, subscriber.queue) + + @asynccontextmanager + async def subscribe(self): + """Yield a one-slot wake queue; coalescing cannot lose durable Events.""" + + subscriber_id = uuid4().hex + subscriber = _Subscriber( + loop=asyncio.get_running_loop(), + queue=asyncio.Queue(maxsize=1), + ) + with self._lock: + self._subscribers[subscriber_id] = subscriber + try: + yield subscriber.queue + finally: + with self._lock: + self._subscribers.pop(subscriber_id, None) + + @property + def subscriber_count(self) -> int: + with self._lock: + return len(self._subscribers) diff --git a/ai2apps/events/store.py b/ai2apps/events/store.py new file mode 100644 index 00000000..bd8e6f2e --- /dev/null +++ b/ai2apps/events/store.py @@ -0,0 +1,144 @@ +"""Transactional append and cursor replay for semantic platform Events.""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + ResourceNotFoundError, + new_entity_id, + utc_now_text, +) +from ai2apps.events.bus import EventNotificationBus +from ai2apps.storage.database import PlatformDatabase +from ai2apps.storage.models import EventRecord +from ai2apps.storage.records import canonical_json, event_from_row + + +class EventStore: + """Append-only Event Store using the platform database's global cursor.""" + + def __init__( + self, + database: PlatformDatabase, + notifications: EventNotificationBus | None = None, + ) -> None: + self.database = database + self.notifications = notifications + + def append_in_transaction( + self, + connection: sqlite3.Connection, + *, + event_type: str, + subject_id: str, + payload: dict[str, Any] | None = None, + app_instance_id: str | None = None, + session_id: str | None = None, + trace_id: str | None = None, + schema_version: int = 1, + occurred_at: str | None = None, + ) -> EventRecord: + """Append using a caller-owned transaction for atomic state plus Event.""" + + event_id = new_entity_id(EntityIdKind.EVENT) + cursor = connection.execute( + """ + INSERT INTO events( + id, type, occurred_at, app_instance_id, session_id, + subject_id, trace_id, schema_version, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + event_type, + occurred_at or utc_now_text(), + app_instance_id, + session_id, + subject_id, + trace_id, + schema_version, + canonical_json(payload or {}), + ), + ) + row = connection.execute( + "SELECT * FROM events WHERE sequence = ?", (cursor.lastrowid,) + ).fetchone() + assert row is not None + event = event_from_row(row) + if self.notifications is not None: + self.database.after_commit(connection, self.notifications.notify) + return event + + def append(self, **kwargs: Any) -> EventRecord: + """Append a standalone Event in its own short transaction.""" + + with self.database.transaction(write=True) as connection: + return self.append_in_transaction(connection, **kwargs) + + def get(self, event_id: str) -> EventRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM events WHERE id = ?", (event_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("event", event_id) + return event_from_row(row) + + def latest_for_subject( + self, + subject_id: str, + *, + event_type: str | None = None, + ) -> EventRecord | None: + clauses = ["subject_id = ?"] + params: list[Any] = [subject_id] + if event_type is not None: + clauses.append("type = ?") + params.append(event_type) + query = ( + "SELECT * FROM events WHERE " + + " AND ".join(clauses) + + " ORDER BY sequence DESC LIMIT 1" + ) + with self.database.transaction() as connection: + row = connection.execute(query, params).fetchone() + return None if row is None else event_from_row(row) + + def list_after( + self, + after_sequence: int = 0, + *, + session_id: str | None = None, + app_instance_id: str | None = None, + subject_id: str | None = None, + limit: int = 100, + ) -> tuple[EventRecord, ...]: + """Replay Events strictly after a durable global sequence cursor.""" + + if after_sequence < 0: + raise ValueError("after_sequence must be non-negative") + if not 1 <= limit <= 1_000: + raise ValueError("limit must be between 1 and 1000") + clauses = ["sequence > ?"] + params: list[Any] = [after_sequence] + if session_id is not None: + clauses.append("session_id = ?") + params.append(session_id) + if app_instance_id is not None: + clauses.append("app_instance_id = ?") + params.append(app_instance_id) + if subject_id is not None: + clauses.append("subject_id = ?") + params.append(subject_id) + params.append(limit) + query = ( + "SELECT * FROM events WHERE " + + " AND ".join(clauses) + + " ORDER BY sequence LIMIT ?" + ) + with self.database.transaction() as connection: + rows = connection.execute(query, params).fetchall() + return tuple(event_from_row(row) for row in rows) diff --git a/ai2apps/events/stream.py b/ai2apps/events/stream.py new file mode 100644 index 00000000..ab85ae7f --- /dev/null +++ b/ai2apps/events/stream.py @@ -0,0 +1,56 @@ +"""SSE projection over durable Event replay and commit notifications.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator + +from ai2apps.api.models import EventResponse +from ai2apps.events.bus import EventNotificationBus +from ai2apps.events.store import EventStore + + +def encode_sse_event(event) -> str: + payload = EventResponse.from_record(event).model_dump(mode="json") + return ( + f"id: {event.sequence}\n" + f"event: {event.type}\n" + f"data: {json.dumps(payload, separators=(',', ':'))}\n\n" + ) + + +async def stream_events( + store: EventStore, + notifications: EventNotificationBus, + *, + after_sequence: int = 0, + session_id: str | None = None, + app_instance_id: str | None = None, + subject_id: str | None = None, + heartbeat_seconds: float = 15.0, + replay_batch_size: int = 100, +) -> AsyncIterator[str]: + """Replay without gaps, then wait on a coalescing one-slot wake queue.""" + + cursor = after_sequence + async with notifications.subscribe() as wake_queue: + while True: + events = await asyncio.to_thread( + store.list_after, + cursor, + session_id=session_id, + app_instance_id=app_instance_id, + subject_id=subject_id, + limit=replay_batch_size, + ) + if events: + for event in events: + cursor = event.sequence + yield encode_sse_event(event) + if len(events) == replay_batch_size: + continue + try: + await asyncio.wait_for(wake_queue.get(), timeout=heartbeat_seconds) + except TimeoutError: + yield ": heartbeat\n\n" diff --git a/ai2apps/platform_runtime.py b/ai2apps/platform_runtime.py new file mode 100644 index 00000000..d223f7f8 --- /dev/null +++ b/ai2apps/platform_runtime.py @@ -0,0 +1,514 @@ +"""Lifecycle boundary for the durable AI2Apps platform backend.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from contextlib import suppress +from dataclasses import dataclass +from typing import Literal + +from ai2apps.agents import ( + AgentRepository, + AgentRuntime, + install_delegation_service, + install_diagnostic_agent, + install_general_agent, +) +from ai2apps.apps import ensure_system_apps +from ai2apps.browser import ( + BrowserManager, + BrowserRuntimeConfig, + ChromeBrowserBackend, + install_browser_service, +) +from ai2apps.capabilities import ( + CapabilityPolicyEngine, + CapabilityRepository, + PolicyEffect, +) +from ai2apps.chat import ChatRepository +from ai2apps.cloud_client import ( + DEFAULT_AI2APPS_CLOUD_BASE_URL, + AI2AppsCloudClient, + CloudSessionStore, +) +from ai2apps.coder import CoderManager +from ai2apps.config import ( + DEFAULT_SESSION_RETENTION_INTERVAL_SECONDS, + PlatformConfig, +) +from ai2apps.documents import ( + DocumentManager, + DocumentRepository, + install_document_service, +) +from ai2apps.events import EventNotificationBus, EventStore +from ai2apps.extensions import ExtensionRepository, InteractivePackageManager +from ai2apps.images import install_image_service +from ai2apps.packages import PackageRepository, ServicePackageManager +from ai2apps.packages.registry import RegistryPackageManager +from ai2apps.remote import ( + RemoteAccessManager, + RemoteDeviceRepository, + RemoteFrpcConfig, + RemoteFrpcSupervisor, +) +from ai2apps.processes import ProcessManager, install_process_service +from ai2apps.research import install_research_agent, install_web_research_service +from ai2apps.secrets import SecretRepository, create_secret_backend +from ai2apps.services import ( + MCPServiceAdapter, + OmlxModelServiceAdapter, + ServiceRegistry, + ServiceRepository, + ToolGateway, + install_echo_service, +) +from ai2apps.storage import PlatformDatabase +from ai2apps.storage.repositories import SessionRepository +from ai2apps.terminal import TerminalManager, install_terminal_service +from ai2apps.workspace import WorkspaceRepository, install_workspace_service + +logger = logging.getLogger(__name__) + +DatabaseRuntimeState = Literal["unconfigured", "not_initialized", "ready"] + + +@dataclass(frozen=True, slots=True) +class PlatformDatabaseStatus: + """Database state exposed to platform health contracts.""" + + configured: bool + status: DatabaseRuntimeState + schema_version: int + target_schema_version: int + filename: str + journal_mode: str | None = None + + +class PlatformRuntime: + """Own platform startup state without depending on oMLX internals.""" + + def __init__(self, config: PlatformConfig) -> None: + self.config = config + self._database_status = self.status_before_start(config) + self.database: PlatformDatabase | None = None + self.notifications: EventNotificationBus | None = None + self.events: EventStore | None = None + self.services: ServiceRepository | None = None + self.service_registry: ServiceRegistry | None = None + self.tools: ToolGateway | None = None + self.capabilities: CapabilityRepository | None = None + self.secrets: SecretRepository | None = None + self.cloud: AI2AppsCloudClient | None = None + self.capability_policy: CapabilityPolicyEngine | None = None + self.agents: AgentRepository | None = None + self.agent_runtime: AgentRuntime | None = None + self.workspace: WorkspaceRepository | None = None + self.processes: ProcessManager | None = None + self.web_provider = None + self.browser: BrowserManager | None = None + self.terminal: TerminalManager | None = None + self.coder: CoderManager | None = None + self.documents: DocumentRepository | None = None + self.document_manager: DocumentManager | None = None + self.package_repository: PackageRepository | None = None + self.package_manager: ServicePackageManager | None = None + self.registry_packages: RegistryPackageManager | None = None + self.remote: RemoteAccessManager | None = None + self.extension_repository: ExtensionRepository | None = None + self.extension_manager: InteractivePackageManager | None = None + self._retention_stop: asyncio.Event | None = None + self._retention_task: asyncio.Task[None] | None = None + + @staticmethod + def status_before_start(config: PlatformConfig) -> PlatformDatabaseStatus: + configured = config.paths is not None + return PlatformDatabaseStatus( + configured=configured, + status="not_initialized" if configured else "unconfigured", + schema_version=0, + target_schema_version=config.database_schema_version, + filename=config.database_filename, + ) + + @property + def database_status(self) -> PlatformDatabaseStatus: + return self._database_status + + async def start_background_tasks( + self, + *, + retention_interval_seconds: float = DEFAULT_SESSION_RETENTION_INTERVAL_SECONDS, + ) -> None: + """Start bounded platform maintenance loops after database startup.""" + + if self.database is None or self.events is None or self._retention_task: + return + if retention_interval_seconds <= 0: + raise ValueError("retention_interval_seconds must be positive") + self._retention_stop = asyncio.Event() + self._retention_task = asyncio.create_task( + self._run_session_retention(retention_interval_seconds), + name="ai2apps-session-retention", + ) + if self.package_manager is not None: + await self.package_manager.startup() + if self.processes is not None: + await self.processes.startup() + if self.terminal is not None: + await self.terminal.startup() + if self.agent_runtime is not None: + await self.agent_runtime.start() + if self.document_manager is not None: + await self.document_manager.startup() + if self.remote is not None: + await self.remote.startup() + + async def _run_session_retention(self, interval_seconds: float) -> None: + assert self.database is not None + assert self.events is not None + assert self._retention_stop is not None + repository = SessionRepository(self.database, self.events) + while not self._retention_stop.is_set(): + try: + await asyncio.to_thread(repository.expire_temporary) + except Exception: + logger.exception("AI2Apps temporary Session retention pass failed") + with suppress(TimeoutError): + await asyncio.wait_for( + self._retention_stop.wait(), timeout=interval_seconds + ) + + async def stop_background_tasks(self) -> None: + """Stop maintenance loops and wait until their current batch completes.""" + + if self.agent_runtime is not None: + await self.agent_runtime.stop() + if self.document_manager is not None: + await self.document_manager.shutdown() + if self.remote is not None: + await self.remote.shutdown() + if self.cloud is not None: + await self.cloud.close() + if self.browser is not None: + await self.browser.close() + if self.package_manager is not None: + await self.package_manager.shutdown() + if self.processes is not None: + await self.processes.shutdown() + if self.terminal is not None: + await self.terminal.shutdown() + if self._retention_stop is not None: + self._retention_stop.set() + if self._retention_task is not None: + await self._retention_task + self._retention_task = None + self._retention_stop = None + + async def set_safe_mode(self, active: bool, reason: str = "user-request") -> dict: + """Apply the unpatchable recovery boundary across platform subsystems.""" + if self.extension_manager is None: + raise RuntimeError("Interactive package runtime is unavailable") + revoked = () + stopped = 0 + if active and self.capabilities is not None: + revoked = self.capabilities.revoke_all(reason=f"safe-mode:{reason}") + if active and self.processes is not None: + records = await asyncio.to_thread(self.processes.repository.active) + results = await asyncio.gather( + *( + self.processes.cancel( + record.id, + session_id=record.session_id, + run_id=record.run_id, + ) + for record in records + ), + return_exceptions=True, + ) + stopped = sum(not isinstance(result, BaseException) for result in results) + closed_terminals = 0 + if active and self.terminal is not None: + terminal_ids = tuple(item["id"] for item in self.terminal.list()) + results = await asyncio.gather( + *(self.terminal.close(session_id) for session_id in terminal_ids), + return_exceptions=True, + ) + closed_terminals = sum( + not isinstance(result, BaseException) for result in results + ) + state = self.extension_manager.safe_mode(active, reason) + return { + **state, + "revoked_grants": len(revoked), + "stopped_processes": stopped, + "closed_terminals": closed_terminals, + } + + def start(self) -> PlatformDatabaseStatus: + """Initialize the single platform database when a data root exists.""" + + if self.config.paths is None: + return self._database_status + + database = PlatformDatabase(self.config.paths.database_path) + state = database.initialize() + notifications = EventNotificationBus() + self.database = database + self.notifications = notifications + self.events = EventStore(database, notifications) + ChatRepository(database, self.events).ensure_builtin() + ensure_system_apps(database, self.events) + self.services = ServiceRepository(database, self.events) + interrupted_invocations = self.services.recover_interrupted_invocations() + for invocation in interrupted_invocations: + with database.transaction(write=True) as connection: + self.events.append_in_transaction( + connection, + event_type="tool.invocation.interrupted", + subject_id=invocation.tool_id, + session_id=invocation.session_id, + trace_id=invocation.trace_id, + payload={ + "invocation_id": invocation.id, + "caller_id": invocation.caller_id, + "status": "interrupted", + "code": "runtime_restarted", + }, + ) + self.service_registry = ServiceRegistry(self.services) + self.tools = ToolGateway( + database, + self.events, + self.services, + self.service_registry, + ) + secret_backend = create_secret_backend( + self.config.paths.secrets_path, + configured=self.config.secret_backend, + ) + self.secrets = SecretRepository(database, self.events, secret_backend) + cloud_base_url = os.environ.get( + "AI2APPS_CLOUD_BASE_URL", DEFAULT_AI2APPS_CLOUD_BASE_URL + ) + self.cloud = AI2AppsCloudClient( + base_url=cloud_base_url, + session_store=CloudSessionStore(secret_backend, cloud_base_url), + ) + remote_runtime_directory = self.config.paths.base_path / "platform" / "remote" + remote_config_error = None + try: + remote_frpc_config = RemoteFrpcConfig.from_environment( + remote_runtime_directory + ) + except ValueError as error: + remote_frpc_config = None + remote_config_error = str(error) + if remote_frpc_config is None and remote_config_error is None: + remote_config_error = RemoteFrpcConfig.unavailable_reason( + remote_runtime_directory + ) + self.remote = RemoteAccessManager( + cloud=self.cloud, + repository=RemoteDeviceRepository(database), + secret_backend=secret_backend, + client_version=os.environ.get("AI2APPS_CLIENT_VERSION", "0.2.0"), + frpc=RemoteFrpcSupervisor( + remote_frpc_config, + secret_backend, + unavailable_reason=remote_config_error, + ), + ) + self.tools.bind_secret_resolver(self.secrets.inject_arguments) + install_echo_service(self.services, self.service_registry) + self.capabilities = CapabilityRepository(database, self.events) + self.capabilities.ensure_builtin_defaults() + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-workspace-write", + effect=PolicyEffect.ALLOW, + capability_pattern="workspace.write", + agent_pattern="ai2apps.general-agent", + tool_pattern="workspace.*", + priority=100, + source="builtin", + ) + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-document-create-pdf", + effect=PolicyEffect.ALLOW, + capability_pattern="artifact.create", + agent_pattern="ai2apps.general-agent", + tool_pattern="document.create_pdf", + priority=100, + source="builtin", + ) + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-document-create-pdf-workspace", + effect=PolicyEffect.ALLOW, + capability_pattern="workspace.write", + agent_pattern="ai2apps.general-agent", + tool_pattern="document.create_pdf", + priority=100, + source="builtin", + ) + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-artifact-create", + effect=PolicyEffect.ALLOW, + capability_pattern="artifact.create", + agent_pattern="ai2apps.general-agent", + tool_pattern="artifact.create", + priority=100, + source="builtin", + ) + self.capability_policy = CapabilityPolicyEngine(self.capabilities) + self.workspace = WorkspaceRepository(database, self.events, self.config.paths) + install_workspace_service(self.workspace, self.services, self.service_registry) + self.documents = DocumentRepository(database, self.config.paths) + self.document_manager = DocumentManager(self.documents) + install_document_service( + self.documents, self.workspace, self.services, self.service_registry + ) + install_image_service( + base_path=self.config.paths.base_path, + cloud_client=self.cloud, + workspace=self.workspace, + repository=self.services, + registry=self.service_registry, + runtime_provider=lambda: self, + ) + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-image-artifact", + effect=PolicyEffect.ALLOW, + capability_pattern="artifact.create", + agent_pattern="ai2apps.general-agent", + tool_pattern="image.generate", + priority=100, + source="builtin", + ) + self.capabilities.upsert_policy( + policy_key="builtin.general-agent-image-workspace", + effect=PolicyEffect.ALLOW, + capability_pattern="workspace.write", + agent_pattern="ai2apps.general-agent", + tool_pattern="image.generate", + priority=100, + source="builtin", + ) + self.web_provider = install_web_research_service( + self.services, self.service_registry + ) + self.browser = BrowserManager( + ChromeBrowserBackend( + BrowserRuntimeConfig( + profile_path=str(self.config.paths.browsers_path / "chrome-default") + ) + ), + workspace=self.workspace, + ) + install_browser_service(self.browser, self.services, self.service_registry) + self.processes = ProcessManager(database, self.events, self.workspace) + install_process_service(self.processes, self.services, self.service_registry) + self.terminal = TerminalManager() + install_terminal_service(self.terminal, self.services, self.service_registry) + self.coder = CoderManager( + database, + self.terminal, + project_root=self.config.paths.projects_path, + testflight_root=self.config.paths.packages_path / "testflight", + ) + self.package_repository = PackageRepository(database, self.events) + self.package_manager = ServicePackageManager( + self.config.paths, + self.package_repository, + self.services, + self.service_registry, + ) + self.package_manager.restore_registry() + self.agents = AgentRepository(database, self.events, self.capabilities) + self.agent_runtime = AgentRuntime( + self.agents, self.tools, self.capability_policy, self.capabilities + ) + self.agent_runtime.bind_run_terminal_handler( + self.processes.schedule_cancel_by_run + ) + install_diagnostic_agent(self.agents, self.agent_runtime) + install_general_agent( + self.agents, + self.agent_runtime, + database, + self.events, + self.tools, + ) + install_research_agent(self.agents) + install_delegation_service( + self.agents, + self.agent_runtime, + self.services, + self.service_registry, + ) + self.extension_manager = InteractivePackageManager( + database, + self.events, + self.config.paths.packages_path, + self.package_repository, + self.agents, + ) + self.extension_repository = self.extension_manager.repository + self.registry_packages = RegistryPackageManager( + cloud=self.cloud, + root=self.config.paths.packages_path, + secrets=self.secrets, + extension_manager=self.extension_manager, + service_manager=self.package_manager, + ) + self._database_status = PlatformDatabaseStatus( + configured=True, + status="ready", + schema_version=state.schema_version, + target_schema_version=self.config.database_schema_version, + filename=state.path.name, + journal_mode=state.journal_mode, + ) + return self._database_status + + def bind_builtin_runtime_services( + self, + *, + engine_pool_provider, + mcp_manager_provider, + ) -> None: + """Bind existing oMLX providers after their own startup has completed.""" + + if self.services is None or self.service_registry is None: + return + OmlxModelServiceAdapter(engine_pool_provider).bind( + self.services, + self.service_registry, + ) + MCPServiceAdapter(mcp_manager_provider).bind( + self.services, + self.service_registry, + ) + + def bind_ai_capability_auditor(self, auditor) -> None: + """Bind an optional independent AI reviewer for ask-policy decisions.""" + + if self.capability_policy is None: + raise RuntimeError("Capability policy runtime is not ready") + self.capability_policy.bind_ai_auditor(auditor) + + def bind_service_package_auditor(self, auditor) -> None: + """Bind an optional independent local AI source auditor for Service packages.""" + + if self.package_manager is None: + raise RuntimeError("Service package runtime is not ready") + self.package_manager.trust.bind_local_ai_auditor(auditor) + + def stop(self) -> None: + """Release runtime resources. + + Connections are deliberately transaction-scoped in this milestone, so + shutdown currently has no persistent handle to close. + """ diff --git a/ai2apps/services/__init__.py b/ai2apps/services/__init__.py new file mode 100644 index 00000000..b42d811e --- /dev/null +++ b/ai2apps/services/__init__.py @@ -0,0 +1,49 @@ +"""AI2Apps Service Registry, Tool Registry, adapters, and gateway.""" + +from .adapters import ( + ExternalJsonToolProvider, + MCPServiceAdapter, + OmlxModelServiceAdapter, + install_echo_service, +) +from .models import ( + ServiceDependency, + ServiceDescriptorRecord, + ServiceInstanceRecord, + ServiceInstanceStatus, + ServiceRuntimeMode, + ServiceStatus, + ToolCallContext, + ToolDescriptorRecord, + ToolExecutionResult, + ToolGatewayError, + ToolInvocationRecord, + ToolInvocationStatus, + ToolProviderError, +) +from .registry import ServiceLifecycle, ServiceRegistry, ToolGateway +from .repository import ServiceRepository + +__all__ = [ + "ExternalJsonToolProvider", + "MCPServiceAdapter", + "OmlxModelServiceAdapter", + "ServiceDependency", + "ServiceDescriptorRecord", + "ServiceInstanceRecord", + "ServiceInstanceStatus", + "ServiceLifecycle", + "ServiceRegistry", + "ServiceRepository", + "ServiceRuntimeMode", + "ServiceStatus", + "ToolCallContext", + "ToolDescriptorRecord", + "ToolExecutionResult", + "ToolGateway", + "ToolGatewayError", + "ToolInvocationRecord", + "ToolInvocationStatus", + "ToolProviderError", + "install_echo_service", +] diff --git a/ai2apps/services/adapters.py b/ai2apps/services/adapters.py new file mode 100644 index 00000000..10a54390 --- /dev/null +++ b/ai2apps/services/adapters.py @@ -0,0 +1,329 @@ +"""Built-in adapters around existing AI2Apps/oMLX capabilities.""" + +from __future__ import annotations + +import asyncio +import json +import urllib.error +import urllib.request +from collections.abc import Callable +from typing import Any + +from .models import ( + ServiceInstanceStatus, + ServiceRuntimeMode, + ToolCallContext, + ToolProviderError, +) +from .registry import ServiceLifecycle, ServiceRegistry +from .repository import ServiceRepository + +OBJECT_SCHEMA = {"type": "object"} + + +def install_echo_service( + repository: ServiceRepository, registry: ServiceRegistry +) -> None: + service = repository.ensure_service( + service_key="ai2apps.diagnostics", + package_id="ai2apps.diagnostics", + package_version="1.0.0", + display_name="AI2Apps Diagnostics", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("diagnostics",), + ) + instance = repository.ensure_instance( + service_id=service.id, + provider_key="builtin:diagnostics", + status=ServiceInstanceStatus.RUNNING, + endpoint="/v1/platform/tools/system.echo/invoke", + health={"status": "ok"}, + ) + repository.ensure_tool( + service_id=service.id, + qualified_name="system.echo", + display_name="Echo", + description="Return the supplied JSON value for Service gateway diagnostics.", + input_schema={ + "type": "object", + "properties": {"value": {}}, + "required": ["value"], + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "properties": {"value": {}}, + "required": ["value"], + "additionalProperties": False, + }, + effects=(), + timeout_ms=5_000, + ) + + async def echo(arguments: dict[str, Any], _: ToolCallContext) -> dict[str, Any]: + return {"value": arguments["value"]} + + registry.bind_tool("system.echo", provider_key=instance.provider_key, handler=echo) + + +class OmlxModelServiceAdapter: + """Expose the existing EnginePool without moving model-runtime ownership.""" + + def __init__(self, engine_pool_provider: Callable[[], Any | None]) -> None: + self.engine_pool_provider = engine_pool_provider + + def bind(self, repository: ServiceRepository, registry: ServiceRegistry) -> None: + pool = self.engine_pool_provider() + service = repository.ensure_service( + service_key="ai2apps.model-runtime", + package_id="ai2apps.model-runtime", + package_version="1.0.0", + display_name="Model Runtime", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("model.discovery", "model.lifecycle", "model.inference"), + config={ + "compatibility_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/embeddings", + "/v1/rerank", + ], + "inference_contract": "openai-compatible", + }, + ) + instance = repository.ensure_instance( + service_id=service.id, + provider_key="builtin:omlx-model-runtime", + status=( + ServiceInstanceStatus.RUNNING + if pool is not None + else ServiceInstanceStatus.STOPPED + ), + endpoint="/v1", + health={"runtime": "omlx", "available": pool is not None}, + ) + tools = ( + ( + "model.status", + "Model Status", + "Return the existing oMLX EnginePool status.", + {"type": "object", "additionalProperties": False}, + (), + self._status, + ), + ( + "model.load", + "Load Model", + "Load a discovered model into the existing oMLX runtime.", + { + "type": "object", + "properties": {"model_id": {"type": "string", "minLength": 1}}, + "required": ["model_id"], + "additionalProperties": False, + }, + ("model.manage",), + self._load, + ), + ( + "model.unload", + "Unload Model", + "Unload a model from the existing oMLX runtime.", + { + "type": "object", + "properties": {"model_id": {"type": "string", "minLength": 1}}, + "required": ["model_id"], + "additionalProperties": False, + }, + ("model.manage",), + self._unload, + ), + ) + for name, title, description, input_schema, capabilities, handler in tools: + repository.ensure_tool( + service_id=service.id, + qualified_name=name, + display_name=title, + description=description, + input_schema=input_schema, + output_schema=OBJECT_SCHEMA, + effects=("memory",) if name != "model.status" else (), + required_capabilities=capabilities, + timeout_ms=300_000 if name == "model.load" else 30_000, + ) + registry.bind_tool( + name, provider_key=instance.provider_key, handler=handler + ) + registry.bind_lifecycle( + service.service_key, + lifecycle=ServiceLifecycle( + start=lambda: self.bind(repository, registry), + restart=lambda: self.bind(repository, registry), + ), + ) + + def _pool(self): + pool = self.engine_pool_provider() + if pool is None: + raise ToolProviderError("oMLX EnginePool is not initialized") + return pool + + async def _status(self, _: dict[str, Any], __: ToolCallContext) -> dict[str, Any]: + return dict(self._pool().get_status()) + + async def _load( + self, arguments: dict[str, Any], _: ToolCallContext + ) -> dict[str, Any]: + model_id = arguments["model_id"] + pool = self._pool() + if pool.get_entry(model_id) is None: + raise ToolProviderError(f"Model not found: {model_id}") + await pool.get_engine(model_id) + return {"status": "ok", "model_id": model_id} + + async def _unload( + self, arguments: dict[str, Any], _: ToolCallContext + ) -> dict[str, Any]: + model_id = arguments["model_id"] + pool = self._pool() + entry = pool.get_entry(model_id) + if entry is None: + raise ToolProviderError(f"Model not found: {model_id}") + if entry.engine is None: + return {"status": "ok", "model_id": model_id, "already_unloaded": True} + await pool._unload_engine(model_id) + return {"status": "ok", "model_id": model_id} + + +class MCPServiceAdapter: + """Project discovered MCP servers/tools through the shared Tool Registry.""" + + def __init__(self, manager_provider: Callable[[], Any | None]) -> None: + self.manager_provider = manager_provider + + def bind(self, repository: ServiceRepository, registry: ServiceRegistry) -> None: + manager = self.manager_provider() + service = repository.ensure_service( + service_key="ai2apps.mcp", + package_id="ai2apps.mcp", + package_version="1.0.0", + display_name="MCP Service", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("mcp.discovery", "mcp.execution"), + ) + statuses = [] if manager is None else manager.get_server_status() + instance = repository.ensure_instance( + service_id=service.id, + provider_key="builtin:omlx-mcp", + status=( + ServiceInstanceStatus.RUNNING + if manager is not None + else ServiceInstanceStatus.STOPPED + ), + endpoint="/v1/mcp", + health={ + "available": manager is not None, + "servers": [status.to_dict() for status in statuses], + }, + ) + active_names: set[str] = set() + if manager is not None: + for mcp_tool in manager.get_all_tools(): + qualified_name = f"mcp.{mcp_tool.full_name}" + active_names.add(qualified_name) + repository.ensure_tool( + service_id=service.id, + qualified_name=qualified_name, + display_name=mcp_tool.name, + description=mcp_tool.description, + input_schema=mcp_tool.input_schema or OBJECT_SCHEMA, + output_schema=OBJECT_SCHEMA, + effects=("external",), + timeout_ms=60_000, + ) + + async def execute( + arguments: dict[str, Any], + _: ToolCallContext, + *, + full_name: str = mcp_tool.full_name, + ) -> dict[str, Any]: + current = self.manager_provider() + if current is None: + raise ToolProviderError("MCP manager is not initialized") + result = await current.execute_tool(full_name, arguments) + if result.is_error: + raise ToolProviderError( + result.error_message or f"MCP tool failed: {full_name}" + ) + return {"content": result.content, "is_error": False} + + registry.bind_tool( + qualified_name, + provider_key=instance.provider_key, + handler=execute, + ) + repository.disable_unseen_tools(service.id, active_names) + if manager is not None: + + async def start() -> None: + current = self.manager_provider() + if current is None: + raise ToolProviderError("MCP manager is not initialized") + await current.start() + self.bind(repository, registry) + + async def stop() -> None: + current = self.manager_provider() + if current is not None: + await current.stop() + + async def restart() -> None: + await stop() + await start() + + registry.bind_lifecycle( + service.service_key, + lifecycle=ServiceLifecycle( + start=start, + stop=stop, + restart=restart, + ), + ) + + +class ExternalJsonToolProvider: + """Bind an external JSON-over-HTTP endpoint behind a stable Tool identity.""" + + def __init__(self, endpoint: str, *, headers: dict[str, str] | None = None) -> None: + self.endpoint = endpoint + self.headers = dict(headers or {}) + + async def __call__( + self, + arguments: dict[str, Any], + _: ToolCallContext, + ) -> dict[str, Any]: + def request() -> dict[str, Any]: + payload = json.dumps(arguments).encode("utf-8") + headers = {"Content-Type": "application/json", **self.headers} + try: + with urllib.request.urlopen( + urllib.request.Request( + self.endpoint, + data=payload, + headers=headers, + method="POST", + ) + ) as response: + value = json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + raise ToolProviderError( + f"External Service request failed: {exc}" + ) from exc + if not isinstance(value, dict): + raise ToolProviderError( + "External Service response must be a JSON object" + ) + return value + + return await asyncio.to_thread(request) diff --git a/ai2apps/services/models.py b/ai2apps/services/models.py new file mode 100644 index 00000000..f2a175db --- /dev/null +++ b/ai2apps/services/models.py @@ -0,0 +1,189 @@ +"""Stable Service and Tool contracts for the AI2Apps Harness.""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + + +class ServiceRuntimeMode(StrEnum): + IN_PROCESS = "in_process" + MANAGED_PROCESS = "managed_process" + EXTERNAL = "external" + + +class ServiceStatus(StrEnum): + ENABLED = "enabled" + DISABLED = "disabled" + + +class ServiceInstanceStatus(StrEnum): + INSTALLED = "installed" + DISABLED = "disabled" + STARTING = "starting" + RUNNING = "running" + DEGRADED = "degraded" + STOPPING = "stopping" + STOPPED = "stopped" + RESTARTING = "restarting" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class ServiceDependency: + service_key: str + version_spec: str = "*" + optional: bool = False + + +@dataclass(frozen=True, slots=True) +class ServiceDescriptorRecord: + id: str + service_key: str + package_id: str + package_version: str + display_name: str + runtime_mode: ServiceRuntimeMode + source: str + status: ServiceStatus + capabilities: tuple[str, ...] + config: dict[str, Any] + package_digest: str | None + permissions: dict[str, Any] + dependencies: tuple[ServiceDependency, ...] + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class ServiceInstanceRecord: + id: str + service_id: str + provider_key: str + status: ServiceInstanceStatus + endpoint: str | None + health: dict[str, Any] + last_error: str | None + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class ToolDescriptorRecord: + id: str + service_id: str + qualified_name: str + display_name: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + effects: tuple[str, ...] + required_capabilities: tuple[str, ...] + capability_rules: tuple[dict[str, Any], ...] + retry_policy: dict[str, Any] + timeout_ms: int + enabled: bool + revision: int + created_at: datetime + updated_at: datetime + + +ToolProgressReporter = Callable[ + [dict[str, Any]], None | Awaitable[None] +] + + +@dataclass(frozen=True, slots=True) +class ToolCallContext: + caller_id: str + session_id: str | None = None + granted_capabilities: frozenset[str] = frozenset() + trace_id: str | None = None + invocation_id: str | None = None + progress_reporter: ToolProgressReporter | None = None + + async def report_progress( + self, + text: str, + *, + phase: str = "tool", + progress: float | None = None, + content: dict[str, Any] | None = None, + ) -> None: + if self.progress_reporter is None: + return + update = { + "phase": phase, + "text": text, + "progress": progress, + "content": content or {}, + } + value = self.progress_reporter(update) + if inspect.isawaitable(value): + await value + + +class ToolInvocationStatus(StrEnum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + INTERRUPTED = "interrupted" + + +@dataclass(frozen=True, slots=True) +class ToolInvocationRecord: + id: str + tool_id: str + qualified_name: str + provider_key: str + caller_id: str + session_id: str | None + trace_id: str | None + status: ToolInvocationStatus + arguments: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + progress: dict[str, Any] + timeout_ms: int + attempt: int + duration_ms: int | None + revision: int + created_at: datetime + updated_at: datetime + finished_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class ToolExecutionResult: + invocation_id: str + tool_id: str + qualified_name: str + provider_key: str + output: dict[str, Any] + duration_ms: int + + +class ToolGatewayError(RuntimeError): + def __init__( + self, + code: str, + message: str, + *, + retryable: bool = False, + details: dict[str, Any] | None = None, + ) -> None: + self.code = code + self.retryable = retryable + self.details = details or {} + super().__init__(message) + + +class ToolProviderError(RuntimeError): + """A bound provider returned a stable execution failure.""" diff --git a/ai2apps/services/registry.py b/ai2apps/services/registry.py new file mode 100644 index 00000000..0024e920 --- /dev/null +++ b/ai2apps/services/registry.py @@ -0,0 +1,554 @@ +"""In-memory provider bindings and the authoritative Tool gateway.""" + +from __future__ import annotations + +import asyncio +import inspect +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace +from typing import Any + +from jsonschema import Draft202012Validator, ValidationError + +from ai2apps.core import ResourceNotFoundError, RevisionConflictError +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .models import ( + ServiceInstanceStatus, + ServiceStatus, + ToolCallContext, + ToolExecutionResult, + ToolGatewayError, + ToolInvocationStatus, + ToolProviderError, +) +from .repository import ServiceRepository + +ToolHandler = Callable[ + [dict[str, Any], ToolCallContext], dict[str, Any] | Awaitable[dict[str, Any]] +] +LifecycleHandler = Callable[[], None | Awaitable[None]] +SecretResolver = Callable[[dict[str, Any], str], Any] + + +@dataclass(frozen=True, slots=True) +class BoundTool: + provider_key: str + handler: ToolHandler + + +@dataclass(frozen=True, slots=True) +class ServiceLifecycle: + start: LifecycleHandler | None = None + stop: LifecycleHandler | None = None + restart: LifecycleHandler | None = None + + +async def _await_if_needed(value): + if inspect.isawaitable(value): + return await value + return value + + +class ServiceRegistry: + """Combines durable descriptors with process-local provider authority.""" + + def __init__(self, repository: ServiceRepository) -> None: + self.repository = repository + self._handlers: dict[str, BoundTool] = {} + self._lifecycles: dict[str, ServiceLifecycle] = {} + + def bind_tool( + self, + qualified_name: str, + *, + provider_key: str, + handler: ToolHandler, + ) -> None: + tool = self.repository.get_tool(qualified_name) + instance = self.repository.get_instance_for_service(tool.service_id) + if instance.provider_key != provider_key: + raise ToolGatewayError( + "provider_identity_mismatch", + f"Provider {provider_key} does not own {qualified_name}", + ) + self._handlers[qualified_name] = BoundTool(provider_key, handler) + + def bind_lifecycle( + self, + service_key: str, + *, + lifecycle: ServiceLifecycle, + ) -> None: + service = self.repository.get_service(service_key) + self._lifecycles[service.id] = lifecycle + + def bound_tool(self, qualified_name: str) -> BoundTool | None: + return self._handlers.get(qualified_name) + + async def set_enabled( + self, + service_key: str, + *, + expected_revision: int, + enabled: bool, + ): + service = self.repository.get_service(service_key) + if service.revision != expected_revision: + raise RevisionConflictError( + service.id, + expected_revision, + service.revision, + ) + instance = self.repository.get_instance_for_service(service.id) + lifecycle = self._lifecycles.get(service.id, ServiceLifecycle()) + if enabled: + self.repository.set_instance_status( + instance.id, ServiceInstanceStatus.STARTING + ) + try: + if lifecycle.start is not None: + await _await_if_needed(lifecycle.start()) + self.repository.set_instance_status( + instance.id, ServiceInstanceStatus.RUNNING + ) + except Exception as exc: + self.repository.set_instance_status( + instance.id, + ServiceInstanceStatus.FAILED, + last_error=str(exc), + ) + raise + return self.repository.set_service_status( + service.id, + expected_revision=expected_revision, + status=ServiceStatus.ENABLED, + ) + self.repository.set_instance_status(instance.id, ServiceInstanceStatus.STOPPING) + try: + if lifecycle.stop is not None: + await _await_if_needed(lifecycle.stop()) + self.repository.set_instance_status( + instance.id, ServiceInstanceStatus.DISABLED + ) + except Exception as exc: + self.repository.set_instance_status( + instance.id, + ServiceInstanceStatus.FAILED, + last_error=str(exc), + ) + raise + return self.repository.set_service_status( + service.id, + expected_revision=expected_revision, + status=ServiceStatus.DISABLED, + ) + + async def restart(self, service_key: str) -> None: + service = self.repository.get_service(service_key) + if service.status is ServiceStatus.DISABLED: + raise ToolGatewayError( + "service_disabled", f"Service {service_key} is disabled" + ) + instance = self.repository.get_instance_for_service(service.id) + lifecycle = self._lifecycles.get(service.id, ServiceLifecycle()) + self.repository.set_instance_status( + instance.id, ServiceInstanceStatus.RESTARTING + ) + try: + if lifecycle.restart is not None: + await _await_if_needed(lifecycle.restart()) + else: + if lifecycle.stop is not None: + await _await_if_needed(lifecycle.stop()) + if lifecycle.start is not None: + await _await_if_needed(lifecycle.start()) + self.repository.set_instance_status( + instance.id, ServiceInstanceStatus.RUNNING + ) + except Exception as exc: + self.repository.set_instance_status( + instance.id, + ServiceInstanceStatus.FAILED, + last_error=str(exc), + ) + raise + + +class ToolGateway: + """Validate, authorize, route, time-bound, and audit Tool calls.""" + + def __init__( + self, + database: PlatformDatabase, + events: EventStore, + repository: ServiceRepository, + registry: ServiceRegistry, + ) -> None: + self.database = database + self.events = events + self.repository = repository + self.registry = registry + self._secret_resolver: SecretResolver | None = None + + def bind_secret_resolver(self, resolver: SecretResolver | None) -> None: + """Resolve secret:// references only after validation and authorization.""" + + self._secret_resolver = resolver + + def list_tools( + self, + context: ToolCallContext, + *, + include_requiring_approval: bool = False, + ) -> tuple: + """Return active bound Tools visible for execution or Agent planning. + + Planning may include Tools whose capabilities have not been granted yet; + execution still checks those capabilities and cannot bypass approval. + """ + + visible = [] + for tool in self.repository.list_tools(): + service = self.repository.get_service(tool.service_id) + if service.status is not ServiceStatus.ENABLED: + continue + try: + instance = self.repository.get_instance_for_service(tool.service_id) + except ResourceNotFoundError: + continue + if instance.status not in { + ServiceInstanceStatus.RUNNING, + ServiceInstanceStatus.DEGRADED, + }: + continue + if not include_requiring_approval and not set( + tool.required_capabilities + ).issubset(context.granted_capabilities): + continue + binding = self.registry.bound_tool(tool.qualified_name) + if binding is None or binding.provider_key != instance.provider_key: + continue + visible.append(tool) + return tuple(visible) + + @staticmethod + def required_capabilities(tool, arguments: dict[str, Any]) -> frozenset[str]: + """Resolve static plus declarative argument-dependent capabilities.""" + + required = set(tool.required_capabilities) + for rule in tool.capability_rules: + condition = rule.get("when", {}) + property_name = condition.get("property") + if not isinstance(property_name, str): + continue + if arguments.get(property_name) == condition.get("equals"): + values = rule.get("require", []) + if isinstance(values, list) and all(isinstance(x, str) for x in values): + required.update(values) + return frozenset(required) + + def _audit( + self, + *, + tool_id: str, + invocation_id: str, + context: ToolCallContext, + status: str, + duration_ms: int, + code: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + with self.database.transaction(write=True) as connection: + app_instance_id = None + if context.session_id is not None: + row = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (context.session_id,), + ).fetchone() + if row is None: + raise ToolGatewayError( + "session_not_found", + f"Session not found: {context.session_id}", + ) + app_instance_id = row["app_instance_id"] + event_type = { + "started": "tool.invocation.started", + "progress": "tool.invocation.progress", + "retrying": "tool.invocation.retrying", + "completed": "tool.invocation.completed", + "cancelled": "tool.invocation.cancelled", + }.get(status, "tool.invocation.failed") + self.events.append_in_transaction( + connection, + event_type=event_type, + subject_id=tool_id, + app_instance_id=app_instance_id, + session_id=context.session_id, + trace_id=context.trace_id, + payload={ + "caller_id": context.caller_id, + "invocation_id": invocation_id, + "status": status, + "duration_ms": duration_ms, + **(details or {}), + **({} if code is None else {"code": code}), + }, + ) + + async def execute( + self, + qualified_name: str, + arguments: dict[str, Any], + *, + context: ToolCallContext, + timeout_ms: int | None = None, + ) -> ToolExecutionResult: + try: + tool = self.repository.get_tool(qualified_name) + except ResourceNotFoundError as exc: + raise ToolGatewayError("tool_not_found", str(exc)) from exc + service = self.repository.get_service(tool.service_id) + instance = self.repository.get_instance_for_service(tool.service_id) + binding = self.registry.bound_tool(tool.qualified_name) + if not tool.enabled or service.status is not ServiceStatus.ENABLED: + raise ToolGatewayError( + "tool_disabled", f"Tool {qualified_name} is disabled" + ) + if instance.status not in { + ServiceInstanceStatus.RUNNING, + ServiceInstanceStatus.DEGRADED, + }: + raise ToolGatewayError( + "service_unavailable", + f"Service provider for {qualified_name} is {instance.status.value}", + retryable=True, + ) + if binding is None: + raise ToolGatewayError( + "provider_unavailable", + f"No runtime provider is bound for {qualified_name}", + retryable=True, + ) + if binding.provider_key != instance.provider_key: + raise ToolGatewayError( + "provider_identity_mismatch", + f"Bound provider does not own {qualified_name}", + ) + missing = sorted( + self.required_capabilities(tool, arguments) - context.granted_capabilities + ) + if missing: + raise ToolGatewayError( + "capability_denied", + f"Missing capabilities for {qualified_name}", + details={"missing": missing}, + ) + if context.session_id is not None: + with self.database.transaction() as connection: + session = connection.execute( + "SELECT id FROM sessions WHERE id = ? AND status = 'active'", + (context.session_id,), + ).fetchone() + if session is None: + raise ToolGatewayError( + "session_not_found", + f"Active Session not found: {context.session_id}", + ) + try: + Draft202012Validator(tool.input_schema).validate(arguments) + except ValidationError as exc: + raise ToolGatewayError( + "invalid_tool_input", + exc.message, + details={"path": list(exc.absolute_path)}, + ) from exc + + handler_arguments = arguments + sensitive_values: tuple[str, ...] = () + if self._secret_resolver is not None: + injection = self._secret_resolver(arguments, qualified_name) + handler_arguments = injection.arguments + sensitive_values = injection.sensitive_values + + def redact(value: Any) -> Any: + if isinstance(value, dict): + return {key: redact(item) for key, item in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, str): + for secret in sensitive_values: + if secret: + value = value.replace(secret, "[secret]") + return value + + effective_timeout_ms = tool.timeout_ms + if timeout_ms is not None: + if timeout_ms <= 0: + raise ToolGatewayError("invalid_timeout", "timeout_ms must be positive") + effective_timeout_ms = min(timeout_ms, tool.timeout_ms) + invocation = self.repository.create_invocation( + tool=tool, + provider_key=instance.provider_key, + caller_id=context.caller_id, + session_id=context.session_id, + trace_id=context.trace_id, + arguments=arguments, + timeout_ms=effective_timeout_ms, + ) + started = time.monotonic() + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="started", + duration_ms=0, + details={"qualified_name": qualified_name, "attempt": 1}, + ) + + async def report_progress(update: dict[str, Any]) -> None: + safe_update = redact(update) + self.repository.update_invocation_progress(invocation.id, safe_update) + duration = int((time.monotonic() - started) * 1_000) + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="progress", + duration_ms=duration, + details={"update": safe_update}, + ) + if context.progress_reporter is not None: + await _await_if_needed(context.progress_reporter(safe_update)) + + execution_context = replace( + context, + invocation_id=invocation.id, + progress_reporter=report_progress, + ) + retry_policy = tool.retry_policy + max_attempts = retry_policy.get("max_attempts", 1) + retry_codes = set(retry_policy.get("retry_codes", ())) + backoff_ms = retry_policy.get("backoff_ms", 0) + output = None + attempt = 0 + while attempt < max_attempts: + attempt += 1 + if attempt > 1: + self.repository.set_invocation_attempt(invocation.id, attempt) + error_code = None + error_message = None + retryable = False + caught: Exception | None = None + try: + async with asyncio.timeout(effective_timeout_ms / 1_000): + output = await _await_if_needed( + binding.handler(handler_arguments, execution_context) + ) + Draft202012Validator(tool.output_schema).validate(output) + break + except asyncio.CancelledError: + duration = int((time.monotonic() - started) * 1_000) + self.repository.settle_invocation( + invocation.id, + status=ToolInvocationStatus.CANCELLED, + duration_ms=duration, + error={"code": "tool_cancelled"}, + ) + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="cancelled", + duration_ms=duration, + code="tool_cancelled", + ) + raise + except TimeoutError as exc: + caught = exc + error_code = "tool_timeout" + error_message = ( + f"Tool {qualified_name} exceeded {effective_timeout_ms} ms" + ) + retryable = True + except ValidationError as exc: + caught = exc + error_code = "invalid_tool_output" + error_message = exc.message + except ToolProviderError as exc: + caught = exc + error_code = "provider_error" + error_message = redact(str(exc)) + retryable = True + except Exception as exc: + caught = exc + error_code = "provider_error" + error_message = redact(str(exc)) + retryable = True + + duration = int((time.monotonic() - started) * 1_000) + if error_code in retry_codes and attempt < max_attempts: + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="retrying", + duration_ms=duration, + code=error_code, + details={"attempt": attempt, "next_attempt": attempt + 1}, + ) + if backoff_ms: + await asyncio.sleep(backoff_ms / 1_000) + continue + self.repository.settle_invocation( + invocation.id, + status=ToolInvocationStatus.FAILED, + duration_ms=duration, + error={ + "code": error_code, + "message": error_message, + "retryable": retryable, + }, + ) + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="failed", + duration_ms=duration, + code=error_code, + details={"attempt": attempt}, + ) + raise ToolGatewayError( + error_code or "provider_error", + error_message or "Tool provider failed", + retryable=retryable, + ) from caught + + assert output is not None + output = redact(output) + duration = int((time.monotonic() - started) * 1_000) + self.repository.settle_invocation( + invocation.id, + status=ToolInvocationStatus.COMPLETED, + duration_ms=duration, + output=output, + ) + self._audit( + tool_id=tool.id, + invocation_id=invocation.id, + context=context, + status="completed", + duration_ms=duration, + details={"attempt": attempt}, + ) + return ToolExecutionResult( + invocation_id=invocation.id, + tool_id=tool.id, + qualified_name=qualified_name, + provider_key=instance.provider_key, + output=output, + duration_ms=duration, + ) diff --git a/ai2apps/services/repository.py b/ai2apps/services/repository.py new file mode 100644 index 00000000..1c73b1ab --- /dev/null +++ b/ai2apps/services/repository.py @@ -0,0 +1,866 @@ +"""Durable Service and Tool registries.""" + +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Iterable +from typing import Any + +from jsonschema import Draft202012Validator + +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .models import ( + ServiceDependency, + ServiceDescriptorRecord, + ServiceInstanceRecord, + ServiceInstanceStatus, + ServiceRuntimeMode, + ServiceStatus, + ToolDescriptorRecord, + ToolInvocationRecord, + ToolInvocationStatus, +) + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +class ServiceRepository: + def __init__(self, database: PlatformDatabase, events: EventStore) -> None: + self.database = database + self.events = events + + @staticmethod + def _dependencies(connection, service_id: str) -> tuple[ServiceDependency, ...]: + rows = connection.execute( + """ + SELECT dependency_key, version_spec, optional + FROM service_dependencies WHERE service_id = ? + ORDER BY dependency_key + """, + (service_id,), + ).fetchall() + return tuple( + ServiceDependency( + row["dependency_key"], row["version_spec"], bool(row["optional"]) + ) + for row in rows + ) + + @classmethod + def _service(cls, connection, row) -> ServiceDescriptorRecord: + return ServiceDescriptorRecord( + id=row["id"], + service_key=row["service_key"], + package_id=row["package_id"], + package_version=row["package_version"], + display_name=row["display_name"], + runtime_mode=ServiceRuntimeMode(row["execution_mode"]), + source=row["source"], + status=ServiceStatus(row["status"]), + capabilities=tuple(json.loads(row["capabilities_json"])), + config=json.loads(row["config_json"]), + package_digest=row["active_package_digest"], + permissions=json.loads(row["permissions_json"]), + dependencies=cls._dependencies(connection, row["id"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _instance(row) -> ServiceInstanceRecord: + return ServiceInstanceRecord( + id=row["id"], + service_id=row["service_id"], + provider_key=row["provider_key"], + status=ServiceInstanceStatus(row["status"]), + endpoint=row["endpoint"], + health=json.loads(row["health_json"]), + last_error=row["last_error"], + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _tool(row) -> ToolDescriptorRecord: + return ToolDescriptorRecord( + id=row["id"], + service_id=row["service_id"], + qualified_name=row["qualified_name"], + display_name=row["display_name"], + description=row["description"], + input_schema=json.loads(row["input_schema_json"]), + output_schema=json.loads(row["output_schema_json"]), + effects=tuple(json.loads(row["effects_json"])), + required_capabilities=tuple(json.loads(row["required_capabilities_json"])), + capability_rules=tuple(json.loads(row["capability_rules_json"])), + retry_policy=json.loads(row["retry_policy_json"]), + timeout_ms=row["timeout_ms"], + enabled=bool(row["enabled"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + def ensure_service( + self, + *, + service_key: str, + package_id: str, + package_version: str, + display_name: str, + runtime_mode: ServiceRuntimeMode, + source: str = "builtin", + capabilities: Iterable[str] = (), + config: dict[str, Any] | None = None, + package_digest: str | None = None, + permissions: dict[str, Any] | None = None, + dependencies: Iterable[ServiceDependency] = (), + ) -> ServiceDescriptorRecord: + now = utc_now_text() + service_id = new_entity_id(EntityIdKind.SERVICE) + capabilities_value = tuple(sorted(set(capabilities))) + dependencies_value = tuple(dependencies) + try: + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM service_descriptors WHERE service_key = ?", + (service_key,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO service_descriptors( + id, service_key, package_id, package_version, display_name, + runtime_mode, execution_mode, source, capabilities_json, + config_json, active_package_digest, permissions_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + service_id, + service_key, + package_id, + package_version, + display_name, + ( + ServiceRuntimeMode.EXTERNAL.value + if runtime_mode is ServiceRuntimeMode.MANAGED_PROCESS + else runtime_mode.value + ), + runtime_mode.value, + source, + _json(capabilities_value), + _json(config or {}), + package_digest, + _json(permissions or {}), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="service.registered", + subject_id=service_id, + payload={ + "service_key": service_key, + "runtime_mode": runtime_mode.value, + }, + ) + else: + service_id = row["id"] + if row["package_id"] != package_id: + raise ResourceConflictError( + f"Service key {service_key} is owned by {row['package_id']}" + ) + old_dependencies = self._dependencies(connection, service_id) + descriptor_changed = ( + row["package_version"] != package_version + or row["display_name"] != display_name + or row["execution_mode"] != runtime_mode.value + or row["source"] != source + or row["capabilities_json"] != _json(capabilities_value) + or row["config_json"] != _json(config or {}) + or row["active_package_digest"] != package_digest + or row["permissions_json"] != _json(permissions or {}) + or old_dependencies + != tuple( + sorted( + dependencies_value, + key=lambda dependency: dependency.service_key, + ) + ) + ) + if descriptor_changed: + connection.execute( + """ + UPDATE service_descriptors + SET package_version = ?, display_name = ?, runtime_mode = ?, + execution_mode = ?, source = ?, capabilities_json = ?, + config_json = ?, active_package_digest = ?, permissions_json = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + ( + package_version, + display_name, + ( + ServiceRuntimeMode.EXTERNAL.value + if runtime_mode + is ServiceRuntimeMode.MANAGED_PROCESS + else runtime_mode.value + ), + runtime_mode.value, + source, + _json(capabilities_value), + _json(config or {}), + package_digest, + _json(permissions or {}), + now, + service_id, + ), + ) + connection.execute( + "DELETE FROM service_dependencies WHERE service_id = ?", + (service_id,), + ) + for dependency in dependencies_value: + connection.execute( + """ + INSERT INTO service_dependencies( + service_id, dependency_key, version_spec, optional + ) VALUES (?, ?, ?, ?) + """, + ( + service_id, + dependency.service_key, + dependency.version_spec, + int(dependency.optional), + ), + ) + row = connection.execute( + "SELECT * FROM service_descriptors WHERE id = ?", (service_id,) + ).fetchone() + assert row is not None + return self._service(connection, row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_service(self, service_id_or_key: str) -> ServiceDescriptorRecord: + with self.database.transaction() as connection: + row = connection.execute( + """ + SELECT * FROM service_descriptors + WHERE id = ? OR service_key = ? + """, + (service_id_or_key, service_id_or_key), + ).fetchone() + if row is not None: + return self._service(connection, row) + raise ResourceNotFoundError("service", service_id_or_key) + + def list_services(self) -> tuple[ServiceDescriptorRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM service_descriptors ORDER BY service_key" + ).fetchall() + return tuple(self._service(connection, row) for row in rows) + + def set_service_status( + self, + service_id_or_key: str, + *, + expected_revision: int, + status: ServiceStatus, + ) -> ServiceDescriptorRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + current = connection.execute( + "SELECT * FROM service_descriptors WHERE id = ? OR service_key = ?", + (service_id_or_key, service_id_or_key), + ).fetchone() + if current is None: + raise ResourceNotFoundError("service", service_id_or_key) + if current["revision"] != expected_revision: + raise RevisionConflictError( + current["id"], expected_revision, current["revision"] + ) + connection.execute( + """ + UPDATE service_descriptors + SET status = ?, revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (status.value, now, current["id"]), + ) + row = connection.execute( + "SELECT * FROM service_descriptors WHERE id = ?", (current["id"],) + ).fetchone() + assert row is not None + self.events.append_in_transaction( + connection, + event_type=f"service.{status.value}", + subject_id=current["id"], + payload={"service_key": current["service_key"]}, + ) + return self._service(connection, row) + + def ensure_instance( + self, + *, + service_id: str, + provider_key: str, + status: ServiceInstanceStatus, + endpoint: str | None = None, + health: dict[str, Any] | None = None, + ) -> ServiceInstanceRecord: + now = utc_now_text() + instance_id = new_entity_id(EntityIdKind.SERVICE_INSTANCE) + try: + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM service_instances WHERE provider_key = ?", + (provider_key,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO service_instances( + id, service_id, provider_key, status, endpoint, + health_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + instance_id, + service_id, + provider_key, + status.value, + endpoint, + _json(health or {}), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="service.instance.registered", + subject_id=instance_id, + payload={ + "service_id": service_id, + "provider_key": provider_key, + }, + ) + else: + instance_id = row["id"] + if row["service_id"] != service_id: + raise ResourceConflictError( + f"Provider key {provider_key} is bound to another Service" + ) + connection.execute( + """ + UPDATE service_instances + SET status = ?, endpoint = ?, health_json = ?, last_error = NULL, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (status.value, endpoint, _json(health or {}), now, instance_id), + ) + row = connection.execute( + "SELECT * FROM service_instances WHERE id = ?", (instance_id,) + ).fetchone() + assert row is not None + return self._instance(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_instance_for_service(self, service_id: str) -> ServiceInstanceRecord: + with self.database.transaction() as connection: + row = connection.execute( + """ + SELECT * FROM service_instances WHERE service_id = ? + ORDER BY created_at LIMIT 1 + """, + (service_id,), + ).fetchone() + if row is None: + raise ResourceNotFoundError("service_instance", service_id) + return self._instance(row) + + def set_instance_status( + self, + instance_id: str, + status: ServiceInstanceStatus, + *, + health: dict[str, Any] | None = None, + last_error: str | None = None, + ) -> ServiceInstanceRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE service_instances + SET status = ?, health_json = COALESCE(?, health_json), last_error = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + ( + status.value, + None if health is None else _json(health), + last_error, + now, + instance_id, + ), + ) + if cursor.rowcount == 0: + raise ResourceNotFoundError("service_instance", instance_id) + row = connection.execute( + "SELECT * FROM service_instances WHERE id = ?", (instance_id,) + ).fetchone() + assert row is not None + self.events.append_in_transaction( + connection, + event_type=f"service.instance.{status.value}", + subject_id=instance_id, + payload={"service_id": row["service_id"]}, + ) + return self._instance(row) + + def ensure_tool( + self, + *, + service_id: str, + qualified_name: str, + display_name: str, + description: str, + input_schema: dict[str, Any], + output_schema: dict[str, Any], + effects: Iterable[str] = (), + required_capabilities: Iterable[str] = (), + capability_rules: Iterable[dict[str, Any]] = (), + retry_policy: dict[str, Any] | None = None, + timeout_ms: int = 30_000, + ) -> ToolDescriptorRecord: + Draft202012Validator.check_schema(input_schema) + Draft202012Validator.check_schema(output_schema) + if timeout_ms <= 0: + raise ValueError("timeout_ms must be positive") + effects_value = tuple(sorted(set(effects))) + supplied_retry_policy = retry_policy or {} + unknown_retry_fields = set(supplied_retry_policy) - { + "max_attempts", + "backoff_ms", + "retry_codes", + "allow_effect_replay", + } + if unknown_retry_fields: + raise ValueError( + f"Unknown retry_policy fields: {sorted(unknown_retry_fields)}" + ) + retry_policy_value = { + "max_attempts": 1, + "backoff_ms": 0, + "retry_codes": [], + "allow_effect_replay": False, + **supplied_retry_policy, + } + if ( + not isinstance(retry_policy_value["max_attempts"], int) + or not 1 <= retry_policy_value["max_attempts"] <= 3 + ): + raise ValueError("retry_policy.max_attempts must be between 1 and 3") + if ( + not isinstance(retry_policy_value["backoff_ms"], int) + or not 0 <= retry_policy_value["backoff_ms"] <= 5_000 + ): + raise ValueError("retry_policy.backoff_ms must be between 0 and 5000") + retry_codes = retry_policy_value["retry_codes"] + if not isinstance(retry_codes, list) or not all( + isinstance(code, str) for code in retry_codes + ): + raise ValueError("retry_policy.retry_codes must be an array of strings") + if set(retry_codes) - {"provider_error", "tool_timeout"}: + raise ValueError("retry_policy contains an unsupported retry code") + if ( + effects_value + and retry_policy_value["max_attempts"] > 1 + and retry_policy_value["allow_effect_replay"] is not True + ): + raise ValueError( + "Effectful Tool retries require retry_policy.allow_effect_replay" + ) + now = utc_now_text() + tool_id = new_entity_id(EntityIdKind.TOOL) + values = ( + display_name, + description, + _json(input_schema), + _json(output_schema), + _json(effects_value), + _json(tuple(sorted(set(required_capabilities)))), + _json(tuple(capability_rules)), + _json(retry_policy_value), + timeout_ms, + ) + try: + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM tool_descriptors WHERE qualified_name = ?", + (qualified_name,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO tool_descriptors( + id, service_id, qualified_name, display_name, description, + input_schema_json, output_schema_json, effects_json, + required_capabilities_json, capability_rules_json, + retry_policy_json, + timeout_ms, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (tool_id, service_id, qualified_name, *values, now, now), + ) + self.events.append_in_transaction( + connection, + event_type="tool.registered", + subject_id=tool_id, + payload={ + "service_id": service_id, + "qualified_name": qualified_name, + }, + ) + else: + tool_id = row["id"] + if row["service_id"] != service_id: + raise ResourceConflictError( + f"Tool name {qualified_name} is owned by another Service" + ) + current_values = ( + row["display_name"], + row["description"], + row["input_schema_json"], + row["output_schema_json"], + row["effects_json"], + row["required_capabilities_json"], + row["capability_rules_json"], + row["retry_policy_json"], + row["timeout_ms"], + ) + if current_values != values or not row["enabled"]: + connection.execute( + """ + UPDATE tool_descriptors + SET display_name = ?, description = ?, input_schema_json = ?, + output_schema_json = ?, effects_json = ?, + required_capabilities_json = ?, capability_rules_json = ?, + retry_policy_json = ?, + timeout_ms = ?, enabled = 1, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (*values, now, tool_id), + ) + row = connection.execute( + "SELECT * FROM tool_descriptors WHERE id = ?", (tool_id,) + ).fetchone() + assert row is not None + return self._tool(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + @staticmethod + def _invocation(row) -> ToolInvocationRecord: + return ToolInvocationRecord( + id=row["id"], + tool_id=row["tool_id"], + qualified_name=row["qualified_name"], + provider_key=row["provider_key"], + caller_id=row["caller_id"], + session_id=row["session_id"], + trace_id=row["trace_id"], + status=ToolInvocationStatus(row["status"]), + arguments=json.loads(row["arguments_json"]), + output=( + None if row["output_json"] is None else json.loads(row["output_json"]) + ), + error=( + None if row["error_json"] is None else json.loads(row["error_json"]) + ), + progress=json.loads(row["progress_json"]), + timeout_ms=row["timeout_ms"], + attempt=row["attempt"], + duration_ms=row["duration_ms"], + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + finished_at=( + None if row["finished_at"] is None else parse_utc(row["finished_at"]) + ), + ) + + def create_invocation( + self, + *, + tool: ToolDescriptorRecord, + provider_key: str, + caller_id: str, + session_id: str | None, + trace_id: str | None, + arguments: dict[str, Any], + timeout_ms: int, + ) -> ToolInvocationRecord: + invocation_id = new_entity_id(EntityIdKind.TOOL_INVOCATION) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """ + INSERT INTO tool_invocations( + id, tool_id, qualified_name, provider_key, caller_id, + session_id, trace_id, status, arguments_json, timeout_ms, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?) + """, + ( + invocation_id, + tool.id, + tool.qualified_name, + provider_key, + caller_id, + session_id, + trace_id, + _json(arguments), + timeout_ms, + now, + now, + ), + ) + row = connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (invocation_id,) + ).fetchone() + assert row is not None + return self._invocation(row) + + def update_invocation_progress( + self, invocation_id: str, progress: dict[str, Any] + ) -> ToolInvocationRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE tool_invocations + SET progress_json = ?, revision = revision + 1, updated_at = ? + WHERE id = ? AND status = 'running' + """, + (_json(progress), now, invocation_id), + ) + if cursor.rowcount != 1: + raise ResourceConflictError( + f"Tool invocation is not running: {invocation_id}" + ) + row = connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (invocation_id,) + ).fetchone() + assert row is not None + return self._invocation(row) + + def set_invocation_attempt( + self, invocation_id: str, attempt: int + ) -> ToolInvocationRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """ + UPDATE tool_invocations + SET attempt = ?, revision = revision + 1, updated_at = ? + WHERE id = ? AND status = 'running' + """, + (attempt, now, invocation_id), + ) + row = connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (invocation_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("tool_invocation", invocation_id) + return self._invocation(row) + + def settle_invocation( + self, + invocation_id: str, + *, + status: ToolInvocationStatus, + duration_ms: int, + output: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, + ) -> ToolInvocationRecord: + if status is ToolInvocationStatus.RUNNING: + raise ValueError("A Tool invocation cannot settle as running") + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE tool_invocations + SET status = ?, output_json = ?, error_json = ?, duration_ms = ?, + finished_at = ?, revision = revision + 1, updated_at = ? + WHERE id = ? AND status = 'running' + """, + ( + status.value, + None if output is None else _json(output), + None if error is None else _json(error), + duration_ms, + now, + now, + invocation_id, + ), + ) + if cursor.rowcount != 1: + raise ResourceConflictError( + f"Tool invocation is not running: {invocation_id}" + ) + row = connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (invocation_id,) + ).fetchone() + assert row is not None + return self._invocation(row) + + def get_invocation(self, invocation_id: str) -> ToolInvocationRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (invocation_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("tool_invocation", invocation_id) + return self._invocation(row) + + def list_invocations( + self, + *, + session_id: str | None = None, + trace_id: str | None = None, + status: ToolInvocationStatus | None = None, + limit: int = 100, + ) -> tuple[ToolInvocationRecord, ...]: + conditions = [] + params: list[Any] = [] + if session_id is not None: + conditions.append("session_id = ?") + params.append(session_id) + if trace_id is not None: + conditions.append("trace_id = ?") + params.append(trace_id) + if status is not None: + conditions.append("status = ?") + params.append(status.value) + where = "" if not conditions else "WHERE " + " AND ".join(conditions) + params.append(max(1, min(limit, 1000))) + with self.database.transaction() as connection: + rows = connection.execute( + f""" + SELECT * FROM tool_invocations {where} + ORDER BY created_at DESC LIMIT ? + """, + params, + ).fetchall() + return tuple(self._invocation(row) for row in rows) + + def recover_interrupted_invocations(self) -> tuple[ToolInvocationRecord, ...]: + now = utc_now_text() + error = _json({"code": "runtime_restarted"}) + with self.database.transaction(write=True) as connection: + rows = connection.execute( + "SELECT id FROM tool_invocations WHERE status = 'running'" + ).fetchall() + connection.execute( + """ + UPDATE tool_invocations + SET status = 'interrupted', error_json = ?, finished_at = ?, + revision = revision + 1, updated_at = ? + WHERE status = 'running' + """, + (error, now, now), + ) + recovered = [ + connection.execute( + "SELECT * FROM tool_invocations WHERE id = ?", (row["id"],) + ).fetchone() + for row in rows + ] + return tuple(self._invocation(row) for row in recovered if row is not None) + + def get_tool(self, tool_id_or_name: str) -> ToolDescriptorRecord: + with self.database.transaction() as connection: + row = connection.execute( + """ + SELECT * FROM tool_descriptors + WHERE id = ? OR qualified_name = ? + """, + (tool_id_or_name, tool_id_or_name), + ).fetchone() + if row is None: + raise ResourceNotFoundError("tool", tool_id_or_name) + return self._tool(row) + + def list_tools( + self, *, include_disabled: bool = False + ) -> tuple[ToolDescriptorRecord, ...]: + condition = "" if include_disabled else "WHERE enabled = 1" + with self.database.transaction() as connection: + rows = connection.execute( + f"SELECT * FROM tool_descriptors {condition} ORDER BY qualified_name" + ).fetchall() + return tuple(self._tool(row) for row in rows) + + def disable_unseen_tools(self, service_id: str, active_names: set[str]) -> None: + with self.database.transaction(write=True) as connection: + rows = connection.execute( + "SELECT id, qualified_name FROM tool_descriptors WHERE service_id = ?", + (service_id,), + ).fetchall() + for row in rows: + if row["qualified_name"] not in active_names: + connection.execute( + "UPDATE tool_descriptors SET enabled = 0 WHERE id = ?", + (row["id"],), + ) + + def remove_service(self, service_id_or_key: str) -> None: + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT id, service_key FROM service_descriptors WHERE id = ? OR service_key = ?", + (service_id_or_key, service_id_or_key), + ).fetchone() + if row is None: + raise ResourceNotFoundError("service", service_id_or_key) + connection.execute( + "DELETE FROM tool_descriptors WHERE service_id = ?", (row["id"],) + ) + connection.execute( + "DELETE FROM service_instances WHERE service_id = ?", (row["id"],) + ) + connection.execute( + "DELETE FROM service_dependencies WHERE service_id = ?", (row["id"],) + ) + connection.execute( + "DELETE FROM service_descriptors WHERE id = ?", (row["id"],) + ) + self.events.append_in_transaction( + connection, + event_type="service.uninstalled", + subject_id=row["id"], + payload={"service_key": row["service_key"]}, + ) diff --git a/ai2apps/storage/__init__.py b/ai2apps/storage/__init__.py new file mode 100644 index 00000000..8b4d3861 --- /dev/null +++ b/ai2apps/storage/__init__.py @@ -0,0 +1,51 @@ +"""Durable storage primitives for the AI2Apps platform.""" + +from .database import ( + DatabaseBackupState, + DatabaseDiagnostics, + DatabaseState, + PlatformDatabase, +) +from .migrations import ( + DatabaseBusyError, + DatabaseCorruptionError, + FutureSchemaError, + MigrationError, +) +from .models import ( + AppDefinitionRecord, + AppendMessageResult, + AppInstanceRecord, + BuiltinChatRecord, + ChatCollectionRecord, + ChatThreadRecord, + EventRecord, + MessagePartInput, + MessagePartRecord, + MessageRecord, + MessageWithParts, + SessionRecord, +) + +__all__ = [ + "DatabaseCorruptionError", + "DatabaseBusyError", + "DatabaseBackupState", + "DatabaseDiagnostics", + "DatabaseState", + "AppendMessageResult", + "AppDefinitionRecord", + "AppInstanceRecord", + "BuiltinChatRecord", + "ChatCollectionRecord", + "ChatThreadRecord", + "EventRecord", + "FutureSchemaError", + "MigrationError", + "MessagePartInput", + "MessagePartRecord", + "MessageRecord", + "MessageWithParts", + "PlatformDatabase", + "SessionRecord", +] diff --git a/ai2apps/storage/database.py b/ai2apps/storage/database.py new file mode 100644 index 00000000..6ddecacc --- /dev/null +++ b/ai2apps/storage/database.py @@ -0,0 +1,243 @@ +"""SQLite connection and bootstrap boundary for AI2Apps platform state.""" + +from __future__ import annotations + +import logging +import os +import sqlite3 +import tempfile +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from threading import Lock + +from ai2apps.config import PLATFORM_DATABASE_SCHEMA_VERSION +from ai2apps.storage.migrations import ( + DatabaseBusyError, + DatabaseCorruptionError, + apply_migrations, +) + +DEFAULT_BUSY_TIMEOUT_MS = 5_000 +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class DatabaseState: + """Observed state after a successful database bootstrap.""" + + path: Path + schema_version: int + journal_mode: str + + +@dataclass(frozen=True, slots=True) +class DatabaseDiagnostics: + """Read-only operator diagnostics for the live SQLite database.""" + + path: Path + schema_version: int + journal_mode: str + quick_check: str + foreign_key_violations: int + page_count: int + page_size: int + + +@dataclass(frozen=True, slots=True) +class DatabaseBackupState: + """Validated snapshot produced by SQLite's online backup API.""" + + source_path: Path + destination_path: Path + schema_version: int + quick_check: str + + +class PlatformDatabase: + """Own SQLite setup while keeping transactions explicit and short-lived.""" + + def __init__( + self, + path: str | Path, + *, + busy_timeout_ms: int = DEFAULT_BUSY_TIMEOUT_MS, + ) -> None: + self.path = Path(path).expanduser().resolve() + self.busy_timeout_ms = busy_timeout_ms + self._commit_hooks: dict[int, list[Callable[[], None]]] = {} + self._commit_hooks_lock = Lock() + + def after_commit( + self, + connection: sqlite3.Connection, + callback: Callable[[], None], + ) -> None: + """Register a non-throwing notification callback for this transaction.""" + + with self._commit_hooks_lock: + self._commit_hooks.setdefault(id(connection), []).append(callback) + + def _take_commit_hooks(self, connection: sqlite3.Connection): + with self._commit_hooks_lock: + return self._commit_hooks.pop(id(connection), []) + + def connect(self) -> sqlite3.Connection: + """Open a configured connection; callers own its transaction and close.""" + + connection = sqlite3.connect( + self.path, + timeout=self.busy_timeout_ms / 1_000, + isolation_level=None, + ) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}") + return connection + + @contextmanager + def transaction(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: + """Open one explicit short transaction and always close its connection.""" + + connection = self.connect() + connection.row_factory = sqlite3.Row + try: + connection.execute("BEGIN IMMEDIATE" if write else "BEGIN") + yield connection + connection.commit() + for callback in self._take_commit_hooks(connection): + try: + callback() + except Exception: + logger.exception("AI2Apps post-commit notification failed") + except Exception: + connection.rollback() + self._take_commit_hooks(connection) + raise + finally: + connection.close() + + def _enable_wal(self, connection: sqlite3.Connection) -> str: + """Enable WAL with bounded retry for SQLite's journal-mode lock gap.""" + + deadline = time.monotonic() + self.busy_timeout_ms / 1_000 + while True: + try: + row = connection.execute("PRAGMA journal_mode = WAL").fetchone() + return str(row[0]).lower() if row else "" + except sqlite3.OperationalError as exc: + if "locked" not in str(exc).lower(): + raise + if time.monotonic() >= deadline: + raise DatabaseBusyError( + "Platform database remained locked during WAL setup" + ) from exc + time.sleep(0.025) + + def initialize(self) -> DatabaseState: + """Create the managed directory, verify SQLite, and migrate to latest.""" + + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = self.connect() + try: + journal_mode = self._enable_wal(connection) + connection.execute("PRAGMA synchronous = NORMAL") + quick_check = connection.execute("PRAGMA quick_check").fetchone() + if quick_check is None or str(quick_check[0]).lower() != "ok": + detail = "unknown" if quick_check is None else str(quick_check[0]) + raise DatabaseCorruptionError( + f"Platform database integrity check failed: {detail}" + ) + schema_version = apply_migrations(connection) + except sqlite3.OperationalError as exc: + if "locked" in str(exc).lower(): + raise DatabaseBusyError( + "Platform database remained locked during startup" + ) from exc + raise DatabaseCorruptionError( + f"Platform database could not be read safely: {exc}" + ) from exc + except sqlite3.DatabaseError as exc: + raise DatabaseCorruptionError( + f"Platform database could not be read safely: {exc}" + ) from exc + finally: + connection.close() + + if schema_version != PLATFORM_DATABASE_SCHEMA_VERSION: + raise RuntimeError( + "Migration target does not match PLATFORM_DATABASE_SCHEMA_VERSION" + ) + return DatabaseState( + path=self.path, + schema_version=schema_version, + journal_mode=journal_mode, + ) + + def diagnose(self) -> DatabaseDiagnostics: + """Inspect integrity and schema state without mutating the database.""" + + with self.connect() as connection: + quick_check_row = connection.execute("PRAGMA quick_check").fetchone() + quick_check = ( + "unknown" if quick_check_row is None else str(quick_check_row[0]) + ) + foreign_key_violations = len( + connection.execute("PRAGMA foreign_key_check").fetchall() + ) + schema_version = int( + connection.execute("PRAGMA user_version").fetchone()[0] + ) + journal_mode = str( + connection.execute("PRAGMA journal_mode").fetchone()[0] + ).lower() + page_count = int(connection.execute("PRAGMA page_count").fetchone()[0]) + page_size = int(connection.execute("PRAGMA page_size").fetchone()[0]) + return DatabaseDiagnostics( + path=self.path, + schema_version=schema_version, + journal_mode=journal_mode, + quick_check=quick_check, + foreign_key_violations=foreign_key_violations, + page_count=page_count, + page_size=page_size, + ) + + def backup(self, destination: str | Path) -> DatabaseBackupState: + """Create, validate, then atomically publish an online SQLite backup.""" + + destination_path = Path(destination).expanduser().resolve() + if destination_path == self.path: + raise ValueError("Backup destination must differ from the live database") + destination_path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination_path.name}.", + suffix=".tmp", + dir=destination_path.parent, + ) + os.close(descriptor) + temporary_path = Path(temporary_name) + try: + with self.connect() as source, sqlite3.connect(temporary_path) as target: + source.backup(target) + quick_check_row = target.execute("PRAGMA quick_check").fetchone() + quick_check = ( + "unknown" if quick_check_row is None else str(quick_check_row[0]) + ) + schema_version = int( + target.execute("PRAGMA user_version").fetchone()[0] + ) + if quick_check.lower() != "ok": + raise DatabaseCorruptionError( + f"Platform backup integrity check failed: {quick_check}" + ) + os.replace(temporary_path, destination_path) + finally: + temporary_path.unlink(missing_ok=True) + return DatabaseBackupState( + source_path=self.path, + destination_path=destination_path, + schema_version=schema_version, + quick_check=quick_check, + ) diff --git a/ai2apps/storage/migrations.py b/ai2apps/storage/migrations.py new file mode 100644 index 00000000..d4bd23c0 --- /dev/null +++ b/ai2apps/storage/migrations.py @@ -0,0 +1,2125 @@ +"""Ordered, transactional migrations for the AI2Apps platform database.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Sequence +from dataclasses import dataclass + +from ai2apps.core import utc_now_text + + +class MigrationError(RuntimeError): + """Base class for migration and schema compatibility failures.""" + + +class FutureSchemaError(MigrationError): + """The database was written by a newer AI2Apps schema version.""" + + +class DatabaseCorruptionError(MigrationError): + """SQLite or the migration ledger reported inconsistent durable state.""" + + +class DatabaseBusyError(MigrationError): + """The database remained locked beyond the configured startup timeout.""" + + +@dataclass(frozen=True, slots=True) +class Migration: + """One immutable, ordered database migration.""" + + version: int + name: str + statements: tuple[str, ...] = () + + +MIGRATIONS: tuple[Migration, ...] = ( + Migration(version=1, name="platform_bootstrap"), + Migration( + version=2, + name="apps_sessions_messages_events", + statements=( + """ + CREATE TABLE app_definitions ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'app_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + package_id TEXT NOT NULL CHECK (length(package_id) > 0), + package_version TEXT NOT NULL CHECK (length(package_version) > 0), + display_name TEXT NOT NULL CHECK (length(display_name) > 0), + instance_mode TEXT NOT NULL + CHECK (instance_mode IN ('multiple', 'singleton')), + singleton_scope TEXT + CHECK (singleton_scope IN ('system', 'user', 'session')), + source TEXT NOT NULL + CHECK (source IN ('builtin', 'local', 'installed')), + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + manifest_schema_version INTEGER NOT NULL DEFAULT 1 + CHECK (manifest_schema_version >= 1), + manifest_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(manifest_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + UNIQUE (package_id, package_version), + CHECK ( + (instance_mode = 'multiple' AND singleton_scope IS NULL) + OR + (instance_mode = 'singleton' AND singleton_scope IS NOT NULL) + ) + ) + """, + """ + CREATE TABLE app_instances ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'appi_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + app_definition_id TEXT NOT NULL, + singleton_key TEXT UNIQUE, + status TEXT NOT NULL DEFAULT 'creating' + CHECK (status IN ( + 'creating', 'active', 'background', 'suspended', + 'closed', 'degraded', 'failed' + )), + state_schema_version INTEGER NOT NULL DEFAULT 1 + CHECK (state_schema_version >= 1), + state_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(state_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + closed_at TEXT + CHECK ( + closed_at IS NULL OR + (length(closed_at) = 27 AND substr(closed_at, -1) = 'Z') + ), + FOREIGN KEY (app_definition_id) + REFERENCES app_definitions(id) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_app_instances_definition + ON app_instances(app_definition_id, status) + """, + """ + CREATE TRIGGER app_instances_policy_insert + BEFORE INSERT ON app_instances + WHEN ( + SELECT instance_mode = 'singleton' + AND NEW.singleton_key IS NULL + FROM app_definitions WHERE id = NEW.app_definition_id + ) OR ( + SELECT instance_mode = 'multiple' + AND NEW.singleton_key IS NOT NULL + FROM app_definitions WHERE id = NEW.app_definition_id + ) + BEGIN + SELECT RAISE(ABORT, 'app instance key violates definition policy'); + END + """, + """ + CREATE TRIGGER app_instances_policy_update + BEFORE UPDATE OF app_definition_id, singleton_key ON app_instances + WHEN ( + SELECT instance_mode = 'singleton' + AND NEW.singleton_key IS NULL + FROM app_definitions WHERE id = NEW.app_definition_id + ) OR ( + SELECT instance_mode = 'multiple' + AND NEW.singleton_key IS NOT NULL + FROM app_definitions WHERE id = NEW.app_definition_id + ) + BEGIN + SELECT RAISE(ABORT, 'app instance key violates definition policy'); + END + """, + """ + CREATE TRIGGER app_definition_policy_update + BEFORE UPDATE OF instance_mode, singleton_scope ON app_definitions + WHEN EXISTS ( + SELECT 1 FROM app_instances + WHERE app_definition_id = OLD.id + ) AND ( + NEW.instance_mode != OLD.instance_mode + OR NEW.singleton_scope IS NOT OLD.singleton_scope + ) + BEGIN + SELECT RAISE(ABORT, 'cannot change instance policy with instances'); + END + """, + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'ses_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + app_instance_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'archived', 'deleted')), + is_home INTEGER NOT NULL DEFAULT 0 CHECK (is_home IN (0, 1)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + archived_at TEXT + CHECK ( + archived_at IS NULL OR + (length(archived_at) = 27 AND substr(archived_at, -1) = 'Z') + ), + deleted_at TEXT + CHECK ( + deleted_at IS NULL OR + (length(deleted_at) = 27 AND substr(deleted_at, -1) = 'Z') + ), + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT + ) + """, + """ + CREATE UNIQUE INDEX uq_sessions_home_per_instance + ON sessions(app_instance_id) WHERE is_home = 1 + """, + """ + CREATE INDEX idx_sessions_instance_status_updated + ON sessions(app_instance_id, status, updated_at DESC) + """, + """ + CREATE TABLE messages ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'msg_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + role TEXT NOT NULL + CHECK (role IN ('user', 'assistant', 'system', 'tool', 'app')), + status TEXT NOT NULL DEFAULT 'completed' + CHECK (status IN ('in_progress', 'completed', 'failed', 'cancelled')), + idempotency_key TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + UNIQUE (session_id, sequence) + ) + """, + """ + CREATE UNIQUE INDEX uq_messages_session_idempotency + ON messages(session_id, idempotency_key) + WHERE idempotency_key IS NOT NULL + """, + """ + CREATE INDEX idx_messages_session_created + ON messages(session_id, sequence) + """, + """ + CREATE TABLE message_parts ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'part_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + message_id TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + kind TEXT NOT NULL CHECK (length(kind) > 0), + content_json TEXT NOT NULL CHECK (json_valid(content_json)), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + FOREIGN KEY (message_id) + REFERENCES messages(id) ON DELETE RESTRICT, + UNIQUE (message_id, position) + ) + """, + """ + CREATE INDEX idx_message_parts_message_position + ON message_parts(message_id, position) + """, + """ + CREATE TABLE events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'evt_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + type TEXT NOT NULL CHECK (length(type) > 0), + occurred_at TEXT NOT NULL + CHECK (length(occurred_at) = 27 AND substr(occurred_at, -1) = 'Z'), + app_instance_id TEXT, + session_id TEXT, + subject_id TEXT NOT NULL CHECK (length(subject_id) > 0), + trace_id TEXT, + schema_version INTEGER NOT NULL DEFAULT 1 + CHECK (schema_version >= 1), + payload_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(payload_json)), + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + CHECK (session_id IS NULL OR app_instance_id IS NOT NULL) + ) + """, + """ + CREATE INDEX idx_events_session_sequence + ON events(session_id, sequence) + """, + """ + CREATE INDEX idx_events_instance_sequence + ON events(app_instance_id, sequence) + """, + """ + CREATE INDEX idx_events_type_sequence + ON events(type, sequence) + """, + """ + CREATE TRIGGER events_scope_matches_session_insert + BEFORE INSERT ON events + WHEN NEW.session_id IS NOT NULL + AND NEW.app_instance_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sessions + WHERE id = NEW.session_id + AND app_instance_id = NEW.app_instance_id + ) + BEGIN + SELECT RAISE(ABORT, 'event scope does not own session'); + END + """, + """ + CREATE TRIGGER events_are_append_only_update + BEFORE UPDATE ON events + BEGIN + SELECT RAISE(ABORT, 'events are append-only'); + END + """, + """ + CREATE TRIGGER events_are_append_only_delete + BEFORE DELETE ON events + BEGIN + SELECT RAISE(ABORT, 'events are append-only'); + END + """, + ), + ), + Migration( + version=3, + name="generic_session_classification", + statements=( + """ + ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'app' + CHECK (session_kind IN ( + 'app', 'chat_thread', 'mini_chat', 'in_app_chat', 'agent_child' + )) + """, + """ + ALTER TABLE sessions ADD COLUMN visibility TEXT NOT NULL DEFAULT 'listed' + CHECK (visibility IN ('listed', 'unlisted')) + """, + """ + ALTER TABLE sessions ADD COLUMN retention TEXT NOT NULL DEFAULT 'durable' + CHECK (retention IN ('durable', 'temporary')) + """, + """ + ALTER TABLE sessions ADD COLUMN expires_at TEXT + CHECK ( + expires_at IS NULL OR + (length(expires_at) = 27 AND substr(expires_at, -1) = 'Z') + ) + """, + """ + CREATE INDEX idx_sessions_collection + ON sessions( + app_instance_id, session_kind, visibility, retention, + status, updated_at DESC + ) + """, + """ + CREATE TRIGGER chat_threads_are_listed_durable_insert + BEFORE INSERT ON sessions + WHEN NEW.session_kind = 'chat_thread' + AND (NEW.visibility != 'listed' OR NEW.retention != 'durable') + BEGIN + SELECT RAISE(ABORT, 'chat threads must be listed and durable'); + END + """, + """ + CREATE TRIGGER chat_threads_are_listed_durable_update + BEFORE UPDATE OF session_kind, visibility, retention ON sessions + WHEN NEW.session_kind = 'chat_thread' + AND (NEW.visibility != 'listed' OR NEW.retention != 'durable') + BEGIN + SELECT RAISE(ABORT, 'chat threads must be listed and durable'); + END + """, + ), + ), + Migration( + version=4, + name="temporary_session_retention", + statements=( + """ + UPDATE sessions + SET expires_at = + strftime('%Y-%m-%dT%H:%M:%f', updated_at, '+1 day') || '000Z' + WHERE retention = 'temporary' AND expires_at IS NULL + """, + """ + CREATE INDEX idx_sessions_temporary_expiry + ON sessions(expires_at, id) + WHERE retention = 'temporary' AND status != 'deleted' + """, + """ + CREATE TRIGGER session_retention_expiry_insert + BEFORE INSERT ON sessions + WHEN (NEW.retention = 'temporary' AND NEW.expires_at IS NULL) + OR (NEW.retention = 'durable' AND NEW.expires_at IS NOT NULL) + BEGIN + SELECT RAISE(ABORT, 'session expiry violates retention policy'); + END + """, + """ + CREATE TRIGGER session_retention_expiry_update + BEFORE UPDATE OF retention, expires_at ON sessions + WHEN (NEW.retention = 'temporary' AND NEW.expires_at IS NULL) + OR (NEW.retention = 'durable' AND NEW.expires_at IS NOT NULL) + BEGIN + SELECT RAISE(ABORT, 'session expiry violates retention policy'); + END + """, + ), + ), + Migration( + version=5, + name="singleton_chat_collection", + statements=( + """ + CREATE TABLE chat_collections ( + app_instance_id TEXT PRIMARY KEY, + selected_session_id TEXT, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY (selected_session_id) + REFERENCES sessions(id) ON DELETE RESTRICT + ) + """, + """ + CREATE TABLE chat_thread_entries ( + session_id TEXT PRIMARY KEY, + app_instance_id TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), + sort_order INTEGER NOT NULL CHECK (sort_order >= 1), + legacy_thread_id TEXT UNIQUE, + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (app_instance_id) + REFERENCES chat_collections(app_instance_id) ON DELETE RESTRICT, + UNIQUE (app_instance_id, sort_order) + ) + """, + """ + CREATE INDEX idx_chat_threads_collection + ON chat_thread_entries(app_instance_id, pinned DESC, sort_order DESC) + """, + """ + CREATE TRIGGER chat_collection_selected_insert + BEFORE INSERT ON chat_collections + WHEN NEW.selected_session_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM sessions + WHERE id = NEW.selected_session_id + AND app_instance_id = NEW.app_instance_id + AND session_kind = 'chat_thread' + AND status = 'active' + ) + BEGIN + SELECT RAISE(ABORT, 'selected Chat thread is not active or owned'); + END + """, + """ + CREATE TRIGGER chat_collection_selected_update + BEFORE UPDATE OF selected_session_id ON chat_collections + WHEN NEW.selected_session_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM sessions + WHERE id = NEW.selected_session_id + AND app_instance_id = NEW.app_instance_id + AND session_kind = 'chat_thread' + AND status = 'active' + ) + BEGIN + SELECT RAISE(ABORT, 'selected Chat thread is not active or owned'); + END + """, + """ + CREATE TRIGGER chat_thread_entry_insert + BEFORE INSERT ON chat_thread_entries + WHEN NOT EXISTS ( + SELECT 1 FROM sessions + WHERE id = NEW.session_id + AND app_instance_id = NEW.app_instance_id + AND session_kind = 'chat_thread' + AND visibility = 'listed' + AND retention = 'durable' + ) + BEGIN + SELECT RAISE(ABORT, 'Chat entry must reference an owned Chat thread'); + END + """, + """ + CREATE TRIGGER chat_thread_classification_update + BEFORE UPDATE OF app_instance_id, session_kind, visibility, retention + ON sessions + WHEN EXISTS ( + SELECT 1 FROM chat_thread_entries WHERE session_id = OLD.id + ) AND ( + NEW.app_instance_id != OLD.app_instance_id + OR NEW.session_kind != 'chat_thread' + OR NEW.visibility != 'listed' + OR NEW.retention != 'durable' + ) + BEGIN + SELECT RAISE(ABORT, 'managed Chat thread classification is immutable'); + END + """, + """ + CREATE TRIGGER selected_chat_thread_status_update + BEFORE UPDATE OF status ON sessions + WHEN NEW.status != 'active' AND EXISTS ( + SELECT 1 FROM chat_collections + WHERE selected_session_id = OLD.id + ) + BEGIN + SELECT RAISE(ABORT, 'selected Chat thread must be reassigned first'); + END + """, + """ + CREATE TRIGGER home_chat_thread_status_update + BEFORE UPDATE OF status ON sessions + WHEN NEW.status != 'active' AND OLD.is_home = 1 AND EXISTS ( + SELECT 1 FROM chat_thread_entries WHERE session_id = OLD.id + ) + BEGIN + SELECT RAISE(ABORT, 'Home Chat thread must be reassigned first'); + END + """, + ), + ), + Migration( + version=6, + name="service_and_tool_registry", + statements=( + """ + CREATE TABLE service_descriptors ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'svc_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + service_key TEXT NOT NULL UNIQUE CHECK (length(service_key) > 0), + package_id TEXT NOT NULL CHECK (length(package_id) > 0), + package_version TEXT NOT NULL CHECK (length(package_version) > 0), + display_name TEXT NOT NULL CHECK (length(display_name) > 0), + runtime_mode TEXT NOT NULL + CHECK (runtime_mode IN ('in_process', 'external')), + source TEXT NOT NULL + CHECK (source IN ('builtin', 'local', 'installed')), + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + capabilities_json TEXT NOT NULL DEFAULT '[]' + CHECK ( + json_valid(capabilities_json) + AND json_type(capabilities_json) = 'array' + ), + config_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(config_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + UNIQUE (package_id, package_version) + ) + """, + """ + CREATE TABLE service_dependencies ( + service_id TEXT NOT NULL, + dependency_key TEXT NOT NULL CHECK (length(dependency_key) > 0), + version_spec TEXT NOT NULL DEFAULT '*' CHECK (length(version_spec) > 0), + optional INTEGER NOT NULL DEFAULT 0 CHECK (optional IN (0, 1)), + PRIMARY KEY (service_id, dependency_key), + FOREIGN KEY (service_id) + REFERENCES service_descriptors(id) ON DELETE CASCADE, + CHECK (dependency_key != '') + ) + """, + """ + CREATE TABLE service_instances ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'svci_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + service_id TEXT NOT NULL, + provider_key TEXT NOT NULL UNIQUE CHECK (length(provider_key) > 0), + status TEXT NOT NULL DEFAULT 'installed' + CHECK (status IN ( + 'installed', 'disabled', 'starting', 'running', + 'degraded', 'stopping', 'stopped', 'restarting', 'failed' + )), + endpoint TEXT, + health_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(health_json)), + last_error TEXT, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (service_id) + REFERENCES service_descriptors(id) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_service_instances_service_status + ON service_instances(service_id, status) + """, + """ + CREATE TABLE tool_descriptors ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'tool_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + service_id TEXT NOT NULL, + qualified_name TEXT NOT NULL UNIQUE + CHECK (length(qualified_name) > 0), + display_name TEXT NOT NULL CHECK (length(display_name) > 0), + description TEXT NOT NULL DEFAULT '', + input_schema_json TEXT NOT NULL + CHECK (json_valid(input_schema_json)), + output_schema_json TEXT NOT NULL + CHECK (json_valid(output_schema_json)), + effects_json TEXT NOT NULL DEFAULT '[]' + CHECK ( + json_valid(effects_json) + AND json_type(effects_json) = 'array' + ), + required_capabilities_json TEXT NOT NULL DEFAULT '[]' + CHECK ( + json_valid(required_capabilities_json) + AND json_type(required_capabilities_json) = 'array' + ), + timeout_ms INTEGER NOT NULL DEFAULT 30000 CHECK (timeout_ms > 0), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (service_id) + REFERENCES service_descriptors(id) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_tools_service_enabled + ON tool_descriptors(service_id, enabled, qualified_name) + """, + ), + ), + Migration( + version=7, + name="asynchronous_agent_runtime", + statements=( + """ + CREATE TABLE agent_concurrency_groups ( + group_key TEXT PRIMARY KEY CHECK (length(group_key) > 0), + concurrency_limit INTEGER NOT NULL CHECK (concurrency_limit > 0), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z') + ) + """, + """ + CREATE TABLE agent_definitions ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'agt_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + agent_key TEXT NOT NULL UNIQUE CHECK (length(agent_key) > 0), + package_version TEXT NOT NULL CHECK (length(package_version) > 0), + display_name TEXT NOT NULL CHECK (length(display_name) > 0), + description TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL + CHECK (source IN ('builtin', 'local', 'installed')), + status TEXT NOT NULL DEFAULT 'enabled' + CHECK (status IN ('enabled', 'disabled')), + executor_key TEXT NOT NULL CHECK (length(executor_key) > 0), + concurrency_group TEXT, + resume_policy TEXT NOT NULL DEFAULT 'restart' + CHECK (resume_policy IN ('restart', 'fail')), + max_steps INTEGER NOT NULL DEFAULT 20 CHECK (max_steps > 0), + timeout_seconds INTEGER NOT NULL DEFAULT 300 + CHECK (timeout_seconds > 0), + manifest_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(manifest_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (concurrency_group) + REFERENCES agent_concurrency_groups(group_key) ON DELETE RESTRICT + ) + """, + """ + CREATE TABLE agent_runs ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'run_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + agent_definition_id TEXT NOT NULL, + session_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ( + 'queued', 'planning', 'running', 'waiting_input', + 'waiting_capability', 'interrupted', 'completed', + 'failed', 'cancelled' + )), + idempotency_key TEXT, + priority INTEGER NOT NULL DEFAULT 0, + input_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(input_json)), + output_json TEXT CHECK (output_json IS NULL OR json_valid(output_json)), + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + granted_capabilities_json TEXT NOT NULL DEFAULT '[]' + CHECK ( + json_valid(granted_capabilities_json) + AND json_type(granted_capabilities_json) = 'array' + ), + current_step INTEGER NOT NULL DEFAULT 0 CHECK (current_step >= 0), + cancel_requested INTEGER NOT NULL DEFAULT 0 + CHECK (cancel_requested IN (0, 1)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + deadline_at TEXT NOT NULL + CHECK (length(deadline_at) = 27 AND substr(deadline_at, -1) = 'Z'), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + started_at TEXT + CHECK ( + started_at IS NULL OR + (length(started_at) = 27 AND substr(started_at, -1) = 'Z') + ), + finished_at TEXT + CHECK ( + finished_at IS NULL OR + (length(finished_at) = 27 AND substr(finished_at, -1) = 'Z') + ), + FOREIGN KEY (agent_definition_id) + REFERENCES agent_definitions(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT + ) + """, + """ + CREATE UNIQUE INDEX uq_agent_runs_session_idempotency + ON agent_runs(session_id, idempotency_key) + WHERE idempotency_key IS NOT NULL + """, + """ + CREATE INDEX idx_agent_runs_dispatch + ON agent_runs(status, priority DESC, created_at, id) + """, + """ + CREATE INDEX idx_agent_runs_session_created + ON agent_runs(session_id, created_at DESC) + """, + """ + CREATE TABLE agent_status_lines ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'stl_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + run_id TEXT NOT NULL, + status_key TEXT NOT NULL DEFAULT 'primary', + phase TEXT NOT NULL CHECK (length(phase) > 0), + text TEXT NOT NULL, + presentation TEXT NOT NULL DEFAULT 'plain' + CHECK (presentation IN ( + 'plain', 'pulse', 'progress', 'indeterminate', + 'warning', 'error', 'safe_html', 'sandbox_html' + )), + progress REAL CHECK (progress IS NULL OR (progress >= 0 AND progress <= 1)), + content_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(content_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE, + UNIQUE (run_id, status_key) + ) + """, + """ + CREATE TABLE run_steps ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'step_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + run_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + action_key TEXT NOT NULL CHECK (length(action_key) > 0), + kind TEXT NOT NULL + CHECK (kind IN ('model', 'tool', 'interaction', 'internal')), + status TEXT NOT NULL + CHECK (status IN ( + 'pending', 'running', 'completed', 'failed', + 'cancelled', 'uncertain' + )), + tool_name TEXT, + input_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(input_json)), + output_json TEXT CHECK (output_json IS NULL OR json_valid(output_json)), + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + started_at TEXT, + finished_at TEXT, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE, + UNIQUE (run_id, sequence), + UNIQUE (run_id, action_key) + ) + """, + """ + CREATE TABLE agent_interactions ( + id TEXT PRIMARY KEY + CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'int_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + run_id TEXT NOT NULL, + request_key TEXT NOT NULL CHECK (length(request_key) > 0), + kind TEXT NOT NULL + CHECK (kind IN ('text', 'menu', 'file', 'form', 'approval')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ( + 'pending', 'submitted', 'approved', 'denied', + 'expired', 'cancelled' + )), + prompt TEXT NOT NULL, + response_schema_json TEXT NOT NULL + CHECK (json_valid(response_schema_json)), + ui_hints_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(ui_hints_json)), + request_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(request_json)), + response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)), + response_id TEXT, + deadline_at TEXT NOT NULL + CHECK (length(deadline_at) = 27 AND substr(deadline_at, -1) = 'Z'), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL + CHECK (length(created_at) = 27 AND substr(created_at, -1) = 'Z'), + updated_at TEXT NOT NULL + CHECK (length(updated_at) = 27 AND substr(updated_at, -1) = 'Z'), + resolved_at TEXT, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE, + UNIQUE (run_id, request_key), + UNIQUE (run_id, response_id) + ) + """, + """ + CREATE UNIQUE INDEX uq_agent_interactions_one_pending + ON agent_interactions(run_id) WHERE status = 'pending' + """, + """ + CREATE TRIGGER agent_run_status_transition + BEFORE UPDATE OF status ON agent_runs + WHEN NEW.status != OLD.status AND NOT ( + (OLD.status = 'queued' AND NEW.status IN ('planning', 'cancelled')) OR + (OLD.status = 'planning' AND NEW.status IN ( + 'running', 'queued', 'failed', 'cancelled' + )) OR + (OLD.status = 'running' AND NEW.status IN ( + 'queued', 'waiting_input', 'waiting_capability', + 'interrupted', 'completed', 'failed', 'cancelled' + )) OR + (OLD.status IN ('waiting_input', 'waiting_capability') + AND NEW.status IN ('queued', 'failed', 'cancelled')) OR + (OLD.status = 'interrupted' + AND NEW.status IN ('queued', 'failed', 'cancelled')) + ) + BEGIN + SELECT RAISE(ABORT, 'invalid AgentRun status transition'); + END + """, + ), + ), + Migration( + version=8, + name="capability_policy_and_grant_leases", + statements=( + """ + CREATE TABLE capability_policies ( + id TEXT PRIMARY KEY, + policy_key TEXT NOT NULL UNIQUE, + effect TEXT NOT NULL + CHECK (effect IN ('allow', 'deny', 'require_approval')), + capability_pattern TEXT NOT NULL, + agent_pattern TEXT NOT NULL DEFAULT '*', + tool_pattern TEXT NOT NULL DEFAULT '*', + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + source TEXT NOT NULL DEFAULT 'local' + CHECK (source IN ('builtin', 'local', 'installed', 'ai_auditor')), + conditions_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(conditions_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE INDEX idx_capability_policies_evaluate + ON capability_policies(enabled, priority DESC) + """, + """ + CREATE TABLE grant_leases ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL + CHECK (scope IN ('run', 'session', 'agent', 'app')), + scope_id TEXT NOT NULL, + agent_definition_id TEXT NOT NULL, + session_id TEXT NOT NULL, + app_instance_id TEXT NOT NULL, + capabilities_json TEXT NOT NULL + CHECK (json_valid(capabilities_json) + AND json_type(capabilities_json) = 'array'), + tool_pattern TEXT NOT NULL DEFAULT '*', + resource_selector_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(resource_selector_json)), + issued_by TEXT NOT NULL, + evidence_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(evidence_json)), + expires_at TEXT, + revoked_at TEXT, + revoke_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_definition_id) + REFERENCES agent_definitions(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_grant_leases_active_scope + ON grant_leases(scope, scope_id, revoked_at, expires_at) + """, + """ + CREATE TABLE capability_decisions ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + interaction_id TEXT, + decision TEXT NOT NULL + CHECK (decision IN ('allow', 'deny', 'require_approval')), + decision_source TEXT NOT NULL, + capabilities_json TEXT NOT NULL + CHECK (json_valid(capabilities_json)), + tool_name TEXT NOT NULL, + effects_json TEXT NOT NULL CHECK (json_valid(effects_json)), + matched_policy_ids_json TEXT NOT NULL DEFAULT '[]' + CHECK (json_valid(matched_policy_ids_json)), + evidence_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(evidence_json)), + created_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE, + FOREIGN KEY (interaction_id) + REFERENCES agent_interactions(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_capability_decisions_run + ON capability_decisions(run_id, created_at) + """, + ), + ), + Migration( + version=9, + name="workspace_resources_and_artifacts", + statements=( + """ + CREATE TABLE session_sandboxes ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + quota_bytes INTEGER NOT NULL CHECK (quota_bytes > 0), + used_bytes INTEGER NOT NULL DEFAULT 0 CHECK (used_bytes >= 0), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE artifacts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + run_id TEXT, + name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + storage_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'trashed')), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE SET NULL, + UNIQUE (session_id, content_hash, name) + ) + """, + """ + CREATE INDEX idx_artifacts_session + ON artifacts(session_id, status, created_at DESC) + """, + """ + CREATE TABLE resource_handles ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + artifact_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('file', 'directory', 'artifact')), + display_name TEXT NOT NULL, + locator_kind TEXT NOT NULL + CHECK (locator_kind IN ('workspace', 'artifact', 'external')), + locator TEXT NOT NULL, + capabilities_json TEXT NOT NULL CHECK (json_valid(capabilities_json)), + media_type TEXT, + size_bytes INTEGER, + content_hash TEXT, + source TEXT NOT NULL, + expires_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_resource_handles_session + ON resource_handles(session_id, revoked_at, expires_at) + """, + """ + CREATE TABLE artifact_exports ( + id TEXT PRIMARY KEY, + artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, + destination_handle_id TEXT NOT NULL, + destination_name TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('pending', 'completed', 'failed')), + content_hash TEXT, + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (destination_handle_id) + REFERENCES resource_handles(id) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_artifact_exports_session + ON artifact_exports(session_id, created_at DESC) + """, + ), + ), + Migration( + version=10, + name="sandboxed_process_service", + statements=( + """ + ALTER TABLE tool_descriptors ADD COLUMN capability_rules_json TEXT + NOT NULL DEFAULT '[]' CHECK (json_valid(capability_rules_json)) + """, + """ + CREATE TABLE process_executions ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + run_id TEXT, + caller_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'starting', 'running', 'exited', 'failed', 'cancelled', + 'timed_out', 'idle_timeout', 'output_limit', 'orphaned' + )), + argv_json TEXT NOT NULL CHECK (json_valid(argv_json)), + cwd TEXT NOT NULL, + environment_keys_json TEXT NOT NULL CHECK (json_valid(environment_keys_json)), + sandbox_backend TEXT NOT NULL, + network_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (network_enabled IN (0, 1)), + pid INTEGER, + exit_code INTEGER, + limits_json TEXT NOT NULL CHECK (json_valid(limits_json)), + stdin_open INTEGER NOT NULL DEFAULT 1 CHECK (stdin_open IN (0, 1)), + output_bytes INTEGER NOT NULL DEFAULT 0 CHECK (output_bytes >= 0), + last_activity_at TEXT NOT NULL, + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_process_executions_session + ON process_executions(session_id, status, created_at DESC) + """, + """ + CREATE INDEX idx_process_executions_run + ON process_executions(run_id, status) + """, + """ + CREATE TABLE process_log_chunks ( + id TEXT PRIMARY KEY, + process_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 1), + stream TEXT NOT NULL CHECK (stream IN ('stdout', 'stderr', 'system')), + encoding TEXT NOT NULL CHECK (encoding IN ('utf-8', 'base64')), + content TEXT NOT NULL, + byte_count INTEGER NOT NULL CHECK (byte_count >= 0), + created_at TEXT NOT NULL, + FOREIGN KEY (process_id) + REFERENCES process_executions(id) ON DELETE CASCADE, + UNIQUE (process_id, sequence) + ) + """, + """ + CREATE TABLE host_broker_requests ( + id TEXT PRIMARY KEY, + process_id TEXT, + session_id TEXT NOT NULL, + run_id TEXT, + operation TEXT NOT NULL, + nonce TEXT NOT NULL UNIQUE, + token_digest TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('issued', 'accepted', 'denied', 'expired')), + expires_at TEXT NOT NULL, + evidence_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(evidence_json)), + created_at TEXT NOT NULL, + resolved_at TEXT, + FOREIGN KEY (process_id) + REFERENCES process_executions(id) ON DELETE SET NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_host_broker_requests_session + ON host_broker_requests(session_id, created_at DESC) + """, + ), + ), + Migration( + version=11, + name="trusted_service_packages", + statements=( + """ + ALTER TABLE grant_leases ADD COLUMN tool_service_digest TEXT + """, + """ + ALTER TABLE service_descriptors ADD COLUMN execution_mode TEXT + NOT NULL DEFAULT 'in_process' + CHECK (execution_mode IN ('in_process', 'managed_process', 'external')) + """, + """ + UPDATE service_descriptors SET execution_mode = runtime_mode + """, + """ + ALTER TABLE service_descriptors ADD COLUMN active_package_digest TEXT + """, + """ + ALTER TABLE service_descriptors ADD COLUMN permissions_json TEXT + NOT NULL DEFAULT '{}' CHECK (json_valid(permissions_json)) + """, + """ + CREATE TABLE publisher_trust ( + id TEXT PRIMARY KEY, + publisher_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + key_id TEXT NOT NULL, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT NOT NULL, + trust_status TEXT NOT NULL + CHECK (trust_status IN ('trusted', 'untrusted', 'revoked')), + source TEXT NOT NULL CHECK (source IN ('builtin', 'user', 'organization')), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT + ) + """, + """ + CREATE TABLE service_packages ( + id TEXT PRIMARY KEY, + service_key TEXT NOT NULL, + package_version TEXT NOT NULL, + package_digest TEXT NOT NULL UNIQUE, + publisher_key TEXT NOT NULL, + runtime_mode TEXT NOT NULL + CHECK (runtime_mode IN ('in_process', 'managed_process', 'external')), + protocol TEXT NOT NULL, + entrypoint TEXT, + archive_path TEXT NOT NULL, + store_path TEXT NOT NULL, + manifest_json TEXT NOT NULL CHECK (json_valid(manifest_json)), + permissions_json TEXT NOT NULL CHECK (json_valid(permissions_json)), + compatibility_json TEXT NOT NULL CHECK (json_valid(compatibility_json)), + sbom_json TEXT NOT NULL CHECK (json_valid(sbom_json)), + verification_json TEXT NOT NULL CHECK (json_valid(verification_json)), + status TEXT NOT NULL CHECK (status IN ( + 'installed', 'active', 'retained', 'rejected', 'uninstalled' + )), + installed_at TEXT NOT NULL, + activated_at TEXT, + retired_at TEXT, + FOREIGN KEY (publisher_key) + REFERENCES publisher_trust(publisher_key) ON DELETE RESTRICT, + UNIQUE (service_key, package_version) + ) + """, + """ + CREATE INDEX idx_service_packages_active + ON service_packages(service_key, status, installed_at DESC) + """, + """ + CREATE TABLE service_package_files ( + package_id TEXT NOT NULL, + path TEXT NOT NULL, + content_hash TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + media_type TEXT, + PRIMARY KEY (package_id, path), + FOREIGN KEY (package_id) REFERENCES service_packages(id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE package_attestations ( + id TEXT PRIMARY KEY, + package_digest TEXT NOT NULL, + kind TEXT NOT NULL, + issuer TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('pass', 'review', 'reject')), + risk TEXT NOT NULL CHECK (risk IN ('low', 'medium', 'high', 'critical')), + model TEXT, + policy_version TEXT, + evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)), + signature_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(signature_json)), + created_at TEXT NOT NULL, + FOREIGN KEY (package_digest) + REFERENCES service_packages(package_digest) ON DELETE CASCADE + ) + """, + """ + CREATE INDEX idx_package_attestations_digest + ON package_attestations(package_digest, kind, created_at DESC) + """, + """ + CREATE TABLE service_dependency_locks ( + service_key TEXT NOT NULL, + package_digest TEXT NOT NULL, + dependency_key TEXT NOT NULL, + dependency_version TEXT NOT NULL, + dependency_digest TEXT NOT NULL, + optional INTEGER NOT NULL DEFAULT 0 CHECK (optional IN (0, 1)), + created_at TEXT NOT NULL, + PRIMARY KEY (service_key, package_digest, dependency_key), + FOREIGN KEY (package_digest) + REFERENCES service_packages(package_digest) ON DELETE CASCADE, + FOREIGN KEY (dependency_digest) + REFERENCES service_packages(package_digest) ON DELETE RESTRICT + ) + """, + """ + CREATE TABLE service_operations ( + id TEXT PRIMARY KEY, + service_key TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ( + 'install', 'upgrade', 'rollback', 'uninstall', 'enable', + 'disable', 'start', 'stop', 'restart', 'audit' + )), + status TEXT NOT NULL CHECK (status IN ( + 'pending', 'running', 'completed', 'failed', 'rolled_back' + )), + from_digest TEXT, + to_digest TEXT, + plan_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(plan_json)), + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT + ) + """, + """ + CREATE INDEX idx_service_operations_service + ON service_operations(service_key, created_at DESC) + """, + """ + CREATE TABLE service_logs ( + id TEXT PRIMARY KEY, + service_key TEXT NOT NULL, + process_id TEXT, + sequence INTEGER NOT NULL CHECK (sequence >= 1), + level TEXT NOT NULL CHECK (level IN ( + 'trace', 'debug', 'info', 'warning', 'error', 'critical' + )), + stream TEXT NOT NULL CHECK (stream IN ('stdout', 'stderr', 'system')), + message TEXT NOT NULL, + fields_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(fields_json)), + created_at TEXT NOT NULL, + UNIQUE (service_key, sequence) + ) + """, + """ + CREATE TABLE managed_service_processes ( + id TEXT PRIMARY KEY, + service_key TEXT NOT NULL, + package_digest TEXT NOT NULL, + pid INTEGER, + status TEXT NOT NULL CHECK (status IN ( + 'starting', 'running', 'stopping', 'stopped', 'failed', 'orphaned' + )), + endpoint TEXT, + restart_count INTEGER NOT NULL DEFAULT 0 CHECK (restart_count >= 0), + started_at TEXT, + stopped_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (package_digest) + REFERENCES service_packages(package_digest) ON DELETE RESTRICT + ) + """, + """ + CREATE INDEX idx_managed_service_processes_service + ON managed_service_processes(service_key, status, created_at DESC) + """, + ), + ), + Migration( + version=12, + name="installable_agents_apps_and_local_patches", + statements=( + "ALTER TABLE agent_definitions ADD COLUMN upstream_digest TEXT", + "ALTER TABLE agent_definitions ADD COLUMN effective_digest TEXT", + "ALTER TABLE app_definitions ADD COLUMN upstream_digest TEXT", + "ALTER TABLE app_definitions ADD COLUMN effective_digest TEXT", + """ + CREATE TABLE interactive_packages ( + id TEXT PRIMARY KEY, + package_kind TEXT NOT NULL CHECK (package_kind IN ('agent', 'app')), + unit_key TEXT NOT NULL, + package_version TEXT NOT NULL, + package_digest TEXT NOT NULL UNIQUE, + publisher_key TEXT NOT NULL, + archive_path TEXT NOT NULL, + store_path TEXT NOT NULL, + manifest_json TEXT NOT NULL CHECK (json_valid(manifest_json)), + file_index_json TEXT NOT NULL CHECK (json_valid(file_index_json)), + sbom_json TEXT NOT NULL CHECK (json_valid(sbom_json)), + verification_json TEXT NOT NULL CHECK (json_valid(verification_json)), + status TEXT NOT NULL CHECK ( + status IN ('installed', 'active', 'retained', 'conflicted', 'uninstalled') + ), + installed_at TEXT NOT NULL, + activated_at TEXT, + retired_at TEXT, + UNIQUE(package_kind, unit_key, package_version, package_digest) + ) + """, + """ + CREATE INDEX idx_interactive_packages_active + ON interactive_packages(package_kind, unit_key, status) + """, + """ + CREATE TABLE local_patches ( + id TEXT PRIMARY KEY, + target_kind TEXT NOT NULL CHECK (target_kind IN ('agent', 'app')), + target_key TEXT NOT NULL, + patch_version TEXT NOT NULL, + patch_digest TEXT NOT NULL UNIQUE, + base_digest TEXT NOT NULL, + intent TEXT NOT NULL, + rebase_policy TEXT NOT NULL CHECK ( + rebase_policy IN ('strict', 'preserve-local', 'ai-assisted', 'drop-if-satisfied') + ), + operations_json TEXT NOT NULL CHECK (json_valid(operations_json)), + resources_json TEXT NOT NULL CHECK (json_valid(resources_json)), + tests_json TEXT NOT NULL CHECK (json_valid(tests_json)), + audit_json TEXT NOT NULL CHECK (json_valid(audit_json)), + signature_json TEXT NOT NULL CHECK (json_valid(signature_json)), + stack_order INTEGER NOT NULL CHECK (stack_order >= 0), + status TEXT NOT NULL CHECK ( + status IN ('clean', 'rebased', 'needs-review', 'conflicted', + 'disabled', 'superseded', 'failed-tests') + ), + conflict_json TEXT CHECK (conflict_json IS NULL OR json_valid(conflict_json)), + archive_path TEXT NOT NULL, + store_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(target_kind, target_key, stack_order) + ) + """, + """ + CREATE TABLE effective_definitions ( + id TEXT PRIMARY KEY, + unit_kind TEXT NOT NULL CHECK (unit_kind IN ('agent', 'app')), + unit_key TEXT NOT NULL, + upstream_digest TEXT NOT NULL, + patch_set_digest TEXT NOT NULL, + effective_digest TEXT NOT NULL UNIQUE, + effective_version TEXT NOT NULL, + manifest_json TEXT NOT NULL CHECK (json_valid(manifest_json)), + resources_json TEXT NOT NULL CHECK (json_valid(resources_json)), + audit_json TEXT NOT NULL CHECK (json_valid(audit_json)), + status TEXT NOT NULL CHECK (status IN ('candidate', 'active', 'retained', 'conflicted')), + revision INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + activated_at TEXT, + retired_at TEXT + ) + """, + """ + CREATE UNIQUE INDEX uq_effective_definition_active + ON effective_definitions(unit_kind, unit_key) WHERE status = 'active' + """, + """ + CREATE TABLE app_mounts ( + id TEXT PRIMARY KEY, + app_instance_id TEXT NOT NULL, + interaction_session_id TEXT, + placement TEXT NOT NULL CHECK (placement IN ('entry', 'inline', 'sidebar')), + renderer TEXT NOT NULL CHECK (renderer IN ('host', 'schema', 'safe-html', 'sandbox')), + resource TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('mounted', 'unmounted')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(app_instance_id) REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY(interaction_session_id) REFERENCES sessions(id) ON DELETE RESTRICT + ) + """, + """ + CREATE TABLE app_state_snapshots ( + id TEXT PRIMARY KEY, + app_instance_id TEXT NOT NULL, + effective_digest TEXT NOT NULL, + state_schema_version INTEGER NOT NULL, + state_json TEXT NOT NULL CHECK (json_valid(state_json)), + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(app_instance_id) REFERENCES app_instances(id) ON DELETE RESTRICT + ) + """, + """ + CREATE TABLE interactive_operations ( + id TEXT PRIMARY KEY, + unit_kind TEXT NOT NULL CHECK (unit_kind IN ('agent', 'app')), + unit_key TEXT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'rolled_back')), + detail_json TEXT NOT NULL CHECK (json_valid(detail_json)), + created_at TEXT NOT NULL, + finished_at TEXT + ) + """, + "CREATE INDEX idx_app_mounts_instance ON app_mounts(app_instance_id, status)", + "CREATE INDEX idx_app_snapshots_instance ON app_state_snapshots(app_instance_id, created_at DESC)", + """ + CREATE TABLE safe_mode_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active INTEGER NOT NULL CHECK (active IN (0,1)), + reason TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE safe_mode_patch_states ( + patch_id TEXT PRIMARY KEY, + prior_status TEXT NOT NULL, + FOREIGN KEY(patch_id) REFERENCES local_patches(id) ON DELETE CASCADE + ) + """, + ), + ), + Migration( + version=13, + name="app_mount_context", + statements=( + """ + ALTER TABLE app_mounts + ADD COLUMN context_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(context_json)) + """, + """ + CREATE INDEX idx_app_mounts_interaction + ON app_mounts(interaction_session_id, status, created_at) + """, + ), + ), + Migration( + version=14, + name="unified_capability_requests", + statements=( + """ + CREATE TABLE capability_requests ( + id TEXT PRIMARY KEY, + subject_kind TEXT NOT NULL CHECK (subject_kind IN ('app', 'agent_run')), + app_instance_id TEXT NOT NULL, + session_id TEXT NOT NULL, + run_id TEXT, + capabilities_json TEXT NOT NULL CHECK ( + json_valid(capabilities_json) + AND json_type(capabilities_json) = 'array' + ), + tool_name TEXT NOT NULL DEFAULT '*', + effects_json TEXT NOT NULL DEFAULT '[]' CHECK ( + json_valid(effects_json) + AND json_type(effects_json) = 'array' + ), + resource_selector_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(resource_selector_json)), + reason TEXT NOT NULL, + risk_level TEXT NOT NULL CHECK ( + risk_level IN ('low', 'medium', 'high', 'critical') + ), + status TEXT NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'approved', 'denied', 'cancelled', 'expired') + ), + requested_by TEXT NOT NULL, + decision_scope TEXT CHECK ( + decision_scope IS NULL + OR decision_scope IN ('once', 'run', 'session', 'agent', 'app') + ), + decision_evidence_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(decision_evidence_json)), + grant_lease_id TEXT, + deadline_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + resolved_at TEXT, + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (run_id) + REFERENCES agent_runs(id) ON DELETE CASCADE + ) + """, + """ + CREATE INDEX idx_capability_requests_pending + ON capability_requests(status, created_at) + """, + """ + CREATE INDEX idx_capability_requests_app + ON capability_requests(app_instance_id, status, created_at) + """, + """ + ALTER TABLE grant_leases RENAME TO grant_leases_v13 + """, + """ + CREATE TABLE grant_leases ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL + CHECK (scope IN ('run', 'session', 'agent', 'app')), + scope_id TEXT NOT NULL, + agent_definition_id TEXT, + session_id TEXT NOT NULL, + app_instance_id TEXT NOT NULL, + capabilities_json TEXT NOT NULL + CHECK (json_valid(capabilities_json) + AND json_type(capabilities_json) = 'array'), + tool_pattern TEXT NOT NULL DEFAULT '*', + tool_service_digest TEXT, + resource_selector_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(resource_selector_json)), + issued_by TEXT NOT NULL, + evidence_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(evidence_json)), + request_id TEXT, + expires_at TEXT, + revoked_at TEXT, + revoke_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_definition_id) + REFERENCES agent_definitions(id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY (app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY (request_id) + REFERENCES capability_requests(id) ON DELETE SET NULL + ) + """, + """ + INSERT INTO grant_leases( + id, scope, scope_id, agent_definition_id, session_id, + app_instance_id, capabilities_json, tool_pattern, + tool_service_digest, resource_selector_json, issued_by, + evidence_json, expires_at, revoked_at, revoke_reason, + created_at, updated_at + ) + SELECT id, scope, scope_id, agent_definition_id, session_id, + app_instance_id, capabilities_json, tool_pattern, + tool_service_digest, resource_selector_json, issued_by, + evidence_json, expires_at, revoked_at, revoke_reason, + created_at, updated_at + FROM grant_leases_v13 + """, + "DROP TABLE grant_leases_v13", + """ + CREATE INDEX idx_grant_leases_active_scope + ON grant_leases(scope, scope_id, revoked_at, expires_at) + """, + ), + ), + Migration( + version=15, + name="durable_tool_invocations", + statements=( + """ + ALTER TABLE tool_descriptors + ADD COLUMN retry_policy_json TEXT NOT NULL + DEFAULT '{"max_attempts":1,"backoff_ms":0,"retry_codes":[]}' + CHECK (json_valid(retry_policy_json)) + """, + """ + CREATE TABLE tool_invocations ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'tinv_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + tool_id TEXT NOT NULL, + qualified_name TEXT NOT NULL, + provider_key TEXT NOT NULL, + caller_id TEXT NOT NULL, + session_id TEXT, + trace_id TEXT, + status TEXT NOT NULL CHECK ( + status IN ( + 'running', 'completed', 'failed', 'cancelled', + 'interrupted' + ) + ), + arguments_json TEXT NOT NULL CHECK (json_valid(arguments_json)), + output_json TEXT CHECK ( + output_json IS NULL OR json_valid(output_json) + ), + error_json TEXT CHECK ( + error_json IS NULL OR json_valid(error_json) + ), + progress_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(progress_json)), + timeout_ms INTEGER NOT NULL CHECK (timeout_ms > 0), + attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0), + duration_ms INTEGER CHECK (duration_ms IS NULL OR duration_ms >= 0), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + finished_at TEXT, + FOREIGN KEY (session_id) + REFERENCES sessions(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_tool_invocations_session_status + ON tool_invocations(session_id, status, created_at) + """, + """ + CREATE INDEX idx_tool_invocations_trace + ON tool_invocations(trace_id, created_at) + """, + """ + CREATE INDEX idx_tool_invocations_tool + ON tool_invocations(tool_id, created_at) + """, + ), + ), + Migration( + version=16, + name="pausable_agent_runs", + statements=( + "DROP TRIGGER agent_run_status_transition", + """ + CREATE TRIGGER agent_run_status_transition + BEFORE UPDATE OF status ON agent_runs + WHEN NEW.status != OLD.status AND NOT ( + (OLD.status = 'queued' AND NEW.status IN ( + 'planning', 'interrupted', 'cancelled' + )) OR + (OLD.status = 'planning' AND NEW.status IN ( + 'running', 'queued', 'interrupted', 'failed', 'cancelled' + )) OR + (OLD.status = 'running' AND NEW.status IN ( + 'queued', 'waiting_input', 'waiting_capability', + 'interrupted', 'completed', 'failed', 'cancelled' + )) OR + (OLD.status IN ('waiting_input', 'waiting_capability') + AND NEW.status IN ('queued', 'failed', 'cancelled')) OR + (OLD.status = 'interrupted' + AND NEW.status IN ('queued', 'failed', 'cancelled')) + ) + BEGIN + SELECT RAISE(ABORT, 'invalid AgentRun status transition'); + END + """, + ), + ), + Migration( + version=17, + name="agent_run_delegation", + statements=( + """ + ALTER TABLE agent_runs + ADD COLUMN parent_run_id TEXT REFERENCES agent_runs(id) ON DELETE CASCADE + """, + """ + ALTER TABLE agent_runs + ADD COLUMN root_run_id TEXT REFERENCES agent_runs(id) ON DELETE CASCADE + """, + """ + ALTER TABLE agent_runs + ADD COLUMN depth INTEGER NOT NULL DEFAULT 0 + CHECK (depth >= 0 AND depth <= 4) + """, + """ + ALTER TABLE agent_runs + ADD COLUMN delegation_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(delegation_json)) + """, + "UPDATE agent_runs SET root_run_id = id WHERE root_run_id IS NULL", + """ + CREATE INDEX idx_agent_runs_parent + ON agent_runs(parent_run_id, created_at) + """, + """ + CREATE INDEX idx_agent_runs_root + ON agent_runs(root_run_id, depth, created_at) + """, + """ + CREATE TABLE agent_delegations ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'dlg_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + parent_run_id TEXT NOT NULL, + child_run_id TEXT NOT NULL UNIQUE, + request_key TEXT NOT NULL, + target_agent_key TEXT NOT NULL, + task TEXT NOT NULL, + parameters_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(parameters_json)), + context_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(context_json)), + budget_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(budget_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (parent_run_id) + REFERENCES agent_runs(id) ON DELETE CASCADE, + FOREIGN KEY (child_run_id) + REFERENCES agent_runs(id) ON DELETE CASCADE, + UNIQUE (parent_run_id, request_key) + ) + """, + """ + CREATE INDEX idx_agent_delegations_parent + ON agent_delegations(parent_run_id, created_at) + """, + ), + ), + Migration( + version=18, + name="coder_projects_and_threads", + statements=( + """ + CREATE TABLE coder_projects ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'cprj_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120), + root_path TEXT NOT NULL UNIQUE, + project_kind TEXT NOT NULL DEFAULT 'general' + CHECK (project_kind IN ('general', 'ai2apps')), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE coder_threads ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'cthr_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + project_id TEXT NOT NULL, + parent_thread_id TEXT, + title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 160), + agent TEXT NOT NULL CHECK (agent IN ('codex', 'opencode', 'claude')), + model_source TEXT NOT NULL + CHECK (model_source IN ('default', 'ai2apps')), + model TEXT NOT NULL DEFAULT '', + terminal_session_id TEXT, + native_session_id TEXT, + status TEXT NOT NULL DEFAULT 'created' + CHECK (status IN ('created', 'running', 'stopped', 'failed', 'archived')), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES coder_projects(id) ON DELETE CASCADE, + FOREIGN KEY (parent_thread_id) REFERENCES coder_threads(id) ON DELETE SET NULL + ) + """, + """ + CREATE INDEX idx_coder_threads_project_updated + ON coder_threads(project_id, updated_at DESC) + """, + """ + CREATE INDEX idx_coder_threads_parent + ON coder_threads(parent_thread_id, created_at) + """, + ), + ), + Migration( + version=19, + name="durable_attachments_and_documents", + statements=( + """ + CREATE TABLE document_blobs ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'dbl_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + sha256 TEXT NOT NULL UNIQUE CHECK ( + length(sha256) = 64 AND sha256 = lower(sha256) + AND sha256 NOT GLOB '*[^0-9a-f]*' + ), + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + storage_key TEXT NOT NULL UNIQUE, + parse_status TEXT NOT NULL DEFAULT 'queued' + CHECK (parse_status IN ('queued','parsing','ready','failed')), + parser TEXT, + parser_version TEXT, + error_json TEXT CHECK (error_json IS NULL OR json_valid(error_json)), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE attachments ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'attc_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + session_id TEXT NOT NULL, + blob_id TEXT NOT NULL, + filename TEXT NOT NULL CHECK (length(filename) BETWEEN 1 AND 512), + media_type TEXT NOT NULL CHECK (length(media_type) BETWEEN 1 AND 255), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + FOREIGN KEY (blob_id) REFERENCES document_blobs(id) ON DELETE RESTRICT + ) + """, + "CREATE INDEX idx_attachments_session_created ON attachments(session_id, created_at DESC)", + """ + CREATE TABLE document_blocks ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 37 AND substr(id, 1, 5) = 'dblk_' + AND id = lower(id) + AND substr(id, 6) NOT GLOB '*[^0-9a-f]*' + ), + blob_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + kind TEXT NOT NULL DEFAULT 'text', + text TEXT NOT NULL, + page INTEGER, + section TEXT, + sheet TEXT, + slide INTEGER, + cell_range TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(metadata_json)), + FOREIGN KEY (blob_id) REFERENCES document_blobs(id) ON DELETE CASCADE, + UNIQUE(blob_id, ordinal) + ) + """, + "CREATE INDEX idx_document_blocks_blob_ordinal ON document_blocks(blob_id, ordinal)", + ), + ), + Migration( + version=20, + name="keychain_secret_metadata", + statements=( + """ + CREATE TABLE secret_records ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 36 AND substr(id, 1, 4) = 'sec_' + AND id = lower(id) + AND substr(id, 5) NOT GLOB '*[^0-9a-f]*' + ), + name TEXT NOT NULL UNIQUE CHECK (length(name) BETWEEN 1 AND 128), + backend_key TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL DEFAULT '', + allowed_tools_json TEXT NOT NULL DEFAULT '[]' + CHECK (json_valid(allowed_tools_json)), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','deleted')), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + """, + "CREATE INDEX idx_secret_records_status_name ON secret_records(status, name)", + ), + ), + Migration( + version=21, + name="mobile_app_mounts", + statements=( + """ + CREATE TABLE app_mounts_v21 ( + id TEXT PRIMARY KEY, + app_instance_id TEXT NOT NULL, + interaction_session_id TEXT, + placement TEXT NOT NULL + CHECK (placement IN ('entry', 'inline', 'sidebar', 'mobile')), + renderer TEXT NOT NULL + CHECK (renderer IN ('host', 'schema', 'safe-html', 'sandbox')), + resource TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('mounted', 'unmounted')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + context_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(context_json)), + entry_source TEXT NOT NULL DEFAULT 'entry' + CHECK (entry_source IN ('entry', 'mini_entry', 'mobile_entry')), + FOREIGN KEY(app_instance_id) + REFERENCES app_instances(id) ON DELETE RESTRICT, + FOREIGN KEY(interaction_session_id) + REFERENCES sessions(id) ON DELETE RESTRICT + ) + """, + """ + INSERT INTO app_mounts_v21( + id,app_instance_id,interaction_session_id,placement,renderer, + resource,status,created_at,updated_at,context_json,entry_source + ) + SELECT id,app_instance_id,interaction_session_id,placement,renderer, + resource,status,created_at,updated_at,context_json, + CASE WHEN placement IN ('inline','sidebar') + THEN 'mini_entry' ELSE 'entry' END + FROM app_mounts + """, + "DROP TABLE app_mounts", + "ALTER TABLE app_mounts_v21 RENAME TO app_mounts", + "CREATE INDEX idx_app_mounts_instance ON app_mounts(app_instance_id, status)", + """ + CREATE INDEX idx_app_mounts_interaction + ON app_mounts(interaction_session_id, status, created_at) + """, + """ + CREATE INDEX idx_app_mounts_mobile + ON app_mounts(placement, status, updated_at DESC) + """, + ), + ), + Migration( + version=22, + name="remote_client_devices", + statements=( + """ + CREATE TABLE remote_client_devices ( + device_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + platform TEXT NOT NULL, + client_version TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active','suspended','revoked')), + suspension_reason TEXT, + access_epoch INTEGER NOT NULL CHECK (access_epoch >= 1), + public_origin TEXT NOT NULL, + credential_version INTEGER NOT NULL CHECK (credential_version >= 1), + credential_expires_at TEXT NOT NULL, + server_addr TEXT NOT NULL, + server_port INTEGER NOT NULL CHECK (server_port BETWEEN 1 AND 65535), + proxy_name TEXT NOT NULL, + subdomain TEXT NOT NULL, + secret_backend_key TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0,1)), + online INTEGER NOT NULL DEFAULT 0 CHECK (online IN (0,1)), + proxy_connected INTEGER NOT NULL DEFAULT 0 CHECK (proxy_connected IN (0,1)), + last_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + "CREATE INDEX idx_remote_client_devices_status ON remote_client_devices(status, updated_at DESC)", + ), + ), +) + + +def _validate_migrations(migrations: Sequence[Migration]) -> None: + versions = [migration.version for migration in migrations] + expected = list(range(1, len(migrations) + 1)) + if versions != expected: + raise MigrationError( + f"Migration versions must be contiguous from 1; got {versions!r}" + ) + names = [migration.name for migration in migrations] + if len(names) != len(set(names)): + raise MigrationError("Migration names must be unique") + + +def _pragma_user_version(connection: sqlite3.Connection) -> int: + row = connection.execute("PRAGMA user_version").fetchone() + if row is None: + raise DatabaseCorruptionError("SQLite did not return PRAGMA user_version") + return int(row[0]) + + +def apply_migrations( + connection: sqlite3.Connection, + migrations: Sequence[Migration] = MIGRATIONS, +) -> int: + """Apply pending migrations atomically under a SQLite write lock.""" + + _validate_migrations(migrations) + target_version = len(migrations) + connection.execute("BEGIN IMMEDIATE") + try: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ) + """ + ) + ledger = { + int(row[0]): str(row[1]) + for row in connection.execute( + "SELECT version, name FROM schema_migrations ORDER BY version" + ) + } + user_version = _pragma_user_version(connection) + + if user_version > target_version: + raise FutureSchemaError( + "Platform database schema " + f"v{user_version} is newer than supported v{target_version}" + ) + if any(version > target_version for version in ledger): + raise FutureSchemaError( + "Migration ledger contains a version newer than this AI2Apps build" + ) + + expected_applied = { + migration.version: migration.name + for migration in migrations + if migration.version <= user_version + } + if ledger != expected_applied: + raise DatabaseCorruptionError( + "PRAGMA user_version and schema_migrations ledger disagree" + ) + + for migration in migrations[user_version:]: + for statement in migration.statements: + connection.execute(statement) + connection.execute( + "INSERT INTO schema_migrations(version, name, applied_at) " + "VALUES (?, ?, ?)", + (migration.version, migration.name, utc_now_text()), + ) + connection.execute(f"PRAGMA user_version = {migration.version}") + + connection.commit() + return target_version + except Exception: + connection.rollback() + raise diff --git a/ai2apps/storage/models.py b/ai2apps/storage/models.py new file mode 100644 index 00000000..60525740 --- /dev/null +++ b/ai2apps/storage/models.py @@ -0,0 +1,154 @@ +"""Typed records returned by AI2Apps persistence repositories.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from ai2apps.core.models import ( + AppDefinitionStatus, + AppInstanceMode, + AppInstanceStatus, + MessageRole, + MessageStatus, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + SingletonScope, +) + +JsonObject = dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class AppDefinitionRecord: + id: str + package_id: str + package_version: str + display_name: str + instance_mode: AppInstanceMode + singleton_scope: SingletonScope | None + source: str + status: AppDefinitionStatus + manifest_schema_version: int + manifest: JsonObject + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class AppInstanceRecord: + id: str + app_definition_id: str + singleton_key: str | None + status: AppInstanceStatus + state_schema_version: int + state: JsonObject + revision: int + created_at: datetime + updated_at: datetime + closed_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class SessionRecord: + id: str + app_instance_id: str + title: str + status: SessionStatus + is_home: bool + session_kind: SessionKind + visibility: SessionVisibility + retention: SessionRetention + revision: int + metadata: JsonObject + created_at: datetime + updated_at: datetime + archived_at: datetime | None + deleted_at: datetime | None + expires_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class ChatCollectionRecord: + app_instance_id: str + selected_session_id: str | None + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class ChatThreadRecord: + session: SessionRecord + pinned: bool + sort_order: int + legacy_thread_id: str | None + collection_created_at: datetime + collection_updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class BuiltinChatRecord: + definition: AppDefinitionRecord + instance: AppInstanceRecord + collection: ChatCollectionRecord + + +@dataclass(frozen=True, slots=True) +class MessageRecord: + id: str + session_id: str + sequence: int + role: MessageRole + status: MessageStatus + idempotency_key: str | None + metadata: JsonObject + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class MessagePartInput: + kind: str + content: JsonObject + + +@dataclass(frozen=True, slots=True) +class MessagePartRecord: + id: str + message_id: str + position: int + kind: str + content: JsonObject + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class MessageWithParts: + message: MessageRecord + parts: tuple[MessagePartRecord, ...] + + +@dataclass(frozen=True, slots=True) +class EventRecord: + id: str + sequence: int + type: str + occurred_at: datetime + app_instance_id: str | None + session_id: str | None + subject_id: str + trace_id: str | None + schema_version: int + payload: JsonObject + + +@dataclass(frozen=True, slots=True) +class AppendMessageResult: + value: MessageWithParts + event: EventRecord | None + created: bool diff --git a/ai2apps/storage/records.py b/ai2apps/storage/records.py new file mode 100644 index 00000000..ffaa7d5e --- /dev/null +++ b/ai2apps/storage/records.py @@ -0,0 +1,154 @@ +"""SQLite row decoding kept separate from repository behavior.""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any + +from ai2apps.core import parse_utc +from ai2apps.core.models import ( + AppDefinitionStatus, + AppInstanceMode, + AppInstanceStatus, + MessageRole, + MessageStatus, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + SingletonScope, +) +from ai2apps.storage.models import ( + AppDefinitionRecord, + AppInstanceRecord, + ChatCollectionRecord, + ChatThreadRecord, + EventRecord, + MessagePartRecord, + MessageRecord, + SessionRecord, +) + + +def canonical_json(value: dict[str, Any]) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _optional_time(value: str | None): + return None if value is None else parse_utc(value) + + +def app_definition_from_row(row: sqlite3.Row) -> AppDefinitionRecord: + scope = row["singleton_scope"] + return AppDefinitionRecord( + id=row["id"], + package_id=row["package_id"], + package_version=row["package_version"], + display_name=row["display_name"], + instance_mode=AppInstanceMode(row["instance_mode"]), + singleton_scope=None if scope is None else SingletonScope(scope), + source=row["source"], + status=AppDefinitionStatus(row["status"]), + manifest_schema_version=row["manifest_schema_version"], + manifest=json.loads(row["manifest_json"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + +def app_instance_from_row(row: sqlite3.Row) -> AppInstanceRecord: + return AppInstanceRecord( + id=row["id"], + app_definition_id=row["app_definition_id"], + singleton_key=row["singleton_key"], + status=AppInstanceStatus(row["status"]), + state_schema_version=row["state_schema_version"], + state=json.loads(row["state_json"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + closed_at=_optional_time(row["closed_at"]), + ) + + +def session_from_row(row: sqlite3.Row) -> SessionRecord: + return SessionRecord( + id=row["id"], + app_instance_id=row["app_instance_id"], + title=row["title"], + status=SessionStatus(row["status"]), + is_home=bool(row["is_home"]), + session_kind=SessionKind(row["session_kind"]), + visibility=SessionVisibility(row["visibility"]), + retention=SessionRetention(row["retention"]), + revision=row["revision"], + metadata=json.loads(row["metadata_json"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + archived_at=_optional_time(row["archived_at"]), + deleted_at=_optional_time(row["deleted_at"]), + expires_at=_optional_time(row["expires_at"]), + ) + + +def chat_collection_from_row(row: sqlite3.Row) -> ChatCollectionRecord: + return ChatCollectionRecord( + app_instance_id=row["app_instance_id"], + selected_session_id=row["selected_session_id"], + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + +def chat_thread_from_joined_row(row: sqlite3.Row) -> ChatThreadRecord: + return ChatThreadRecord( + session=session_from_row(row), + pinned=bool(row["chat_pinned"]), + sort_order=row["chat_sort_order"], + legacy_thread_id=row["chat_legacy_thread_id"], + collection_created_at=parse_utc(row["chat_created_at"]), + collection_updated_at=parse_utc(row["chat_updated_at"]), + ) + + +def message_from_row(row: sqlite3.Row) -> MessageRecord: + return MessageRecord( + id=row["id"], + session_id=row["session_id"], + sequence=row["sequence"], + role=MessageRole(row["role"]), + status=MessageStatus(row["status"]), + idempotency_key=row["idempotency_key"], + metadata=json.loads(row["metadata_json"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + +def message_part_from_row(row: sqlite3.Row) -> MessagePartRecord: + return MessagePartRecord( + id=row["id"], + message_id=row["message_id"], + position=row["position"], + kind=row["kind"], + content=json.loads(row["content_json"]), + created_at=parse_utc(row["created_at"]), + ) + + +def event_from_row(row: sqlite3.Row) -> EventRecord: + return EventRecord( + id=row["id"], + sequence=row["sequence"], + type=row["type"], + occurred_at=parse_utc(row["occurred_at"]), + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + subject_id=row["subject_id"], + trace_id=row["trace_id"], + schema_version=row["schema_version"], + payload=json.loads(row["payload_json"]), + ) diff --git a/ai2apps/storage/repositories/__init__.py b/ai2apps/storage/repositories/__init__.py new file mode 100644 index 00000000..373b5734 --- /dev/null +++ b/ai2apps/storage/repositories/__init__.py @@ -0,0 +1,7 @@ +"""Explicit repositories for durable AI2Apps platform resources.""" + +from .apps import AppRepository +from .messages import MessageRepository +from .sessions import SessionRepository + +__all__ = ["AppRepository", "MessageRepository", "SessionRepository"] diff --git a/ai2apps/storage/repositories/apps.py b/ai2apps/storage/repositories/apps.py new file mode 100644 index 00000000..4e52d90d --- /dev/null +++ b/ai2apps/storage/repositories/apps.py @@ -0,0 +1,246 @@ +"""AppDefinition and AppInstance persistence.""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from ai2apps.core import ( + AppDefinitionStatus, + AppInstanceMode, + AppInstanceStatus, + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, + SingletonScope, + new_entity_id, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage.database import PlatformDatabase +from ai2apps.storage.models import AppDefinitionRecord, AppInstanceRecord +from ai2apps.storage.records import ( + app_definition_from_row, + app_instance_from_row, + canonical_json, +) + + +class AppRepository: + def __init__( + self, + database: PlatformDatabase, + event_store: EventStore | None = None, + ) -> None: + self.database = database + self.events = event_store or EventStore(database) + + def create_definition( + self, + *, + package_id: str, + package_version: str, + display_name: str, + instance_mode: AppInstanceMode, + singleton_scope: SingletonScope | None = None, + source: str = "local", + status: AppDefinitionStatus = AppDefinitionStatus.ENABLED, + manifest_schema_version: int = 1, + manifest: dict[str, Any] | None = None, + trace_id: str | None = None, + ) -> AppDefinitionRecord: + definition_id = new_entity_id(EntityIdKind.APP_DEFINITION) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + connection.execute( + """ + INSERT INTO app_definitions( + id, package_id, package_version, display_name, + instance_mode, singleton_scope, source, status, + manifest_schema_version, manifest_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + definition_id, + package_id, + package_version, + display_name, + instance_mode.value, + None if singleton_scope is None else singleton_scope.value, + source, + status.value, + manifest_schema_version, + canonical_json(manifest or {}), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="app.definition.created", + subject_id=definition_id, + trace_id=trace_id, + payload={ + "package_id": package_id, + "package_version": package_version, + }, + ) + row = connection.execute( + "SELECT * FROM app_definitions WHERE id = ?", (definition_id,) + ).fetchone() + assert row is not None + return app_definition_from_row(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_definition(self, definition_id: str) -> AppDefinitionRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM app_definitions WHERE id = ?", (definition_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("app_definition", definition_id) + return app_definition_from_row(row) + + def create_instance( + self, + *, + app_definition_id: str, + singleton_key: str | None = None, + status: AppInstanceStatus = AppInstanceStatus.ACTIVE, + state_schema_version: int = 1, + state: dict[str, Any] | None = None, + trace_id: str | None = None, + ) -> AppInstanceRecord: + instance_id = new_entity_id(EntityIdKind.APP_INSTANCE) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + definition = connection.execute( + "SELECT id FROM app_definitions WHERE id = ?", + (app_definition_id,), + ).fetchone() + if definition is None: + raise ResourceNotFoundError( + "app_definition", app_definition_id + ) + connection.execute( + """ + INSERT INTO app_instances( + id, app_definition_id, singleton_key, status, + state_schema_version, state_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + instance_id, + app_definition_id, + singleton_key, + status.value, + state_schema_version, + canonical_json(state or {}), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="app.instance.created", + subject_id=instance_id, + app_instance_id=instance_id, + trace_id=trace_id, + payload={"app_definition_id": app_definition_id}, + ) + row = connection.execute( + "SELECT * FROM app_instances WHERE id = ?", (instance_id,) + ).fetchone() + assert row is not None + return app_instance_from_row(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_instance(self, instance_id: str) -> AppInstanceRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM app_instances WHERE id = ?", (instance_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("app_instance", instance_id) + return app_instance_from_row(row) + + def update_instance( + self, + instance_id: str, + *, + expected_revision: int, + status: AppInstanceStatus | None = None, + state_schema_version: int | None = None, + state: dict[str, Any] | None = None, + trace_id: str | None = None, + ) -> AppInstanceRecord: + changes: dict[str, Any] = {} + if status is not None: + changes["status"] = status.value + if state_schema_version is not None: + changes["state_schema_version"] = state_schema_version + if state is not None: + changes["state_json"] = canonical_json(state) + if not changes: + raise ValueError("At least one AppInstance field must change") + + now = utc_now_text() + changes["updated_at"] = now + if status is AppInstanceStatus.CLOSED: + changes["closed_at"] = now + elif status is not None: + changes["closed_at"] = None + assignments = [f"{column} = ?" for column in changes] + assignments.append("revision = revision + 1") + params = [*changes.values(), instance_id, expected_revision] + + try: + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + f""" + UPDATE app_instances SET {', '.join(assignments)} + WHERE id = ? AND revision = ? + """, + params, + ) + if cursor.rowcount == 0: + current = connection.execute( + "SELECT revision FROM app_instances WHERE id = ?", + (instance_id,), + ).fetchone() + if current is None: + raise ResourceNotFoundError("app_instance", instance_id) + raise RevisionConflictError( + instance_id, + expected_revision, + int(current["revision"]), + ) + row = connection.execute( + "SELECT * FROM app_instances WHERE id = ?", (instance_id,) + ).fetchone() + assert row is not None + updated = app_instance_from_row(row) + self.events.append_in_transaction( + connection, + event_type=( + "app.instance.closed" + if status is AppInstanceStatus.CLOSED + else "app.instance.updated" + ), + subject_id=instance_id, + app_instance_id=instance_id, + trace_id=trace_id, + payload={ + "changed_fields": sorted(changes), + "revision": updated.revision, + }, + ) + return updated + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc diff --git a/ai2apps/storage/repositories/messages.py b/ai2apps/storage/repositories/messages.py new file mode 100644 index 00000000..4f745182 --- /dev/null +++ b/ai2apps/storage/repositories/messages.py @@ -0,0 +1,273 @@ +"""Structured Message persistence with Session-scoped idempotency.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Sequence +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + IdempotencyConflictError, + MessageRole, + MessageStatus, + ResourceConflictError, + ResourceNotFoundError, + new_entity_id, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage.database import PlatformDatabase +from ai2apps.storage.models import ( + AppendMessageResult, + MessagePartInput, + MessagePartRecord, + MessageWithParts, +) +from ai2apps.storage.records import ( + canonical_json, + event_from_row, + message_from_row, + message_part_from_row, +) + + +class MessageRepository: + def __init__( + self, + database: PlatformDatabase, + event_store: EventStore | None = None, + ) -> None: + self.database = database + self.events = event_store or EventStore(database) + + @staticmethod + def _load( + connection: sqlite3.Connection, + message_id: str, + ) -> MessageWithParts | None: + row = connection.execute( + "SELECT * FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + return None + part_rows = connection.execute( + "SELECT * FROM message_parts WHERE message_id = ? ORDER BY position", + (message_id,), + ).fetchall() + return MessageWithParts( + message=message_from_row(row), + parts=tuple(message_part_from_row(part) for part in part_rows), + ) + + @staticmethod + def _same_request( + existing: MessageWithParts, + *, + role: MessageRole, + status: MessageStatus, + metadata: dict[str, Any], + parts: Sequence[MessagePartInput], + ) -> bool: + return ( + existing.message.role is role + and existing.message.status is status + and existing.message.metadata == metadata + and tuple((part.kind, part.content) for part in existing.parts) + == tuple((part.kind, part.content) for part in parts) + ) + + def append( + self, + *, + session_id: str, + role: MessageRole, + parts: Sequence[MessagePartInput], + status: MessageStatus = MessageStatus.COMPLETED, + idempotency_key: str | None = None, + metadata: dict[str, Any] | None = None, + app_instance_id: str | None = None, + trace_id: str | None = None, + ) -> AppendMessageResult: + if not parts: + raise ValueError("A Message must contain at least one part") + if any(not part.kind for part in parts): + raise ValueError("Message part kind must not be empty") + metadata_value = metadata or {} + + try: + with self.database.transaction(write=True) as connection: + session = connection.execute( + "SELECT app_instance_id, status FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + if session is None or ( + app_instance_id is not None + and session["app_instance_id"] != app_instance_id + ): + raise ResourceNotFoundError("session", session_id) + if session["status"] == "deleted": + raise ResourceConflictError( + f"Cannot append a Message to deleted Session {session_id}" + ) + owner_id = str(session["app_instance_id"]) + + if idempotency_key is not None: + existing_row = connection.execute( + """ + SELECT id FROM messages + WHERE session_id = ? AND idempotency_key = ? + """, + (session_id, idempotency_key), + ).fetchone() + if existing_row is not None: + existing = self._load(connection, existing_row["id"]) + assert existing is not None + if not self._same_request( + existing, + role=role, + status=status, + metadata=metadata_value, + parts=parts, + ): + raise IdempotencyConflictError( + session_id, idempotency_key + ) + event_row = connection.execute( + """ + SELECT * FROM events + WHERE subject_id = ? AND type = 'message.created' + ORDER BY sequence DESC LIMIT 1 + """, + (existing.message.id,), + ).fetchone() + return AppendMessageResult( + value=existing, + event=None if event_row is None else event_from_row(event_row), + created=False, + ) + + message_id = new_entity_id(EntityIdKind.MESSAGE) + now = utc_now_text() + next_sequence = int( + connection.execute( + """ + SELECT COALESCE(MAX(sequence), 0) + 1 + FROM messages WHERE session_id = ? + """, + (session_id,), + ).fetchone()[0] + ) + connection.execute( + """ + INSERT INTO messages( + id, session_id, sequence, role, status, + idempotency_key, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + message_id, + session_id, + next_sequence, + role.value, + status.value, + idempotency_key, + canonical_json(metadata_value), + now, + now, + ), + ) + part_records: list[MessagePartRecord] = [] + for position, part in enumerate(parts): + part_id = new_entity_id(EntityIdKind.MESSAGE_PART) + connection.execute( + """ + INSERT INTO message_parts( + id, message_id, position, kind, content_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + part_id, + message_id, + position, + part.kind, + canonical_json(part.content), + now, + ), + ) + part_row = connection.execute( + "SELECT * FROM message_parts WHERE id = ?", (part_id,) + ).fetchone() + assert part_row is not None + part_records.append(message_part_from_row(part_row)) + + message_row = connection.execute( + "SELECT * FROM messages WHERE id = ?", (message_id,) + ).fetchone() + assert message_row is not None + value = MessageWithParts( + message=message_from_row(message_row), + parts=tuple(part_records), + ) + event = self.events.append_in_transaction( + connection, + event_type="message.created", + subject_id=message_id, + app_instance_id=owner_id, + session_id=session_id, + trace_id=trace_id, + payload={ + "part_ids": [part.id for part in part_records], + "role": role.value, + "sequence": next_sequence, + }, + ) + return AppendMessageResult(value=value, event=event, created=True) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get( + self, + message_id: str, + *, + session_id: str | None = None, + ) -> MessageWithParts: + with self.database.transaction() as connection: + value = self._load(connection, message_id) + if value is None or ( + session_id is not None and value.message.session_id != session_id + ): + raise ResourceNotFoundError("message", message_id) + return value + + def list_for_session( + self, + session_id: str, + *, + app_instance_id: str | None = None, + after_sequence: int = 0, + limit: int = 100, + ) -> tuple[MessageWithParts, ...]: + if after_sequence < 0: + raise ValueError("after_sequence must be non-negative") + if not 1 <= limit <= 1_000: + raise ValueError("limit must be between 1 and 1000") + with self.database.transaction() as connection: + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + if session is None or ( + app_instance_id is not None + and session["app_instance_id"] != app_instance_id + ): + raise ResourceNotFoundError("session", session_id) + rows = connection.execute( + """ + SELECT id FROM messages + WHERE session_id = ? AND sequence > ? + ORDER BY sequence LIMIT ? + """, + (session_id, after_sequence, limit), + ).fetchall() + values = tuple(self._load(connection, row["id"]) for row in rows) + return tuple(value for value in values if value is not None) diff --git a/ai2apps/storage/repositories/sessions.py b/ai2apps/storage/repositories/sessions.py new file mode 100644 index 00000000..b08ff17d --- /dev/null +++ b/ai2apps/storage/repositories/sessions.py @@ -0,0 +1,320 @@ +"""ConversationSession persistence with optimistic revisions.""" + +from __future__ import annotations + +import sqlite3 +from datetime import timedelta +from typing import Any + +from ai2apps.config import DEFAULT_TEMPORARY_SESSION_TTL_SECONDS +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + format_utc, + new_entity_id, + parse_utc, + utc_now, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage.database import PlatformDatabase +from ai2apps.storage.models import SessionRecord +from ai2apps.storage.records import canonical_json, session_from_row + + +class SessionRepository: + def __init__( + self, + database: PlatformDatabase, + event_store: EventStore | None = None, + ) -> None: + self.database = database + self.events = event_store or EventStore(database) + + def create( + self, + *, + app_instance_id: str, + title: str = "", + is_home: bool = False, + session_kind: SessionKind = SessionKind.APP, + visibility: SessionVisibility = SessionVisibility.LISTED, + retention: SessionRetention = SessionRetention.DURABLE, + expires_at: str | None = None, + metadata: dict[str, Any] | None = None, + trace_id: str | None = None, + ) -> SessionRecord: + session_id = new_entity_id(EntityIdKind.SESSION) + now_value = utc_now() + now = format_utc(now_value) + if retention is SessionRetention.TEMPORARY and expires_at is None: + expires_at = format_utc( + now_value + timedelta(seconds=DEFAULT_TEMPORARY_SESSION_TTL_SECONDS) + ) + elif retention is SessionRetention.DURABLE and expires_at is not None: + raise ValueError("Durable Sessions cannot have expires_at") + if expires_at is not None: + parse_utc(expires_at) + try: + with self.database.transaction(write=True) as connection: + owner = connection.execute( + "SELECT id FROM app_instances WHERE id = ?", (app_instance_id,) + ).fetchone() + if owner is None: + raise ResourceNotFoundError("app_instance", app_instance_id) + connection.execute( + """ + INSERT INTO sessions( + id, app_instance_id, title, is_home, session_kind, + visibility, retention, expires_at, metadata_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + app_instance_id, + title, + int(is_home), + session_kind.value, + visibility.value, + retention.value, + expires_at, + canonical_json(metadata or {}), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="session.created", + subject_id=session_id, + app_instance_id=app_instance_id, + session_id=session_id, + trace_id=trace_id, + payload={ + "is_home": is_home, + "retention": retention.value, + "session_kind": session_kind.value, + "title": title, + "visibility": visibility.value, + }, + ) + row = connection.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + assert row is not None + return session_from_row(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get( + self, + session_id: str, + *, + app_instance_id: str | None = None, + ) -> SessionRecord: + query = "SELECT * FROM sessions WHERE id = ?" + params: tuple[Any, ...] = (session_id,) + if app_instance_id is not None: + query += " AND app_instance_id = ?" + params += (app_instance_id,) + with self.database.transaction() as connection: + row = connection.execute(query, params).fetchone() + if row is None: + raise ResourceNotFoundError("session", session_id) + return session_from_row(row) + + def list_for_instance( + self, + app_instance_id: str, + *, + include_deleted: bool = False, + session_kind: SessionKind | None = None, + visibility: SessionVisibility | None = None, + limit: int = 100, + ) -> tuple[SessionRecord, ...]: + if not 1 <= limit <= 1_000: + raise ValueError("limit must be between 1 and 1000") + query = "SELECT * FROM sessions WHERE app_instance_id = ?" + params: list[Any] = [app_instance_id] + if not include_deleted: + query += " AND status != 'deleted'" + if session_kind is not None: + query += " AND session_kind = ?" + params.append(session_kind.value) + if visibility is not None: + query += " AND visibility = ?" + params.append(visibility.value) + query += " ORDER BY is_home DESC, updated_at DESC, id LIMIT ?" + params.append(limit) + with self.database.transaction() as connection: + owner = connection.execute( + "SELECT id FROM app_instances WHERE id = ?", (app_instance_id,) + ).fetchone() + if owner is None: + raise ResourceNotFoundError("app_instance", app_instance_id) + rows = connection.execute(query, params).fetchall() + return tuple(session_from_row(row) for row in rows) + + def update( + self, + session_id: str, + *, + expected_revision: int, + app_instance_id: str | None = None, + title: str | None = None, + status: SessionStatus | None = None, + is_home: bool | None = None, + metadata: dict[str, Any] | None = None, + visibility: SessionVisibility | None = None, + retention: SessionRetention | None = None, + trace_id: str | None = None, + ) -> SessionRecord: + changes: dict[str, Any] = {} + if title is not None: + changes["title"] = title + if status is not None: + changes["status"] = status.value + if is_home is not None: + changes["is_home"] = int(is_home) + if metadata is not None: + changes["metadata_json"] = canonical_json(metadata) + if visibility is not None: + changes["visibility"] = visibility.value + if retention is not None: + changes["retention"] = retention.value + if not changes: + raise ValueError("At least one Session field must change") + + now_value = utc_now() + now = format_utc(now_value) + if retention is SessionRetention.TEMPORARY: + changes["expires_at"] = format_utc( + now_value + timedelta(seconds=DEFAULT_TEMPORARY_SESSION_TTL_SECONDS) + ) + elif retention is SessionRetention.DURABLE: + changes["expires_at"] = None + if status is SessionStatus.ARCHIVED: + changes["archived_at"] = now + changes["deleted_at"] = None + elif status is SessionStatus.DELETED: + changes["deleted_at"] = now + elif status is SessionStatus.ACTIVE: + changes["archived_at"] = None + changes["deleted_at"] = None + changes["updated_at"] = now + + assignments = [f"{column} = ?" for column in changes] + assignments.append("revision = revision + 1") + params = list(changes.values()) + where = "id = ? AND revision = ?" + params.extend((session_id, expected_revision)) + if app_instance_id is not None: + where += " AND app_instance_id = ?" + params.append(app_instance_id) + + try: + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + f"UPDATE sessions SET {', '.join(assignments)} WHERE {where}", + params, + ) + if cursor.rowcount == 0: + current = connection.execute( + "SELECT revision, app_instance_id FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + if current is None or ( + app_instance_id is not None + and current["app_instance_id"] != app_instance_id + ): + raise ResourceNotFoundError("session", session_id) + raise RevisionConflictError( + session_id, + expected_revision, + int(current["revision"]), + ) + row = connection.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + assert row is not None + updated = session_from_row(row) + event_type = ( + "session.archived" + if status is SessionStatus.ARCHIVED + else "session.deleted" + if status is SessionStatus.DELETED + else "session.updated" + ) + self.events.append_in_transaction( + connection, + event_type=event_type, + subject_id=session_id, + app_instance_id=updated.app_instance_id, + session_id=session_id, + trace_id=trace_id, + payload={ + "changed_fields": sorted(changes), + "revision": updated.revision, + }, + ) + return updated + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def expire_temporary( + self, + *, + now: str | None = None, + limit: int = 100, + ) -> tuple[SessionRecord, ...]: + """Soft-delete one bounded batch of expired temporary Sessions.""" + + if not 1 <= limit <= 1_000: + raise ValueError("limit must be between 1 and 1000") + cutoff = now or utc_now_text() + parse_utc(cutoff) + expired: list[SessionRecord] = [] + with self.database.transaction(write=True) as connection: + rows = connection.execute( + """ + SELECT id FROM sessions + WHERE retention = 'temporary' + AND status != 'deleted' + AND expires_at <= ? + ORDER BY expires_at, id + LIMIT ? + """, + (cutoff, limit), + ).fetchall() + for candidate in rows: + connection.execute( + """ + UPDATE sessions + SET status = 'deleted', deleted_at = ?, updated_at = ?, + revision = revision + 1 + WHERE id = ? AND status != 'deleted' + """, + (cutoff, cutoff, candidate["id"]), + ) + row = connection.execute( + "SELECT * FROM sessions WHERE id = ?", (candidate["id"],) + ).fetchone() + assert row is not None + record = session_from_row(row) + expired.append(record) + self.events.append_in_transaction( + connection, + event_type="session.expired", + subject_id=record.id, + app_instance_id=record.app_instance_id, + session_id=record.id, + payload={"expires_at": format_utc(record.expires_at)}, + ) + return tuple(expired) diff --git a/docs/ai2apps-backend-development-plan.md b/docs/ai2apps-backend-development-plan.md new file mode 100644 index 00000000..706ef14a --- /dev/null +++ b/docs/ai2apps-backend-development-plan.md @@ -0,0 +1,2040 @@ +# AI2Apps Backend Development Plan + +Status: Draft v2.9 — Stabilization active; W9 Coding Harness deferred +Last updated: 2026-08-11 +Architecture source: [AI2Apps Platform Architecture](ai2apps-platform-architecture.md) + +## 1. Objective + +Build AI2Apps from its current oMLX-based model server into a local-first App +and Agent Harness backend without destabilizing the existing inference runtime. + +The backend must eventually provide: + +- durable App, AppInstance, Thread/Session, Message, Run, Step, and Event state; +- an authoritative asynchronous Agent loop; +- a Service Registry, Service Gateway, and Tool Registry; +- model and MCP capabilities through Service adapters; +- Workspace, Artifact, Process, Web, Memory, and other foundational Services; +- status-line, user input, interactive View, approval, and cancellation + primitives; +- Session sandboxes, capabilities, ResourceHandles, GrantLeases, and audit; +- installable, signed, auditable, dependency-aware Service/Agent/App packages; +- Apple Silicon/oMLX, NVIDIA/CUDA Linux, and AMD/ROCm Linux backend providers. + +Implementation proceeds through small vertical slices. Every milestone must +leave the server runnable, preserve current OpenAI-compatible behavior, and +have an explicit acceptance gate. + +## 2. Current baseline + +The current repository has these relevant characteristics: + +- `omlx.server` owns the FastAPI application, lifespan, model runtime, + OpenAI-compatible APIs, MCP initialization, and administrative routes; +- the `ai2apps` package is currently a thin product boundary around oMLX plus + AI2Apps-specific inference/Fusion functionality; +- model management, inference scheduling, streaming, MCP execution, auth, and + extensive runtime tests already exist; +- there is no SQLite-backed application database in the current checkout. The + only `sqlite` match in runtime configuration is an example external MCP + server command; +- durable server state currently uses several purpose-specific stores: + `settings.json`, model settings/profile/template JSON files, bounded + per-response JSON records, metric JSON, and model/package files; +- current Chat thread/history state is browser-owned `localStorage`, not a + backend database; +- no authoritative AI2Apps App/Agent/Session persistence or general Tool + Runtime exists yet; +- the WebUI is being separated into `ai2apps/web`; backend work must avoid + depending on unfinished UI implementation; +- the worktree contains ongoing product/UI and inference experiments, so + backend changes must stay isolated under `ai2apps` and avoid unrelated files. + +The plan therefore adds a new AI2Apps platform layer beside oMLX and integrates +it through narrow adapters. Existing model, router, cache, scheduler, and kernel +code is not moved merely to satisfy the new architecture. + +### 2.1 Reuse-first inventory + +Before implementing each milestone, classify existing behavior as **reuse**, +**wrap**, **migrate**, or **replace**. “New AI2Apps abstraction” does not mean +“new implementation” when the current implementation already satisfies the +contract. + +| Existing capability | Location | Plan | +| --- | --- | --- | +| FastAPI app and lifespan | `omlx.server` | Reuse; mount one AI2Apps router and attach one PlatformRuntime lifecycle | +| API-key authentication | `omlx.server.verify_api_key`, admin auth | Reuse initially through FastAPI dependencies; extend later for user/package identity | +| Base/data path and hierarchical configuration | `omlx.settings` | Reuse path resolution and settings; put the platform DB/artifacts under the same resolved data root | +| Global and model settings persistence | `omlx.settings`, `omlx.model_settings` | Reuse existing versioned/atomic JSON stores through adapters; do not migrate stable settings into SQLite without need | +| EnginePool, scheduler, memory guard, model load/unload | oMLX runtime | Reuse unchanged behind Model Runtime Service adapter | +| In-process model ownership registry | `omlx.model_registry` | Reuse for engine ownership; do not confuse it with the durable Service Registry | +| MCP connections, discovery, execution, timeout, parallel limit | `omlx.mcp` | Reuse behind MCP Service/Tool adapters; add Session identity, policy, audit, and cancellation around it | +| Tool-call parsing and OpenAI/Anthropic formatting | `omlx.api` adapters/parsers | Reuse where model-format compatible; normalize into AI2Apps ToolCall/ToolResult contracts | +| OpenAI Responses previous-response store | `omlx.api.responses_utils.ResponseStore` | Preserve for API compatibility; optionally import/link records, but do not use its bounded JSON store as the Session database | +| SSE formatting and benchmark progress streams | `omlx.api.adapters`, admin benchmark routes | Reuse format/header/heartbeat lessons; replace in-memory replay logs with a durable Event Store for Harness semantics | +| Atomic JSON writes | settings, profiles, metrics, response records | Reuse for their current bounded configuration/statistics roles | +| Chat history and UI preferences | `ai2apps/web` localStorage | Migrate thread/message state to backend Sessions; retain device-local presentation preferences where appropriate | +| MarkItDown, audio, embedding, reranking, downloaders | existing oMLX APIs/runtime | Wrap as built-in Service capabilities before considering replacement | +| SQLite App/Agent/Session/Event storage | not present | Add once as the shared AI2Apps transactional platform database | + +The target is one AI2Apps platform database per resolved installation/data +root, not one database per App or Service. Services may own private databases +only when their package contract requires independent internal state; those +databases are not substitutes for the shared control-plane/session database. + +### 2.2 Storage coexistence rule + +SQLite becomes the source of truth for relational, transactional Harness state: +Apps, instances, Sessions, Messages, Runs, Steps, semantic Events, installed +packages, capabilities, Grants, ResourceHandles, and Artifact metadata. + +Existing JSON stores remain authoritative for the oMLX settings they already +own. The AI2Apps database references their logical objects through adapters and +stable IDs rather than duplicating their complete content. Migration into the +database is justified only when a feature needs cross-object transactions, +queries, ownership, or durable event ordering that the current store cannot +provide. + +## 3. Implementation principles + +1. **One authoritative backend.** Web, native, CLI, and future remote clients + use the same APIs and event stream. +2. **Contract before orchestration.** Persisted schemas, IDs, state machines, + events, and tool contracts are fixed before building a complex Agent loop. +3. **Semantic events are replayable.** Live token deltas may be ephemeral, but + state transitions, approvals, status, tool results, and final message parts + must survive restart and reconnect. +4. **Security begins with the first Tool.** Every Tool declares effects and + capabilities even before the final OS sandbox adapters are complete. +5. **No ambient host authority.** Host paths, credentials, network, processes, + and external side effects require scoped handles or Grants. +6. **Adapters protect oMLX.** The initial model provider delegates to existing + oMLX code without rewriting inference internals. +7. **SQLite first, explicit repositories.** Use SQLite transactions and small + repository interfaces for new Harness state, sharing the existing resolved + installation data root and lifecycle. Do not rewrite working oMLX JSON stores + merely for storage uniformity. Avoid a distributed system or a large ORM + until a demonstrated requirement exists. +8. **Async at the boundary, bounded underneath.** Long work returns a Run or + Operation ID, supports cancellation, and emits progress. Blocking libraries + execute through bounded workers. +9. **Backward compatibility is tested.** Existing `/v1/*`, admin APIs, CLI, + and model tests remain gates during migration. +10. **Backend contracts are hardware-neutral.** MLX, CUDA, ROCm, macOS, and + Linux details remain behind providers and platform adapters. + +## 4. Reference implementation strategy + +### 4.1 OpenCode + +OpenCode is a useful primary reference because it separates a local server from +its clients, publishes OpenAPI, models sessions/messages as resources, supports +asynchronous prompts and SSE, provides a schema-based Tool Registry, passes +Session context into tools, and applies action/resource permission rules. + +AI2Apps should study and adapt these patterns: + +| OpenCode pattern | AI2Apps treatment | +| --- | --- | +| Local headless server plus multiple clients | Adopt; AI2Apps FastAPI is authoritative for WebUI, native shell, and API clients | +| OpenAPI-generated client contract | Adopt after the first resource schemas stabilize | +| Session, message, and structured message parts | Adapt to ConversationSession, AgentRun, Step, StatusLine, View, and Artifact | +| REST snapshot plus SSE updates | Adopt with durable replay for all semantic events | +| JSON-schema custom and built-in tools | Adopt behind stable Service-qualified Tool IDs | +| Tool execution receives Session context | Extend with AppInstance, AgentRun, package digest, Sandbox, and CapabilityContext | +| `allow` / `ask` / `deny` action-resource rules | Extend into deterministic policy plus CapabilityRequest and scoped GrantLease | +| MCP tool aggregation | Adopt through `ai2apps.mcp`, preserving provider identity and permissions | +| Child sessions for subagents | Adapt with explicit depth, budget, cancellation, and permission narrowing | +| Coding-specific workspace and shell assumptions | Do not generalize into platform contracts | +| Host-authority shell execution | Do not adopt; Process Service runs inside a Session sandbox | +| Custom tool name replacing a built-in | Do not adopt by default; identity includes signed provider and version | +| Project-wide remembered approval | Replace with explicit, expiring, revocable GrantLease scopes | + +OpenCode is MIT licensed. Source-level reuse is legally possible when its +copyright and license conditions are preserved, but AI2Apps should normally use +clean interfaces and independent Python implementations. Any copied or derived +code must be isolated, attributed, reviewed, and recorded in `NOTICE`. + +Primary references: + +- [OpenCode server architecture and API](https://dev.opencode.ai/docs/server/) +- [OpenCode built-in tools](https://dev.opencode.ai/docs/tools/) +- [OpenCode custom Tool contract](https://opencode.ai/docs/custom-tools/) +- [OpenCode permission model](https://opencode.ai/v2/docs/permissions) +- [OpenCode source repository](https://github.com/anomalyco/opencode) +- [OpenCode MIT license](https://github.com/anomalyco/opencode/blob/dev/LICENSE) + +### 4.2 MCP + +MCP remains the external tool/context interoperability protocol. AI2Apps is the +host and policy boundary; an MCP server is not automatically trusted merely +because it implements MCP. + +Patterns to adopt include capability negotiation, isolated client/server +connections, tool/resource/prompt discovery, JSON Schema inputs, progress +notifications, and stdio/Streamable HTTP transports. AI2Apps adds Session +identity, Sandbox policy, ResourceHandles, audit, and GrantLease enforcement +around MCP calls. + +Primary references: + +- [MCP architecture](https://modelcontextprotocol.io/specification/2025-06-18/architecture) +- [MCP architecture overview and primitives](https://modelcontextprotocol.io/docs/learn/architecture) +- [MCP official example servers](https://modelcontextprotocol.io/examples) + +## 5. Target backend module boundaries + +The implementation should grow toward this structure without creating empty +modules prematurely: + +```text +ai2apps/ + api/ + router.py + errors.py + dependencies.py + health.py + sessions.py + events.py + services.py + tools.py + runs.py + capabilities.py + artifacts.py + apps.py + + storage/ + database.py + migrations.py + repositories.py + schema/ + + events/ + models.py + store.py + bus.py + stream.py + + sessions/ + models.py + repository.py + service.py + + services/ + models.py + registry.py + gateway.py + client.py + operations.py + builtin/ + model_runtime.py + mcp.py + workspace.py + artifacts.py + process.py + + tools/ + models.py + registry.py + executor.py + results.py + + agents/ + models.py + registry.py + runtime.py + loop.py + context.py + interaction.py + + apps/ + models.py + registry.py + instances.py + sessions.py + builtin/ + chat.py + + sandbox/ + models.py + policy.py + capabilities.py + resources.py + broker_client.py + platform/ + macos.py + linux.py + + artifacts/ + models.py + store.py + + packages/ + manifests.py + verification.py + resolver.py + audit.py +``` + +`omlx.server` should eventually call one AI2Apps bootstrap function and mount +one AI2Apps router. It must not import every platform implementation module. + +## 6. Initial contracts + +### 6.1 ID and time rules + +- IDs are opaque, lowercase, prefixed values such as `ses_`, `msg_`, `run_`, + `step_`, `evt_`, `svc_`, `tool_`, `capreq_`, and `grant_`. +- The first implementation may use UUID4 payloads with a separate ordered + database sequence; clients must not infer ordering from IDs. +- Stored timestamps are UTC RFC 3339 with microsecond precision. +- API payloads include `schema_version` where durable compatibility matters. +- Mutating API calls accept an idempotency key when replay is plausible. + +### 6.2 Durable Event envelope + +```json +{ + "event_id": "evt_...", + "sequence": 1042, + "type": "agent.run.status.changed", + "occurred_at": "2026-08-11T12:00:00.000000Z", + "scope": { + "app_instance_id": "appi_...", + "session_id": "ses_...", + "run_id": "run_..." + }, + "subject_id": "run_...", + "schema_version": 1, + "payload": {} +} +``` + +Rules: + +- the state mutation and its Event are committed in one transaction; +- Event sequences are monotonically increasing per database; +- SSE accepts `Last-Event-ID` or an explicit `after` cursor; +- authorization is checked both when the snapshot is fetched and when Events + are streamed; +- slow clients have bounded buffers and reconnect through durable replay; +- token/reasoning deltas may use a separate transient channel, but completion + produces a durable message-part Event. + +### 6.3 Tool contract + +Every registered Tool has a `ToolDescriptor`: + +```text +tool ID and version +provider Service ID and package digest +title and model-facing description +JSON Schema input and output +effect classes +required capabilities +timeout and cancellation behavior +idempotency and retry policy +stream/progress support +result kinds: JSON | text | Artifact | ResourceHandle | View +risk and approval defaults +``` + +Every invocation receives a `ToolExecutionContext`: + +```text +user and installation identity +AppDefinition / AppInstance +AgentDefinition / effective package and Patch digest +ConversationSession / Message / AgentRun / Step +SandboxInstance / CapabilityContext / active GrantLeases +trace ID, deadline, cancellation token, and event emitter +``` + +Tool output is never an untyped exception or arbitrary stdout. Failures map to +stable typed errors such as `invalid_input`, `permission_required`, `denied`, +`not_found`, `conflict`, `timeout`, `cancelled`, `unavailable`, and +`internal_error`. + +### 6.4 Initial persistence schema + +The first migrations should cover: + +```text +schema_migrations +app_definitions +app_instances +sessions +messages +message_parts +agent_runs +run_steps +events +service_definitions +service_instances +tool_definitions +capability_requests +grant_leases +resource_handles +artifacts +``` + +Large file content, package archives, model files, and Artifact payloads remain +outside SQLite. SQLite stores metadata, hashes, ownership, paths inside managed +storage, and lifecycle state. + +## 7. Delivery milestones + +Milestones are dependency-ordered rather than calendar estimates. Each should +be implemented and reviewed as one or more small, independently testable +changes. + +### Milestone 0 — Backend seam and contract scaffold + +Deliverables: + +- create `ai2apps.api`, `ai2apps.storage`, `ai2apps.events`, and shared core + model/error modules only as required; +- add a single `create_ai2apps_router()` or bootstrap boundary; +- mount it from `omlx.server` without changing inference routes and attach one + PlatformRuntime to the existing server lifespan; +- add `GET /v1/platform/health` with platform/database schema information; +- derive platform storage paths from the existing resolved base/data path; +- reuse the existing API-key dependency rather than implementing parallel auth; +- ensure the `ai2apps` product entry enables the platform while the legacy + `omlx` entry remains compatible; +- define API error envelope, ID helpers, UTC clock, and configuration paths; +- add focused backend test directories and fixtures. + +Exit gate: + +- server starts and shuts down cleanly through both product entry points; +- `/v1/platform/health` works under existing authentication policy; +- existing OpenAI-compatible and model tests show no behavior change; +- no new import from oMLX inference internals into general platform modules. + +### Milestone 1 — SQLite, Sessions, Messages, and replayable Events + +Deliverables: + +- SQLite connection/transaction layer with WAL, foreign keys, busy timeout, + migration locking, backup-safe paths, and corruption diagnostics; +- one platform database under the resolved installation data root, with no + per-App database split; +- migrations for AppDefinition, AppInstance, Session, Message, MessagePart, and + Event; +- Session create/get/list/update/archive APIs; +- Message append/list APIs with structured parts; +- transactional Event Store and in-process notification bus; +- per-Session and global SSE with cursor replay and heartbeat; +- idempotent message creation and optimistic version checks. + +Exit gate: + +- restart retains all Sessions, Messages, and semantic Events; +- a disconnected client reconnects without gaps or duplicate effects; +- concurrent threads cannot read or mutate each other's records without an + explicit authorized relationship; +- migration and crash-recovery tests pass. + +### Milestone 2 — Singleton Chat App backend + +Deliverables: + +- seed built-in `ai2apps.general-chat` AppDefinition; +- resolve exactly one Chat AppInstance in the current local-user scope; +- model existing Chat threads as App-owned ConversationSessions; +- implement thread create/list/select/rename/pin/archive/delete semantics; +- designate and reassign the HomeSession/default thread; +- persist Chat collection state separately from thread content; +- add compatibility adapters for existing thread IDs/data when discovered; +- expose generic AppInstance Session APIs plus Chat-friendly route aliases. + +Exit gate: + +- creating ten threads still yields exactly one Chat AppInstance; +- each thread has independent messages and Session identity; +- archiving/deleting a thread does not close the Chat AppInstance; +- two clients can display different threads from the same instance; +- thread migration is idempotent and rollback-safe. + +### Milestone 3 — Service Registry, Tool Registry, and existing adapters + +Deliverables: + +- ServiceDescriptor, ServiceInstance, ToolDescriptor, and lifecycle state; +- in-memory plus persisted Service/Tool registries; +- Service Client/Gateway dispatch that resolves stable identity rather than + physical URLs; +- built-in adapter for the existing oMLX model runtime; +- built-in adapter for the existing MCP manager/executor; +- adapters for existing embedding, reranking, audio, and document-conversion + capabilities where their contracts already satisfy the Service boundary; +- Tool discovery filtered by caller, Agent, Session, policy, and model format; +- schema validation for Tool inputs and outputs; +- no-op/echo diagnostic Tool used only by tests. + +Exit gate: + +- existing model and MCP implementations can be invoked through Service Client + without changing their public compatibility APIs; +- duplicate Tool identity, invalid schema, missing Service, timeout, and + cancellation behavior are deterministic; +- callers cannot spoof a Service or Tool provider ID. + +### Milestone 4 — Logical capability policy and approval flow + +Deliverables: + +- Capability, CapabilityRequest, GrantLease, ResourceHandle, and AuditDecision + models; +- ordered deterministic policy rules with default deny/ask behavior; +- effects and target resources resolved before a Tool begins; +- `once`, `run`, `session`, `app-instance`, `package-version`, and explicit + persistent-rule Grant scopes; +- approve, narrow, deny, expire, and revoke APIs; +- `waiting_capability` Run state and durable approval Events; +- independent AI-audit hook interface, initially manual/deterministic only; +- safe behavior when no UI client is connected to answer a request. + +Exit gate: + +- no Tool side effect occurs before policy resolution; +- a denied or expired Grant cannot be reused; +- changing the effective package/Patch digest invalidates digest-bound Grants; +- pending approval has a deadline and cannot hang a Run forever; +- approval replay is idempotent and auditable. + +### Milestone 5 — Workspace and Artifact Services + +Deliverables: + +- one managed workspace and temporary area per SessionSandbox; +- opaque ResourceHandles for user-selected external files/directories; +- `workspace.list`, `workspace.stat`, `workspace.read`, `workspace.search`, + `workspace.write`, and `workspace.apply_patch` Tools; +- canonical path and symlink-escape protection; +- Artifact create/list/read/preview/export lifecycle; +- atomic writes, content hashes, quotas, and trash/recovery where practical; +- transactional export through the Host Broker abstraction; +- structured truncation/pagination for large Tool results. + +Exit gate: + +- one Session cannot address another Session's workspace or handle; +- relative paths and symlinks cannot escape the sandbox root; +- an SVG can be selected, copied into the Session, transformed into an + Artifact, and exported only after the required Grant; +- cancellation/failure never leaves a partial host export. + +### Milestone 6 — Minimal asynchronous Agent Runtime + +Deliverables: + +- AgentDefinition, EffectiveAgent, AgentRun, RunStep, StatusLine, and AgentView + persistence; +- model -> tool call -> tool result -> model loop using Model Runtime Service; +- strict Run state machine, maximum steps, token/time/resource budgets, retry + policy, and repeated-call/doom-loop detection; +- `queued`, `planning`, `running`, `waiting_input`, `waiting_capability`, + `completed`, `failed`, and `cancelled` states; +- mandatory status-line fallback and progress Events; +- user text/menu/file/approval interaction primitives; +- cancellation propagation through model and Tool calls; +- context assembly and compaction interface; +- per-Session concurrency policy and recovery of interrupted Runs. + +Exit gate: + +- an Agent can complete a multi-step model/Tool task after server reconnect; +- every Run always has a visible durable status; +- cancelling a Run stops or safely fences all child work; +- duplicate client retries do not create duplicate AgentRuns or side effects; +- an interrupted Run resumes safely or reaches an explicit recoverable state. + +### Milestone 7 — Process Service and enforced Session sandbox + +Deliverables: + +- `process.start`, `process.write_stdin`, `process.status`, `process.logs`, and + `process.cancel` Tools; +- bounded process count, CPU, memory, output, wall-time, and idle limits; +- environment allowlist and Secret references rather than ambient environment; +- Session workspace as the default working directory; +- common Host Broker protocol; +- first macOS process/filesystem sandbox adapter; +- Linux adapter contract and test double; +- process-tree cancellation and orphan cleanup after restart; +- network default deny with explicit capability mediation. + +Exit gate: + +- a process cannot access another Session or unauthorized host resource; +- output flooding cannot exhaust server memory or Event storage; +- Run cancellation terminates its process tree; +- broker requests are authenticated, scoped, expiring, and logged; +- third-party executable code remains disabled until this gate passes. + +### Milestone 8 — Service lifecycle and package trust + +Deliverables: + +- embedded, managed-process, and external Service lifecycle implementations; +- health/readiness, logs, operations, restart/backoff, and dependency ordering; +- `.ai2service` parsing, canonical digest, immutable package store, and SBOM; +- signature/publisher trust interface and offline verification; +- dependency solver/lock and platform/accelerator compatibility selection; +- staged install, enable, disable, upgrade, rollback, and uninstall; +- audit attestation storage and local AI audit interface; +- safe failure and rollback across database, filesystem, and processes. + +Exit gate: + +- package code cannot execute before validation and policy approval; +- failed install/upgrade restores the prior active graph; +- dependents prevent unsafe disable/uninstall; +- source, selected native artifacts, permissions, audit, and signatures are + inspectable before installation. + +### Milestone 9 — Installable Agents, Apps, and local Patch stacks + +Deliverables: + +- `.ai2agent`, `.ai2app`, and `.ai2patch` canonical formats; +- immutable upstream definitions and ordered device-signed local Patches; +- EffectiveAgent/EffectiveApp assembly and cache; +- install, enable, disable, upgrade, rollback, and uninstall control plane; +- App Entry/Mini-Entry registration and View bridge contracts; +- multiple/singleton AppInstance enforcement; +- state-schema migration snapshots and atomic activation; +- three-way Patch rebase workspace and explicit conflict state; +- unpatchable Safe Mode recovery controls. + +Exit gate: + +- an upstream upgrade cannot silently discard or reinterpret a local Patch; +- conflicted or unaudited effective definitions do not activate; +- App state migration succeeds for all instances or rolls back atomically; +- Safe Mode can disable Patches and restore built-ins without normal App UI. + +### Milestone 10 — Additional foundational Services + +Implement as independent packages in this priority order: + +1. Web Fetch/Search Service; +2. Memory/Retrieval Service; +3. Browser Service; +4. remaining Document conversion/rendering capabilities not covered by the + existing MarkItDown path; +5. Job/Schedule Service; +6. Media Service; +7. notification and external connector Services. + +Each Service must use the same Tool, progress, cancellation, capability, +ResourceHandle, audit, packaging, and lifecycle contracts. A Service is not +promoted to built-in merely because a single App needs it. + +Exit gate for each Service: + +- contract and capability review; +- isolated unit and integration tests; +- bounded resource and output behavior; +- cancellation and restart behavior; +- no Session data leakage; +- package/audit/install test fixture. + +### Milestone 11 — Hardware-neutral model providers and Linux qualification + +Deliverables: + +- normalized HardwareProfile and shared-memory pressure accounting; +- formal ModelBackendProvider conformance suite; +- preserve oMLX as the Apple Silicon reference provider; +- NVIDIA/CUDA Linux provider package; +- AMD/ROCm Linux provider package; +- Linux Host Broker/sandbox enforcement implementation; +- package compatibility matrix for OS, architecture, driver, runtime, and + native dependencies; +- cross-provider API, cancellation, memory-pressure, and failure tests; +- documented performance gates for each supported device family. + +Exit gate: + +- Apps and Agents run unchanged across providers; +- model identity and compatibility failures are explicit; +- memory admission uses reported live capacity rather than hard-coded RAM/VRAM; +- platform safety and lifecycle tests pass on each qualified Linux target. + +## 8. Initial API slice + +The first implementation cycle should expose only the endpoints needed for +Milestones 0–2: + +```text +GET /v1/platform/health + +GET /v1/apps/ai2apps.general-chat/instances/default +GET /v1/app-instances/{instance-id}/sessions +POST /v1/app-instances/{instance-id}/sessions +GET /v1/app-instances/{instance-id}/sessions/{session-id} +PATCH /v1/app-instances/{instance-id}/sessions/{session-id} +DELETE /v1/app-instances/{instance-id}/sessions/{session-id} + +GET /v1/sessions/{session-id}/messages +POST /v1/sessions/{session-id}/messages +GET /v1/sessions/{session-id}/events +GET /v1/events +``` + +Deletion initially means a retained soft-delete/archive transition. Permanent +destruction and retention policy are separate administrative operations. + +## 9. Agent Runtime execution contract + +The initial loop is deliberately linear and observable: + +```text +accept user message +-> create/idempotently resolve AgentRun +-> assemble context and visible Tool catalog +-> set status-line +-> call Model Runtime Service +-> persist completed text/reasoning/tool-call parts +-> evaluate Tool capability + -> deny and return typed result + -> wait for input/approval + -> execute Tool with deadline and cancellation +-> persist Tool result and Events +-> repeat within budgets +-> complete, fail, cancel, or suspend explicitly +``` + +Graph workflows, speculative multi-agent execution, autonomous background +planning, and distributed queues are deferred. Agent-to-Agent invocation first +uses the same child-Session/Run primitives with explicit depth and budgets. + +## 10. Concurrency, recovery, and consistency + +- A Session has an explicit active-Run policy; the first release may serialize + mutating Runs per Session while allowing independent Sessions in parallel. +- Each Run/Step transition uses compare-and-swap versioning or an equivalent + transactional guard. +- Tool calls have stable call IDs and settlement records. A recovered loop + never blindly repeats an unknown external side effect. +- Internal read-only Tools may be retried according to descriptor policy. +- External/mutating Tools require idempotency support or enter + `needs_reconciliation` after ambiguous failure. +- Run cancellation is durable and checked before every model/Tool boundary. +- Event publication occurs after commit; notification loss is repaired through + the durable Event cursor. +- SQLite writes use short transactions. Model inference, network calls, and + process execution never hold database transactions open. + +## 11. Security gates + +The following features remain disabled until their corresponding gate exists: + +| Feature | Required gate | +| --- | --- | +| Third-party Tool with side effects | Tool schema validation plus logical capability policy | +| External file access | ResourceHandle plus explicit GrantLease | +| Host file mutation/export | transactional Host Broker operation | +| Third-party process Service | enforced process/filesystem/resource sandbox | +| Outbound network | destination-scoped network capability | +| Secret use | non-exportable Secret reference and brokered injection | +| Package installation | digest, file-index, source inspection, dependency, permission, and signature verification | +| AI auto-approval | independent auditor, bounded policy, evidence, timeout, and fail-closed behavior | + +## 12. Test strategy + +### Contract tests + +- JSON schemas and OpenAPI snapshots; +- stable error codes and state-machine transition tables; +- Tool descriptor and capability vocabulary; +- Event backward/forward compatibility fixtures. + +### Repository and migration tests + +- fresh database, every supported upgrade path, rollback, backup, corruption, + and concurrent access; +- foreign-key ownership and cross-Session isolation; +- idempotent commands and optimistic concurrency. + +### Runtime tests + +- model/Tool loop, user input, approval, cancellation, retry, timeout, + compaction, restart, and ambiguous external failure; +- SSE reconnect, cursor replay, heartbeat, slow client, and bounded memory; +- multi-thread Chat with one AppInstance. + +### Security tests + +- path traversal, symlink escape, stale handle, forged Grant, digest change, + expired Grant, confused deputy, secret exfiltration, and network denial; +- process tree escape, output flooding, resource exhaustion, and orphan cleanup; +- malicious package archive and dependency-confusion fixtures. + +### Compatibility tests + +- existing oMLX model load/inference/streaming and MCP suites; +- existing OpenAI-compatible routes and clients; +- `ai2apps` and legacy `omlx` CLI entry points; +- macOS and qualified Linux provider matrices. + +### Performance gates + +- Event append and replay latency; +- SQLite contention with concurrent Sessions; +- SSE memory per client and bounded slow-consumer behavior; +- Tool dispatch overhead; +- Agent loop overhead excluding model time; +- no material regression to full-resident oMLX model TPS/memory gates. + +## 13. Observability + +Every request, Run, Step, Tool call, Service invocation, broker operation, and +Event carries a trace ID plus applicable AppInstance/Session/Run IDs. + +Minimum metrics: + +```text +active Sessions and Runs +Run queue/wait/execution duration +model and Tool call duration/errors/cancellation +pending approvals and approval age +Service readiness/restarts/queue depth +Event append/replay lag and SSE clients +SQLite busy time and transaction failures +workspace/artifact quota usage +sandbox violations and revoked Grant use +model memory reservation and live pressure +``` + +Logs never include secret values, raw authorization tokens, or unrestricted +user file contents. Audit records and operational logs are separate retention +classes. + +## 14. Step-by-step execution protocol + +For each milestone: + +1. inventory overlapping oMLX/AI2Apps capabilities and record each decision as + reuse, wrap, migrate, replace, or genuinely new; +2. select the smallest vertical slice and record its exact acceptance tests; +3. inspect overlapping user changes before editing; +4. add contracts and failing tests; +5. implement the minimum backend behavior behind the AI2Apps boundary; +6. run focused tests, then relevant oMLX compatibility tests; +7. inspect API schema and replay/restart behavior; +8. update architecture/decision records when implementation changes a contract; +9. stop at the milestone gate and review before expanding scope. + +Do not combine a new persistence model, Agent loop, package manager, and OS +sandbox into one change. The system should remain demonstrable after every +slice. + +## 15. First implementation slice + +**Milestone 0A: backend seam is complete.** Its implementation consists of: + +```text +ai2apps/api/router.py +ai2apps/api/health.py +ai2apps/api/errors.py +ai2apps/config.py +tests/test_ai2apps_platform_health.py +``` + +Scope: + +- implement an AI2Apps APIRouter and health response; +- mount it once from `omlx.server`; +- expose product/runtime/database-schema placeholders without opening a + database yet; +- verify authentication and both CLI entry modes; +- make no changes to model execution, MCP behavior, or WebUI. + +Acceptance evidence: + +- the standalone platform contract, embedded configuration, existing API-key + authentication, OpenAPI publication, and stable error envelope are covered + by `tests/test_ai2apps_platform_health.py`; +- the focused platform tests and the existing authentication, status, and + AI2Apps product and CLI compatibility suites pass (59 tests total), and the + AI2Apps CLI help entry point completes successfully; +- the platform path is derived from the existing resolved installation data + root without creating directories or opening a database; +- no WebUI files are part of this slice. + +### Milestone 0B: database bootstrap + +**Milestone 0B is complete.** It adds: + +```text +ai2apps/platform_runtime.py +ai2apps/storage/database.py +ai2apps/storage/migrations.py +tests/test_ai2apps_platform_storage.py +``` + +The platform now creates one `ai2apps-platform.sqlite3` database beneath the +existing resolved installation data root during FastAPI startup. Schema v1 is +deliberately limited to `schema_migrations`; no App, Agent, Session, or Event +tables are introduced before their contracts are implemented. + +Database bootstrap enables WAL, foreign keys, a bounded busy timeout, and +`PRAGMA quick_check`. Migrations run in short `BEGIN IMMEDIATE` transactions, +are idempotent under concurrent startup, roll back on failure, reject newer +schemas without downgrade, and distinguish lock timeout from corruption. +FastAPI lifespan owns PlatformRuntime startup and shutdown, while health reports +actual and target schema versions plus journal mode. + +Acceptance evidence: + +- fresh, repeated, concurrent, rollback, future-schema, corrupt-file, runtime, + health, and complete FastAPI lifespan cases are covered; +- platform, authentication, status, product, CLI, and server-entry regression + suites pass (72 tests total); +- scoped static checks pass; +- no model execution, MCP, existing oMLX data store, or WebUI behavior changed. + +Milestone 1 can now begin with its first vertical slice: core IDs/time helpers +and the AppDefinition, AppInstance, Session, Message, MessagePart, and Event +schema contracts. Resource APIs and SSE should follow only after that migration +is stable. + +## 16. Milestone 1 implementation progress + +### M1A: core contracts and relational schema + +**M1A is complete.** Schema v2 introduces the first Harness resource tables: + +```text +app_definitions +app_instances +sessions +messages +message_parts +events +``` + +Internal resource IDs use lowercase UUID4 payloads with typed prefixes: +`app_`, `appi_`, `ses_`, `msg_`, `part_`, and `evt_`. Package-facing IDs such +as `ai2apps.general-chat` remain separate readable identifiers. Durable time is +canonical UTC RFC 3339 with six fractional digits and a `Z` suffix. + +Relational rules in schema v2 include: + +- App instance mode/scope consistency and database-enforced singleton keys; +- one optional HomeSession per AppInstance without limiting additional + Sessions; +- strict AppInstance -> Session -> Message -> MessagePart ownership through + foreign keys; +- per-Session message ordering and idempotency-key uniqueness; +- JSON validity, lifecycle vocabulary, opaque ID shape, revision, and timestamp + checks; +- a database-global monotonically increasing Event sequence; +- Session/AppInstance Event scope consistency and append-only Event records; +- indexes for App lifecycle, Session collection, message replay, and Event + replay queries. + +The v1 -> v2 migration is transactional, preserves the existing migration +ledger, and remains safe under repeated and concurrent startup. This slice adds +no resource routes and performs no browser-owned thread migration. Core/schema, +platform lifecycle, authentication, status, product, CLI, and server-entry +regression suites pass (92 tests total), and scoped static checks pass. + +The next slice is **M1B: repositories and transactional Event Store**. It will +implement typed row models, App/Session/Message repositories, atomic state plus +Event commits, optimistic revisions, and idempotent message append. REST and +SSE remain M1C so repository behavior can be tested independently first. + +### M1B: repositories and transactional Event Store + +**M1B is complete.** It adds typed records and explicit repositories for +AppDefinition, AppInstance, Session, Message, MessagePart, and Event without +introducing an ORM or changing schema v2. + +All mutations use short caller-owned SQLite transactions. Repository state and +its semantic Event are committed together; an Event failure rolls back the +resource mutation and any structured MessageParts. The Event Store supports a +database-global cursor, bounded replay after a sequence, Session/AppInstance +filters, and subject lookup. + +Implemented concurrency contracts: + +- AppInstance state and Session metadata/lifecycle updates require an expected + revision and return a typed conflict containing the actual revision; +- Message sequence allocation occurs under the write transaction, producing a + gapless per-Session order under concurrent append; +- an idempotency key is scoped to one Session. An identical replay returns the + original Message, parts, and Event without another write; reuse with different + content raises a typed idempotency conflict; +- concurrent identical idempotency requests settle to one Message and one + Event; +- scoped reads treat a resource owned by another AppInstance/Session as not + found rather than exposing it; +- relational conflicts, missing resources, stale revisions, and idempotency + conflicts have separate Repository error types. + +Repository, core/schema, lifecycle, authentication, status, product, CLI, and +server-entry suites pass (103 tests total), and scoped static checks pass. No +REST/SSE endpoint or WebUI file is part of M1B. + +The next slice is **M1C: Session/Message REST and replayable Event transport**. +It should add request/response schemas, Repository-error mapping, snapshot APIs, +an in-process notification bus, and SSE cursor replay with heartbeat and bounded +subscriber queues. + +### M1C: generic Session REST and replayable Event transport + +**M1C is complete.** Schema v3 makes the Session model explicitly broader than +the Chat App thread model: + +```text +session_kind = app | chat_thread | mini_chat | in_app_chat | agent_child +visibility = listed | unlisted +retention = durable | temporary +expires_at = optional UTC timestamp +``` + +`mini_chat` and `in_app_chat` default to `unlisted + temporary`. They remain +authoritative backend Sessions with Messages, Events, Agent context, and future +sandbox ownership, but do not enter the Chat App's persistent ThreadCollection. +A `chat_thread` is database-constrained to `listed + durable`; Chat ownership +itself will be enforced when the built-in Chat App is seeded in Milestone 2. + +M1C exposes authenticated platform APIs for: + +```text +POST/GET /v1/platform/app-instances/{app-instance-id}/sessions +GET/PATCH/DELETE + /v1/platform/app-instances/{app-instance-id}/sessions/{session-id} +POST/GET /v1/platform/sessions/{session-id}/messages +GET /v1/platform/sessions/{session-id}/events +GET /v1/platform/events +``` + +The global Event endpoint is SSE. It accepts `after` or `Last-Event-ID`, replays +durable Events in global sequence order, then waits on an in-process commit +notification bus. Subscriber queues contain only one coalesced wake token, so a +slow client cannot create an unbounded in-memory Event backlog; it catches up +from SQLite. Idle streams emit heartbeat comments. + +Notifications are registered inside the write transaction but published only +after commit. Rollback discards both the Event and notification. REST errors use +the stable platform envelope for authentication, validation, missing resources, +revision conflicts, relational conflicts, and idempotency conflicts. + +REST/SSE, generic Session classification, commit/rollback notification, +heartbeat, cursor replay, bounded backpressure, repository, migration, +authentication, and oMLX compatibility suites pass (114 tests total), and +scoped static checks pass. No WebUI file is part of M1C. + +M1 now has durable schema, repositories, snapshots, and replay transport. Its +remaining hardening slice should cover temporary-Session expiry/retention jobs, +SSE disconnect/load behavior, database backup/corruption operator diagnostics, +and broader crash-recovery/performance gates before declaring the milestone +fully closed. + +### M1D: retention, recovery, and operator hardening + +**M1D is complete, and Milestone 1 is Finished.** Schema v4 closes the +temporary-Session lifecycle contract. A newly created temporary Session gets a +24-hour expiry unless the caller provides an explicit UTC expiry; durable +Sessions cannot carry an expiry. The migration backfills legacy temporary +Sessions and database triggers preserve the retention/expiry invariant. + +PlatformRuntime owns a bounded retention loop. Each pass soft-deletes at most +one batch of expired temporary Sessions and appends `session.expired` in the +same transaction. The operation is ordered and idempotent, preserves Messages +and Events for audit/recovery, and never promotes an unlisted interaction into +a Chat thread. + +SQLite operator support now includes: + +- read-only schema, journal, integrity, foreign-key, page-count, and page-size + diagnostics; +- online backups through SQLite's backup API, written to a temporary sibling, + integrity-checked, then atomically published; +- rejection of a backup target that aliases the live database; +- explicit tests proving uncommitted writes disappear after a connection crash + and committed snapshot state remains readable. + +SSE hardening verifies subscriber cleanup after cancellation, one-slot wake +coalescing, rollback silence, heartbeat behavior, and a 250-Event replay using +small batches without cursor gaps. The M1 backend and oMLX compatibility gate +passes **183 tests**, and scoped static checks pass. No WebUI file is part of +M1D. + +Milestone 1 therefore delivers the generic Session substrate—not a Chat-only +thread store—together with durable Messages, atomic semantic Events, replayable +transport, temporary retention, restart/crash behavior, and operator-safe +SQLite diagnostics/backup. Milestone 2 may now build the singleton Chat App on +these generic contracts. + +## 17. Milestone 2 implementation progress + +### M2A: singleton Chat backend and ThreadCollection + +**M2A is complete.** Schema v5 adds `chat_collections` and +`chat_thread_entries` without creating a second kind of message or conversation +store. Runtime startup idempotently seeds built-in `ai2apps.general-chat`, +resolves the initial local user to the stable singleton key +`ai2apps.general-chat:user:local`, and creates exactly one Chat AppInstance and +one collection under concurrent resolution. + +Every Chat thread remains a generic `chat_thread + listed + durable` Session. +Session owns title, lifecycle, revision, metadata, Messages, and Events; +ThreadCollection owns selected-thread recovery state, pinning, ordering, and an +optional legacy browser-thread identity. Database triggers prevent managed +threads from changing classification and prevent generic Session calls from +archiving/deleting the selected Home thread without first reassigning it. + +The transactional Chat repository implements: + +- create, get, list, rename, pin, archive, and logical delete; +- optimistic collection selection independent of Session revisions; +- HomeSession designation and automatic selected/Home fallback; +- one AppInstance with any number of independently addressable Sessions; +- idempotent legacy-thread import, including browser message content and + Session metadata, with rollback of the entire import if any Event/write fails. + +Chat-friendly backend aliases are available at: + +```text +GET /v1/platform/chat +POST/GET /v1/platform/chat/threads +GET/PATCH /v1/platform/chat/threads/{thread-id} +DELETE /v1/platform/chat/threads/{thread-id} +POST /v1/platform/chat/threads/{thread-id}/select +POST /v1/platform/chat/threads/{thread-id}/home +POST /v1/platform/chat/threads/{thread-id}/archive +``` + +The existing oMLX WebUI stores history in browser `localStorage` under +`omlx_chat_history`; the backend cannot discover that browser-owned data by +itself. M2A therefore supplies the authenticated atomic import contract. M2B +below adopts that contract in the WebUI and completes the migration. + +Singleton concurrency, ten-thread ownership, Message isolation, two-client +projection, selection/revision conflicts, Home fallback, AppInstance survival, +legacy idempotency/rollback, schema migration, platform API, authentication, +CLI, server-entry, and oMLX compatibility suites pass (**191 tests**), and +scoped static/compile checks pass. No WebUI file is part of M2A. + +### M2B: authoritative Chat WebUI adoption + +**M2B and Milestone 2 are complete.** The existing oMLX-style Chat Entry now +uses the authenticated platform Chat API as its authoritative thread store. +The UI creates one backend Session per thread and persists selection, title, +pinning, deletion, branching, imported chats, Session metadata, and message +content with optimistic revisions. Message snapshots remain generic Session +Messages/MessageParts; each replacement also emits a semantic Event. + +On first authenticated startup, browser-owned legacy threads are imported by +stable legacy ID. Import is idempotent and resumable: an interrupted pass can +retry without duplicating threads. The migration marker records completion, +but `omlx_chat_history` is deliberately retained as a local recovery copy and +UI cache; it is no longer the source of truth. A backend failure leaves that +copy readable rather than destroying user history. + +Content writes are debounced and serialized per thread. Every mutation carries +the last observed Session or collection revision, so stale browser windows get +an explicit conflict instead of silently overwriting newer state. Operations +whose local projection would be destructive, including thread deletion, commit +to the backend before removing the local copy. + +The browser workflow was exercised against a real local server: creating, +renaming, pinning, legacy import, refresh, and backend recovery all succeeded. +Focused repository/API/UI suites and the complete M1/M2 compatibility gate pass +(**236 tests**), together with scoped Ruff and Python compile checks. + +## 18. Milestone 3 implementation progress + +### M3A: durable Service and Tool contracts + +**M3A is complete.** Schema v6 adds `service_descriptors`, +`service_dependencies`, `service_instances`, and `tool_descriptors`. Stable +`svc_`, `svci_`, and `tool_` identifiers distinguish persisted identity from a +physical endpoint or process-local provider. Descriptors store runtime mode, +package/version, capabilities, dependency constraints, configuration, JSON +Schemas, declared effects, required capabilities, timeouts, lifecycle state, +health, and optimistic revisions. + +The registry supports embedded and external JSON providers. Package +installation, signature verification, dependency solving, managed-process +supervision, and uninstall remain Milestone 8 responsibilities; M3 establishes +the control-plane records those systems will operate on. + +### M3B: existing model runtime adapter + +**M3B is complete.** `ai2apps.model-runtime` is seeded as an in-process built-in +Service backed by the existing oMLX EnginePool. `model.status`, `model.load`, +and `model.unload` are registered Tools, with lifecycle mutations requiring the +`model.manage` capability. Existing `/v1/chat/completions`, `/v1/responses`, +embedding, and reranking routes remain the model Service's authoritative +OpenAI-compatible inference contract; they are referenced by the Service +descriptor rather than duplicated or internally looped back. + +### M3C: existing MCP adapter + +**M3C is complete.** `ai2apps.mcp` wraps the current MCP Manager. Connected MCP +servers are projected into the shared Tool Registry as `mcp.__` +without replacing the existing `/v1/mcp/*` compatibility API. Refresh disables +tools no longer discovered, execution delegates to the original manager, and +MCP enable/disable/restart delegates to its real start/stop lifecycle. + +### M3D: Service Client/Gateway and control API + +**M3D and Milestone 3 are complete.** The Tool Gateway resolves stable Tool and +provider identity, rejects provider spoofing, filters discovery by lifecycle +and granted capabilities, verifies active Session context before effects, +validates Draft 2020-12 input/output JSON Schemas, bounds execution time, +propagates cancellation, normalizes provider errors, and emits durable semantic +Events for completed, failed, timed-out, and cancelled calls. The built-in +`system.echo` Tool is the end-to-end diagnostic reference. + +The authenticated control surface is: + +```text +GET /v1/platform/services +GET /v1/platform/services/{service-key} +POST /v1/platform/services/{service-key}/enable +POST /v1/platform/services/{service-key}/disable +POST /v1/platform/services/{service-key}/restart +GET /v1/platform/tools +POST /v1/platform/tools/{qualified-name}/invoke +``` + +Service lifecycle changes are revision-checked. Public Tool invocation never +accepts caller-supplied capability claims or provider identity; future Agent +Runtime calls use an internal `ToolCallContext` populated by policy and Session +grants. The complete M1-M3 plus existing MCP compatibility gate passes +(**451 tests**), together with scoped Ruff and Python compile checks. + +## 19. Asynchronous Agent Runtime implementation progress + +This foundation was pulled forward from Milestone 6 because it is the common +execution substrate for capability approvals, workspace Tools, and later App +integration. It does not mark the full Milestone 4 capability/GrantLease policy +or every Milestone 6 context/budget feature complete. + +### AR-A: durable definitions, Runs, steps, status, and interactions + +**AR-A is complete.** Schema v7 adds Agent definitions, shared concurrency +groups, AgentRuns, RunSteps, mandatory primary status-lines, and typed +interactions. Run and interaction state transitions are database-validated; +creation, client responses, and Tool action keys are idempotent. Menu, text, +file, form, and approval interactions carry JSON Schema plus UI hints and have +durable deadlines. + +### AR-B: queueing, resource admission, and restart recovery + +**AR-B is complete.** The asynchronous scheduler atomically claims priority +queue entries. An Agent may be ungrouped, share an N-wide concurrency group, +or claim an exclusive group with limit 1 for scarce hardware. Waiting for user +input or capability approval releases that capacity. Runtime shutdown, +cancellation, deadlines, interaction expiry, and restart recovery are explicit. +Interrupted effectful Tool calls become uncertain and require an operator/user +reconciliation decision before execution continues. + +### AR-C: model and Tool action bridge + +**AR-C is complete for the runtime action contract.** Resumable Agent executors +emit one durable action at a time. Model actions call the existing oMLX +OpenAI-compatible inference route through an in-process ASGI adapter. Tool +actions resolve the registered descriptor and execute through the Tool Gateway. +Missing Tool capabilities create a per-Run approval interaction before any +effect occurs; approval grants only the requested capabilities to that Run, +while denial or expiry fails closed. Durable action IDs prevent settled work +from being repeated during normal retries. + +The diagnostic Agent exercises individual echo, model, Tool, menu, text, file, +and approval paths. The production general loop is completed in AR-E below. +Persistent GrantLease scopes and semantic/token-aware context compaction remain +their original Milestone 4/6 responsibilities. + +### AR-D: frontend synchronization contract + +**AR-D backend support is complete.** Authenticated HTTP endpoints create, +inspect, answer, approve/deny, cancel, and resume Runs. Every snapshot includes +the primary status-line, steps, interactions, revisions, and a Run-scoped SSE +URL. SSE filters by Run, persists sequence numbers, and replays after +`Last-Event-ID`; response IDs make repeated interaction submissions harmless. +The frontend can therefore render immediately from a snapshot and converge by +Events after reconnect without polling or relying on browser-only state. + +At AR-D completion no concrete Chat renderer had changed; AR-F below now +delivers that integration without enabling rich HTML execution. + +### AR-E: built-in General Agent model/Tool loop + +**AR-E is complete.** `ai2apps.general-agent` is seeded as the default public +Agent target. It accepts either an existing Session User `message_id` or a +direct `prompt`; direct prompts and final Assistant answers use Run-derived +idempotency keys. The final Message carries its AgentRun/definition provenance. + +Every scheduler pass reconstructs the OpenAI transcript from bounded Session +history plus completed model and Tool Steps. Model-requested function aliases +are stable across Tool catalog changes and resolve back to canonical qualified +Tool names. Multiple Tool calls in one model response settle durably in order, +retain their original `tool_call_id`, and feed the next model request. Missing +capabilities enter the existing fail-closed approval interaction before the +Tool handler executes. + +The initial production guards include definition-level Tool allow patterns, +message-count context bounds with an explicit omission marker, cumulative +model-token budget, maximum Run steps and wall time, and consecutive identical +Tool-call detection. A final model answer may complete exactly at the Step +budget boundary, while a model that exhausts its token budget requesting an +effect cannot execute that effect. Full semantic context summarization and +persistent GrantLease policy remain later milestones. + +### AR-F: Chat status and interaction renderer + +**AR-F is complete.** New Chat turns create the built-in General Agent instead +of running the model/MCP loop in the browser. Each Run card is anchored beneath +its invoking User Message and converges from a Run snapshot plus authenticated, +cursor-replayable fetch-SSE. Cancelling the Chat turn durably cancels its Run; +page reload restores active Runs from Message provenance and retained Run IDs. +The legacy streaming path remains available to existing regenerate/variant +flows during their later migration. + +The initial `StatusRendererRegistry` enables `status-v1`: semantic theme tones, +host icons, progress, expandable detail, bounded motion effects, terminal-state +normalization, dark-theme inheritance, and reduced-motion behavior. Unknown or +failing renderers always show non-empty text. `safe-html-v1` and +`sandbox-html-v1` are recognized but deliberately disabled; no Agent status +content reaches `x-html`. + +Menu, text/form, and approval interactions render as independent schema-driven +cards and submit idempotent response IDs. File requests render an explicit +unavailable state until the Workspace ResourceHandle bridge exists, rather +than exposing an unsafe ambient browser path. Browser verification exercised a +durable waiting menu, selection submission, SSE convergence, reload recovery, +and terminal collapse. + +## 20. M4A-D capability policy implementation progress + +**M4A-D's initial vertical slice is complete.** Schema v8 introduces ordered +capability policies, durable GrantLeases, and immutable capability decision +records. The runtime evaluates every effectful Tool action before creating a +Tool Step: active non-expired leases are resolved first, then deterministic +rules by priority, with deny winning equal-priority conflicts. The built-in +fallback is `require_approval`; no Tool handler begins while the decision is +deny or unresolved. + +GrantLeases support `run`, `session`, `agent`, and `app` scopes, remain bound to +the requesting Agent definition and Tool pattern, and carry issuer, expiry, +resource-selector, and evidence fields. Run grants expire at the Run deadline; +all grants can be listed and revoked through the authenticated platform API. +The old per-Run capability JSON remains only as a compatibility projection and +is not authoritative for execution, so revocation takes effect on the next +Tool checkpoint. + +Chat approval cards default to **Allow once** and additionally offer **Allow +for session**, **Always allow agent**, and **Deny**. Approval response IDs remain +idempotent; the selected scope and response evidence are persisted with both +the interaction decision and issued lease. The management surface is: + +```text +GET /v1/platform/capability-policies +PUT /v1/platform/capability-policies/{policy-key} +GET /v1/platform/grant-leases +POST /v1/platform/grant-leases/{grant-id}/revoke +``` + +An independent AI auditor can be bound through +`PlatformRuntime.bind_ai_capability_auditor`. It receives a narrowed structured +request and may return allow, deny, or require-approval with evidence. Invalid +output or an auditor error fails closed to user approval. Deterministic denial +is never sent to or overridden by the auditor. Every policy evaluation, user +decision, lease issue, and revocation emits semantic audit Events. + +This slice deliberately does not claim the remaining security-hardening items +from the full Milestone 4 gate: ResourceHandle target resolution arrives with +M5, and package/Patch digest-bound invalidation will be connected when the +package manager has canonical effective digests. + +## 21. M5 Workspace and Artifact implementation progress + +**The M5 vertical slice is complete.** Schema v9 adds one quota-tracked +SessionSandbox record per Session, opaque ResourceHandles, immutable Artifacts, +and durable Artifact export operations. Filesystem state lives below the +configured platform sandbox/artifact roots rather than in SQLite; SQLite holds +identity, ownership, content hashes, lifecycle, and audit metadata. + +The built-in `ai2apps.workspace` Service publishes: + +```text +workspace.list workspace.stat +workspace.read workspace.search +workspace.write workspace.apply_patch +resource.read +artifact.create artifact.list +artifact.preview artifact.export +``` + +All workspace paths are relative to the owning Session root. Absolute paths, +parent traversal, and symlink escape are rejected after canonical resolution. +Reads/search/listing are bounded and paginated; writes and exact text patches +use same-directory temporary files plus `fsync`/atomic replacement, enforce a +per-Session quota, and return SHA-256 content hashes. Workspace writes and +Artifact creation are preauthorized only for the built-in General Agent inside +its own Session; third-party Agents continue through M4 policy. + +Browser file interactions now copy the selected bytes into +`workspace/imports/...`, create a read-only `resource://res_...` handle, and +submit only that URI to the Agent. The backend independently verifies that a +file-interaction handle is live and owned by the Run's Session. A forged, +revoked, expired, or cross-Session handle is rejected even if it matches the +interaction's JSON Schema. + +Artifacts are immutable content-addressed blobs with Session-scoped metadata, +bounded text/base64 previews, authenticated downloads, and idempotent creation +for the same Session/hash/name. Agent-driven external export requires both an +`artifact.export` GrantLease and an opaque directory handle created by a trusted +host picker. The initial local Host Export Broker writes a temporary sibling, +flushes it, and atomically replaces the destination; failures remove the +temporary and leave a durable failed export record. + +The authenticated user API now includes Workspace list/read/write, +ResourceHandle import/list/revoke, and Artifact create/list/preview/download. +End-to-end browser verification exercised a real SVG selection, Session import, +opaque handle submission, Run resume, and terminal completion. The scoped +AI2Apps/Chat/API regression gate passes **319 tests**. + +M5 is a logical filesystem security boundary inside the trusted main process. +It does not claim hostile-process containment or eliminate filesystem TOCTOU +races against a compromised process with ambient host authority; those are M7 +Host Broker and enforced OS sandbox responsibilities. + +## 22. M7 Process Service implementation progress + +**The M7 vertical slice is complete.** Schema v10 adds durable Process +executions, bounded stdout/stderr chunks, authenticated Host Broker requests, +and argument-dependent Tool capabilities. The built-in `ai2apps.process` +Service publishes: + +```text +process.start process.write_stdin +process.status process.logs process.cancel +``` + +Commands are arrays passed to `exec`, never shell strings. An executable must +be system-provided or live inside the owning Session workspace. The child sees +only a constructed environment (`PATH`, Session workspace/home/temp identity, +an allowlist of locale/application keys, and values resolved from opaque Secret +references); the server's ambient environment is never copied. + +Every execution is bound to its Session and, when invoked by an Agent, its +originating Run. Status, logs, stdin, and cancellation fail closed across either +boundary. A Session has a bounded concurrent-process count. CPU, memory, +captured output, wall time, idle time, stdin writes, argv count, and argv bytes +are limited. Output is incrementally drained into bounded SQLite chunks and is +truncated exactly at the configured ceiling before the complete process group +is terminated. + +macOS uses a generated Seatbelt profile importing the platform's system +bootstrap rules, then grants filesystem access only to system runtime paths and +the owning Session's workspace/temporary roots. Network remains denied unless +`process.start(network=true)` has both `process.execute` and the dynamically +resolved `network.outbound` capability. Linux uses the parallel bubblewrap +contract with user/PID/IPC/UTS namespaces, `--die-with-parent`, read-only system +mounts, writable Session roots, and an unshared network namespace by default. +Production selection fails closed when the OS adapter is unavailable; the +unconfined adapter exists only as an explicit test double. + +Before spawn, the in-process Host Broker issues an HMAC-authenticated, +operation/Session/Run-scoped, nonce-bearing, short-lived envelope. SQLite stores +only its digest and audit evidence, then records accepted or denied resolution. +Agent terminal/cancel callbacks terminate the whole process group. Graceful +platform shutdown does the same; restart recovery first verifies PID birth time +against the durable record, reaps the matching stale process group, and then +marks the execution orphaned, avoiding unsafe PID-reuse kills. + +The M7 gate covers Session/Run isolation, environment denial, exact output +bounds, stdin, concurrency and idle limits, process-tree cancellation, verified +orphan reaping, Broker tamper/scope checks, dynamic network capability +resolution, Linux command construction, and real macOS cross-Session Seatbelt +denial. Rich Process-specific frontend work was unnecessary: existing Agent +status-line, approval, interaction, and cancel surfaces carry this Service. + +## 23. M8 Service lifecycle and package trust implementation progress + +**The M8 vertical slice is complete.** Schema v11 adds trusted publishers, +immutable Service package/version records, complete file indexes, audit +attestations, dependency locks, lifecycle operations, bounded structured logs, +and managed-process supervision records. Active Service descriptors and +GrantLeases now carry the exact package digest; an upgrade therefore invalidates +authority issued for the previous implementation. + +The `.ai2service` reader accepts a bounded ZIP archive with `service.yaml`, an +exact SHA-256 `META/files.json`, SPDX 2.2/2.3 SBOM, publisher attestation, and +Ed25519 signature. It rejects traversal, links, duplicates, unindexed content, +hash/size disagreement, malformed manifests, and undeclared native artifacts. +The canonical package digest covers the normalized manifest and complete file +index. Installed payloads are extracted into a content-addressed immutable +store and are re-hashed before every activation or restart. + +Publisher verification is fully offline and supports trusted, untrusted, and +revoked states. Embedded code is restricted to trusted publishers. A bounded +static source snapshot and findings are passed to an independently bindable +local AI auditor; its decision, model/policy metadata, evidence, and reviewed +file set are persisted as an attestation. Missing or inconclusive AI audit +requires explicit review approval, while rejection and auditor failure fail +closed before package code is imported or a process starts. + +Dependency resolution is deterministic and produces digest-pinned locks with +cycle detection and dependency-first activation. Required reverse dependents +block stop, disable, and uninstall, and an upgrade is rejected if its version +would violate an unchanged active dependent. OS, architecture, Python, +accelerator, feature, and signed variant compatibility are selected before +execution. + +The Service manager now controls embedded, managed-process, and external JSON +Services through install, audit, start, stop, enable, disable, restart, +upgrade, rollback, and uninstall operations. Managed Services use Seatbelt on +macOS or bubblewrap on Linux, with read-only package files, separate writable +data/temp roots, default-denied outbound network, readiness checks, process +group termination, bounded structured logs, restart/backoff, and PID birth-time +orphan verification. Transactional compensation restores the previous active +Service graph and removes newly staged files after a failed install or upgrade. + +The authenticated platform API exposes publisher trust, package +inspect/audit/install/detail, lifecycle controls, rollback/uninstall, and +paginated Service logs. Existing Service list/detail surfaces report active +package digests and declared permissions, so a future management UI can be +added without another backend contract change. + +The final M1–M8 backend regression gate passes **185 tests**, including real +macOS Seatbelt managed-Service execution and the isolated server-lifespan +health boundary. The scoped M8 implementation also passes Ruff and Python +bytecode compilation checks. + +## 24. M9 installable Agent, App, and local Patch implementation progress + +**The M9 backend vertical slice is complete.** Schema v12 adds immutable +Agent/App upstream packages, ordered device-local Patch stacks, cached Effective +definitions, App mounts, state snapshots, operation records, and durable Safe +Mode state. Agent and App definitions now bind both their verified upstream +digest and independently computed Effective digest. + +`.ai2agent`, `.ai2app`, and `.ai2patch` use bounded archives, exact SHA-256 file +indexes, canonical manifest/file digests, SPDX SBOM, and the M8 publisher trust +store. Upstream Agent/App packages require a trusted Ed25519 publisher. Local +Patches are signed by an installation-local Ed25519 key stored with owner-only +permissions; a Patch copied from another device fails closed rather than being +silently treated as local authority. Source/UI inputs are bounded and included +in the independently bindable local AI audit request before activation. + +Effective definitions are assembled from an immutable upstream plus the +ordered enabled Patch stack. Semantic operations carry stable dotted targets, +optional kind/digest preconditions, intent, rebase policy, resources, and +declarative acceptance tests. The cache identity separately covers upstream, +Patch-set, manifest, and resources. A changed target produces a durable +conflict and leaves the previous package and Effective definition active. +Explicit preserve-local, accept-upstream, or disable resolutions are required +before a conflicted candidate can activate. + +Installed Agents reuse the existing asynchronous Agent Runtime and executor +registry; package manifests control status/instructions/runtime limits without +introducing a second scheduler. Installed Apps reuse AppDefinition, +AppInstance, and Session persistence. Every App declares Entry, may declare a +dedicated inline/sidebar Mini-Entry, registers navigation metadata, and can be +launched independently to create or restore its HomeSession. Existing database +constraints enforce multiple or scoped-singleton instance policy. + +App upgrades snapshot every live instance, dry-run declarative state migration +for all instances, and atomically switch definition plus migrated state. Any +missing or failed migration retains the old EffectiveApp, instance state, and +upstream package. Retained versions support rollback; disable, enable, candidate +activation, uninstall protection, and structured operation history share the +same authenticated management API. + +Safe Mode saves each Patch's prior state, disables all local Agent/App Patches, +reassembles clean upstream Effective definitions, and can later restore the +exact Patch stack. The minimal recovery endpoint is independent of App Entry +rendering. AI-created local Patches can be exported as device-signed +`.ai2patch` archives and reinstalled through the same verification path. + +The final M1–M9 backend gate passes **198 tests**, including the isolated real +server-lifespan health test. M9's source passes scoped Ruff and Python bytecode +compilation. No WebUI files were changed: Entry/Mini-Entry/navigation/mount and +conflict contracts are ready for a separately agreed frontend implementation. + +## 25. WebUI Shell and System App migration plan + +The post-M9 frontend phase turns the separated `ai2apps/web` surface into an +AI-device Shell rather than extending the old administrative navigation. The +Shell is an unpatchable recovery boundary and owns the Dock, App Launcher, +current App frame, overlays, theme/locale propagation, authenticated App +Bridge, and Safe Mode entry. Apps cannot draw over or impersonate Shell UI. + +### 25.1 Dock contract + +The Dock has two persisted presentation modes: + +- `docked`: always visible; the current App frame occupies the content region + below it; +- `immersive`: the App frame remains full viewport size; the Dock appears as an + overlay when requested by the App Bridge, keyboard/touch affordance, or a + delayed pointer hot zone at the top edge. + +Dock identity distinguishes `pinned`, `running`, and `current`. Pinned Apps may +be stopped; running Apps expose an indicator and optional instance count; +current is the one AppInstance projected by the frame host. Singleton clicks +focus the existing instance. Multiple-instance Apps focus the most recent +instance by default and expose explicit new/switch actions. Shell-managed +status badges cover notifications, waiting approval, degraded, and failed +states. A bounded recently-used frame cache may keep fast-switching Apps +mounted; older frames suspend rather than remaining indefinitely resident. + +### 25.2 App Launcher contract + +App Launcher is a full-Shell overlay, not an ordinary third-party App. It +lists enabled installed Apps as an icon grid with search and categories derived +from system classification, signed manifest metadata, and user override. +Clicking an icon starts or focuses the correct AppInstance and closes the +Launcher. Initial categories are System, AI & Chat, Models, Developer, +Utilities, User-created, and Third-party. Drag sorting, folders, and richer +touch editing are deferred until the launch/focus lifecycle is stable. + +### 25.3 App frame and Bridge + +The route hierarchy is `/apps/{app-id}` and +`/apps/{app-id}/instances/{instance-id}`. The Shell owns browser history and +loads the selected Entry in an iframe App Frame. `host`, `schema`, `safe-html`, +and `sandbox` renderers share one mount envelope, with progressively stricter +isolation for third-party content. API credentials are never handed to an App +frame; authenticated operations cross a source/instance-validated Bridge or +the normal protected platform API. + +The first Bridge vocabulary includes `app.ready`, `app.set_title`, +`app.set_badge`, `app.request_dock`, `app.navigate`, `app.open_entry`, +`app.mount_mini_entry`, `app.request_capability`, `app.create_agent_run`, +`app.export_artifact`, and `app.close`. Host messages include theme, locale, +instance, HomeSession, interaction Session, visibility, resume/suspend, and +safe-area changes. Parent DOM access, arbitrary top navigation, credential +access, and unvalidated cross-App messaging remain denied. + +### 25.4 System App migration order + +The old oMLX pages become built-in Apps while their URLs remain compatibility +redirects: + +1. Dashboard/Status -> `ai2apps.dashboard`, singleton/system; +2. Models/Downloads -> `ai2apps.models`, singleton/system; +3. Settings -> `ai2apps.settings`, singleton/system; +4. Logs -> `ai2apps.logs`, singleton/system; +5. Accuracy/Context/Throughput Bench -> `ai2apps.benchmark`, one singleton App + with internal pages; +6. Chat -> existing `ai2apps.general-chat`, singleton/user with multiple + thread Sessions. + +App Launcher, Dock, frame recovery controls, and Safe Mode stay in the Shell +and are not converted into patchable Apps. + +### 25.5 Delivery slices and gates + +**W1 — Shell foundation:** App Frame Host, docked/immersive layout, top hot +zone, persisted preference, keyboard/touch accessibility, theme and locale. + +**W2 — Launcher and lifecycle:** installed-App discovery, pin persistence, +running/current projection, singleton launch/focus, multiple-instance menu, +history/deep links, suspend/resume. + +**W3 — System App migration:** move Dashboard first, then Models, Settings, +Logs, Benchmark, and finally Chat; preserve API/runtime behavior and legacy URL +redirects at every step. + +**W4 — constrained Views:** schema host renderer, sanitized safe HTML, sandbox +iframe/CSP/Bridge, Mini-Entry inline/sidebar, conflict workspace, package trust +details, and Safe Mode management. + +The first programming slice is W1 plus W2's minimal Launcher and Dashboard as +the reference System App. Its exit gate requires functional keyboard and +pointer navigation, correct docked/immersive geometry, no App resize when the +immersive Dock overlays, stable deep-link/back behavior, singleton Dashboard +reuse, and unchanged backend/OpenAI-compatible tests. + +### 25.6 First implementation slice + +**W1 and the system-App portion of W2 are now implemented.** The authenticated +Shell is available at `/apps/{app-id}` and the reserved multi-instance form +`/apps/{app-id}/instances/{instance-id}`. The legacy `/admin/dashboard` entry +opens Dashboard inside the same Shell, so existing login and bookmark flows +continue to work. + +The first Dock supports persisted docked and immersive modes, pinned/running/ +current projections, pointer and keyboard access, an immersive top-edge hot +zone, and a same-origin App Bridge button that explicitly requests the Dock. +The App Launcher provides category filtering, search, launch/focus, and pin or +unpin controls. Dashboard, Models, Settings, Logs, Benchmark, and the existing +`ai2apps.general-chat` are exposed as built-in system Apps. + +During this compatibility slice, Dashboard/Models/Settings/Logs/Benchmark use +one existing dashboard renderer with an App-specific initial tab; its old +navbar is suppressed when embedded. This deliberately preserves the mature +oMLX behavior while establishing the Shell boundary. The next slice must bind +Launcher discovery and running instances to the M9 App lifecycle API, serve +verified third-party Entry resources through the constrained View host, add +multi-instance switching, and replace the initial same-origin bridge with +instance-bound message envelopes before third-party content is enabled. + +### 25.7 Authoritative App Runtime integration + +**W2 lifecycle integration is now implemented.** Platform startup idempotently +registers Dashboard, Models, Chat, Settings, Logs, and Benchmark as built-in +AppDefinitions. The M9 App catalog now returns built-in and installed Apps in +one ordered response with navigation metadata, instance policy, live non-closed +instances, HomeSession identity when one exists, and running counts. + +The Shell uses an administrator-session adapter rather than receiving the model +API key. Launch, focus, suspend, close, Entry resolution, and package resource +requests pass through that adapter to the existing M9 App Runtime. Singleton +instances reopen after close without violating their durable singleton key; +multiple-instance Apps can create, list, switch, close, and restore exact +instances. Running state is SQLite-authoritative. Only Dock mode and pin order +remain device-local preferences. Canonical instance URLs and browser history +restore the requested AppInstance after refresh, forward, and back navigation. + +The Entry Frame Host now selects among four renderer boundaries: + +- `host` resolves only product-owned resource identifiers; +- `schema` loads verified JSON into a non-executable generic host renderer; +- `safe-html` sanitizes verified HTML through the bundled DOMPurify runtime; +- `sandbox` serves re-hashed package resources under restrictive iframe and CSP + sandboxing with network and form submission denied. + +Installed resources are resolved only from the active digest-bound package or +enabled local Patch store and are re-hashed before every response. Frame +messages carry a per-mount random token and exact AppInstance identity; the +Shell also validates the mounted window and expected same-origin or opaque +sandbox origin. Broader capability, AgentRun, Artifact, and Mini-Entry bridge +operations remain separately policy-gated W4 work. + +### 25.8 Independent System App Entries + +**W3 is now implemented.** Dashboard, Models, Settings, Logs, and Benchmark no +longer mount the complete legacy dashboard document and select one hidden tab. +Each built-in App has an independent Host Entry template containing only its +owned surface. Chat was already an independent Entry and now returns to the +canonical Dashboard App route through the Shell. + +The five dashboard-derived Apps intentionally share the mature oMLX-derived +Alpine business controller, CSS, API clients, translations, and focused +partials. This is a compatibility runtime, not a shared page: model-management +modals are mounted only by Models, sibling panels are absent from the DOM, and +the fixed App identity cannot be changed with an iframe query parameter. This +preserves runtime behavior without duplicating thousands of lines of proven +model, settings, log, and benchmark logic. + +Legacy `/admin/dashboard` bookmarks remain valid. Its historical `tab` query +selects the matching System App (`models`, `settings`, `logs`, or `bench`) in +the Shell, while unknown or missing values open Dashboard. Canonical App and +AppInstance URLs remain authoritative after resolution. + +### 25.9 W4 constrained Views and Mini-Entry completion + +**W4A–D are now implemented.** W4A replaces the initial Dock-only message +hook with an instance-bound App Bridge. Every request carries a random mount +token and AppInstance identity, and the Shell verifies the mounted source +window and expected origin before handling title, badge, navigation, Entry, +Mini-Entry, capability, AgentRun, Artifact, or close operations. Capability and +Artifact requests remain policy-gated and cannot use the Bridge to bypass an +approval or trusted host picker. + +W4B makes Mini-Entry a durable projection of the same AppInstance rather than +a second lightweight App. Chat can mount it inline beside the triggering user +message, move it to the right sidebar, close it, or expand it into the full +Entry without losing App state. The interaction Session and message context +are stored in SQLite schema v13, so mounted Mini-Entries restore with the +conversation after refresh. Installed Apps may declare natural-language +activation examples, but third-party matches are suggestions requiring an +explicit user action; they do not silently auto-mount. + +W4C adds the Shell-owned System Control surface. It exposes installed Agent and +App package identity, version, publisher, signature verification, local audit +evidence, requested permissions, dependency metadata, renderer boundary, and +the ordered local Patch stack. This recovery UI is outside every patchable App +frame and remains available when normal App UI is damaged. + +W4D connects Patch conflict decisions and Safe Mode to effective runtime +state. Keep-local, accept-upstream, and disable decisions reassemble the target +definition and activate the recoverable upgrade candidate once all conflicts +are resolved. Multi-conflict decisions remain durable between steps while the +previous active package stays usable. Safe Mode temporarily removes eligible +local Patches, rebuilds active Agent/App definitions from signed upstream +packages, and restores the recorded Patch states and effective definitions on +exit. + +### 25.10 W5 capability approval and recovery completion + +**W5A–D are now implemented.** W5A adds one Shell-owned Approval Inbox for +both AgentRun approval interactions and App Bridge CapabilityRequests. Each +card identifies the requesting App or Agent, capability, Tool, side-effect +class, resource selector, risk level, reason, and deadline. The same Agent +approval remains visible inline in Chat so the user can decide without leaving +the conversation. + +W5B exposes active GrantLeases in System Control with scope, subject, Session, +resource selector, issuer, expiry, and explicit revocation. App requests can be +approved once, for the interaction Session, or for the App. “Once” is a +short-lived App lease tied to the exact approved request; durable Session/App +decisions remain revocable. Agent approvals additionally retain Run and Agent +scopes. Grant identity and lifecycle remain SQLite-authoritative. + +W5C turns `requestCapability()` into a real asynchronous Bridge operation. The +calling frame's Promise stays pending while the durable request is in the +Inbox, then resolves with the decision and GrantLease without exposing an API +credential. AgentRun approval continues to use the existing +`waiting_capability -> queued` transition and wakes the scheduler immediately +after approval. Frame/source/origin/mount-token checks remain in force for the +entire wait. + +W5D extends Safe Mode across subsystems. Entering it revokes every active +GrantLease, terminates active managed sandbox processes, disables eligible +local Patches, and rebuilds signed effective definitions. Grant creation, +decision, expiry, revocation, and Safe Mode recovery actions are emitted into +the durable event audit stream with subject, AppInstance, Session, risk, scope, +resource, and evidence. Revoked Grants deliberately remain revoked when +leaving Safe Mode. + +Schema v14 adds generic `capability_requests`, request/App indexes derived from +the Inbox queries, and request-linked App GrantLeases whose AgentDefinition may +be absent. Existing Agent grants migrate without changing their policy or +matching semantics. + +Browser acceptance also corrected a W4 Bridge ambiguity: the authenticated +caller `instanceId` and Mini-Entry/Entry `targetInstanceId` are now distinct. +Chat can therefore mount Dashboard (or another App), move the same instance to +its sidebar, and expand that exact instance into full Entry without +accidentally mounting Chat itself. + +### 25.11 W6 Agent Harness Tool execution completion + +**W6A–D are now implemented.** Earlier M3/M5/M7 and Agent Runtime slices had +already established the Service/Tool Registry, Workspace and Process +providers, and a resumable General Agent loop. W6 formalizes those components +as one Harness execution contract rather than introducing a parallel Tool +system: a Tool is the model-visible invocation and authorization boundary, +while its owning Service remains the installation, lifecycle, dependency, and +execution boundary. + +W6A adds durable `ToolInvocation` identity and schema v15 persistence. Every +accepted call records the canonical Tool and provider, caller, Session and Run +trace, validated arguments, effective timeout, progress, attempt count, +terminal output or error, and timestamps. The Gateway emits started, progress, +retrying, completed, failed, cancelled, and restart-interrupted audit events. +Installed Service packages may declare an explicit retry policy bounded to +three attempts; no Tool retries implicitly, and only declared stable error +codes are retried. + +W6B projects the existing Workspace/Resource/Artifact Service through that +Gateway. List, stat, bounded read/search, atomic write, exact patch, +ResourceHandle read, and Artifact create/list/preview/export remain +Session-sandboxed and capability checked. Write and patch operations now emit +progress through the invocation and Agent status-line channels. + +W6C completes the Process Tool family with start, stdin, status, logs, bounded +wait, and cancel. `process.wait` has an explicit maximum timeout and prevents a +model from consuming steps with unbounded polling. Dynamic network requests +continue to add `network.outbound` to the required capability set, and every +process remains scoped to the originating Session and, when present, Run. + +W6D connects invocation progress to the existing durable General Agent +`model -> Tool -> model` loop. RunSteps remain the replay authority for model +conversation reconstruction; ToolInvocations provide the finer execution and +audit authority. Step and token budgets, repeated-call protection, bounded +Session context, approval pause/resume, cancellation, uncertain effectful +steps, idempotent final Messages, and restart recovery all remain enforced. +No new WebUI surface is required: status-line and W5 Approval Inbox protocols +carry Harness progress and decisions. + +### 25.12 W7A Chat Agent Mode completion + +**W7A is now implemented.** Chat exposes a Session-scoped Chat/Agent switch +without splitting conversation history. Chat mode streams directly from the +selected model and deliberately omits model-visible MCP Tools. Agent mode +creates an `ai2apps.general-agent` AgentRun in the same Session so the Harness +may select only its registered and policy-approved Tools and Apps. The selected +mode is stored in authoritative Chat session metadata and survives navigation, +refresh, branch creation, and backend migration. + +Every Agent turn is anchored beneath the invoking user message. The existing +replayable authenticated event stream converges status-line, RunStep, Tool +activity, approval/menu/text/file interactions, output, and terminal state with +snapshot refresh. The compact card shows current status and aggregated Tool +names/counts; expanded details retain step and recovery evidence. Agent turns +do not lock the composer, so independent Runs may coexist in one Session while +each Run retains its own cancel control. + +Run lifecycle controls now include pause and resume in addition to cancel. +Schema v16 adds explicit `queued/planning -> interrupted` transitions while +preserving the database state-machine guard. Pausing cancels active execution +but keeps the Run durable: a non-effectful Tool is abandoned for a safe retry, +an effectful in-flight Tool becomes uncertain and requires the user to choose +retry or assume-completed, and model/planning work resumes from the durable +queue. The WebUI restores interrupted Runs and exposes the required recovery +choice inline. + +### 25.13 W7B installed Agent selection completion + +**W7B is now implemented.** Agent mode no longer assumes that every turn uses +the built-in General Agent. Chat loads the authoritative installed Agent +catalog from `/v1/platform/agents`, presents enabled definitions in a compact +selector, and stores the selected `agent_key` in the current Chat Session. +New and branched Sessions receive an explicit selection, while a disabled or +removed definition falls back deterministically to the General Agent (or the +first enabled definition when the General Agent is unavailable). + +Each Agent turn records `execution_agent` on its invoking message and submits +that exact key to AgentRun creation. AgentRun API snapshots now include the +canonical Agent key and display name alongside the immutable definition ID, so +restored status cards identify their executor without relying on current +catalog ordering. Catalog refresh occurs at login and when Chat becomes visible +after Agent installation or management changes. + +W7B deliberately keeps Agent choice explicit. Natural-language routing and +delegation are separate policy decisions: the system does not silently replace +the Session-selected Agent based only on prompt text. + +### 25.14 W7C schema-driven Agent invocation completion + +**W7C is now implemented.** Agent definitions may declare `discoverable`, +`aliases`, a JSON Schema `invocation_schema`, and non-executable +`invocation_ui` hints. Built-in Diagnostic Agent is hidden from ordinary Chat +discovery, while General Agent publishes stable `general` and `agent` aliases. +Third-party `.ai2agent` archives fail closed before installation when these +fields are malformed or the invocation schema is invalid or non-object-shaped. + +Chat renders only controlled string, number, integer, boolean, and enum fields; +it never renders Agent-provided HTML. Parameter defaults are stored per Agent +inside authoritative Session metadata, inherited by thread branches, and may be +overridden before each turn. Required and basic type constraints are checked in +the client for immediate feedback, then the Agent Repository validates the +complete parameter object with JSON Schema before a Run is accepted. + +A leading `@alias` explicitly chooses an enabled discoverable Agent for one +turn without mutating the Session default. The visible user message retains the +mention, while the Agent receives the cleaned natural-language prompt. Every +invoking message records Agent key, invocation source, and parameter snapshot; +the Run input replaces caller-asserted identity with the authoritative +definition ID, key, package version, and bounded source string. AgentRun API +snapshots expose the captured package version for audit and replay. + +### 25.15 W7D bounded Agent delegation completion + +**W7D is now implemented.** Schema v17 gives every AgentRun a durable tree +position (`parent_run_id`, `root_run_id`, and depth) and adds a request-keyed +delegation ledger. The built-in `ai2apps.agent-runtime` Service publishes +`agent.delegate`; it creates or reattaches to a child Run, projects the parent +status as `waiting_subruns`, waits for a terminal result, and settles that +result through the ordinary durable Tool step before the parent continues. + +The first scheduling envelope allows at most two child levels and four direct +children per Run. Child timeouts cannot outlive the parent, and delegated +step/model-token budgets can only reduce definition limits. Children share the +conversation Session for resource attribution but do not inherit parent Run +capabilities and do not append their private prompt or output as Chat Messages. +Their Tools and approval requests pass independently through the existing +Harness policy boundary. + +Cancel and pause operations cascade to active descendants, while terminal +waiters are awakened from durable Run state and idempotent delegation replay +reuses the prior child. AgentRun API snapshots expose tree identity and direct +child IDs, with a dedicated children endpoint. Chat renders recursive child +status lines and child interactions beneath the invoking root Run, and only +the root completion is projected as the final assistant message. + +### 25.16 W8 Agent Manager completion + +**W8 Agent Manager is now implemented without Agent Studio.** The new +`ai2apps.agents` singleton system App has three bounded surfaces: Catalog for +definition identity, lifecycle and policy inspection; Runs for filtered global +AgentRun operations; and Packages for signed archive installation, uninstall, +version provenance, Effective Definition identity, and local Patch/conflict +diagnostics. + +The Agent API now exposes complete definition management metadata, persistent +enable/disable operations, filtered Run listing, Run counts, and an aggregated +management snapshot. Run controls reuse the existing W7 pause/resume/cancel +state machine, including parent/child cascade. Package changes reuse M9's +audited interactive-package APIs rather than introducing a second installer. + +No source editor, AI code generator, manifest editor, or executable status +renderer is included. Necessary Agents will be developed with Codex, tested in +Session sandboxes, audited, signed, and installed. Those concrete workflows +will become evidence for a later Coding plan and eventual Agent Studio design. + +## 26. Deferred W9 Coding Harness plan + +**Status: Deferred.** W9 must not begin until the current Shell, system Apps, +Chat, Agent Runtime, Agent Manager, Service/Tool control plane, and package +flows have completed a stabilization pass. The active development priority is +to reproduce, classify, fix, and regression-test current WebUI and system +issues. Stabilization fixes may refine existing contracts but must not +silently introduce Coding Studio or IDE scope. + +### 26.1 W9A Project and Workspace contract + +Build on the existing Session sandbox and Workspace Service. Add explicit +project identity, project-to-Session attachment, bounded file indexing, +working-directory rules, and project ResourceHandles. Do not introduce a +second filesystem abstraction. + +### 26.2 W9B structured Git Service + +Expose repository-aware read Tools such as status, diff, log, branch, and +blame, followed by capability-gated mutation Tools for stage, commit, branch +creation, and switching. Git mutations must retain exact repository, worktree, +path, caller, Session, Run, approval, and result evidence. Raw Process Tool +execution remains an escape hatch rather than the primary Git contract. + +### 26.3 W9C diagnostics and test execution + +Normalize test, build, Linter, type-checker, and compiler results into durable +structured diagnostics while preserving complete Process logs. Providers may +be language-specific, but the Agent-facing result contract must consistently +identify file, location, severity, code, message, command, exit status, and +related Artifact or log handles. + +### 26.4 W9D first Coding Agent + +**Status: Planned for later implementation with Codex; no Agent Studio UI.** +Create a signed installable Coding Agent package only after W9A-C and the +stabilization gate pass. Its first version must: + +- inspect the attached project and relevant repository state; +- state and update a bounded execution plan; +- search, read, and apply exact file changes through Workspace Tools; +- run targeted diagnostics/tests and iterate from their structured results; +- use status-line phases for inspection, planning, editing, verification, and + waiting for user input or approval; +- request capabilities before effects and preserve every Tool invocation in + the Run audit trail; +- delegate bounded test or review work through child AgentRuns when useful; +- stop with a clear verified result, partial result, or actionable blocker; +- avoid committing, pushing, deleting, or escaping the Session sandbox unless + explicitly authorized. + +The initial Coding Agent will be authored and maintained in Codex so real +package structure, prompts, Tools, interactions, Evals, failure recovery, and +Patch experience can be collected. Those findings will inform a later Coding +App and Agent Studio rather than being prematurely encoded into either UI. + +### 26.5 W9 entry gate + +W9 may be resumed only after the current issue-fixing pass has: + +1. recorded reproducible cases for the known WebUI/system problems; +2. added regression coverage for fixed lifecycle and navigation failures; +3. verified Shell/App iframe loading, authentication, refresh, and recovery; +4. verified Chat and AgentRun persistence, interaction, cancellation, and + parent/child presentation against the real local server; +5. left no known data-loss, authorization-bypass, or unrecoverable lifecycle + defect in the current milestone. diff --git a/docs/ai2apps-platform-architecture.md b/docs/ai2apps-platform-architecture.md new file mode 100644 index 00000000..58ef4e6f --- /dev/null +++ b/docs/ai2apps-platform-architecture.md @@ -0,0 +1,2942 @@ +# AI2Apps Platform Architecture + +Status: Draft v2.9 — Stabilization active; Coding Harness deferred +Last updated: 2026-08-11 +Implementation plan: [AI2Apps Backend Development Plan](ai2apps-backend-development-plan.md) + +## 1. Purpose + +AI2Apps is evolving from an oMLX-based local model platform into a local-first +AI application and agent platform for unified-memory AI devices. The target +system has three product-level objects—App, Agent, and Service—running on a +hardware-neutral platform above one or more model runtime backends. + +Apple Silicon/macOS with oMLX is the first implementation, not the platform +boundary. Target deployments also include Linux AI boxes based on NVIDIA and +AMD accelerators where the device exposes unified, coherent, or otherwise +runtime-managed shared memory suitable for local AI workloads. + +The architecture must preserve the existing oMLX model, scheduling, routing, +cache, and inference behavior while adding AI2Apps-owned orchestration, +packaging, security, interaction, hardware discovery, and backend-adaptation +layers. Platform-level App, Agent, Service, package, session, and sandbox +contracts must not depend on macOS, MLX, CUDA, or ROCm details. + +## 2. Product model + +### 2.1 Core concepts + +| Concept | Responsibility | Does not own | +| --- | --- | --- | +| App | Agent-compatible intelligent behavior plus Entry/Mini-Entry, instances, sessions, persistent state, inputs, outputs, and artifacts | Service deployment and unrestricted host execution | +| Agent | Goal-directed reasoning, instructions, model policy, tools, memory, and execution strategy | Service deployment and user-interface implementation | +| Service | Versioned, callable, installable, manageable, and auditable capability exposed through a URL/API contract | User experience and autonomous goal selection | +| Model | A managed inference resource exposed through a model runtime Service | Platform orchestration | + +The intended dependency direction is: + +```text +User -> App -> Agent -> Service -> Runtime/Resources +``` + +An App may use one or more Agents and may also call Services directly for +deterministic UI operations. An Agent uses Services to access models, tools, +storage, retrieval, media processing, and other capabilities. + +### 2.2 Logical architecture + +```mermaid +flowchart TB + USER["User / API Client / Native App"] --> APP["App Layer
Interaction, sessions, files, artifacts"] + APP --> AGENT["Agent Layer
Goals, instructions, policy, memory"] + APP --> CLIENT["Service Client"] + AGENT --> CLIENT + + CLIENT --> GATEWAY["Service Gateway
Identity resolution, auth, routing, policy"] + GATEWAY --> EMBEDDED["Embedded Services
In-process ASGI routers"] + GATEWAY --> PROCESS["Managed Services
Supervised local processes"] + GATEWAY --> EXTERNAL["External Services
Remote endpoints"] + + EMBEDDED --> MODEL["Model Runtime Service
hardware-neutral contract"] + EMBEDDED --> MCP["MCP Gateway Service"] + PROCESS --> EXTENSIONS["Retrieval, storage, media, custom APIs"] + + MODEL --> MLX["MLX backend
Apple Silicon"] + MODEL --> CUDA["CUDA backend
NVIDIA Linux"] + MODEL --> ROCM["ROCm backend
AMD Linux"] + + MANAGER["Service Manager"] --> GATEWAY + MANAGER --> REGISTRY["Registry and dependency graph"] + MANAGER --> SUPERVISOR["Lifecycle, health, logs, restart"] + MANAGER --> SECURITY["Signature, permissions, audit"] + MANAGER --> PACKAGES["Service packages"] +``` + +## 3. Architectural principles + +1. **Preserve oMLX as the first model backend.** AI2Apps additions live under + the `ai2apps` package and use an adapter around oMLX rather than copying or + rewriting it. The platform contract also permits NVIDIA/CUDA and AMD/ROCm + backends on Linux without leaking backend details into Apps or Agents. +2. **The server owns Agent execution.** Browsers render state and handle user + interaction; they do not own the authoritative tool loop. +3. **Service identity is stable; location is not.** Apps and Agents bind to a + Service ID or capability, not a host and port. +4. **Third-party code is isolated by default.** Untrusted or independently + versioned Services run in supervised processes rather than the main process. +5. **No package code runs before verification.** Integrity, signature, + dependencies, permissions, and audit are processed before installation or + import. +6. **Core state is local and durable.** SQLite stores metadata and execution + state; the filesystem stores large artifacts and immutable packages. +7. **Existing APIs remain compatible.** Current `/v1/*` and `/admin/*` routes + continue to work while new App, Agent, and Service APIs are introduced. +8. **The current visual language is retained.** AI2Apps keeps the existing oMLX + typography, spacing, rounded controls, light/dark themes, and interaction + style. +9. **Sandboxing is the default execution model.** The AI2Apps runtime, every + Session, managed Service, Agent/App code Hook, and interactive View operates + inside an explicit sandbox. Host access is granted only through a minimal, + auditable capability broker. +10. **Unified-memory AI devices are the primary deployment class.** Hardware + discovery, memory budgeting, model placement, and scheduling use reported + device capabilities rather than OS/vendor assumptions. Apple Silicon, + NVIDIA Linux, and AMD Linux are peer target families. + +### 3.1 Deployment and hardware profile + +Each AI2Apps node exposes a normalized `HardwareProfile` containing at least: + +```text +operating system and architecture +accelerator vendor, devices, and runtime +memory model: unified | coherent | managed-shared | device-local +total, reserved, available, and pressure-adjusted memory +supported data types and kernel features +peer-to-peer/interconnect topology +power and thermal operating state when available +installed model backend providers +``` + +The primary optimization target is a single-device or tightly coupled AI box +where models, KV cache, routed experts, Agent workloads, and application data +can share a large memory pool without treating host RAM and accelerator memory +as unrelated fixed silos. Backends may still support device-local memory as a +compatibility mode, but Apps, Agents, and Services never assume a particular +memory topology. + +Hardware profiles are dynamic. Admission control and model placement use +current available/pressure-adjusted capacity rather than total advertised +memory. Backend-specific metrics are normalized for the Service and WebUI while +remaining available as optional diagnostic extensions. + +## 4. Service architecture + +### 4.1 Definition + +A Service is a versioned execution unit that: + +- has a globally stable Service ID; +- exposes a machine-readable API contract, normally JSON over HTTP; +- declares capabilities, dependencies, permissions, and health behavior; +- owns its workload queue, concurrency, and internal load handling; +- can be installed, verified, enabled, disabled, started, stopped, restarted, + upgraded, rolled back, and uninstalled; +- has immutable source/resources for a given package digest; +- carries publisher integrity information and audit attestations. + +The platform manages admission, routing, authentication, process supervision, +health, and lifecycle. The Service manages its own work scheduling and queue. + +### 4.2 Identity, capability, and URL + +Service consumers should bind to an identity or capability: + +```text +service: ai2apps.model-runtime +capability: model.chat@1 +``` + +They must not bind directly to an implementation address such as +`http://127.0.0.1:8123`. + +The stable gateway namespace is provisionally: + +```text +/services/{service-id}/{path} +``` + +Examples: + +```text +/services/ai2apps.model-runtime/v1/chat +/services/ai2apps.mcp/v1/tools +/services/com.example.retrieval/v1/search +``` + +The gateway resolves the active Service instance and forwards the request. For +an embedded Service, the implementation may dispatch directly to an ASGI app +without a physical HTTP loopback while preserving the same protocol semantics. + +The concrete public URL prefix is still a design decision. Consumers must use +the Service Client abstraction so the prefix can change without affecting App +or Agent definitions. + +### 4.3 Protocol profiles + +The initial protocol profiles are: + +- `http-json`: JSON HTTP API described by OpenAPI 3.1; +- `mcp`: MCP server exposed through the AI2Apps Service system; +- `openai-compatible`: OpenAI-compatible inference or tool endpoint; +- `internal-asgi`: trusted in-process implementation of an HTTP/JSON contract. + +Protocol-specific adapters normalize discovery, health, authentication, and +invocation through the Service Client. + +### 4.4 Runtime modes + +#### Embedded + +An embedded Service runs inside the AI2Apps main process and mounts an ASGI +router. It offers the lowest overhead but shares dependencies and failure scope +with the host. + +Embedded mode is restricted to built-in or explicitly trusted packages. An +embedded Service must depend only on the stable AI2Apps Service SDK and the host +runtime dependency set. + +#### Managed process + +A managed Service runs in an isolated process supervised by AI2Apps. It has its +own environment, dependencies, port, logs, health state, and restart policy. +The Service Gateway hides the physical port from consumers. + +This is the default mode for third-party Services. + +#### External + +An external Service points to an endpoint managed outside AI2Apps. Its package +contains the API contract, connection adapter, authentication requirements, and +metadata. AI2Apps manages configuration, enablement, trust, and health, but not +the remote process lifecycle. + +### 4.5 Standard control surface + +Every Service instance must expose or allow the platform to synthesize: + +- descriptor/capabilities; +- liveness and readiness; +- API contract; +- version and package digest; +- queue/load summary when available; +- structured logs and diagnostics; +- graceful shutdown behavior. + +The exact `/.well-known/ai2apps-service` control contract remains to be +specified. + +### 4.6 Initial implemented control plane + +The first implementation uses one shared SQLite registry for Service +descriptors, dependency edges, runtime instances, and Tool descriptors. Durable +identity is separate from both the process-local provider key and physical +endpoint. A process-local registry binds an authenticated provider to each Tool; +the gateway refuses a binding whose provider key does not own the persisted +Service instance. + +Tool discovery and invocation use a policy-owned `ToolCallContext` containing +caller, Session, granted capabilities, and trace identity. The gateway verifies +Service/instance lifecycle, active Session existence, capabilities, input and +output JSON Schemas, timeout, and provider identity before returning a result. +Completion, failure, timeout, and cancellation produce semantic Events. Public +HTTP callers cannot submit their own provider identity or capability grants. + +The built-in implementation identities are `ai2apps.model-runtime`, +`ai2apps.mcp`, and `ai2apps.diagnostics`. Existing OpenAI-compatible and MCP +URLs remain compatibility endpoints backed by the same providers. External +JSON Services can be bound through the same stable Tool identity; managed +process supervision and package trust are provided by the M8 control plane. + +## 5. Service packages + +### 5.1 Package layout + +The provisional package extension is `.ai2service`: + +```text +example-service.ai2service +├── service.yaml +├── src/ +├── resources/ +├── contracts/ +│ └── openapi.json +├── META/ +│ ├── files.json +│ ├── permissions.json +│ └── sbom.spdx.json +├── attestations/ +│ ├── publisher.json +│ ├── static-analysis.json +│ └── ai-audit.json +└── signatures/ + └── publisher.sig +``` + +The package contains source code rather than an opaque executable-only payload. +Platform policy may additionally permit reviewed native binaries, but they must +be declared, hashed, signed, architecture-specific, and represented in the +SBOM. + +### 5.2 Manifest draft + +```yaml +schema: ai2apps.service/v1 +id: com.example.retrieval +name: Local Retrieval +version: 1.2.0 + +publisher: + id: com.example + +runtime: + mode: process + protocol: http-json + entrypoint: service.main:create_app + contract: contracts/openapi.json + +capabilities: + - retrieval.search@1 + - retrieval.index@1 + +requires: + services: + - id: ai2apps.embedding + version: ">=1.0,<2.0" + python: ">=3.11,<3.14" + +permissions: + filesystem: + - app-data + network: + outbound: false + secrets: + - embedding-api-key + +health: + path: /health + startup_timeout_seconds: 30 + +queue: + owned_by_service: true + concurrency: 4 +``` + +### 5.2.1 Installable Model Providers + +A managed or external HTTP Service may add models to the unified AI2Apps +catalog. The Package contains the runtime and signed recipe; large checkpoint +weights stay in the provider's model/Hugging Face cache and are verified by the +provider rather than embedded in the Package archive. + +```yaml +runtime: + mode: process + protocol: openai-compatible + +models: + - id: com.example.media/chat-v1 + display_name: Example Chat + model_type: llm + upstream_id: local-chat-checkpoint + context_window: 32768 + + - id: com.example.media/image-v1 + display_name: Example Image + model_type: image_generation + upstream_id: local-diffusion-checkpoint + capabilities: [image_generation] + endpoints: + image_generation: /v1/images/generations + image_edit: /v1/images/edits +``` + +Supported model types are `llm`, `vlm`, `image_generation`, `audio_stt`, +`audio_tts`, `audio_processing`, and `video_generation`. Model IDs must be +owned by the Service namespace (`/...`). Installing a Package does +not change a system default: the user selects the new model in Models. Active +models are exposed through `/v1/models` and use the standard Chat Completions, +Responses, Images, Audio, and Video endpoints. Disabling, stopping, or +uninstalling the Service removes its models from discovery and routing. + +Local MLX providers request two narrow, auditable permissions instead of +embedding or duplicating checkpoint weights: + +```yaml +permissions: + model_weights: + huggingface_cache: read + accelerator: + metal: true +``` + +The first exposes the host Hugging Face cache read-only; downloads, when +needed, go to Package-owned data. The second enables only the Metal/IOKit and +compiler services required by the GPU inside the managed-process sandbox. +Ordinary Services receive neither permission. Platform-to-provider loopback +HTTP also ignores host proxy environment variables so private requests cannot +be redirected through an external proxy. + +`packages/qwen35-provider` is the first concrete reference implementation. It +registers Qwen3.5 2B/0.8B 4-bit as independent VLM entries and implements Chat +Completions plus non-streaming Responses without storing weights in the +Package. Build and run its isolated end-to-end smoke test with: + +```bash +python scripts/build_model_provider_package.py packages/qwen35-provider +python scripts/smoke_qwen35_provider_package.py +``` + +### 5.3 Platform and accelerator compatibility + +Packages declare portable requirements separately from platform-specific +artifacts. Relevant compatibility fields include OS, CPU architecture, Python +ABI, accelerator family, backend provider/API version, memory-model features, +native libraries, and required kernel capabilities. + +One logical Service package may contain signed variants or resolve signed +platform-specific dependency artifacts. Installation selects a compatible +variant before executing package code and presents that selection during audit. +An incompatible package remains installable only if it contains a usable +portable path; the installer must not silently substitute an unaudited binary. + +Apps and Agents should normally be hardware-neutral. Only Services and model +backend providers declare accelerator-specific requirements unless an App or +Agent includes its own native executable component. + +### 5.4 Integrity and signatures + +`META/files.json` contains canonical cryptographic hashes for all immutable +package content. The publisher signature covers the manifest and complete file +index rather than only the archive bytes. + +Audit records are independent attestations referring to the same package +digest. This allows a marketplace, organization, or local AI audit to add a +review without modifying the signed source package. + +Changing any covered byte creates a new package digest and invalidates all +attestations issued for the previous digest. + +The v1 offline signature is Ed25519 over the canonical `sha256:` package digest. +The SQLite trust store pins publisher ID, key ID, algorithm, and public key and +records trusted, untrusted, or revoked status. Key rotation uses a distinct +publisher/key identity rather than silently replacing pinned key material. +Network-backed provenance may be an additional signal but is not required. + +### 5.5 Audit model + +Installation verification can include: + +- manifest and archive safety validation; +- SBOM validation; +- static source analysis; +- permission-to-code consistency checks; +- license and vulnerability policy; +- trusted third-party audit attestations; +- a local AI source review. + +A local AI audit records at least: + +```text +package digest +audit model and version +audit policy version +review scope +findings and evidence +risk classification +recommended decision +timestamp +``` + +AI audit is a security signal, not a replacement for integrity verification, +isolation, permissions, or user approval. + +### 5.6 Installation pipeline + +No package source is imported or executed before the verification and approval +steps finish. + +```text +Acquire package +-> parse manifest safely +-> verify file hashes +-> verify signatures and publisher trust +-> resolve dependency graph +-> inspect permissions +-> run configured static/local AI audits +-> present installation plan +-> obtain required approval +-> install transactionally +-> create runtime environment +-> start and health-check +-> activate, or roll back on failure +``` + +Installed versions are immutable. Mutable configuration and runtime data live +outside the package directory. + +## 6. Dependency management + +The Service Manager maintains both forward and reverse dependency graphs. +Dependencies use Service IDs and semantic version constraints. Capability-based +binding may be used by Apps and Agents, but installation locks must resolve to +specific Service IDs and versions for reproducibility. + +Required behavior includes: + +- dependency DAG construction and cycle detection; +- automatic dependency acquisition with a visible installation plan; +- deterministic version resolution and lock generation; +- conflict reporting before mutation; +- reverse-dependency checks before disable, upgrade, or uninstall; +- atomic installation and rollback; +- retention of a previous healthy version for rollback. + +The first implementation may allow multiple installed versions but only one +active version per Service ID. Multi-version activation scoped to an App or +Agent is deferred until a concrete need justifies the additional routing and +state complexity. + +Disabling or uninstalling a required Service is blocked by default. Explicit +cascade operations may be added later, but must show all affected dependents. + +## 7. Service lifecycle and management + +### 7.1 States + +The initial lifecycle state machine is: + +```text +not_installed +-> verifying +-> installed +-> disabled | stopped +-> starting +-> healthy +-> degraded +-> failed +-> upgrading +``` + +Additional internal transitional states may be introduced, but externally +reported states should remain stable and easy to understand. + +### 7.2 Operations + +The control plane supports: + +```text +install +uninstall +enable +disable +start +stop +restart +upgrade +rollback +audit +view logs +``` + +Operations are idempotent where practical and return an operation/run ID for +long-running work. Lifecycle events are persisted and streamed to the WebUI. + +### 7.3 Service Manager components + +The Service Manager consists of: + +- package store; +- installed-version registry; +- dependency resolver and lock store; +- signature verifier and publisher trust store; +- audit/attestation store; +- permission and secret broker; +- embedded router registry; +- process supervisor; +- Service Gateway; +- health and readiness monitor; +- structured log collector; +- operation/event store. + +## 8. Built-in Services + +### 8.1 Model runtime + +`ai2apps.model-runtime` is a hardware-neutral built-in Service. The current oMLX +runtime is its first embedded backend provider. Existing oMLX code is not moved +merely to satisfy the abstraction. + +Initial capabilities include: + +```text +model.list +model.load +model.unload +model.chat +model.responses +model.embedding +model.rerank +model.audio +``` + +Existing OpenAI-compatible `/v1/*` APIs remain public compatibility routes. +The Service adapter maps the new capability system onto the same runtime. + +#### 8.1.1 Model backend provider contract + +A `ModelBackendProvider` isolates accelerator/runtime-specific behavior behind +one contract. A provider is responsible for: + +- hardware discovery and normalized `HardwareProfile` reporting; +- model format and quantization compatibility; +- memory estimation, allocation, pressure reporting, and release; +- model load/unload and inference execution; +- backend-native scheduling, batching, cache, kernel, and topology behavior; +- normalized health, metrics, errors, cancellation, and capability discovery. + +Initial provider families are: + +| Provider | Initial environment | Role | +| --- | --- | --- | +| `omlx-mlx` | Apple Silicon on macOS | First/reference implementation preserving current oMLX behavior | +| `nvidia-cuda` | NVIDIA-based Linux AI boxes | CUDA-family backend selected by installed provider and hardware capabilities | +| `amd-rocm` | AMD-based Linux AI boxes | ROCm-family backend selected by installed provider and hardware capabilities | + +CUDA and ROCm provider packages are implementation units, not dependencies of +the platform core. They may wrap an appropriate native inference engine while +presenting the same Model Runtime Service contract. A node may install multiple +providers and the runtime selects by explicit policy, model compatibility, +memory pressure, and hardware availability. + +The backend abstraction does not attempt to erase useful differences. Common +operations are portable; provider-specific optimizations and diagnostics are +advertised as optional capabilities. Model manifests may state portable +requirements such as minimum memory, data type, architecture, context length, +or kernel feature, plus optional provider-specific constraints. + +#### 8.1.2 Unified-memory scheduling + +The model runtime publishes reservations for model weights, KV cache, routed +experts, temporary buffers, and safety margin. Admission is based on a shared +node memory budget and live pressure rather than a hard-coded distinction such +as `RAM` versus `VRAM`. + +Each backend maps the normalized budget to its actual memory system. This may +be physically unified memory, cache-coherent CPU/accelerator memory, a managed +shared-memory runtime, or a more explicit placement scheme exposed through the +same accounting contract. Eviction and cache policies remain backend-owned but +must report their reservations and pressure to the platform scheduler. + +### 8.2 MCP + +The existing MCP manager is initially exposed as the built-in embedded Service +`ai2apps.mcp` with capabilities such as: + +```text +tools.list +tools.execute +servers.list +servers.manage +``` + +Individual MCP servers can later be represented as `service.kind: mcp` +instances. `ai2apps.mcp` remains the protocol adapter and aggregation layer. + +## 9. Agent architecture + +### 9.1 Definition and session ownership + +An Agent is an installable intelligent execution definition that can be invoked +with natural language or structured input and runs asynchronously in a +conversation information stream. + +The architecture distinguishes: + +- `AgentDefinition`: installed, versioned, shareable Agent definition; +- `AgentRun`: one asynchronous invocation of an Agent; +- `AgentView`: an interactive view mounted by a Run in the conversation stream + or sidebar; +- `EffectiveAgent`: the immutable upstream Agent plus an ordered local Patch + stack. + +An `AgentDefinition` is reusable and does not belong to a single conversation. +Every `AgentRun` belongs to exactly one `ConversationSession` and is anchored to +the message or turn that invoked it. + +An Agent definition contains: + +- stable ID and version; +- natural-language activation description and examples; +- instructions and optional prompt assets; +- model capability and routing policy; +- allowed Services, tools, and supporting Agents; +- memory and context policy; +- execution limits and approval policy; +- input and output schemas; +- status-line and interactive View definitions; +- tests and result constraints; +- optional Fusion/review policy. + +Example: + +```yaml +id: general-assistant +name: General Assistant +version: 1.0.0 +instructions: You are a helpful local assistant. + +activation: + description: General local assistant for questions, files, and tasks. + examples: + - Summarize this document + - Convert this SVG to PDF + accepts: + - text + - file + +model: + capability: model.chat@1 + preferred_service: ai2apps.model-runtime + model: qwen3.6 + +services: + allow: + - ai2apps.mcp + +tools: + allow: + - filesystem__read + - web__search + approval: + - filesystem__write + +status: + primary: + kind: text + presentation: pulse + +memory: + conversation: true + app_scope: true + +runtime: + max_steps: 10 + timeout_seconds: 300 + parallel_tools: true + max_tool_parallelism: 4 +``` + +### 9.2 Natural-language dispatch and asynchronous API + +Agents can be selected through: + +1. explicit user selection or an `@Agent` reference; +2. the current App's default Agent; +3. an Agent Resolver matching natural language, attachments, context, + activation examples, and accepted input types. + +Natural language is a dispatch surface over a structured Run request. Ambiguous +matches produce a user-visible choice rather than silently executing a low- +confidence selection. Elevated permissions still require approval regardless +of routing confidence. + +The provisional Session-centered API is: + +```text +POST /v1/sessions/{session-id}/agent-runs +GET /v1/agent-runs/{run-id} +GET /v1/agent-runs/{run-id}/events +POST /v1/agent-runs/{run-id}/inputs/{input-id} +POST /v1/agent-runs/{run-id}/approve/{approval-id} +POST /v1/agent-runs/{run-id}/cancel +POST /v1/agent-runs/{run-id}/resume +``` + +Run creation returns HTTP 202 with a Run ID and event-stream URL. Multiple Runs +may execute concurrently in one Session. Each Run independently updates the +status-line anchored under its invoking message, so a long-running Agent does +not block subsequent conversation. + +### 9.3 Agent Runtime and state machine + +The server-side Agent Runtime owns: + +- context construction; +- model invocation through Service Client; +- tool and supporting-Agent selection and execution; +- step limits, deadlines, cancellation, and budgets; +- permission and approval checkpoints; +- status, interaction, and artifact event streaming; +- state and artifact persistence; +- retry, pause/resume, and failure policy; +- canonical final result selection. + +The initial Run state machine is: + +```text +queued +-> planning +-> running +-> waiting_input | waiting_capability | interrupted +-> running +-> completed | failed | cancelled +``` + +Agent definitions optionally name a durable `concurrency_group` and its limit. +Runs without a group are limited only by the runtime-wide admission bound; +definitions sharing a group also share its capacity. This models an exclusive +accelerator as limit 1, a bounded model pool as limit N, and ordinary I/O +Agents as ungrouped. Capacity is held only while a Run is `planning` or +`running`: waiting for a menu, file, text, or approval response releases it and +the answered Run returns to the priority queue. + +Claiming a Run and checking its shared group are one SQLite transaction. Thus +multiple scheduler tasks cannot oversubscribe the same hardware group. Queue +order is priority first and creation order second. This is an in-process +durable scheduler contract; a future multi-process implementation must retain +the same atomic-claim semantics. + +The current browser-side MCP loop is treated as a compatibility prototype. +New Agent execution is server-owned. Model actions reuse the authoritative +`/v1/chat/completions` route through an in-process ASGI transport, and Tool +actions pass through the common Tool Gateway. + +#### 9.3.1 Built-in General Agent + +`ai2apps.general-agent` is the default AgentRun target. A caller supplies one +of two unambiguous durable inputs: + +- `message_id`: an existing completed User Message in the Run's Session; or +- `prompt`: text that the Agent idempotently persists as a User Message first. + +The executor reconstructs its complete state from Session Messages and +completed RunSteps on every scheduling pass: + +```text +bounded Session context +-> model action with active Tool schemas +-> zero or more durable Tool actions +-> Tool results with original tool_call_id +-> next model action +-> idempotent final Assistant Message +-> completed AgentRun +``` + +It never trusts in-memory loop state. Model-selected Tools use aliases derived +independently from their stable qualified names, including a digest whenever +normalization is required. Installing another Service therefore cannot remap a +Tool alias in a recovering Run. Tool execution still resolves the canonical +qualified name and passes through the Gateway. + +Agent manifests bound the Tool allowlist, Session message count, cumulative +model tokens, repeated identical Tool calls, total Run steps, and wall time. +History beyond the deterministic message-count boundary is represented by an +explicit omission marker; a later semantic compactor may replace that marker +without changing the executor interface. When the model has exhausted its +token budget while asking for a Tool, the Run fails before that Tool can cause +an effect. + +The final model answer is appended to the Session with an idempotency key +derived from the Run ID and metadata linking it back to the Agent definition +and Run. A crash between Message creation and Run completion can consequently +retry without duplicating the visible answer. + +### 9.4 Conversation event, status-line, and interaction protocol + +Every AgentRun must always have one primary status-line in the conversation +information stream. An Agent may provide custom status events; when it does not, +the platform derives a safe fallback from the Run state. + +The initial status presentations are: + +- `plain`: one line of text; +- `pulse`: animated/breathing text; +- `progress`: determinate progress; +- `indeterminate`: unknown progress; +- `warning`: waiting or degraded state; +- `error`: failure state. + +Example event envelope: + +```json +{ + "id": "evt_12", + "seq": 12, + "session_id": "session_1", + "run_id": "run_456", + "type": "agent.status", + "payload": { + "status_id": "primary", + "phase": "rendering", + "text": "Generating image…", + "presentation": "pulse", + "progress": null + } +} +``` + +The platform fallback labels cover queued, planning, running, waiting for input, +waiting for approval, completed, failed, and cancelled states. A completed Run +retains a collapsed execution summary in conversation history. + +Interactive Agent events include: + +```text +agent.status +agent.input.request +agent.approval.request +agent.view.mount +agent.view.update +agent.view.focus +agent.view.close +agent.artifact +agent.completed +agent.failed +``` + +Input requests use JSON Schema plus UI hints for menus, text, numbers, dates, +toggles, forms, files, and directories. File selection returns a scoped resource +handle rather than ambient filesystem access. + +Agent Views can be mounted `inline` beneath the invoking message or in the +conversation `sidebar`. Supported View trust levels are: + +1. `schema`: host-rendered declarative UI, the default; +2. `safe-html`: strictly sanitized script-free HTML; +3. `sandbox`: signed or local-audited HTML/JavaScript in an isolated iframe, + communicating through a constrained message bridge. + +Arbitrary Agent HTML/JavaScript is never inserted directly into the host DOM. +A primary status-line remains present even while an interactive View is shown. + +SSE is the initial durable server-to-client event transport and supports replay +from an event sequence after reconnect. Input, approval, and View actions use +authenticated HTTP requests; a bidirectional transport may be introduced later +without changing event semantics. + +The implemented foundation exposes: + +```text +GET /v1/platform/agents +POST /v1/platform/sessions/{session-id}/agent-runs +GET /v1/platform/agent-runs/{run-id} +GET /v1/platform/agent-runs/{run-id}/events +POST /v1/platform/agent-runs/{run-id}/interactions/{interaction-id}/respond +POST /v1/platform/agent-runs/{run-id}/approve/{interaction-id} +POST /v1/platform/agent-runs/{run-id}/deny/{interaction-id} +POST /v1/platform/agent-runs/{run-id}/cancel +POST /v1/platform/agent-runs/{run-id}/resume +``` + +Run creation is idempotent and returns HTTP 202 with the complete initial +snapshot and a Run-scoped SSE URL. The frontend first paints that snapshot, +then subscribes with its last durable Event sequence. `agent.status` replaces +the primary status-line by revision; `agent.input.request` and +`agent.approval.request` mount a control selected from `kind`, JSON Schema, and +`ui_hints`. A response carries a unique `response_id`, so reconnect retries do +not answer twice. Menus, text, files, forms, and approvals all use this one +interaction settlement protocol. + +The primary status-line is persisted before a Run becomes visible. Rich status +content is data only: `safe_html` must pass the host sanitizer and +`sandbox_html` must use the isolated View bridge when the WebUI renderer is +implemented. An unanswered interaction expires to a durable failed state; the +runtime never silently approves it. + +The first Chat renderer is a registry-based `status-v1` implementation. It +supports semantic tones (`neutral`, `info`, `success`, `warning`, `danger`, and +`accent`), host-owned icons, determinate/indeterminate progress, expandable +plain-text detail, and bounded effects (`pulse`, `blink`, `shimmer`, `dots`, +and `spin`). All motion observes `prefers-reduced-motion`, and terminal states +stop animation regardless of Agent-provided presentation. + +`safe-html-v1` and `sandbox-html-v1` are reserved renderer identities only. +The current host never inserts their content into the DOM and displays the +mandatory text fallback instead. File interactions likewise remain visible but +disabled until the Workspace Service can return a scoped ResourceHandle; the +browser must not substitute a local path or ambient file object. + +Chat uses authenticated fetch-based SSE rather than native `EventSource`, so +the API key and durable `after` cursor can be sent without query-string +credentials. Events trigger a debounced authoritative Run snapshot refresh. +Active Run references are retained locally for reconnect, while durable User +and Assistant Message metadata provides recovery across devices or cleared +browser state. + +On shutdown or restart, pure planning/model work may be queued again. A Tool +step interrupted while its side effect is unknown becomes `uncertain`, and its +Run becomes `interrupted`; resumption then requires an explicit `retry` or +`assume_completed` reconciliation choice. The scheduler never blindly repeats +such a call. + +### 9.5 Agent packages and local AI creation + +The provisional package extension is `.ai2agent`: + +```text +example-agent.ai2agent +├── agent.yaml +├── instructions/ +├── workflows/ +├── schemas/ +├── ui/ +├── tests/ +├── META/ +├── attestations/ +└── signatures/ +``` + +Agent packages are declarative by default and contain instructions, model and +Service bindings, workflows, schemas, View resources, constraints, and tests. +They use the same digest, signature, attestation, trust, and immutable package +principles as Service packages. + +A local user can create or modify an Agent through a privileged Agent Builder: + +```text +natural-language request +-> editable Patch workspace +-> dependency and permission analysis +-> generated definition/resources/code/tests +-> sandbox simulation and UI preview +-> local AI audit +-> user approval +-> device-signed local Agent or local Patch +``` + +Local packages and Patches explicitly display local/device trust rather than +publisher trust. + +### 9.6 Local Patch model + +Installed upstream Agent packages remain immutable. User customization is an +ordered, separately stored `local-patch` stack: + +```text +EffectiveAgent = ImmutableUpstreamAgent + OrderedLocalPatchStack +``` + +This keeps the upstream signature, identity, and update channel while allowing +AI-assisted modifications to status presentation, parameters, instructions, +result constraints, workflows, UI resources, tests, and controlled code. + +Each Patch records: + +1. the user's natural-language intent; +2. the source Agent version and package digest; +3. structured operations and semantic target IDs; +4. expected target kind, schema, and digest; +5. added or changed resources/code; +6. dependency and permission changes; +7. acceptance tests; +8. audit results and local signature. + +Patch operations include `merge`, `replace`, `extend`, `transform`, `remove`, +`add-resource`, `code-patch`, and `add-code`. Semantic targets such as +`ui.status.primary` are preferred over raw line numbers. Text/code diffs may be +stored as implementation detail but do not define compatibility by themselves. + +An effective version is identified independently from its upstream package: + +```text +upstream: com.example.image-agent@2.1.0 +effective: com.example.image-agent@2.1.0+local.3 +upstream digest: sha256:... +patch-set digest: sha256:... +effective digest: sha256:... +``` + +Trust reporting distinguishes verified upstream content, device-signed local +Patches, and the audit status of the assembled EffectiveAgent. + +### 9.7 Upgrade, rebase, and conflict handling + +Updating a locally patched Agent is a three-way semantic rebase: + +```text +old immutable upstream ++ local effective result ++ new immutable upstream +-> replay/re-synthesize ordered local Patches +-> tests, permission review, and audit +-> atomic activation or retain previous EffectiveAgent +``` + +Each Patch can declare a rebase policy: + +- `strict`: any target change requires review; +- `preserve-local`: retain an explicit local replacement while warning about + upstream changes; +- `ai-assisted`: use Patch intent and tests to propose a new implementation; +- `drop-if-satisfied`: suggest removing the Patch when upstream now satisfies + its intent. + +Patch states include `clean`, `rebased`, `needs-review`, `conflicted`, +`disabled`, `superseded`, and `failed-tests`. + +For example, if an upstream text status-line was locally replaced by an HTML +View and a later upstream version introduces its own interactive HTML status, +the type/schema precondition fails. The system must not silently overwrite the +new upstream View. It presents a conflict workspace with at least these choices: + +1. preserve the complete local replacement; +2. merge the local intent into the new upstream View; +3. accept upstream and retain only still-needed customization; +4. disable the Patch; +5. keep the previously active EffectiveAgent and postpone the update. + +AI may propose a resolution, but executable code, new permissions, new +dependencies, or changed sandbox behavior require preview, tests, audit, and +explicit user approval before activation. The current effective version remains +active until the replacement passes all gates. + +Local Patches may be exported as `.ai2patch` artifacts. A user may also promote +an accumulated Patch stack into a true Fork with a new Agent ID and independent +release lifecycle. + +### 9.8 Code and security boundary + +Agent-local code is appropriate for status/interaction UI, validators, +workflow conditions, transformations, result constraints, and lifecycle hooks. +It runs in the constrained Agent Runner or UI sandbox and is audited as part of +the EffectiveAgent. + +Code requiring broad filesystem/network access, subprocesses, native +dependencies, independent queues, long-running workers, or a new Web API is +materialized as a local Service package. The Agent Patch adds a dependency on +that Service. AI may present this as one Agent edit, but the Service execution +and permission boundary remains explicit. + +Patch risk is classified at least by whether it changes only presentation, +instructions/schema, sandboxed code, permissions, or executable Services. +Unchanged upstream audit attestations remain reusable, while each Patch and the +assembled EffectiveAgent receive differential and composition-level checks. + +### 9.9 Agent management and Multi-Agent strategy + +Agent definition management actions are: + +```text +install | uninstall | enable | disable | update | rollback +patch | rebase | fork | edit | audit | test | export +``` + +AgentRun actions are: + +```text +cancel | pause | resume | retry | approve | reject +``` + +Agents do not have a process-level `restart`; persistent execution processes +belong to Services. + +The first implementation focuses on a reliable single-Agent loop. Multi-Agent +composition is added by exposing an Agent invocation as a controlled capability: + +```text +Coordinator Agent + -> call_agent(researcher) + -> call_agent(writer) + -> call_agent(reviewer) +``` + +This reuses the same Run, Step, status, View, permission, event, and audit +infrastructure. A separate graph/workflow engine is deferred until concrete App +requirements show that Agent-as-capability composition is insufficient. + +Fusion is a model-quality orchestration strategy, not a replacement for the +Agent Runtime. An Agent may select a Fusion model policy for a model step. + +## 10. App architecture + +### 10.1 App as an Agent superset + +An App is the product interaction layer over Agents and Services and is a +semantic superset of Agent behavior. Every App can be invoked through natural +language or structured input, can create asynchronous AgentRuns, can interact +with users, and inherits the Agent package, trust, audit, and local Patch model. + +An App additionally owns complete UI surfaces, persistent instances, a Home +Session, optional additional App-owned Sessions, durable state, and integration +with system navigation. + +Conceptually: + +```text +App = Agent behavior + + Entry + + Mini-Entry + + AppInstance lifecycle + + HomeSession + + optional AppSession collection + + persistent state + + navigation integration +``` + +Implementation should use a shared interactive-unit/event foundation rather +than duplicate the Agent protocol or force all deterministic system Apps to run +an LLM loop. An App activation may delegate to an Agent, invoke a deterministic +action, or only mount a UI surface while preserving the same activation and +event semantics. + +### 10.2 App object model + +The architecture distinguishes: + +- `AppDefinition`: installed, signed, versioned App definition; +- `AppInstance`: one persistent usable instance of an App; +- `AppSession`: a ConversationSession owned by an AppInstance rather than merely + hosting its Mini-Entry; +- `HomeSession`: the distinguished initial/default AppSession created when an + AppInstance first runs independently; +- `EntryMount`: the complete App page mounted below the system navigation; +- `MiniEntryMount`: a compact App surface mounted inline or in a conversation + sidebar; +- `InteractionSession`: the conversation that invoked or currently hosts an App + mount; +- `EffectiveApp`: immutable upstream App plus an ordered local Patch stack. + +An AppInstance owns business state, artifacts, background Runs, its HomeSession, +and any additional AppSessions independently from currently mounted UI. Closing +a page or Mini-Entry normally unmounts the View without destroying the instance, +its Sessions, or background work. + +### 10.3 Independent launch and HomeSession + +Launching an App from navigation, App Launcher, or a URL performs: + +```text +resolve or create AppInstance +-> create or restore HomeSession +-> restore AppSession collection and select the current/default Session +-> mount Entry +-> restore persistent state, artifacts, and background Runs +``` + +The HomeSession exists even when Entry does not visually resemble a chat page. +AI actions, approvals, notifications, and execution history can still be stored +in this information stream and surfaced by the App when useful. An App may +create additional AppSessions when multiple independent conversation histories +are part of its product model; doing so does not create another AppInstance. + +When a Mini-Entry is launched from another conversation, that conversation is +the `InteractionSession`. The App keeps its own HomeSession for durable internal +history and state. The host conversation stores the activation, user-visible +interaction, important results, and an AppInstance reference rather than +copying all private App events into the host stream. + +### 10.4 Entry + +Every App must define an Entry that runs in the AI2Apps system shell below the +global navigation bar. Provisional routes are: + +```text +/apps/{app-id} +/apps/{app-id}/instances/{instance-id} +``` + +Singleton Apps may expose stable aliases such as `/apps/settings` or +`/apps/dashboard`. + +Entry trust/rendering modes are: + +- `host`: trusted built-in UI integrated with native AI2Apps WebUI components; +- `schema`: host-rendered declarative UI; +- `safe-html`: strictly sanitized script-free HTML; +- `sandbox`: signed or local-audited HTML/JavaScript in an isolated iframe. + +Third-party App code does not execute directly in the system shell DOM. Entry +receives scoped AppInstance state and actions through a constrained App View +bridge. + +### 10.5 Mini-Entry and conversational activation + +Every App may define a dedicated Mini-Entry for `inline` and/or `sidebar` +placement in a ConversationSession. Mini-Entry is a purpose-built compact +surface rather than a scaled-down Entry. + +Mini-Entry and Entry share the same AppInstance, persistent state, active Runs, +permissions, Service bindings, and artifacts. Expanding a Mini-Entry opens the +same AppInstance in Entry without resetting its state. + +Apps inherit Agent activation metadata and can be selected by explicit user +choice, the current App's routing policy, or natural-language matching. Example: + +```yaml +activation: + description: Browse nearby restaurants and create food orders. + examples: + - I am hungry + - Help me order lunch + accepts: + - text + - location + behavior: suggest +``` + +Activation behaviors are: + +- `explicit`: only direct selection or an explicit App reference; +- `suggest`: show a user-confirmable suggestion in the information stream; +- `auto-mount`: automatically mount Mini-Entry after a confident match. + +Third-party Apps default to `suggest`. Users may explicitly grant trusted Apps +auto-mount behavior. Activation never authorizes high-risk side effects such as +placing an order or payment; those remain separate approval steps. + +If an App has no custom Mini-Entry, the platform may render a generic compact +launcher with App name, status, and an Open Entry action. + +### 10.6 Instance policies + +The initial instance modes are: + +- `multiple`: multiple independent AppInstances may coexist; +- `singleton`: at most one AppInstance exists in the declared scope. + +The scope vocabulary reserves: + +- `system`: one instance for the local AI2Apps installation; +- `user`: one instance per user; +- `session`: one instance per ConversationSession. + +The first local single-user implementation may begin with `multiple` and +`singleton/system` while keeping manifests forward-compatible with the other +scopes. + +Typical policies are: + +| App | Policy | +| --- | --- | +| Dashboard | singleton/system | +| Settings | singleton/system | +| User Profile | singleton/user | +| Chat | singleton/user; multiple AppSessions/threads | +| Calculator | multiple | +| Game | multiple | +| File Browser | multiple | +| Document Editor | multiple | + +Each multiple AppInstance has an independent instance ID, HomeSession, state, +mounted Views, background Runs, and artifacts. Closing a Mini-Entry is distinct +from suspending or closing the AppInstance. + +The initial AppInstance lifecycle is: + +```text +creating -> active -> background -> suspended -> active -> closed + \-> degraded | failed +``` + +Singleton system Apps normally support reset, disable, or restore rather than +permanent instance deletion. + +Instance cardinality and Session cardinality are independent. `singleton` +limits AppInstances, not the number of ConversationSessions an instance may +own. Creating, selecting, renaming, archiving, or deleting an App-owned Session +is ordinary App state/session lifecycle and never implicitly creates or closes +an AppInstance. + +### 10.7 App execution and event reuse + +Apps do not introduce a second intelligent Run protocol. Natural-language +activation and UI actions that require intelligence create AgentRuns associated +with the AppInstance and current InteractionSession. Deterministic UI actions +may update App state directly through controlled App actions. + +App-specific events extend the shared conversation event system: + +```text +app.instance.created +app.instance.restored +app.entry.mount +app.mini_entry.mount +app.view.update +app.view.unmount +app.state.changed +app.backgrounded +app.closed +``` + +Every asynchronous AgentRun started by an App retains the mandatory Agent +status-line. A passive Mini-Entry itself does not need a separate status-line. + +### 10.8 App packages + +The provisional package extension is `.ai2app`: + +```text +example-app.ai2app +├── app.yaml +├── agents/ +├── instructions/ +├── workflows/ +├── schemas/ +├── ui/ +│ ├── entry.html +│ └── mini-entry.html +├── migrations/ +├── tests/ +├── META/ +├── attestations/ +└── signatures/ +``` + +Example manifest: + +```yaml +schema: ai2apps.app/v1 +id: com.example.food-order +name: Food Order +version: 1.0.0 + +instances: + mode: singleton + scope: user + on_launch: focus-existing + +activation: + description: Browse restaurants and prepare food orders. + examples: + - I am hungry + - Help me order lunch + behavior: suggest + +entry: + kind: sandbox + resource: ui/entry.html + +mini_entry: + kind: sandbox + resource: ui/mini-entry.html + placements: + - inline + - sidebar + expandable_to_entry: true + +agents: + entry: order-assistant + +services: + require: + - location.search@1 + - food.catalog@1 + - order.create@1 +``` + +App packages use the same immutable content, digest, signature, dependency, +permission, attestation, and audit foundations as Agent and Service packages. + +### 10.9 Local AI creation and maintenance + +Users can create a local App or modify an installed App through an AI-powered +App Studio: + +```text +natural-language request +-> editable App/Patch workspace +-> Entry and Mini-Entry generation +-> Agent and Service creation/binding +-> instance policy and state schema +-> Entry/Mini-Entry preview +-> activation and interaction simulation +-> persistence/migration tests +-> dependency, permission, and local AI audit +-> user approval +-> device-signed installation or Patch +``` + +The App Studio is itself a privileged singleton App with a Builder Agent. It +provides source editing, live Entry/Mini-Entry preview, instance inspection, +state migration tests, audit, and package/Patch export. + +Locally created Apps use a new local App ID and device trust. Installed upstream +Apps remain immutable; AI-assisted customization is stored as a local App Patch +stack. + +### 10.10 App local Patch model + +The App customization model is: + +```text +EffectiveApp + = ImmutableUpstreamApp + + OrderedLocalPatchStack + + MigratedInstanceState +``` + +App Patches reuse the Agent semantic Patch/rebase engine and additionally target: + +```text +activation +instances.policy +navigation +ui.entry +ui.mini_entry +ui.routes +ui.actions +state.schema +state.defaults +agents.entry +agents.supporting +services.dependencies +permissions +artifacts +workflows +``` + +Patch intent, semantic targets, preconditions, resources/code, tests, +dependencies, permissions, audit, local signing, effective digests, and rebase +policies follow the Agent Patch model. App code/definition Patches apply to all +instances of the EffectiveApp. Per-instance differences belong to instance +settings/state rather than separate code Patch stacks. + +Accumulated App Patches may be exported as `.ai2patch` or promoted to a Fork +with a new App ID and independent release lifecycle. + +### 10.11 App upgrade and instance-state migration + +App upgrades must rebase local Patches and migrate persistent AppInstance state: + +```text +verify new immutable upstream +-> semantic rebase of ordered local App Patches +-> assemble candidate EffectiveApp +-> compare old/new state schemas +-> select or AI-generate a state migration +-> snapshot current instance data +-> dry-run migration for every instance +-> test Entry, Mini-Entry, activation, Agents, Services, and permissions +-> local audit and user approval +-> atomic activation, or retain the old EffectiveApp +``` + +App-specific conflicts include changed Entry/Mini-Entry type or bridge contract, +route changes, incompatible instance policy, state-schema incompatibility, +removed Agent/Service dependencies, and navigation or permission changes. + +For example, a local sandbox Mini-Entry Patch cannot be silently replayed when a +new upstream replaces the old Mini-Entry with a different schema-based View. +The conflict workspace offers preservation, semantic merge, acceptance of the +new upstream, Patch disablement, or postponing the update. AI may propose and +preview a migrated View, but tests, audit, and explicit approval gate activation. + +Any failed instance migration blocks activation by default. The previous +EffectiveApp, instance snapshots, and rollback path remain available. + +### 10.12 Code boundary and Safe Mode + +Entry/Mini-Entry UI, App state transformations, and constrained lifecycle Hooks +may live in the App package/Patch sandbox. Agent behavior is implemented by an +embedded/referenced Agent. Broad filesystem/network access, subprocesses, +native dependencies, long-running workers, independent queues, or a new Web API +belong to a Service package, even when App Studio presents the change as one App +edit. + +Dashboard, Settings, App Studio, and other system Apps may be locally patched, +but AI2Apps must retain an unpatchable minimal recovery surface. Safe Mode can: + +- disable all local App/Agent Patches; +- restore built-in system Apps; +- select a previous EffectiveApp; +- inspect Patch conflicts and failed migrations; +- disable or uninstall a broken App. + +### 10.13 Initial built-in Apps and UI modes + +The current Chat UI becomes the first built-in App, provisionally identified as +`ai2apps.general-chat`. The existing `/admin/chat` route remains as a +compatibility redirect after the App runtime route is available. + +#### 10.13.1 Chat instance and thread model + +Chat is the reference example of a singleton App with multiple App-owned +Sessions: + +```text +ai2apps.general-chat AppDefinition + -> one Chat AppInstance per user + -> ThreadCollection + -> ConversationSession (thread A) + -> ConversationSession (thread B) + -> ConversationSession (thread C) +``` + +In the initial local single-user deployment, `singleton/user` resolves to one +Chat AppInstance for the installation. The existing behavior of creating and +switching among multiple threads is preserved inside that instance. + +`ThreadCollection` is Chat-owned collection state built from standard +AppSession/ConversationSession records, not a second platform-wide Session +type. Its durable record holds the selected-thread recovery pointer and +collection revision. Membership records hold pin, stable ordering, and an +optional legacy client identity. Title, lifecycle, metadata, Messages, and +semantic Events remain on the generic Session resource. + +Each Chat thread is a full ConversationSession with its own messages, AgentRuns, +status-lines, mounted Views, artifacts, context/memory policy, SandboxInstance, +ResourceHandles, and GrantLeases. A new Chat thread creates a new +ConversationSession—not a new Chat AppInstance. Thread-scoped resources and +authority do not leak into another thread merely because both belong to the +same Chat instance. + +The designated initial/default thread fulfills the HomeSession role; that role +is reassigned transactionally if the default thread is removed. The Chat +AppInstance owns collection-level state such as thread order, pinning, +selected-thread recovery, drafts, search index, and UI preferences. Thread +title and archive state remain Session fields. Deleting or archiving a thread +applies its Session retention policy but leaves the singleton Chat AppInstance +running. + +Entry renders the selected thread and the thread navigation/sidebar. Multiple +browser windows or EntryMounts may project the same Chat AppInstance and may +select different threads without duplicating App state. Agent execution remains +owned by the selected ConversationSession. + +The Chat Entry treats the platform database as authoritative. Browser +`localStorage` may retain an offline/recovery projection, but it never owns the +canonical thread revision. Existing browser-owned oMLX chats are imported once +by stable legacy identity through an idempotent backend transaction; the local +copy is preserved so migration failure cannot erase history. Thread and content +mutations use optimistic Session/collection revisions, and stale projections +surface conflicts instead of applying last-writer-wins overwrites. + +#### 10.13.2 Session is broader than Chat Thread + +`ConversationSession` is a platform execution and information-stream resource, +not an alias for a Chat App thread. A Session may be owned by any AppInstance, +serve as an App HomeSession, host an Agent child context, or provide a temporary +Mini-Chat/In-App-Chat embedded in another App. + +The initial classification dimensions are: + +```text +session_kind: app | chat_thread | mini_chat | in_app_chat | agent_child +visibility: listed | unlisted +retention: durable | temporary +expires_at: required UTC expiry for temporary retention; absent for durable +``` + +Chat's ThreadCollection contains only Sessions explicitly classified as +`chat_thread`, owned by that Chat AppInstance. A Chat thread is always listed +and durable. `mini_chat` and `in_app_chat` default to unlisted and temporary: +they still receive normal Message, Event, Agent, approval, sandbox, and replay +semantics, but they do not appear as persistent threads in the Chat App. + +Temporary does not mean browser-only or non-authoritative. The backend may +persist a temporary Session across a short restart window and later expire it +according to retention policy. The initial platform default is 24 hours, with +an explicit expiry allowed at creation. A bounded runtime janitor soft-deletes +expired Sessions and emits `session.expired` atomically; Messages and Events +remain available for audit and recovery policy. Promoting a temporary +conversation into a Chat thread must be an explicit copy/adopt operation with +an audit Event; it is never inferred merely because the interaction looks +conversational. + +The initial reusable Entry interaction modes remain: + +- `chat`: conversational assistant; +- `form`: structured input followed by execution; +- `workspace`: conversation plus files, previews, and artifacts; +- `workflow`: phases, progress, approvals, and results. + +Apps may implement these modes through host-rendered schema UI or sandboxed +custom Entry/Mini-Entry resources while preserving the system shell, trust, and +event contracts. + +## 11. Runtime state model + +The common state hierarchy is: + +```text +AppDefinition + -> AppInstance + -> AppSession[] + -> HomeSession (default role) + -> SessionSandbox + -> ConversationTurn / Message + -> AgentRun + -> RunCapabilityContext + -> StatusLine + -> AgentView + -> ModelStep + -> ServiceStep / ToolStep + -> InputStep / ApprovalStep + -> Artifact + -> PersistentState / Artifacts + -> EntryMount / MiniEntryMount + -> ViewSandbox + -> InteractionSessionBinding + -> external ConversationSession + -> SessionSandbox + -> ConversationTurn / Message + -> AgentRun + -> RunCapabilityContext + -> StatusLine + -> AgentView + -> ModelStep + -> ServiceStep / ToolStep + -> InputStep / ApprovalStep + -> Artifact +``` + +Definitions: + +- `AppInstance`: persistent stateful instance of an App definition; +- `AppSession`: a ConversationSession in the one-or-more Session collection + owned by an AppInstance; +- `HomeSession`: the initial/default role assigned to one AppSession; +- `SessionSandbox`: isolated workspace, resources, policies, Grants, quotas, + and audit stream for a ConversationSession; +- `EntryMount/MiniEntryMount`: full or compact UI projection of an AppInstance; +- `ViewSandbox`: isolated renderer and constrained bridge for executable Views; +- `InteractionSessionBinding`: reference to an external conversation currently + invoking or hosting an App; +- `ConversationSession`: durable information stream shared by the user, Apps, + and asynchronous Agents; +- `ConversationTurn/Message`: user, Agent, system, or tool content and the + anchor for one or more AgentRuns; +- `AgentRun`: one goal-directed asynchronous execution owned by exactly one + ConversationSession; +- `RunCapabilityContext`: narrowed capabilities delegated from the Session to a + specific Run; +- `StatusLine`: mandatory primary live status for an AgentRun; +- `AgentView`: interactive inline or sidebar UI mounted by an AgentRun; +- `Step`: a model, Service, tool, approval, or internal orchestration action; +- `Event`: append-only progress/state transition emitted by a Run or operation; +- `Artifact`: a durable output such as a file, document, image, or structured + result; +- `Memory`: conversation, App-scoped, user-scoped, or Agent-scoped retained + context. + +SSE is the initial transport for live Run and Service operation events. The +event model must support replay after reconnect, not only transient streaming. + +Core execution state is stored server-side. Browser `localStorage` is limited +to non-authoritative UI preferences such as theme and panel layout. + +## 12. Storage model + +The initial local-first storage layout uses: + +- SQLite for App/Agent/Service metadata, versions, bindings, sessions, Runs, + Steps, events, Agent/App package and Patch metadata, EffectiveAgent/ + EffectiveApp assemblies, AppInstances, state-schema migrations, dependency + locks, SandboxInstances, capabilities, GrantLeases, ResourceHandles, + SandboxSnapshots, trust decisions, and audit records; +- immutable filesystem directories for installed packages; +- per-Service mutable data directories and per-AppInstance state directories; +- an artifact store for generated files and large outputs; +- a secret store abstraction so manifests refer to secret names rather than + embedding secret values. + +Storage paths, backup semantics, retention, migrations, and multi-user scoping +remain to be specified. + +## 13. Sandbox, security, and permission model + +### 13.1 Default-deny platform model + +Sandboxing is a first-class platform abstraction rather than an optional Agent +or Service setting. The AI2Apps runtime, every ConversationSession, each +managed Service, executable Agent/App Hook, and untrusted View begins without +ambient host access. + +Host resources are accessed only through explicit, scoped, expiring, +revocable, and auditable capabilities. Natural-language references to a path, +URL, credential, or external action are requests, not authorization. + +The threat model includes malicious or mistaken model output, prompt injection, +untrusted App/Agent/Service packages, compromised UI code, cross-Session data +leakage, excessive permissions, supply-chain compromise, and accidental or +deliberate destructive operations. + +### 13.2 Root Sandbox and trusted Host Broker + +Most of AI2Apps runs inside a Root Sandbox that limits access to the AI2Apps +installation/data directories, configured model storage, package store, +database, required loopback endpoints, and explicitly allowed system resources. +It does not receive unrestricted access to user files, credentials, devices, +network, subprocesses, or external applications. + +If approved operations can cross the Root Sandbox, a minimal trusted Host +Broker must run outside it. Otherwise sandboxed code cannot safely perform an +approved elevation. The Host Broker is part of the trusted computing base and: + +- does not execute model output or third-party package code; +- validates signed/scoped capability requests; +- resolves host resources into opaque handles; +- starts approved isolated processes; +- applies or revokes GrantLeases; +- performs approved export/commit operations; +- emits append-only audit events. + +The broker interface is narrow and policy-driven. Compromising the general +AI2Apps runtime must not implicitly grant broker authority. + +### 13.3 Sandbox hierarchy + +```mermaid +flowchart TB + HOST["Host OS / User Data"] --> BROKER["Trusted Host Broker
minimal privileged component"] + BROKER --> ROOT["AI2Apps Root Sandbox
WebUI, API, Agent Runtime, Package Manager"] + + ROOT --> SESSION["Conversation Session Sandbox"] + ROOT --> HOME["App HomeSession Sandbox"] + ROOT --> SERVICE["Managed Service Sandbox"] + ROOT --> VIEW["App/Agent View Sandbox"] + + SESSION --> RUN1["AgentRun Capability Context"] + SESSION --> RUN2["AgentRun Capability Context"] + HOME --> APPRUN["App AgentRun Capability Context"] + + RUN1 -. "CapabilityRequest" .-> BROKER + APPRUN -. "CapabilityRequest" .-> BROKER + SERVICE -. "Delegated Capability" .-> BROKER +``` + +The hierarchy is an authorization model even where the host OS does not provide +literal nested sandboxes. Every enforcement layer must preserve equivalent +isolation through processes, resource namespaces, capability tokens, brokers, +and storage boundaries. + +### 13.4 Session Sandbox + +Every ConversationSession, including an AppInstance HomeSession, owns a +SandboxInstance with: + +```text +workspace +temporary storage +artifacts +resource mounts +secret references +network policy +Service grants +process/resource limits +storage quota +audit/event history +``` + +Sessions are mutually isolated by default. A handle, mount, secret, workspace, +or Grant issued to one Session is invalid in another unless an explicit, +audited delegation is created. + +An AgentRun executes with a child capability context derived from its Session. +It can receive narrower time, resource, network, and Service limits, but cannot +expand the parent Session's authority by itself. + +Session retention policy determines whether its sandbox is destroyed, archived, +snapshotted, retained for a period, or reduced to selected Artifacts when the +conversation closes. + +### 13.5 Service Sandbox and Session delegation + +Each managed Service runs in its own process/environment sandbox according to +its signed manifest. Installation permission describes the maximum capability +the Service may request; it is not a permanent runtime grant. + +For a Service invocation on behalf of a Session: + +```text +EffectiveServiceCapability + = RootPolicy + intersection ServiceManifestPermissions + intersection SessionDelegation + intersection ActiveGrantLease +``` + +A Service that handles multiple Sessions must isolate temporary data, queues, +resource handles, secrets, and results by Session/caller identity. It receives +scoped resource handles rather than raw host paths whenever possible. + +Embedded Services are not exempt. Privileged embedded operations use the same +broker/capability interface instead of relying on ambient main-process access. + +### 13.6 View Sandbox + +Agent status HTML, AgentView, App Entry, and Mini-Entry have a separate UI +sandbox boundary controlling DOM access, cookies/storage, navigation, network, +downloads, clipboard, device APIs, and window creation. + +Host-rendered Schema Views require no executable third-party UI code. +`safe-html` is script-free and sanitized. Executable `sandbox` Views run in an +isolated iframe with restrictive CSP and communicate only through a constrained, +authenticated View bridge. + +View code does not receive host filesystem or Session authority directly. It +requests actions from the App/Agent runtime, which applies Session policy and +the Host Broker flow. + +### 13.7 Capabilities and resource handles + +The effective authority for an operation is: + +```text +EffectiveCapability + = RootPolicy + intersection SessionPolicy + intersection DeclaredPackagePermissions + intersection ActiveGrantLease +``` + +Permission classes include: + +- scoped filesystem/resource access; +- inbound and outbound network access; +- subprocess execution; +- model/GPU access; +- secrets; +- access to other Services; +- App, Agent, Session, memory, state, and artifact namespaces; +- external side effects such as messages, publication, orders, and payments. + +User-selected host resources become opaque handles: + +```json +{ + "resource_id": "file_abc", + "display_name": "app.svg", + "capabilities": ["read"], + "scope": "run_456", + "expires_at": "2026-08-11T15:00:00Z" +} +``` + +Agents and Services use `resource://file_abc` rather than assuming access to a +host path. File/directory pickers return handles, and paths typed in natural +language still require resolution and authorization. + +The schema-v9 implementation gives every active Session a lazily created +managed `workspace/` plus `temporary/` directory beneath the platform sandbox +root. Selected browser files are copied into that workspace before a handle is +issued, so an ordinary read handle contains no ambient authority over the +original host file. A file interaction is resolved only when its handle is +live, readable, and owned by the same Session as the AgentRun. + +Artifacts are immutable, SHA-256-addressed blobs stored separately from mutable +workspace files. Artifact metadata and previews remain Session-scoped even +when identical bytes share a physical content blob. Export to the host is a +different operation: it requires an export-capable external directory handle, +an active `artifact.export` GrantLease for Agent calls, and an atomic Host +Export Broker transaction. The current broker is trusted in-process plumbing; +M7 moves privileged filesystem/process enforcement behind OS sandbox adapters. + +Schema v10 realizes that Process boundary. Process authority is exposed only +through the Tool Gateway and resolves to a Session-owned, optionally Run-owned +execution record. Invocation is argv-only, the environment is constructed from +an allowlist and opaque Secret references, output is drained into bounded +chunks, and all control operations independently recheck ownership. Network is +an argument-dependent capability and defaults to denied. + +On macOS the child runs under a generated Seatbelt profile with the Session +workspace and temporary directory as its only writable roots. On Linux the +same portable contract maps to bubblewrap namespaces and mounts. Both adapters +are behind one fail-closed interface; an unconfined implementation is permitted +only as an explicit conformance-test double. Agent Run terminal transitions, +platform shutdown, and identity-verified restart recovery terminate complete +process groups. + +Host Broker spawn authority is represented by a short-lived HMAC envelope +scoped to one operation, Session, Run, request ID, and nonce. Only its digest, +expiry, resolution, and narrowed evidence are persisted. This initial broker +is in-process, but its envelope and audit contract can move across a privilege +boundary when the whole AI2Apps root runtime is placed in its own outer sandbox. + +### 13.8 Capability requests and GrantLeases + +An operation outside the current capability context creates a +`CapabilityRequest` and moves the AgentRun to `waiting_capability` or +`waiting_approval`. + +A request records: + +- requesting App, Agent, effective package/Patch digests, Run, and Session; +- operation and precise target; +- reason and relation to the user's current goal; +- requested scope and duration; +- input/output data flow; +- reversibility and expected side effects; +- diff, export preview, or execution plan when available; +- current trust, audit, and permission state. + +Approval issues a narrow `GrantLease`, not a general sandbox escape. Initial +Grant scopes are: + +- `once`: one operation; +- `run`: current AgentRun; +- `session`: current ConversationSession; +- `app-instance`: current AppInstance; +- `package-version`: a specific EffectiveAgent/EffectiveApp digest; +- `persistent-rule`: an explicit user-created policy rule. + +The default is the narrowest practical `once` or `run` scope. Grants are +revocable. Package upgrades, local Patch changes, or effective digest changes +invalidate digest-bound Grants or require review. + +The schema-v8 implementation starts with four directly resolvable scopes: +`run`, `session`, `agent`, and `app`. All four stay Agent-definition- and +Tool-pattern-bound; broader names describe lifetime, not authority expansion. +The Chat UI maps these to Allow once (run), Allow for session, and Always allow +agent, with run as the default. The policy engine treats active leases and +deterministic rules as authoritative; the historical AgentRun capability list +is only a compatibility projection. Package-version/persistent-rule UX and +effective package/Patch digest invalidation remain tied to package-manager work. + +### 13.9 User approval and configurable AI audit + +Each capability class can use one of these decision modes: + +```text +deny +ask-user +ai-audit-then-ask +ai-audit-auto-approve +preauthorized-rule +``` + +Deterministic policy checks run before any AI audit. The executing Agent cannot +approve its own request. AI review uses an independent Policy/Audit Agent or +model context and records the subject digest, model/version, policy version, +evidence, risk, decision, and limitations. + +AI audit evaluates at least goal relevance, requested scope, less-privileged +alternatives, destinations, privacy/secrets, reversibility, package/patch trust, +and the complete App -> Agent -> Service call chain. + +The initial runtime exposes an independent auditor binding rather than letting +the executing Agent self-approve. Auditor results and evidence are persisted; +malformed/error results fall back to user approval, and deterministic policy +denials cannot be overridden. Concrete auditor model selection, timeout, risk +rubric, and operator configuration UI remain future policy-service work. + +Policy may allow AI auto-approval for bounded low-risk operations. The default +policy requires explicit user approval or a precise preauthorized rule for: + +- irreversible deletion or overwrite of external data; +- sending messages, publication, orders, and payments; +- exposing or exporting secrets; +- installing executable code; +- privilege escalation; +- security/audit policy modification; +- creation of long-lived or broad grants. + +### 13.10 Transactional host operations + +Work is performed inside the Session Sandbox before committing side effects to +the host whenever possible: + +```text +read approved external resource +-> process in Session workspace +-> generate Artifact/diff/plan +-> user or configured AI review +-> export/commit through Host Broker +``` + +Reading an external SVG, converting it in the Session, and exporting a PDF are +separate capabilities. The export step can be denied without losing the +in-sandbox result. + +Destructive and multi-resource operations should use snapshots, staging, +transactional replacement, trash/recovery, or compensating actions when the +underlying resource supports them. + +### 13.11 App, HomeSession, and InteractionSession isolation + +An AppInstance HomeSession has its own SandboxInstance. Mounting Mini-Entry in a +different InteractionSession does not transfer all HomeSession authority. + +The mount receives only explicitly delegated state/actions. Sensitive App data +is exposed through opaque IDs or scoped App storage capabilities rather than +copied into the host conversation. AgentRuns launched from the mount use the +InteractionSession capability context unless an explicit, audited HomeSession +delegation is required. + +### 13.12 Sandbox objects and events + +Core objects are: + +```text +SandboxPolicy +SandboxInstance +Capability +CapabilityRequest +GrantLease +ResourceHandle +AuditDecision +SandboxEvent +SandboxSnapshot +``` + +Session, AppInstance, AgentRun, View, and ServiceInvocation records reference a +SandboxInstance or capability context. + +Events include: + +```text +sandbox.created +sandbox.capability.requested +sandbox.audit.started +sandbox.audit.completed +sandbox.capability.granted +sandbox.capability.denied +sandbox.capability.revoked +sandbox.violation +sandbox.snapshot.created +sandbox.destroyed +``` + +Sandbox events are append-only and replayable. Policy and audit decisions, +package trust decisions, lifecycle changes, approvals, denials, revocations, +violations, and privileged Service operations are retained according to audit +policy. + +### 13.13 Sandbox WebUI and recovery + +Every Session and AppInstance exposes a Sandbox panel showing: + +- workspace and Artifacts; +- mounted host resources; +- active and pending Grants; +- network and Service access; +- secret references without secret values; +- storage/process quotas; +- audit history and violations; +- revoke, export, snapshot, and cleanup actions. + +The user may revoke Grants at any time. New operations fail immediately; +in-flight work is cancelled or allowed to reach a safe boundary according to +capability type. + +The minimal Safe Mode/recovery surface is outside locally patchable App/Agent UI +and can disable Patches, revoke Grants, stop Services, restore built-ins, inspect +violations, and roll back EffectiveApps/EffectiveAgents. + +Managed-process and UI isolation are defense in depth, not proof of a complete +sandbox. macOS and Linux use separate platform adapters for process, filesystem, +network, device, and UI isolation while preserving the same capability and +Host Broker contracts. Concrete enforcement mechanisms and broker deployment +remain implementation design items for each supported OS/runtime pair. + +## 14. API surfaces + +The platform separates control-plane APIs from runtime APIs. + +### 14.1 Control plane + +Provisionally under `/admin/api`: + +```text +/admin/api/services +/admin/api/service-packages +/admin/api/service-operations +/admin/api/agents +/admin/api/apps +/admin/api/runs +/admin/api/audits +/admin/api/publishers +/admin/api/sandboxes +/admin/api/capability-requests +/admin/api/sandbox-policies +``` + +These APIs require administrative authorization. + +### 14.2 Runtime plane + +Provisionally: + +```text +/v1/apps/{app-id}/instances +/v1/app-instances/{instance-id} +/v1/app-instances/{instance-id}/mounts +/v1/app-instances/{instance-id}/sessions +/v1/app-instances/{instance-id}/sessions/{session-id} +/v1/sessions/{session-id}/agent-runs +/v1/agent-runs/{run-id} +/v1/agent-runs/{run-id}/events +/v1/sessions/{session-id}/sandbox +/v1/sandboxes/{sandbox-id}/capability-requests +/v1/capability-requests/{request-id}/approve +/v1/capability-requests/{request-id}/deny +/v1/grants/{grant-id}/revoke +/v1/resources/{resource-id} +/services/{service-id}/{path} +``` + +Current OpenAI-compatible APIs remain supported independently of the new +resource APIs. + +The exact naming, versioning, and route hierarchy remain subject to an API +design pass. + +## 15. WebUI information architecture + +The WebUI retains the current oMLX-derived design system but separates the +administration/workbench surface from user-facing App execution. + +### 15.1 AI2Apps Shell and Dock + +The former administration navigation becomes an AI2Apps-owned Dock. The Shell +is an unpatchable recovery boundary containing App Launcher, pinned and running +Apps, the current App frame, system overlays, and Safe Mode. Dashboard, Models, +Settings, Logs, Benchmark, and Chat become built-in Apps rather than permanent +navigation tabs. + +`docked` mode reserves a stable top region and sizes the App frame below it. +`immersive` mode keeps the App frame at full viewport dimensions; a delayed +top-edge hot zone, keyboard/touch affordance, or validated App Bridge request +shows the Dock as an overlay without resizing the App. Dock state distinguishes +pinned, running, current, multiple-instance, notification, waiting-approval, +degraded, and failed projections. + +App Launcher is a Shell overlay modeled after a device home screen. It lists +enabled Apps by category, supports search, and launches or focuses the correct +AppInstance. Singleton Apps reuse the scoped instance. Multiple Apps focus the +most recent instance unless the user explicitly creates or selects another. + +Models remains a pinned high-frequency System App even though it is implemented +by the model runtime Service. + +Status and Models show the normalized HardwareProfile, active backend provider, +memory model, total/reserved/available memory, live pressure, loaded-model +reservations, and accelerator health. Provider-specific diagnostics are shown +in an expandable detail area so the primary UI remains consistent across Apple, +NVIDIA, and AMD devices. + +### 15.2 App runtime + +```text +/apps App launcher +/apps/{app-id} singleton/default Entry +/apps/{app-id}/instances/{instance-id} specific AppInstance Entry +/apps/{app-id}/instances/{instance-id}/sessions/{session-id} +/apps/ai2apps.general-chat/threads/{session-id} Chat-friendly alias +``` + +The AI2Apps Shell supplies global navigation, HomeSession access, approvals, files, +artifacts, active-instance status, safe View mounting, and theme behavior. +Mini-Entries mount the same AppInstance inline or in a conversation sidebar and +can expand into Entry without losing state. + +For multi-Session Apps such as Chat, the Entry owns Session/thread navigation. +Changing the selected thread changes the projected ConversationSession while +the AppInstance, Entry shell, settings, and collection-level state remain the +same. + +System Apps may be pinned directly in the fixed navigation. Third-party Apps +appear in App Launcher by default and enter the fixed navigation only when the +user pins them, preventing uncontrolled navigation growth. + +The Shell owns the iframe App Frame Host and browser history. App messages are +accepted only from the exact mounted window/origin and carry AppInstance/mount +identity. The Bridge exposes bounded title, badge, Dock, navigation, Entry, +Mini-Entry, capability, AgentRun, Artifact, and close requests. API credentials +and parent DOM access are never delegated to the frame. Theme, locale, +visibility, HomeSession, resume/suspend, and safe-area state flow from host to +App. Third-party `safe-html` and `sandbox` renderers additionally require +sanitization or iframe CSP/sandbox isolation. + +The first implemented compatibility adapter exposes the six built-in system +Apps through this route model. Dashboard-derived Apps share the existing +renderer with a fixed initial page and hide its legacy navigation when framed; +Chat keeps its established Entry while the Shell supplies global navigation. +Presentation mode, pin order, and recent running projections are device-local +preferences. This adapter is intentionally restricted to trusted same-origin +system content. Installed third-party discovery, authoritative AppInstance +projection, instance-bound bridge envelopes, and constrained Entry renderers +remain required before package UI can enter the frame. + +The second implementation slice replaces that temporary catalog projection: +built-in and installed Apps now share durable AppDefinitions and the Shell +projects live AppInstances from the platform database. The administrator Shell +calls a session-authenticated lifecycle adapter, never the credential-bearing +model API from an App frame. Pin order and presentation mode remain local UI +preferences; launch, focus, suspend, close, singleton reuse, multiple-instance +identity, HomeSession association, and running state are backend-owned. + +Verified package Entries are dispatched through host, schema, sanitized HTML, +or CSP-sandboxed resource hosts. Each package resource is re-hashed against its +active upstream or local-Patch store before serving. A random mount token, +AppInstance ID, source-window match, and expected origin bind the initial Dock, +title, and Launcher bridge messages. The full capability-bearing Bridge keeps +the same envelope but additionally requires the capability and approval checks +defined elsewhere in this architecture. + +The W3 migration removes the temporary shared-document projection for built-in +Apps. Dashboard, Models, Settings, Logs, and Benchmark now have distinct Host +Entry templates and DOM ownership while reusing a shared, product-owned +compatibility controller and focused oMLX-derived partials. The App manifest +fixes the permitted former dashboard surface, so query state can select an +internal subpage but cannot turn one App frame into a sibling System App. +Chat remains independently rendered. Historical dashboard tab URLs resolve at +the Shell boundary to the corresponding canonical App. + +W4 completes the constrained interaction surface. The full App Bridge is a +request/response protocol bound to the exact frame window, origin, random mount +token, and AppInstance. It exposes bounded Shell actions without exposing API +credentials or parent DOM access. Capability requests still enter the normal +approval path, AgentRun creation is authorized against the owning or mounted +interaction Session, and Artifact export remains a trusted-host operation. + +Mini-Entry is persisted as an App mount with placement, interaction Session, +renderer/resource identity, and a JSON context envelope containing information +such as the triggering message ID. Chat renders these mounts inline by default, +can move the same AppInstance to its App sidebar, and can expand that instance +to full Entry. A conservative manifest matcher may suggest an App from natural +language activation examples; installed third-party Apps always require user +confirmation before mounting in the initial policy. + +The unpatchable System Control overlay provides package trust evidence, audit +details, permissions/dependencies, Patch order, conflict diagnostics, and Safe +Mode. Patch resolution applies to runtime state, not only metadata: effective +definitions are rebuilt and a conflicted upgrade candidate activates when the +remaining Patch set composes successfully. Safe Mode similarly rebuilds active +definitions without local Patches and restores them on exit. + +### 15.3 Service management + +The Service list shows: + +- name, ID, version, publisher, and type; +- embedded/process/external mode; +- enabled and runtime state; +- health and endpoint; +- trust/audit status; +- platform, architecture, and accelerator compatibility; +- dependent Apps, Agents, and Services. + +Service detail tabs are: + +```text +Overview | API | Dependencies | Permissions | Audit | Logs | Settings +``` + +Service installation is a staged review flow: + +```text +Package +-> signature and publisher +-> dependency plan +-> permissions +-> audits +-> confirmation +-> installation progress +``` + +### 15.4 Sandbox and capability management + +Sandbox management is available both as a global administration view and as a +contextual panel for each Session, AppInstance, AgentRun, Service, and View. + +The global view shows active sandboxes, parent/child relationships, resource +usage, mounted ResourceHandles, active GrantLeases, pending +CapabilityRequests, policy violations, and retained snapshots. The approval +inbox groups compatible requests, but each approval still presents the exact +subject, capability, resource, reason, scope, duration, and audit evidence. + +The conversation status-line can enter `waiting_capability` and render a compact +approval card without losing the AgentRun. Approving, denying, or editing the +scope produces an event in the same replayable information stream. A user can +later revoke the resulting GrantLease from the Session panel or global view. + +Safe Mode exposes the minimum controls needed to revoke Grants, stop managed +processes, export recoverable Artifacts, and inspect the audit trail even when +normal App or Agent UI is broken. + +The first System Control implementation exposes the durable Safe Mode switch, +installed interactive-package verification/audit evidence, and local Patch +conflict resolution. Grant revocation, process stopping, and recovery Artifact +export remain capability-management extensions to this same unpatchable Shell +surface rather than responsibilities of an ordinary App. + +The W5 implementation adds a unified Approval Inbox and GrantLease view to +that Shell boundary. Agent approval interactions and generic App +CapabilityRequests share normalized risk, effect, Tool, resource, reason, +deadline, scope, and evidence fields while retaining their different runtime +subjects. Agent approval resumes the waiting AgentRun; App approval resolves +the exact pending Bridge request. A caller AppInstance never receives platform +credentials or authority beyond the returned scoped lease. + +Generic App requests and their decisions are durable in schema v14. The +request record links AppInstance, interaction Session, optional AgentRun, +capabilities, Tool, effects, resource selector, risk, deadline, resolution, and +resulting GrantLease. A direct App lease may omit AgentDefinition identity; +Agent-generated leases remain Agent-bound and continue to participate in the +deterministic policy matcher. + +Safe Mode now revokes active GrantLeases and stops managed sandbox processes +before rebuilding unpatched effective definitions. These revocations are not +rolled back on exit: permissions must be explicitly granted again. Creation, +allow/deny, expiry, grant, revocation, and recovery transitions are durable +events, providing the first end-to-end approval audit trail. + +### 15.5 Agent Harness Tool and Service boundary + +The W6 execution layer makes Tool and Service deliberately different +abstractions. A Service is the installable, auditable, dependency-aware runtime +and lifecycle boundary. A Tool is a model-visible, schema-validated, +capability-gated invocation contract exported by that Service. One Service may +export many Tools, and the same Tool Gateway can route an in-process provider, +managed or external JSON Service, MCP adapter, or future supporting Agent +without changing the caller's protocol. + +Each accepted Tool call receives durable `tinv_*` identity. Its record binds +the Tool descriptor and provider identity to the caller, Session, Run trace, +arguments, effective timeout, current progress, explicit retry attempt, and +terminal output/error. Provider progress is simultaneously persisted, emitted +as an audit Event, and forwarded to an AgentRun status-line. A process restart +marks any still-running invocation interrupted; RunStep recovery then decides +whether a non-effectful step can be retried or an effectful step must remain +uncertain. + +Retries are opt-in descriptor policy rather than a Gateway guess. A policy +declares at most three attempts, bounded backoff, and the stable failure codes +eligible for retry. This prevents an installed side-effecting Tool from being +silently repeated. Capability policy and GrantLease evaluation still occurs +before invocation creation, and every actual provider attempt stays under the +same approved Tool, arguments, resource selector, timeout, and trace. + +The first built-in Harness Services are Workspace/Resource/Artifact and +Process. Workspace Tools enforce Session path resolution, quotas, atomic writes +and ResourceHandles. Process Tools enforce argv-only sandbox execution, +Session/Run ownership, bounded environment and output, dynamic network +capabilities, explicit wait timeouts, and process-group cancellation. The +General Agent reconstructs model and Tool conversation from durable Messages +and RunSteps while ToolInvocations supply per-call observability and audit. + +## 16. Proposed source layout + +```text +ai2apps/ + model_runtime/ + provider.py + hardware.py + memory.py + scheduler.py + backends/ + mlx.py + cuda.py + rocm.py + + services/ + models.py + manifest.py + registry.py + resolver.py + packages.py + verification.py + audit.py + permissions.py + gateway.py + client.py + supervisor.py + events.py + builtin/ + model_runtime.py + mcp.py + + agents/ + models.py + manifest.py + packages.py + patches.py + rebase.py + registry.py + runtime.py + policy.py + memory.py + views.py + events.py + + apps/ + models.py + manifest.py + packages.py + patches.py + rebase.py + registry.py + instances.py + sessions.py + state.py + migrations.py + activation.py + views.py + builder.py + ui_schema.py + + sandbox/ + models.py + policy.py + capabilities.py + resources.py + sessions.py + services.py + views.py + audit.py + broker_client.py + events.py + platform/ + macos.py + linux.py + + storage/ + database.py + artifacts.py + secrets.py + + api/ + services.py + agents.py + apps.py + runs.py + sandboxes.py + capabilities.py + resources.py + + web/ + templates/ + admin/ + services/ + agents/ + apps/ + runs/ + apps/ + launcher.html + shell.html +``` + +This is a responsibility map rather than a commitment to one file per listed +module. Modules should remain small and be introduced only as implementation +requires them. + +## 17. Migration plan + +### Phase 0: contracts and architecture + +- finalize core schemas and stable IDs; +- define Service protocol/control contract; +- define storage and event semantics; +- define package digest and signature model; +- define SandboxPolicy, Capability, ResourceHandle, CapabilityRequest, + GrantLease, and Host Broker contracts; +- define the default-deny capability vocabulary and configurable audit decision + policies; +- define `HardwareProfile`, `ModelBackendProvider`, normalized memory accounting, + and package platform/accelerator compatibility contracts. + +### Phase 1: Sandbox foundation + +- implement the Root Sandbox boundary and the minimal Host Broker + client/protocol; +- implement SessionSandbox lifecycle, workspace, temporary storage, Artifacts, + resource handles, quotas, and cross-session isolation tests; +- implement CapabilityRequest, explicit user approval, GrantLease issuance and + revocation, deterministic policy evaluation, and append-only events; +- implement managed Service and View sandbox adapters; +- implement common Host Broker semantics with macOS and Linux enforcement + adapters; +- implement the sandbox/approval WebUI and Safe Mode grant revocation. + +### Phase 2: built-in Service foundation + +- implement Service Registry and Service Client; +- implement hardware inventory and normalized memory-pressure reporting; +- register the existing oMLX runtime as the first provider of + `ai2apps.model-runtime`; +- preserve the current oMLX load, cache, scheduling, and inference paths behind + the provider adapter; +- register the existing MCP manager as `ai2apps.mcp`; +- add the Service Gateway without removing existing routes; +- add read-only Service status to the WebUI. + +### Phase 3: Service control plane + +- implement lifecycle state and operation/event persistence; +- implement enable/disable/restart for supported built-ins; +- implement process supervision, health, and logs; +- add Service list/detail management UI. + +### Phase 4: package and trust system + +- implement package parsing and immutable store; +- implement digest/signature verification and publisher trust; +- implement dependency resolution and locks; +- implement permissions, audit attestations, and local AI audit; +- bind installed package digests, EffectiveApp/EffectiveAgent Patch digests, and + publisher identity into capability policy and GrantLease evidence; +- implement transactional install, upgrade, rollback, and uninstall. + +### Phase 5: server-side Agent Runtime + +- implement Agent package, definition, activation, and natural-language + dispatch schemas; +- implement ConversationSession, Run/Step/Status/View/Event persistence and + replayable SSE; +- bind every AgentRun to its SessionSandbox and a narrower per-run capability + context; +- route model and tool access through Service Client; +- implement interactive input, sidebar/inline Views, limits, cancellation, + approval, and audit; +- implement `waiting_capability`, independent configurable AI audit, scoped + resource delegation, transactional host export, and runtime Grant revocation; +- implement immutable upstream Agents, local Patch stacks, EffectiveAgent + assembly, tests, and local signatures; +- implement AI-assisted three-way rebase and conflict workspace; +- migrate the browser-owned tool loop to the server. + +### Phase 6: App platform + +- implement `.ai2app` definitions/packages, AppInstance, AppSession/HomeSession, + InteractionSession bindings, state, and independent instance/session + cardinality policies; +- implement Entry/Mini-Entry mounts, trust modes, View bridge, natural-language + activation, and shared App/Agent event integration; +- enforce HomeSession/InteractionSession capability isolation and explicit + cross-session ResourceHandle delegation; +- implement App Launcher, system shell integration, active instances, pinning, + and multiple/singleton lifecycle; +- migrate current Chat into one singleton `ai2apps.general-chat` AppInstance + whose existing threads become App-owned ConversationSessions; +- implement immutable upstream Apps, local Patch stacks, EffectiveApp assembly, + AI-assisted rebase, conflict preview, and `.ai2patch` export; +- implement state-schema migrations with snapshots, all-instance dry runs, + atomic activation, and rollback; +- implement App Studio, Entry/Mini-Entry preview, activation simulation, local AI + audit, device signing, and App Fork creation; +- implement an unpatchable Safe Mode recovery surface; +- add App and Agent administration pages; +- implement the initial chat/form/workspace/workflow UI modes. + +### Phase 7: composition and ecosystem + +- Agent-as-capability composition; +- implement and qualify NVIDIA/CUDA and AMD/ROCm Linux model backend providers; +- add cross-backend compatibility, conformance, memory-pressure, and performance + test suites using identical model/API behavior where formats permit; +- optional multi-version Service activation; +- marketplace/distribution workflows; +- organization policies and additional audit providers; +- custom App UI extension model if required. + +## 18. Decisions recorded + +The following points are agreed design direction as of this draft: + +1. AI2Apps evolves into an Agent platform rather than remaining only a model + management platform. +2. Apps sit above Agents and own user interaction. +3. Services provide callable capabilities to Apps and Agents. +4. Models and MCP are represented as built-in Services. +5. Services are individually packageable, installable, manageable, versioned, + dependency-aware, auditable, and signed. +6. Services may run embedded, in managed local processes, or externally. +7. Services own their internal queue/load behavior; AI2Apps owns lifecycle, + routing, policy, and supervision. +8. Third-party Services default to managed-process isolation. +9. The authoritative Agent loop moves to the server. +10. The WebUI continues the current oMLX visual design language. +11. Existing oMLX and OpenAI-compatible behavior remains available during the + migration. +12. Agents are asynchronous intelligent endpoints invoked through natural + language or structured input inside a ConversationSession. +13. Every AgentRun has a mandatory status-line and may mount interactive inline + or sidebar Views. +14. Agent definitions are installable and reusable; AgentRuns, not definitions, + belong to conversation Sessions. +15. Installed upstream Agent packages remain immutable. User customization is + stored as an ordered, auditable, device-signed local Patch stack. +16. Agent upgrades replay or AI-rebase local Patches against the new immutable + upstream and never activate a conflicted or unvalidated assembly silently. +17. Agent-local UI/hooks run in constrained sandboxes; independently deployed + or broadly privileged executable capability remains a Service. +18. App is a semantic superset of Agent behavior and adds Entry, Mini-Entry, + persistent AppInstances, HomeSession, state, and navigation integration. +19. Every App defines a complete Entry under the system navigation and may + define inline/sidebar Mini-Entries that share the same AppInstance. +20. AppInstances support multiple and scoped singleton policies; independent + UI mounts do not themselves define or destroy an instance. +21. Apps inherit natural-language activation and shared AgentRun/event behavior; + third-party conversational activation defaults to a user-confirmable + suggestion. +22. Users can create and maintain local Apps through AI. Installed upstream Apps + remain immutable and customization is stored as local App Patches. +23. App upgrades rebase local Patches and migrate every persistent instance's + state before atomic activation. +24. App definition Patches apply installation-wide; per-instance differences + remain instance settings/state. +25. System Apps can be patched, but an unpatchable Safe Mode remains available + to disable Patches, restore built-ins, and roll back broken EffectiveApps. +26. The AI2Apps runtime defaults to a Root Sandbox. A minimal Host Broker outside + it mediates explicitly approved host access through a narrow protocol. +27. Every ConversationSession, including every AppSession, owns an isolated + SessionSandbox; an AgentRun receives a narrower child capability context. +28. Host resources are represented by opaque, scoped ResourceHandles. Knowledge + of a filesystem path or URL is not authorization to use it. +29. Effective authority is the intersection of root policy, Session policy, + declared package permissions, and active GrantLeases. +30. Crossing a sandbox boundary produces a precise, expiring, revocable, and + auditable GrantLease rather than a general sandbox escape. +31. Policy modes may deny, ask the user, audit then ask, allow independent AI + audit to approve within configured limits, or apply a preauthorized rule. + High-risk operations remain user-approved by default. +32. The Agent or model requesting authority cannot approve its own request; + automatic approval uses an independent auditor and deterministic policy. +33. Managed Services and interactive Views receive their own sandboxes. + Embedded Services use the same broker and capability checks as the root + runtime. +34. Host mutations should be staged and committed transactionally where + practical so denial, failure, or cancellation does not leave partial writes. +35. An App HomeSession's authority is not automatically transferred to a + conversation InteractionSession or Mini-Entry mount. +36. AI2Apps targets unified-memory AI devices as a hardware class, not macOS as + an operating-system boundary. +37. Apple Silicon/oMLX is the first model backend; NVIDIA/CUDA and AMD/ROCm Linux + AI boxes are peer target families behind the same Model Runtime Service. +38. App, Agent, Service, Session, package, capability, and WebUI contracts remain + independent of MLX, CUDA, ROCm, and operating-system-specific sandbox APIs. +39. Admission control and model placement use a normalized dynamic + `HardwareProfile` and shared memory budget rather than hard-coded RAM/VRAM + assumptions. +40. Accelerator-native runtimes remain responsible for optimized kernels, + caches, queues, and placement, while reporting normalized capabilities, + reservations, pressure, health, and errors to AI2Apps. +41. Chat is the first reference App and has one singleton AppInstance per user; + the initial local single-user system therefore has exactly one Chat + AppInstance. +42. A Chat thread is an App-owned ConversationSession, not an AppInstance. The + singleton Chat AppInstance may own any number of threads. +43. The first/default Chat thread fulfills the HomeSession role. Additional + threads have the same ConversationSession semantics without becoming new + HomeSessions or AppInstances. +44. Thread creation, selection, rename, pin, archive, retention, and deletion + are Chat Session/collection operations independent of AppInstance lifecycle. +45. Each Chat thread has an independent SessionSandbox, context, Runs, + ResourceHandles, and GrantLeases; membership in the same Chat AppInstance + does not imply cross-thread authority. + +### 18.1 Implemented M8 package and lifecycle contract + +M8 implements the Service package and lifecycle design in this document with +schema v11 and these concrete boundaries: + +- bounded `.ai2service` ZIP parsing, canonical manifest/file-index digest, + exact file coverage, SPDX validation, Ed25519 verification, and immutable + content-addressed storage; +- a pinned offline publisher trust store plus digest-addressed local/static AI + audit attestations whose evidence includes the reviewed source scope; +- deterministic single-active-version resolution, digest locks, cycle checks, + reverse-dependent safety, compatibility checks, and signed accelerator + variant selection; +- embedded, sandboxed managed-process, and externally hosted JSON runtimes + behind the same Service/Tool registry; +- dependency-ordered startup and reverse shutdown, readiness, explicit + lifecycle operations, bounded logs, restart/backoff, and verified orphan + cleanup; +- serialized transactional install/upgrade with filesystem and runtime + compensation, retained-version rollback, and pre-execution revalidation; +- package-digest-bound GrantLeases, preventing an upgraded implementation from + inheriting authority issued to the previous digest. + +Package acquisition/marketplace policy, publisher key-rotation ceremony, +online vulnerability intelligence, richer continuous health policy, and +multi-version simultaneous activation remain extensibility points rather than +part of the v1 local control plane. + +### 18.2 Implemented M9 Agent/App/Patch contract + +M9 implements the shared interactive-package control plane with schema v12: + +- `.ai2agent` and `.ai2app` reuse canonical content digests, trusted publisher + verification, SPDX, immutable storage, bounded local AI review, operations, + retained versions, and transactional activation; +- `.ai2patch` is an independently digest-addressed archive signed by an + installation-local Ed25519 device identity and records intent, base digest, + ordered semantic operations, resources, tests, audit, and rebase policy; +- `EffectiveAgent` and `EffectiveApp` records cache the immutable upstream plus + ordered Patch stack using separate upstream, Patch-set, and Effective digests; +- three-way semantic rebase checks target kind/digest preconditions and records + explicit conflicts while retaining the previously active Effective + definition; preserve-local, accept-upstream, and disable are explicit + resolution actions; +- installed Agents bind into the existing Agent Runtime; installed Apps bind + into existing definitions/instances/Sessions and publish Entry, Mini-Entry, + activation, navigation, mount, and stable route metadata; +- App activation enforces existing multiple/singleton constraints, creates one + HomeSession per new instance, snapshots all instance state, dry-runs every + migration, and atomically switches all instances or none; +- Safe Mode persists prior Patch states, activates clean upstream definitions, + restores built-in recovery reachability, and can restore the Patch stack + without relying on a third-party App UI. + +The backend intentionally reserves actual host/schema/safe-HTML/sandbox iframe +renderers and the visual conflict workspace for the frontend phase. The API +never returns executable third-party content for insertion into the shell DOM; +it returns declared renderer/resource/mount contracts for a constrained View +host to consume. + +### 18.3 Implemented W7A Chat/Agent execution contract + +Chat is one singleton App with many persistent thread Sessions, and each +Session stores its own execution mode. In Chat mode a user turn is a direct +model request with no Tool descriptors. In Agent mode the same message becomes +input to `ai2apps.general-agent`; the Agent Runtime, not the Chat frame, owns +planning, Tool selection, capability checks, queueing, side effects, and final +message settlement. Switching mode affects subsequent turns and never rewrites +earlier message provenance. + +AgentRun state is projected inline through status-line and replayable event +contracts. Tool activity, structured interactions, approvals, pause, resume, +cancel, and uncertain-effect recovery all remain bound to the Run and Session. +Multiple independent Runs may coexist in one conversation. Schema v16 makes +user pause a durable `interrupted` state: safe work can be requeued, while an +in-flight effectful Tool requires explicit recovery before execution continues. + +### 18.4 Implemented W7B Agent selection contract + +The Agent catalog is an authoritative projection of installed +`AgentDefinition` records. Chat exposes enabled definitions and persists one +explicit `agent_key` per Session; every Agent turn additionally records the key +used at invocation time. AgentRun snapshots carry definition ID, key, and +display name, preserving understandable provenance across refreshes and future +package updates. + +Selection is user-directed and deterministic. Removing or disabling the +selected definition causes a safe fallback to General Agent or the first +enabled definition. Prompt interpretation does not implicitly switch Agents; +future natural-language routing must be represented as an auditable router or +delegation decision with its own policy and status projection. + +### 18.5 Implemented W7C invocation-schema contract + +An Agent package can publish discoverability, aliases, an object-shaped JSON +Schema for invocation parameters, and declarative UI hints. Package inspection +and Run creation both validate the contract. Chat renders a bounded set of +native controls and persists per-Agent Session defaults; executable markup is +not part of this invocation surface. + +`@alias` is an explicit one-turn address, not heuristic routing. The invocation +records its source and parameter snapshot on the message, while the repository +adds authoritative Agent definition identity and package version to durable Run +input. Client validation improves interaction speed but never replaces server +JSON Schema enforcement. Natural-language routing policy remains future work; +explicit parent/child delegation is implemented by W7D. + +### 18.6 Implemented W7D parent/child AgentRun contract + +`agent.delegate` is a built-in in-process Service Tool that lets an executing +Agent create a bounded child AgentRun in the same Session. A child has its own +durable identity, definition, status-line, steps, interactions, approvals, +deadline, output, and audit events. The delegation Tool waits asynchronously +for that terminal output and returns it to the parent Tool step; only the root +Agent publishes the final Chat assistant Message. + +Schema v17 stores `parent_run_id`, stable `root_run_id`, depth, budget/context +snapshot, and a request-keyed `agent_delegations` record. Replay of the same +Tool call reattaches to the existing child instead of creating duplicate work. +The initial safety envelope permits depth 2 and four direct children per +parent, bounds child deadlines by the parent's deadline, and applies the lower +of definition and delegated step/token budgets. Capabilities and GrantLeases +are evaluated for the child itself; authority is never copied from the parent. + +While its delegation Tool awaits a child, the parent remains in the compatible +`running` database state and projects the `waiting_subruns` status phase. User +cancel and pause cascade through active descendants. Chat recursively refreshes +and streams child snapshots beneath the root card, where child status and +menu/text/file/approval interactions can be handled without creating a Chat +Thread for the child. + +### 18.7 Implemented W8 Agent Manager contract + +Agent management is exposed as the singleton built-in `ai2apps.agents` App. +It is a control and observability surface, not an Agent authoring environment. +The App presents the definition catalog, authoritative runtime policy, aliases +and invocation schema, lifecycle state, Run statistics, cross-Agent Run +history, package provenance, Effective Definition identity, and local Patch +status or conflicts. + +The management API adds definition enable/disable operations, filtered +AgentRun listing, per-definition terminal/active counts, and one aggregated +detail projection over AgentDefinition, interactive packages, Effective +Definition, local Patches, and recent Runs. Existing pause, resume, cancel, +install, uninstall, rollback, and Patch-resolution contracts remain the +mutation authority and are reused by the App. + +Agent Manager deliberately does not edit manifests, source, Patch operations, +or status HTML. Near-term Agent development happens through Codex in a Session +sandbox, then enters the existing signed package/audit/install flow. Agent +Studio is deferred until real Agent development establishes stable coding, +testing, Eval, interaction, and Patch workflows. + +## 19. Open design questions + +The next design sessions should resolve: + +1. Marketplace acquisition and provenance policy beyond the implemented local + package/import API. +2. Publisher key-rotation/recovery ceremony and optional online revocation + distribution beyond local trust status. +3. Future simultaneous multi-version Service activation and routing semantics. +4. Continuous health/degradation thresholds and automated operator policy. +5. Concrete macOS and Linux Root/Session/Service sandbox enforcement and Host + Broker deployment models, including GPU device mediation. +6. Capability vocabulary, secret and ResourceHandle storage, GrantLease token + format, renewal, and revocation semantics. +7. Service operation/event schemas and health/readiness semantics. +8. Exact Agent event envelope, replay/retention, concurrency, retry, and + approval semantics. +9. Status-line and Agent View schema, sandbox bridge, CSP, and accessibility + contract. +10. Visual Patch conflict workspace, AI-assisted re-synthesis policy, and the + extensible semantic target registry beyond the implemented v1 operations. +11. Fine-grained Patch risk scoring and differential audit reuse beyond the + implemented device signature, package audit, tests, and composition gate. +12. Concrete host/schema/safe-HTML/sandbox Entry and Mini-Entry renderers plus + constrained View bridge protocol over the implemented mount contracts. +13. User/session singleton identity in a future multi-user deployment and + richer focus, suspension, retention, and background execution policy. +14. Cross-App HomeSession/InteractionSession context delegation and privacy UX. +15. Code-authored and AI-authored state migrations beyond the implemented + declarative all-instance atomic migration and snapshot rollback contract. +16. App Studio privilege boundary and preview sandbox over the implemented + local device Patch creation/signing API. +17. SQLite schema, migrations, backup, retention, and user/workspace scoping. +18. Exact URL hierarchy and compatibility routing. +19. Packaging and distribution relationship between Service, Agent, and App + artifacts. +20. Transactional host-operation protocol, conflict handling, mount semantics, + and Artifact export UX. +21. AI auto-approval risk thresholds, auditor independence, evidence format, + model availability, and fail-closed behavior. +22. Cross-session resource delegation, privacy indicators, revocation, and + provenance rules. +23. Sandbox snapshot retention, storage/process/network quotas, cleanup, and + recovery semantics. +24. Multi-tenant Service isolation, request attribution, cancellation, and + behavior when a GrantLease is revoked mid-request. +25. View iframe/process isolation, CSP, bridge capability vocabulary, download + handling, and protection against confused-deputy flows. +26. Exact `HardwareProfile`, memory-pressure, reservation, topology, and thermal + metric normalization across MLX, CUDA, and ROCm. +27. Model backend provider ABI/API, discovery, lifecycle, error mapping, + conformance suite, and provider upgrade compatibility. +28. Supported NVIDIA and AMD Linux memory architectures and the boundary between + primary unified/coherent targets and secondary device-local compatibility. +29. Portable model format/quantization policy and when backend-specific model + variants may share one logical model identity. +30. Linux distribution, driver, CUDA/ROCm version, native dependency, container, + and signed package compatibility matrix. +31. Chat thread URL compatibility, collection metadata schema, search/index + ownership, deletion retention, and migration of existing thread IDs. diff --git a/tests/test_ai2apps_chat.py b/tests/test_ai2apps_chat.py new file mode 100644 index 00000000..7deb9ba3 --- /dev/null +++ b/tests/test_ai2apps_chat.py @@ -0,0 +1,322 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Singleton Chat App, collection, and compatibility contract tests.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ai2apps.api.router import create_ai2apps_router +from ai2apps.chat import ChatRepository, LegacyChatMessageInput +from ai2apps.config import BUILTIN_CHAT_PACKAGE_ID, PlatformConfig +from ai2apps.core import ( + AppInstanceStatus, + MessageRole, + ResourceConflictError, + RevisionConflictError, + SessionStatus, +) +from ai2apps.events import EventStore +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.storage import MessagePartInput +from ai2apps.storage.repositories import MessageRepository, SessionRepository + + +@pytest.fixture +def chat_runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.database is not None + assert runtime.events is not None + return runtime, ChatRepository(runtime.database, runtime.events) + + +def test_builtin_chat_bootstrap_is_idempotent_and_concurrent(chat_runtime): + runtime, repository = chat_runtime + + with ThreadPoolExecutor(max_workers=5) as executor: + records = list(executor.map(lambda _: repository.ensure_builtin(), range(10))) + + assert len({record.definition.id for record in records}) == 1 + assert len({record.instance.id for record in records}) == 1 + assert records[0].definition.package_id == BUILTIN_CHAT_PACKAGE_ID + with runtime.database.transaction() as connection: + assert connection.execute( + """ + SELECT COUNT(*) FROM app_instances i + JOIN app_definitions d ON d.id = i.app_definition_id + WHERE d.package_id = ? + """, + (BUILTIN_CHAT_PACKAGE_ID,), + ).fetchone()[0] == 1 + assert connection.execute("SELECT COUNT(*) FROM chat_collections").fetchone()[ + 0 + ] == 1 + + +def test_ten_threads_share_one_instance_and_keep_messages_isolated(chat_runtime): + runtime, repository = chat_runtime + threads = [repository.create_thread(title=f"Thread {number}")[0] for number in range(10)] + messages = MessageRepository(runtime.database, runtime.events) + messages.append( + session_id=threads[0].session.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "first"}),), + ) + messages.append( + session_id=threads[1].session.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "second"}),), + ) + + assert len({thread.session.app_instance_id for thread in threads}) == 1 + assert threads[0].session.is_home is True + assert sum(thread.session.is_home for thread in threads) == 1 + assert messages.list_for_session(threads[0].session.id)[0].parts[0].content == { + "text": "first" + } + assert messages.list_for_session(threads[1].session.id)[0].parts[0].content == { + "text": "second" + } + + +def test_archive_selected_home_reassigns_without_closing_chat(chat_runtime): + _, repository = chat_runtime + first, _ = repository.create_thread(title="First") + second, _ = repository.create_thread(title="Second") + collection = repository.get_collection() + repository.set_home_thread( + second.session.id, + expected_revision=second.session.revision, + ) + + archived = repository.update_thread( + second.session.id, + expected_revision=2, + status=SessionStatus.ARCHIVED, + ) + current = repository.get_collection() + builtin = repository.ensure_builtin() + + assert archived.session.status is SessionStatus.ARCHIVED + assert current.selected_session_id == first.session.id + assert repository.get_thread(first.session.id).session.is_home is True + assert builtin.instance.status is AppInstanceStatus.ACTIVE + assert collection.app_instance_id == builtin.instance.id + + +def test_rename_pin_select_and_delete_use_independent_revisions(chat_runtime): + _, repository = chat_runtime + first, _ = repository.create_thread(title="First") + second, _ = repository.create_thread(title="Second") + collection = repository.get_collection() + updated = repository.update_thread( + first.session.id, + expected_revision=1, + title="Renamed", + pinned=True, + ) + selected = repository.select_thread( + first.session.id, + expected_revision=collection.revision, + ) + + with pytest.raises(RevisionConflictError): + repository.select_thread( + second.session.id, + expected_revision=collection.revision, + ) + deleted = repository.update_thread( + first.session.id, + expected_revision=updated.session.revision, + status=SessionStatus.DELETED, + ) + + assert updated.session.title == "Renamed" + assert updated.pinned is True + assert selected.selected_session_id == first.session.id + assert deleted.session.status is SessionStatus.DELETED + assert repository.get_collection().selected_session_id == second.session.id + + +def test_generic_session_api_cannot_bypass_selected_home_reassignment(chat_runtime): + runtime, repository = chat_runtime + thread, _ = repository.create_thread(title="Protected") + + with pytest.raises(ResourceConflictError, match="reassigned first"): + SessionRepository(runtime.database, runtime.events).update( + thread.session.id, + expected_revision=1, + status=SessionStatus.ARCHIVED, + ) + + assert repository.get_thread(thread.session.id).session.status is SessionStatus.ACTIVE + + +def test_legacy_thread_mapping_is_idempotent(chat_runtime): + runtime, repository = chat_runtime + + first, created = repository.create_thread( + title="Imported", + legacy_thread_id="legacy-thread-42", + metadata={"model": "legacy-model"}, + legacy_messages=( + LegacyChatMessageInput( + role=MessageRole.USER, + content="hello", + metadata={}, + ), + LegacyChatMessageInput( + role=MessageRole.ASSISTANT, + content=[{"type": "text", "text": "hi"}], + metadata={"finish_reason": "stop"}, + ), + ), + ) + replay, replay_created = repository.create_thread( + title="Ignored on idempotent replay", + legacy_thread_id="legacy-thread-42", + ) + + assert created is True + assert replay_created is False + assert replay.session.id == first.session.id + assert len(repository.list_threads()) == 1 + imported_messages = MessageRepository( + runtime.database, runtime.events + ).list_for_session(first.session.id) + assert imported_messages[0].parts[0].content == {"text": "hello"} + assert imported_messages[1].parts[0].content == { + "content": [{"type": "text", "text": "hi"}] + } + + +def test_chat_content_snapshot_round_trips_and_rejects_stale_writer(chat_runtime): + _, repository = chat_runtime + thread, _ = repository.create_thread(title="Snapshot") + content = repository.replace_content( + thread.session.id, + expected_revision=1, + metadata={"model": "test-model", "systemPrompt": "Be concise"}, + messages=( + LegacyChatMessageInput( + role=MessageRole.USER, + content=[{"type": "text", "text": "hello"}], + metadata={"id": "client-message-1"}, + ), + ), + ) + + assert content.thread.session.revision == 2 + assert content.metadata["model"] == "test-model" + assert content.messages[0].content == [{"type": "text", "text": "hello"}] + assert content.messages[0].metadata == {"id": "client-message-1"} + with pytest.raises(RevisionConflictError): + repository.replace_content( + thread.session.id, + expected_revision=1, + metadata={}, + messages=(), + ) + + +class _FailingEventStore(EventStore): + def append_in_transaction(self, *args, **kwargs): + raise RuntimeError("simulated Chat migration event failure") + + +def test_legacy_import_rolls_back_session_and_mapping(chat_runtime): + runtime, repository = chat_runtime + builtin = repository.ensure_builtin() + failing = ChatRepository(runtime.database, _FailingEventStore(runtime.database)) + + with pytest.raises(RuntimeError, match="migration event failure"): + failing.create_thread(title="Rollback", legacy_thread_id="legacy-broken") + + with runtime.database.transaction() as connection: + assert connection.execute( + "SELECT COUNT(*) FROM chat_thread_entries WHERE legacy_thread_id = ?", + ("legacy-broken",), + ).fetchone()[0] == 0 + assert connection.execute( + """ + SELECT COUNT(*) FROM sessions + WHERE app_instance_id = ? AND title = 'Rollback' + """, + (builtin.instance.id,), + ).fetchone()[0] == 0 + + +def test_chat_aliases_allow_two_clients_to_display_different_threads(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + first_client = TestClient(app) + second_client = TestClient(app) + first = first_client.post( + "/v1/platform/chat/threads", json={"title": "First"} + ).json() + second = second_client.post( + "/v1/platform/chat/threads", json={"title": "Second"} + ).json() + + first_view = first_client.get( + f"/v1/platform/chat/threads/{first['id']}" + ).json() + second_view = second_client.get( + f"/v1/platform/chat/threads/{second['id']}" + ).json() + chat = first_client.get("/v1/platform/chat").json() + + assert first_view["title"] == "First" + assert second_view["title"] == "Second" + assert first_view["app_instance_id"] == second_view["app_instance_id"] + assert chat["selected_thread_id"] == second["id"] + + +def test_chat_content_api_round_trips_ui_snapshot_and_conflict(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + client = TestClient(app) + thread = client.post( + "/v1/platform/chat/threads", json={"title": "Before"} + ).json() + payload = { + "expected_revision": thread["revision"], + "title": "After", + "session_metadata": {"model": "test-model"}, + "messages": [ + { + "role": "user", + "content": "hello", + "metadata": {"id": "ui-message-1"}, + } + ], + } + + replaced = client.put( + f"/v1/platform/chat/threads/{thread['id']}/content", json=payload + ) + stale = client.put( + f"/v1/platform/chat/threads/{thread['id']}/content", json=payload + ) + loaded = client.get( + f"/v1/platform/chat/threads/{thread['id']}/content" + ) + + assert replaced.status_code == 200 + assert replaced.json()["thread"]["title"] == "After" + assert replaced.json()["thread"]["revision"] == 2 + assert loaded.json()["messages"][0] == { + "role": "user", + "content": "hello", + "metadata": {"id": "ui-message-1"}, + } + assert stale.status_code == 409 + assert stale.json()["error"]["code"] == "revision_conflict" diff --git a/tests/test_ai2apps_core_contracts.py b/tests/test_ai2apps_core_contracts.py new file mode 100644 index 00000000..8130d520 --- /dev/null +++ b/tests/test_ai2apps_core_contracts.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for opaque IDs, lifecycle vocabularies, and durable UTC timestamps.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone + +import pytest + +from ai2apps.core import ( + AppInstanceStatus, + EntityIdKind, + format_utc, + new_entity_id, + parse_utc, + utc_now_text, + validate_entity_id, +) + + +@pytest.mark.parametrize("kind", list(EntityIdKind)) +def test_entity_ids_have_exact_typed_prefix_and_uuid_payload(kind): + values = {new_entity_id(kind) for _ in range(100)} + + assert len(values) == 100 + for value in values: + assert value.startswith(kind.prefix) + assert len(value) == len(kind.prefix) + 32 + assert value == value.lower() + assert validate_entity_id(value, kind) == value + + +@pytest.mark.parametrize( + ("value", "kind"), + [ + ("ses_abc", EntityIdKind.SESSION), + ("msg_00000000000000000000000000000000", EntityIdKind.SESSION), + ("ses_0000000000000000000000000000000G", EntityIdKind.SESSION), + ("SES_00000000000000000000000000000000", EntityIdKind.SESSION), + ], +) +def test_entity_id_validation_rejects_wrong_shape(value, kind): + with pytest.raises(ValueError): + validate_entity_id(value, kind) + + +def test_utc_format_is_canonical_and_microsecond_precise(): + value = datetime( + 2026, + 8, + 11, + 12, + 34, + 56, + 123, + tzinfo=timezone(timedelta(hours=8)), + ) + + encoded = format_utc(value) + + assert encoded == "2026-08-11T04:34:56.000123Z" + assert parse_utc(encoded) == value.astimezone(UTC) + assert len(utc_now_text()) == 27 + + +def test_utc_helpers_reject_naive_or_invalid_values(): + with pytest.raises(ValueError, match="timezone-aware"): + format_utc(datetime(2026, 8, 11)) + with pytest.raises(ValueError, match="include a timezone"): + parse_utc("2026-08-11T12:00:00.000000") + with pytest.raises(ValueError, match="Invalid RFC 3339"): + parse_utc("not-a-time") + + +def test_lifecycle_values_are_stable_strings(): + assert AppInstanceStatus.ACTIVE == "active" + assert AppInstanceStatus.SUSPENDED.value == "suspended" diff --git a/tests/test_ai2apps_event_stream.py b/tests/test_ai2apps_event_stream.py new file mode 100644 index 00000000..15bdc88a --- /dev/null +++ b/tests/test_ai2apps_event_stream.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Notification, heartbeat, backpressure, and cursor replay tests.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from ai2apps.events import EventNotificationBus, EventStore +from ai2apps.events.stream import stream_events +from ai2apps.storage import PlatformDatabase + + +@pytest.fixture +def event_runtime(tmp_path): + database = PlatformDatabase(tmp_path / "platform.sqlite3") + database.initialize() + notifications = EventNotificationBus() + return database, notifications, EventStore(database, notifications) + + +@pytest.mark.asyncio +async def test_notifications_fire_only_after_commit_and_coalesce(event_runtime): + database, notifications, events = event_runtime + async with notifications.subscribe() as queue: + with database.transaction(write=True) as connection: + events.append_in_transaction( + connection, + event_type="test.created", + subject_id="test", + ) + assert queue.empty() + await asyncio.sleep(0) + assert queue.qsize() == 1 + + for _ in range(100): + notifications.notify() + await asyncio.sleep(0) + assert queue.qsize() == 1 + + +@pytest.mark.asyncio +async def test_rollback_discards_notification_and_event(event_runtime): + database, notifications, events = event_runtime + async with notifications.subscribe() as queue: + with ( + pytest.raises(RuntimeError), + database.transaction(write=True) as connection, + ): + events.append_in_transaction( + connection, + event_type="test.rollback", + subject_id="test", + ) + raise RuntimeError("rollback") + await asyncio.sleep(0) + assert queue.empty() + assert events.list_after() == () + + +@pytest.mark.asyncio +async def test_stream_replays_cursor_then_emits_live_commit(event_runtime): + _, notifications, events = event_runtime + first = events.append(event_type="test.first", subject_id="first") + second = events.append(event_type="test.second", subject_id="second") + stream = stream_events( + events, + notifications, + after_sequence=first.sequence, + heartbeat_seconds=1, + ) + + replay = await anext(stream) + assert f"id: {second.sequence}\n" in replay + assert "event: test.second\n" in replay + + pending = asyncio.create_task(anext(stream)) + await asyncio.sleep(0) + third = await asyncio.to_thread( + events.append, + event_type="test.third", + subject_id="third", + ) + live = await asyncio.wait_for(pending, timeout=1) + assert f"id: {third.sequence}\n" in live + await stream.aclose() + assert notifications.subscriber_count == 0 + + +@pytest.mark.asyncio +async def test_stream_emits_heartbeat_when_idle(event_runtime): + _, notifications, events = event_runtime + stream = stream_events( + events, + notifications, + heartbeat_seconds=0.01, + ) + assert await anext(stream) == ": heartbeat\n\n" + await stream.aclose() + + +@pytest.mark.asyncio +async def test_cancelled_stream_wait_releases_subscriber(event_runtime): + _, notifications, events = event_runtime + stream = stream_events(events, notifications, heartbeat_seconds=60) + pending = asyncio.create_task(anext(stream)) + await asyncio.sleep(0) + assert notifications.subscriber_count == 1 + + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + await stream.aclose() + + assert notifications.subscriber_count == 0 + + +@pytest.mark.asyncio +async def test_stream_replays_high_volume_without_gaps(event_runtime): + _, notifications, events = event_runtime + for number in range(250): + events.append(event_type="load.item", subject_id=str(number)) + stream = stream_events( + events, + notifications, + heartbeat_seconds=1, + replay_batch_size=17, + ) + + frames = [await anext(stream) for _ in range(250)] + await stream.aclose() + + assert [int(frame.split("\n", 1)[0].removeprefix("id: ")) for frame in frames] == list( + range(1, 251) + ) + assert notifications.subscriber_count == 0 diff --git a/tests/test_ai2apps_platform_schema.py b/tests/test_ai2apps_platform_schema.py new file mode 100644 index 00000000..ade146cc --- /dev/null +++ b/tests/test_ai2apps_platform_schema.py @@ -0,0 +1,308 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Relational contract tests for AI2Apps platform schema v2.""" + +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from ai2apps.core import EntityIdKind, new_entity_id, utc_now_text +from ai2apps.storage import PlatformDatabase + + +@pytest.fixture +def connection(tmp_path): + database = PlatformDatabase(tmp_path / "platform.sqlite3") + database.initialize() + connection = database.connect() + try: + yield connection + finally: + connection.close() + + +def _insert_definition( + connection: sqlite3.Connection, + *, + package_id: str = "example.app", + mode: str = "multiple", + scope: str | None = None, +) -> str: + definition_id = new_entity_id(EntityIdKind.APP_DEFINITION) + now = utc_now_text() + connection.execute( + """ + INSERT INTO app_definitions( + id, package_id, package_version, display_name, instance_mode, + singleton_scope, source, manifest_json, created_at, updated_at + ) VALUES (?, ?, '1.0.0', 'Example', ?, ?, 'local', '{}', ?, ?) + """, + (definition_id, package_id, mode, scope, now, now), + ) + return definition_id + + +def _insert_instance( + connection: sqlite3.Connection, + definition_id: str, + *, + singleton_key: str | None = None, +) -> str: + instance_id = new_entity_id(EntityIdKind.APP_INSTANCE) + now = utc_now_text() + connection.execute( + """ + INSERT INTO app_instances( + id, app_definition_id, singleton_key, status, created_at, updated_at + ) VALUES (?, ?, ?, 'active', ?, ?) + """, + (instance_id, definition_id, singleton_key, now, now), + ) + return instance_id + + +def _insert_session( + connection: sqlite3.Connection, + instance_id: str, + *, + is_home: int = 0, +) -> str: + session_id = new_entity_id(EntityIdKind.SESSION) + now = utc_now_text() + connection.execute( + """ + INSERT INTO sessions( + id, app_instance_id, title, is_home, created_at, updated_at + ) VALUES (?, ?, 'Thread', ?, ?, ?) + """, + (session_id, instance_id, is_home, now, now), + ) + return session_id + + +def _insert_message( + connection: sqlite3.Connection, + session_id: str, + *, + sequence: int = 1, + idempotency_key: str | None = None, +) -> str: + message_id = new_entity_id(EntityIdKind.MESSAGE) + now = utc_now_text() + connection.execute( + """ + INSERT INTO messages( + id, session_id, sequence, role, idempotency_key, + created_at, updated_at + ) VALUES (?, ?, ?, 'user', ?, ?, ?) + """, + (message_id, session_id, sequence, idempotency_key, now, now), + ) + return message_id + + +def test_valid_app_session_message_part_graph_is_durable(connection): + definition_id = _insert_definition(connection) + instance_id = _insert_instance(connection, definition_id) + session_id = _insert_session(connection, instance_id, is_home=1) + message_id = _insert_message(connection, session_id) + part_id = new_entity_id(EntityIdKind.MESSAGE_PART) + connection.execute( + """ + INSERT INTO message_parts(id, message_id, position, kind, content_json, created_at) + VALUES (?, ?, 0, 'text', ?, ?) + """, + (part_id, message_id, json.dumps({"text": "hello"}), utc_now_text()), + ) + + row = connection.execute( + """ + SELECT d.package_id, i.id, s.id, m.id, p.content_json + FROM app_definitions d + JOIN app_instances i ON i.app_definition_id = d.id + JOIN sessions s ON s.app_instance_id = i.id + JOIN messages m ON m.session_id = s.id + JOIN message_parts p ON p.message_id = m.id + """ + ).fetchone() + assert row[0:4] == ("example.app", instance_id, session_id, message_id) + assert json.loads(row[4]) == {"text": "hello"} + + +def test_definition_instance_policy_and_singleton_keys_are_enforced(connection): + now = utc_now_text() + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + INSERT INTO app_definitions( + id, package_id, package_version, display_name, instance_mode, + singleton_scope, source, created_at, updated_at + ) VALUES (?, 'bad.multiple', '1', 'Bad', 'multiple', 'system', + 'local', ?, ?) + """, + (new_entity_id(EntityIdKind.APP_DEFINITION), now, now), + ) + + definition_id = _insert_definition( + connection, + package_id="singleton.app", + mode="singleton", + scope="system", + ) + with pytest.raises(sqlite3.IntegrityError, match="violates definition policy"): + _insert_instance(connection, definition_id) + _insert_instance(connection, definition_id, singleton_key="singleton.app:system") + with pytest.raises(sqlite3.IntegrityError): + _insert_instance( + connection, + definition_id, + singleton_key="singleton.app:system", + ) + + multiple_definition_id = _insert_definition( + connection, + package_id="another.multiple", + ) + with pytest.raises(sqlite3.IntegrityError, match="violates definition policy"): + _insert_instance( + connection, + multiple_definition_id, + singleton_key="not-allowed", + ) + multiple_instance_id = _insert_instance(connection, multiple_definition_id) + with pytest.raises(sqlite3.IntegrityError, match="cannot change instance policy"): + connection.execute( + """ + UPDATE app_definitions + SET instance_mode = 'singleton', singleton_scope = 'system' + WHERE id = ? + """, + (multiple_definition_id,), + ) + assert multiple_instance_id + + +def test_only_one_home_session_exists_per_app_instance(connection): + instance_id = _insert_instance(connection, _insert_definition(connection)) + _insert_session(connection, instance_id, is_home=1) + + with pytest.raises(sqlite3.IntegrityError): + _insert_session(connection, instance_id, is_home=1) + + assert _insert_session(connection, instance_id, is_home=0) + + +def test_message_order_and_idempotency_are_scoped_to_session(connection): + instance_id = _insert_instance(connection, _insert_definition(connection)) + first_session = _insert_session(connection, instance_id) + second_session = _insert_session(connection, instance_id) + _insert_message(connection, first_session, sequence=1, idempotency_key="request-1") + + with pytest.raises(sqlite3.IntegrityError): + _insert_message(connection, first_session, sequence=1) + with pytest.raises(sqlite3.IntegrityError): + _insert_message(connection, first_session, sequence=2, idempotency_key="request-1") + + _insert_message(connection, second_session, sequence=1, idempotency_key="request-1") + + +def test_foreign_keys_json_and_id_shapes_reject_invalid_records(connection): + now = utc_now_text() + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + INSERT INTO sessions(id, app_instance_id, created_at, updated_at) + VALUES (?, ?, ?, ?) + """, + ( + new_entity_id(EntityIdKind.SESSION), + new_entity_id(EntityIdKind.APP_INSTANCE), + now, + now, + ), + ) + + definition_id = _insert_definition(connection) + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + UPDATE app_definitions SET manifest_json = 'not-json' WHERE id = ? + """, + (definition_id,), + ) + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + "UPDATE app_definitions SET id = 'app_bad' WHERE id = ?", + (definition_id,), + ) + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + "UPDATE app_definitions SET id = ? WHERE id = ?", + ("app_" + "g" * 32, definition_id), + ) + + +def test_events_are_ordered_scoped_and_append_only(connection): + first_instance = _insert_instance(connection, _insert_definition(connection)) + second_definition = _insert_definition(connection, package_id="second.app") + second_instance = _insert_instance(connection, second_definition) + first_session = _insert_session(connection, first_instance) + now = utc_now_text() + + first_event_id = new_entity_id(EntityIdKind.EVENT) + connection.execute( + """ + INSERT INTO events( + id, type, occurred_at, app_instance_id, session_id, + subject_id, payload_json + ) VALUES (?, 'session.created', ?, ?, ?, ?, '{}') + """, + (first_event_id, now, first_instance, first_session, first_session), + ) + second_event_id = new_entity_id(EntityIdKind.EVENT) + connection.execute( + """ + INSERT INTO events(id, type, occurred_at, subject_id, payload_json) + VALUES (?, 'platform.ready', ?, 'platform', '{}') + """, + (second_event_id, now), + ) + + sequences = connection.execute( + "SELECT sequence FROM events ORDER BY sequence" + ).fetchall() + assert sequences == [(1,), (2,)] + + with pytest.raises(sqlite3.IntegrityError, match="scope does not own"): + connection.execute( + """ + INSERT INTO events( + id, type, occurred_at, app_instance_id, session_id, + subject_id, payload_json + ) VALUES (?, 'bad.scope', ?, ?, ?, ?, '{}') + """, + ( + new_entity_id(EntityIdKind.EVENT), + now, + second_instance, + first_session, + first_session, + ), + ) + with pytest.raises(sqlite3.IntegrityError): + connection.execute( + """ + INSERT INTO events(id, type, occurred_at, session_id, subject_id) + VALUES (?, 'bad.scope', ?, ?, ?) + """, + (new_entity_id(EntityIdKind.EVENT), now, first_session, first_session), + ) + with pytest.raises(sqlite3.IntegrityError, match="append-only"): + connection.execute( + "UPDATE events SET type = 'changed' WHERE id = ?", + (first_event_id,), + ) + with pytest.raises(sqlite3.IntegrityError, match="append-only"): + connection.execute("DELETE FROM events WHERE id = ?", (first_event_id,)) diff --git a/tests/test_ai2apps_platform_storage.py b/tests/test_ai2apps_platform_storage.py new file mode 100644 index 00000000..0452c823 --- /dev/null +++ b/tests/test_ai2apps_platform_storage.py @@ -0,0 +1,463 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Migration and lifecycle tests for the AI2Apps platform database.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from ai2apps.config import PLATFORM_DATABASE_SCHEMA_VERSION, PlatformConfig +from ai2apps.core import AppInstanceMode, SessionRetention, SessionStatus +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.storage import ( + DatabaseCorruptionError, + DatabaseDiagnostics, + FutureSchemaError, + PlatformDatabase, +) +from ai2apps.storage.migrations import MIGRATIONS, Migration, apply_migrations +from ai2apps.storage.repositories import AppRepository, SessionRepository + + +def test_database_bootstrap_creates_current_platform_schema(tmp_path): + database_path = tmp_path / "platform" / "ai2apps-platform.sqlite3" + database = PlatformDatabase(database_path) + + state = database.initialize() + + assert state.path == database_path.resolve() + assert state.schema_version == PLATFORM_DATABASE_SCHEMA_VERSION + assert state.journal_mode == "wal" + with database.connect() as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + } + ledger = connection.execute( + "SELECT version, name, applied_at FROM schema_migrations" + ).fetchall() + assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5_000 + assert ( + connection.execute("PRAGMA user_version").fetchone()[0] + == PLATFORM_DATABASE_SCHEMA_VERSION + ) + + assert tables == { + "app_definitions", + "app_instances", + "chat_collections", + "chat_thread_entries", + "events", + "message_parts", + "messages", + "schema_migrations", + "sessions", + "service_dependencies", + "service_descriptors", + "service_instances", + "tool_descriptors", + "tool_invocations", + "agent_concurrency_groups", + "agent_definitions", + "agent_interactions", + "agent_runs", + "agent_delegations", + "agent_status_lines", + "run_steps", + "capability_policies", + "grant_leases", + "capability_decisions", + "session_sandboxes", + "resource_handles", + "artifacts", + "artifact_exports", + "process_executions", + "process_log_chunks", + "host_broker_requests", + "publisher_trust", + "service_packages", + "service_package_files", + "package_attestations", + "service_dependency_locks", + "service_operations", + "service_logs", + "managed_service_processes", + "interactive_packages", + "local_patches", + "effective_definitions", + "app_mounts", + "app_state_snapshots", + "interactive_operations", + "safe_mode_state", + "safe_mode_patch_states", + "capability_requests", + "coder_projects", + "coder_threads", + "document_blobs", + "attachments", + "document_blocks", + "secret_records", + } + assert [(row[0], row[1]) for row in ledger] == [ + (1, "platform_bootstrap"), + (2, "apps_sessions_messages_events"), + (3, "generic_session_classification"), + (4, "temporary_session_retention"), + (5, "singleton_chat_collection"), + (6, "service_and_tool_registry"), + (7, "asynchronous_agent_runtime"), + (8, "capability_policy_and_grant_leases"), + (9, "workspace_resources_and_artifacts"), + (10, "sandboxed_process_service"), + (11, "trusted_service_packages"), + (12, "installable_agents_apps_and_local_patches"), + (13, "app_mount_context"), + (14, "unified_capability_requests"), + (15, "durable_tool_invocations"), + (16, "pausable_agent_runs"), + (17, "agent_run_delegation"), + (18, "coder_projects_and_threads"), + (19, "durable_attachments_and_documents"), + (20, "keychain_secret_metadata"), + (21, "mobile_app_mounts"), + ] + assert all(row[2].endswith("Z") for row in ledger) + + +def test_database_bootstrap_is_idempotent(tmp_path): + database = PlatformDatabase(tmp_path / "platform.sqlite3") + database.initialize() + with database.connect() as connection: + first_rows = connection.execute( + "SELECT version, applied_at FROM schema_migrations ORDER BY version" + ).fetchall() + + database.initialize() + + with database.connect() as connection: + rows = connection.execute( + "SELECT version, applied_at FROM schema_migrations" + ).fetchall() + assert rows == first_rows + + +def test_schema_v1_upgrades_to_current_without_rewriting_ledger(tmp_path): + database_path = tmp_path / "upgrade.sqlite3" + connection = sqlite3.connect(database_path, isolation_level=None) + apply_migrations( + connection, + (Migration(version=1, name="platform_bootstrap"),), + ) + first_applied_at = connection.execute( + "SELECT applied_at FROM schema_migrations WHERE version = 1" + ).fetchone()[0] + connection.close() + + state = PlatformDatabase(database_path).initialize() + + assert state.schema_version == PLATFORM_DATABASE_SCHEMA_VERSION + with PlatformDatabase(database_path).connect() as connection: + ledger = connection.execute( + "SELECT version, name, applied_at FROM schema_migrations ORDER BY version" + ).fetchall() + assert ledger[0] == (1, "platform_bootstrap", first_applied_at) + assert ledger[1][0:2] == (2, "apps_sessions_messages_events") + assert ledger[2][0:2] == (3, "generic_session_classification") + assert ledger[3][0:2] == (4, "temporary_session_retention") + assert ledger[4][0:2] == (5, "singleton_chat_collection") + assert ledger[5][0:2] == (6, "service_and_tool_registry") + assert ledger[6][0:2] == (7, "asynchronous_agent_runtime") + assert ledger[7][0:2] == (8, "capability_policy_and_grant_leases") + assert ledger[8][0:2] == (9, "workspace_resources_and_artifacts") + assert ledger[9][0:2] == (10, "sandboxed_process_service") + assert ledger[10][0:2] == (11, "trusted_service_packages") + assert ledger[11][0:2] == (12, "installable_agents_apps_and_local_patches") + assert ledger[12][0:2] == (13, "app_mount_context") + assert ledger[13][0:2] == (14, "unified_capability_requests") + assert ledger[14][0:2] == (15, "durable_tool_invocations") + assert ledger[15][0:2] == (16, "pausable_agent_runs") + assert ledger[16][0:2] == (17, "agent_run_delegation") + assert ledger[17][0:2] == (18, "coder_projects_and_threads") + + +def test_schema_v4_backfills_temporary_expiry_and_enforces_policy(tmp_path): + database_path = tmp_path / "upgrade-v3.sqlite3" + with sqlite3.connect(database_path, isolation_level=None) as connection: + apply_migrations(connection, MIGRATIONS[:3]) + connection.execute("PRAGMA foreign_keys = ON") + now = "2025-01-01T00:00:00.000000Z" + app_id = "app_" + "1" * 32 + instance_id = "appi_" + "2" * 32 + session_id = "ses_" + "3" * 32 + connection.execute( + """ + INSERT INTO app_definitions( + id, package_id, package_version, display_name, instance_mode, + source, created_at, updated_at + ) VALUES (?, 'upgrade.test', '1.0.0', 'Upgrade', 'multiple', + 'local', ?, ?) + """, + (app_id, now, now), + ) + connection.execute( + """ + INSERT INTO app_instances( + id, app_definition_id, status, created_at, updated_at + ) VALUES (?, ?, 'active', ?, ?) + """, + (instance_id, app_id, now, now), + ) + connection.execute( + """ + INSERT INTO sessions( + id, app_instance_id, retention, created_at, updated_at + ) VALUES (?, ?, 'temporary', ?, ?) + """, + (session_id, instance_id, now, now), + ) + + PlatformDatabase(database_path).initialize() + + with sqlite3.connect(database_path) as connection: + expires_at = connection.execute( + "SELECT expires_at FROM sessions WHERE id = ?", (session_id,) + ).fetchone()[0] + assert expires_at == "2025-01-02T00:00:00.000000Z" + with pytest.raises(sqlite3.IntegrityError, match="retention policy"): + connection.execute( + "UPDATE sessions SET expires_at = NULL WHERE id = ?", (session_id,) + ) + + +def test_concurrent_database_bootstrap_serializes_migrations(tmp_path): + database_path = tmp_path / "platform.sqlite3" + + with ThreadPoolExecutor(max_workers=2) as executor: + states = list( + executor.map( + lambda _: PlatformDatabase(database_path).initialize(), + range(2), + ) + ) + + assert [state.schema_version for state in states] == [ + PLATFORM_DATABASE_SCHEMA_VERSION, + PLATFORM_DATABASE_SCHEMA_VERSION, + ] + with PlatformDatabase(database_path).connect() as connection: + assert ( + connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone()[0] + == PLATFORM_DATABASE_SCHEMA_VERSION + ) + + +def test_migration_failure_rolls_back_schema_and_version(tmp_path): + database_path = tmp_path / "rollback.sqlite3" + connection = sqlite3.connect(database_path, isolation_level=None) + migrations = ( + Migration( + version=1, + name="broken", + statements=( + "CREATE TABLE should_rollback (id INTEGER PRIMARY KEY)", + "THIS IS NOT SQL", + ), + ), + ) + + with pytest.raises(sqlite3.OperationalError): + apply_migrations(connection, migrations) + + tables = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + assert tables == [] + assert connection.execute("PRAGMA user_version").fetchone()[0] == 0 + connection.close() + + +def test_future_database_schema_is_rejected_without_downgrade(tmp_path): + database_path = tmp_path / "future.sqlite3" + future_version = PLATFORM_DATABASE_SCHEMA_VERSION + 1 + with sqlite3.connect(database_path) as connection: + connection.execute(f"PRAGMA user_version = {future_version}") + + with pytest.raises(FutureSchemaError, match="newer than supported"): + PlatformDatabase(database_path).initialize() + + with sqlite3.connect(database_path) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == future_version + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE name = 'schema_migrations'" + ).fetchone() + is None + ) + + +def test_corrupt_database_reports_typed_diagnostic(tmp_path): + database_path = tmp_path / "corrupt.sqlite3" + database_path.write_bytes(b"not a sqlite database") + + with pytest.raises(DatabaseCorruptionError, match="could not be read safely"): + PlatformDatabase(database_path).initialize() + + +def test_platform_runtime_reports_ready_database(tmp_path): + config = PlatformConfig.from_base_path(tmp_path) + runtime = PlatformRuntime(config) + + before = runtime.database_status + after = runtime.start() + + assert before.status == "not_initialized" + assert before.schema_version == 0 + assert after.status == "ready" + assert after.schema_version == PLATFORM_DATABASE_SCHEMA_VERSION + assert after.target_schema_version == PLATFORM_DATABASE_SCHEMA_VERSION + assert after.journal_mode == "wal" + + +@pytest.mark.asyncio +async def test_runtime_retention_task_expires_temporary_sessions(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.database is not None + assert runtime.events is not None + apps = AppRepository(runtime.database, runtime.events) + definition = apps.create_definition( + package_id="retention.test", + package_version="1.0.0", + display_name="Retention", + instance_mode=AppInstanceMode.MULTIPLE, + ) + instance = apps.create_instance(app_definition_id=definition.id) + sessions = SessionRepository(runtime.database, runtime.events) + session = sessions.create( + app_instance_id=instance.id, + retention=SessionRetention.TEMPORARY, + expires_at="2025-01-01T00:00:00.000000Z", + ) + + await runtime.start_background_tasks(retention_interval_seconds=0.01) + for _ in range(100): + if sessions.get(session.id).status is SessionStatus.DELETED: + break + await asyncio.sleep(0.01) + await runtime.stop_background_tasks() + + assert sessions.get(session.id).status is SessionStatus.DELETED + assert runtime.events.latest_for_subject(session.id).type == "session.expired" + + +def test_database_diagnostics_and_atomic_online_backup(tmp_path): + database = PlatformDatabase(tmp_path / "live.sqlite3") + database.initialize() + with database.transaction(write=True) as connection: + connection.execute("CREATE TABLE operator_test(value TEXT NOT NULL)") + connection.execute("INSERT INTO operator_test VALUES ('before')") + + diagnostics = database.diagnose() + backup_path = tmp_path / "backups" / "platform.sqlite3" + backup = database.backup(backup_path) + with database.transaction(write=True) as connection: + connection.execute("INSERT INTO operator_test VALUES ('after')") + + assert isinstance(diagnostics, DatabaseDiagnostics) + assert diagnostics.quick_check == "ok" + assert diagnostics.foreign_key_violations == 0 + assert diagnostics.schema_version == PLATFORM_DATABASE_SCHEMA_VERSION + assert diagnostics.page_count > 0 + assert backup.destination_path == backup_path.resolve() + assert backup.quick_check == "ok" + with sqlite3.connect(backup_path) as connection: + assert connection.execute("SELECT value FROM operator_test").fetchall() == [ + ("before",) + ] + + +def test_uncommitted_write_is_absent_after_connection_crash(tmp_path): + database = PlatformDatabase(tmp_path / "crash.sqlite3") + database.initialize() + connection = database.connect() + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "INSERT INTO schema_migrations(version, name, applied_at) VALUES (99, 'lost', ?)", + ("2025-01-01T00:00:00.000000Z",), + ) + connection.close() + + with database.connect() as reopened: + assert ( + reopened.execute( + "SELECT COUNT(*) FROM schema_migrations WHERE version = 99" + ).fetchone()[0] + == 0 + ) + + +def test_server_lifecycle_boundary_publishes_ready_health(tmp_path): + from omlx.server import ( + ServerState, + app, + start_ai2apps_platform, + stop_ai2apps_platform, + ) + + state = ServerState(global_settings=SimpleNamespace(base_path=tmp_path)) + with patch("omlx.server._server_state", state): + start_ai2apps_platform() + try: + response = TestClient(app).get("/v1/platform/health") + finally: + stop_ai2apps_platform() + + assert response.status_code == 200 + assert response.json()["database"] == { + "configured": True, + "status": "ready", + "schema_version": PLATFORM_DATABASE_SCHEMA_VERSION, + "target_schema_version": PLATFORM_DATABASE_SCHEMA_VERSION, + "filename": "ai2apps-platform.sqlite3", + "journal_mode": "wal", + } + + +def test_fastapi_lifespan_starts_and_stops_platform_runtime(tmp_path): + from omlx.server import ServerState, app + from omlx.settings import GlobalSettings + + state = ServerState(global_settings=GlobalSettings(base_path=tmp_path)) + with ( + patch("omlx.server._server_state", state), + patch("omlx.utils.network.detect_server_aliases", return_value=[]), + TestClient(app) as client, + ): + response = client.get("/v1/platform/health") + services = client.get("/v1/platform/services") + echo = client.post( + "/v1/platform/tools/system.echo/invoke", + json={"arguments": {"value": "lifespan-ready"}}, + ) + assert state.ai2apps_platform_runtime is not None + assert response.json()["database"]["status"] == "ready" + assert {item["service_key"] for item in services.json()["items"]} == { + "ai2apps.diagnostics", + "ai2apps.mcp", + "ai2apps.model-runtime", + "ai2apps.process", + "ai2apps.workspace", + } + assert echo.json()["output"] == {"value": "lifespan-ready"} + + assert state.ai2apps_platform_runtime is None diff --git a/tests/test_ai2apps_repositories.py b/tests/test_ai2apps_repositories.py new file mode 100644 index 00000000..e93e6238 --- /dev/null +++ b/tests/test_ai2apps_repositories.py @@ -0,0 +1,388 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Transactional Repository and Event Store tests for Milestone 1B.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from ai2apps.core import ( + AppInstanceMode, + AppInstanceStatus, + IdempotencyConflictError, + MessageRole, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, + SessionRetention, + SessionStatus, + SingletonScope, +) +from ai2apps.events import EventStore +from ai2apps.storage import MessagePartInput, PlatformDatabase +from ai2apps.storage.repositories import ( + AppRepository, + MessageRepository, + SessionRepository, +) + + +@pytest.fixture +def platform(tmp_path): + database = PlatformDatabase(tmp_path / "platform.sqlite3") + database.initialize() + events = EventStore(database) + return database, events, AppRepository(database, events) + + +def _create_instance(apps: AppRepository, *, package_id: str = "example.app"): + definition = apps.create_definition( + package_id=package_id, + package_version="1.0.0", + display_name="Example", + instance_mode=AppInstanceMode.MULTIPLE, + manifest={"entry": "main"}, + ) + return definition, apps.create_instance(app_definition_id=definition.id) + + +def test_app_and_session_repositories_return_typed_records_and_events(platform): + database, events, apps = platform + definition, instance = _create_instance(apps) + sessions = SessionRepository(database, events) + session = sessions.create( + app_instance_id=instance.id, + title="Home", + is_home=True, + metadata={"source": "test"}, + trace_id="trace-create", + ) + + assert definition.manifest == {"entry": "main"} + assert instance.app_definition_id == definition.id + assert session.app_instance_id == instance.id + assert session.is_home is True + assert session.revision == 1 + assert session.metadata == {"source": "test"} + assert sessions.get(session.id) == session + + replay = events.list_after() + assert [event.type for event in replay] == [ + "app.definition.created", + "app.instance.created", + "session.created", + ] + assert [event.sequence for event in replay] == [1, 2, 3] + assert replay[-1].trace_id == "trace-create" + + +def test_singleton_conflicts_are_typed_and_atomic(platform): + database, events, apps = platform + definition = apps.create_definition( + package_id="singleton.app", + package_version="1.0.0", + display_name="Singleton", + instance_mode=AppInstanceMode.SINGLETON, + singleton_scope=SingletonScope.SYSTEM, + ) + first = apps.create_instance( + app_definition_id=definition.id, + singleton_key="singleton.app:system", + ) + + with pytest.raises(ResourceConflictError): + apps.create_instance( + app_definition_id=definition.id, + singleton_key="singleton.app:system", + ) + + assert apps.get_instance(first.id) == first + with database.transaction() as connection: + assert connection.execute( + "SELECT COUNT(*) FROM app_instances" + ).fetchone()[0] == 1 + assert [event.type for event in events.list_after()] == [ + "app.definition.created", + "app.instance.created", + ] + + +def test_app_instance_state_update_uses_optimistic_revision(platform): + _, events, apps = platform + _, instance = _create_instance(apps) + + updated = apps.update_instance( + instance.id, + expected_revision=1, + status=AppInstanceStatus.BACKGROUND, + state={"counter": 1}, + ) + + assert updated.status is AppInstanceStatus.BACKGROUND + assert updated.state == {"counter": 1} + assert updated.revision == 2 + assert events.latest_for_subject(instance.id).type == "app.instance.updated" + + with pytest.raises(RevisionConflictError): + apps.update_instance( + instance.id, + expected_revision=1, + state={"counter": 2}, + ) + assert apps.get_instance(instance.id) == updated + + +def test_missing_app_definition_and_instance_are_not_conflicts(platform): + database, events, apps = platform + missing_definition = "app_" + "0" * 32 + missing_instance = "appi_" + "0" * 32 + + with pytest.raises(ResourceNotFoundError): + apps.create_instance(app_definition_id=missing_definition) + with pytest.raises(ResourceNotFoundError): + SessionRepository(database, events).list_for_instance(missing_instance) + + +def test_session_update_uses_optimistic_revision_and_atomic_event(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + sessions = SessionRepository(database, events) + session = sessions.create(app_instance_id=instance.id, title="Before") + + updated = sessions.update( + session.id, + expected_revision=1, + title="After", + metadata={"color": "blue"}, + ) + + assert updated.title == "After" + assert updated.metadata == {"color": "blue"} + assert updated.revision == 2 + assert events.latest_for_subject(session.id).payload["revision"] == 2 + + with pytest.raises(RevisionConflictError) as error: + sessions.update(session.id, expected_revision=1, title="Stale") + assert error.value.actual == 2 + assert sessions.get(session.id).title == "After" + assert [event.type for event in events.list_after()].count("session.updated") == 1 + + +def test_session_and_message_reads_are_owner_scoped(platform): + database, events, apps = platform + _, first_instance = _create_instance(apps, package_id="first.app") + _, second_instance = _create_instance(apps, package_id="second.app") + sessions = SessionRepository(database, events) + session = sessions.create(app_instance_id=first_instance.id) + messages = MessageRepository(database, events) + message = messages.append( + session_id=session.id, + app_instance_id=first_instance.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "hello"}),), + ) + + with pytest.raises(ResourceNotFoundError): + sessions.get(session.id, app_instance_id=second_instance.id) + with pytest.raises(ResourceNotFoundError): + messages.list_for_session( + session.id, + app_instance_id=second_instance.id, + ) + with pytest.raises(ResourceNotFoundError): + messages.get(message.value.message.id, session_id="ses_" + "0" * 32) + + +def test_message_append_is_idempotent_per_session(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + session = SessionRepository(database, events).create(app_instance_id=instance.id) + messages = MessageRepository(database, events) + parts = ( + MessagePartInput(kind="text", content={"text": "hello"}), + MessagePartInput(kind="json", content={"answer": 42}), + ) + + first = messages.append( + session_id=session.id, + role=MessageRole.USER, + parts=parts, + idempotency_key="request-1", + metadata={"client": "test"}, + ) + replay = messages.append( + session_id=session.id, + role=MessageRole.USER, + parts=parts, + idempotency_key="request-1", + metadata={"client": "test"}, + ) + + assert first.created is True + assert replay.created is False + assert replay.value == first.value + assert replay.event == first.event + + with pytest.raises(IdempotencyConflictError): + messages.append( + session_id=session.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "different"}),), + idempotency_key="request-1", + metadata={"client": "test"}, + ) + + assert len(messages.list_for_session(session.id)) == 1 + assert [event.type for event in events.list_after()].count("message.created") == 1 + + +class _FailingEventStore(EventStore): + def append_in_transaction(self, *args, **kwargs): + raise RuntimeError("simulated event failure") + + +def test_event_failure_rolls_back_message_and_parts(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + session = SessionRepository(database, events).create(app_instance_id=instance.id) + messages = MessageRepository(database, _FailingEventStore(database)) + + with pytest.raises(RuntimeError, match="simulated event failure"): + messages.append( + session_id=session.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "rollback"}),), + ) + + with database.transaction() as connection: + assert connection.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 0 + assert connection.execute("SELECT COUNT(*) FROM message_parts").fetchone()[0] == 0 + assert [event.type for event in events.list_after()].count("message.created") == 0 + + +def test_concurrent_message_appends_receive_gapless_session_sequences(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + session = SessionRepository(database, events).create(app_instance_id=instance.id) + + def append_message(number: int): + return MessageRepository(database, events).append( + session_id=session.id, + role=MessageRole.USER, + parts=( + MessagePartInput(kind="text", content={"text": str(number)}), + ), + idempotency_key=f"request-{number}", + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(append_message, range(12))) + + assert all(result.created for result in results) + stored = MessageRepository(database, events).list_for_session(session.id) + assert [value.message.sequence for value in stored] == list(range(1, 13)) + assert len({value.message.id for value in stored}) == 12 + + +def test_concurrent_identical_idempotency_key_creates_one_message(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + session = SessionRepository(database, events).create(app_instance_id=instance.id) + + def append_same_message(_: int): + return MessageRepository(database, events).append( + session_id=session.id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": "same"}),), + idempotency_key="same-request", + ) + + with ThreadPoolExecutor(max_workers=6) as executor: + results = list(executor.map(append_same_message, range(12))) + + assert sum(result.created for result in results) == 1 + assert len({result.value.message.id for result in results}) == 1 + assert len(MessageRepository(database, events).list_for_session(session.id)) == 1 + assert [event.type for event in events.list_after()].count("message.created") == 1 + + +def test_archive_restart_and_cursor_replay(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + sessions = SessionRepository(database, events) + session = sessions.create(app_instance_id=instance.id, title="Persistent") + MessageRepository(database, events).append( + session_id=session.id, + role=MessageRole.ASSISTANT, + parts=(MessagePartInput(kind="text", content={"text": "saved"}),), + ) + archived = sessions.update( + session.id, + expected_revision=1, + status=SessionStatus.ARCHIVED, + ) + + restarted_events = EventStore(PlatformDatabase(database.path)) + restarted_sessions = SessionRepository(restarted_events.database, restarted_events) + restarted_messages = MessageRepository(restarted_events.database, restarted_events) + + assert restarted_sessions.get(session.id) == archived + assert restarted_messages.list_for_session(session.id)[0].parts[0].content == { + "text": "saved" + } + all_events = restarted_events.list_after() + tail = restarted_events.list_after( + after_sequence=all_events[-2].sequence, + session_id=session.id, + ) + assert [event.type for event in tail] == ["session.archived"] + + +def test_temporary_session_expiry_is_bounded_atomic_and_idempotent(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + sessions = SessionRepository(database, events) + first = sessions.create( + app_instance_id=instance.id, + retention=SessionRetention.TEMPORARY, + expires_at="2025-01-01T00:00:00.000000Z", + ) + second = sessions.create( + app_instance_id=instance.id, + retention=SessionRetention.TEMPORARY, + expires_at="2025-01-01T00:00:01.000000Z", + ) + durable = sessions.create(app_instance_id=instance.id) + + batch = sessions.expire_temporary( + now="2025-01-02T00:00:00.000000Z", limit=1 + ) + remainder = sessions.expire_temporary(now="2025-01-02T00:00:00.000000Z") + repeated = sessions.expire_temporary(now="2025-01-02T00:00:00.000000Z") + + assert [record.id for record in batch] == [first.id] + assert [record.id for record in remainder] == [second.id] + assert repeated == () + assert sessions.get(first.id).status is SessionStatus.DELETED + assert sessions.get(second.id).revision == 2 + assert sessions.get(durable.id).status is SessionStatus.ACTIVE + assert [event.type for event in events.list_after()].count("session.expired") == 2 + + +def test_temporary_session_gets_default_expiry_and_durable_rejects_one(platform): + database, events, apps = platform + _, instance = _create_instance(apps) + sessions = SessionRepository(database, events) + + temporary = sessions.create( + app_instance_id=instance.id, + retention=SessionRetention.TEMPORARY, + ) + + assert temporary.expires_at is not None + with pytest.raises(ValueError, match="Durable Sessions"): + sessions.create( + app_instance_id=instance.id, + expires_at="2025-01-01T00:00:00.000000Z", + ) diff --git a/tests/test_ai2apps_services.py b/tests/test_ai2apps_services.py new file mode 100644 index 00000000..3f2d742f --- /dev/null +++ b/tests/test_ai2apps_services.py @@ -0,0 +1,521 @@ +# SPDX-License-Identifier: Apache-2.0 +"""M3 Service Registry, adapters, and Tool Gateway contracts.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from jsonschema import SchemaError + +from ai2apps.api.router import create_ai2apps_router +from ai2apps.config import PLATFORM_DATABASE_SCHEMA_VERSION, PlatformConfig +from ai2apps.core import ResourceConflictError +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ( + MCPServiceAdapter, + OmlxModelServiceAdapter, + ServiceDependency, + ServiceInstanceStatus, + ServiceRuntimeMode, + ToolCallContext, + ToolGatewayError, + ToolInvocationStatus, + ToolProviderError, +) + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.database is not None + assert runtime.events is not None + assert runtime.services is not None + assert runtime.service_registry is not None + assert runtime.tools is not None + return runtime + + +def _client(runtime): + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + return TestClient(app) + + +def test_schema_v6_seeds_a_durable_echo_service_and_tool(tmp_path): + runtime = _runtime(tmp_path) + + service = runtime.services.get_service("ai2apps.diagnostics") + instance = runtime.services.get_instance_for_service(service.id) + tool = runtime.services.get_tool("system.echo") + + assert service.runtime_mode is ServiceRuntimeMode.IN_PROCESS + assert instance.status is ServiceInstanceStatus.RUNNING + assert tool.service_id == service.id + assert tool.effects == () + with runtime.database.connect() as connection: + assert ( + connection.execute("PRAGMA user_version").fetchone()[0] + == PLATFORM_DATABASE_SCHEMA_VERSION + ) + + +def test_service_dependencies_are_persisted_and_restart_safe(tmp_path): + runtime = _runtime(tmp_path) + runtime.services.ensure_service( + service_key="example.consumer", + package_id="example.consumer", + package_version="1.0.0", + display_name="Consumer", + runtime_mode=ServiceRuntimeMode.EXTERNAL, + dependencies=( + ServiceDependency("ai2apps.model-runtime", ">=1", False), + ServiceDependency("ai2apps.mcp", "*", True), + ), + ) + + restarted = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + restarted.start() + service = restarted.services.get_service("example.consumer") + + assert [(item.service_key, item.optional) for item in service.dependencies] == [ + ("ai2apps.mcp", True), + ("ai2apps.model-runtime", False), + ] + + +def test_echo_api_discovery_invocation_validation_and_audit(tmp_path): + runtime = _runtime(tmp_path) + client = _client(runtime) + + services = client.get("/v1/platform/services") + tools = client.get("/v1/platform/tools") + invoked = client.post( + "/v1/platform/tools/system.echo/invoke", + json={"arguments": {"value": {"hello": "world"}}}, + headers={"x-trace-id": "trace-echo"}, + ) + invalid = client.post( + "/v1/platform/tools/system.echo/invoke", + json={"arguments": {}}, + ) + + assert services.status_code == 200 + assert "ai2apps.diagnostics" in { + item["service_key"] for item in services.json()["items"] + } + assert "ai2apps.agent-runtime" in { + item["service_key"] for item in services.json()["items"] + } + assert "system.echo" in {item["qualified_name"] for item in tools.json()["items"]} + assert invoked.status_code == 200 + assert invoked.json()["invocation_id"].startswith("tinv_") + assert invoked.json()["output"] == {"value": {"hello": "world"}} + assert invoked.json()["provider_key"] == "builtin:diagnostics" + assert invalid.status_code == 422 + assert invalid.json()["error"]["code"] == "invalid_tool_input" + event = runtime.events.latest_for_subject( + invoked.json()["tool_id"], + event_type="tool.invocation.completed", + ) + assert event is not None + assert event.trace_id == "trace-echo" + invocation = client.get( + f"/v1/platform/tool-invocations/{invoked.json()['invocation_id']}" + ) + assert invocation.status_code == 200 + assert invocation.json()["status"] == "completed" + assert invocation.json()["output"] == {"value": {"hello": "world"}} + + +def test_service_lifecycle_is_revisioned_and_controls_tool_visibility(tmp_path): + runtime = _runtime(tmp_path) + client = _client(runtime) + service = client.get("/v1/platform/services/ai2apps.diagnostics").json() + + disabled = client.post( + "/v1/platform/services/ai2apps.diagnostics/disable", + json={"expected_revision": service["revision"]}, + ) + stale = client.post( + "/v1/platform/services/ai2apps.diagnostics/enable", + json={"expected_revision": service["revision"]}, + ) + hidden = client.get("/v1/platform/tools") + blocked = client.post( + "/v1/platform/tools/system.echo/invoke", + json={"arguments": {"value": 1}}, + ) + enabled = client.post( + "/v1/platform/services/ai2apps.diagnostics/enable", + json={"expected_revision": disabled.json()["revision"]}, + ) + restarted = client.post("/v1/platform/services/ai2apps.diagnostics/restart") + + assert disabled.json()["status"] == "disabled" + assert disabled.json()["instance"]["status"] == "disabled" + assert stale.status_code == 409 + assert stale.json()["error"]["code"] == "revision_conflict" + assert "system.echo" not in { + item["qualified_name"] for item in hidden.json()["items"] + } + assert blocked.status_code == 409 + assert blocked.json()["error"]["code"] == "tool_disabled" + assert enabled.json()["status"] == "enabled" + assert restarted.json()["instance"]["status"] == "running" + + +@pytest.mark.asyncio +async def test_gateway_filters_capabilities_and_normalizes_timeout_and_errors(tmp_path): + runtime = _runtime(tmp_path) + service = runtime.services.ensure_service( + service_key="example.secure", + package_id="example.secure", + package_version="1.0.0", + display_name="Secure", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + ) + instance = runtime.services.ensure_instance( + service_id=service.id, + provider_key="local:secure", + status=ServiceInstanceStatus.RUNNING, + ) + runtime.services.ensure_tool( + service_id=service.id, + qualified_name="secure.wait", + display_name="Wait", + description="Test permission and timeout behavior.", + input_schema={ + "type": "object", + "properties": {"delay": {"type": "number", "minimum": 0}}, + "required": ["delay"], + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + "required": ["ok"], + "additionalProperties": False, + }, + required_capabilities=("secure.execute",), + timeout_ms=20, + ) + + async def wait(arguments, _): + await asyncio.sleep(arguments["delay"]) + return {"ok": True} + + runtime.service_registry.bind_tool( + "secure.wait", provider_key=instance.provider_key, handler=wait + ) + denied_context = ToolCallContext(caller_id="agent:test") + allowed_context = ToolCallContext( + caller_id="agent:test", + granted_capabilities=frozenset({"secure.execute"}), + ) + + assert "secure.wait" not in { + tool.qualified_name for tool in runtime.tools.list_tools(denied_context) + } + assert "secure.wait" in { + tool.qualified_name for tool in runtime.tools.list_tools(allowed_context) + } + with pytest.raises(ToolGatewayError) as denied: + await runtime.tools.execute("secure.wait", {"delay": 0}, context=denied_context) + with pytest.raises(ToolGatewayError) as timed_out: + await runtime.tools.execute( + "secure.wait", {"delay": 0.1}, context=allowed_context + ) + with pytest.raises(ToolGatewayError) as invalid: + await runtime.tools.execute( + "secure.wait", {"delay": -1}, context=allowed_context + ) + + assert denied.value.code == "capability_denied" + assert timed_out.value.code == "tool_timeout" + assert invalid.value.code == "invalid_tool_input" + + +@pytest.mark.asyncio +async def test_gateway_propagates_cancellation_and_records_it(tmp_path): + runtime = _runtime(tmp_path) + tool = runtime.services.get_tool("system.echo") + + async def never_finishes(arguments, context): + await asyncio.Event().wait() + return {"value": arguments["value"]} + + runtime.service_registry.bind_tool( + "system.echo", + provider_key="builtin:diagnostics", + handler=never_finishes, + ) + task = asyncio.create_task( + runtime.tools.execute( + "system.echo", + {"value": "cancel"}, + context=ToolCallContext(caller_id="agent:test"), + ) + ) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + event = runtime.events.latest_for_subject( + tool.id, + event_type="tool.invocation.cancelled", + ) + assert event is not None + assert event.payload["code"] == "tool_cancelled" + invocation = runtime.services.list_invocations()[0] + assert invocation.status is ToolInvocationStatus.CANCELLED + assert invocation.error == {"code": "tool_cancelled"} + + +@pytest.mark.asyncio +async def test_gateway_persists_progress_and_explicit_bounded_retry(tmp_path): + runtime = _runtime(tmp_path) + service = runtime.services.ensure_service( + service_key="example.retry", + package_id="example.retry", + package_version="1.0.0", + display_name="Retry fixture", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + ) + instance = runtime.services.ensure_instance( + service_id=service.id, + provider_key="local:retry", + status=ServiceInstanceStatus.RUNNING, + ) + runtime.services.ensure_tool( + service_id=service.id, + qualified_name="fixture.retry", + display_name="Retry", + description="Report progress and retry one declared provider failure.", + input_schema={"type": "object", "additionalProperties": False}, + output_schema={ + "type": "object", + "properties": {"attempt": {"type": "integer"}}, + "required": ["attempt"], + }, + retry_policy={ + "max_attempts": 2, + "backoff_ms": 0, + "retry_codes": ["provider_error"], + }, + ) + attempts = 0 + observed_progress = [] + + async def retry(_arguments, context): + nonlocal attempts + attempts += 1 + await context.report_progress(f"attempt {attempts}", progress=attempts / 2) + if attempts == 1: + raise ToolProviderError("transient fixture") + return {"attempt": attempts} + + runtime.service_registry.bind_tool( + "fixture.retry", provider_key=instance.provider_key, handler=retry + ) + result = await runtime.tools.execute( + "fixture.retry", + {}, + context=ToolCallContext( + caller_id="agent:test", + trace_id="run_retry", + progress_reporter=observed_progress.append, + ), + ) + invocation = runtime.services.get_invocation(result.invocation_id) + events = runtime.events.list_after(subject_id=result.tool_id, limit=20) + + assert result.output == {"attempt": 2} + assert invocation.status is ToolInvocationStatus.COMPLETED + assert invocation.attempt == 2 + assert invocation.progress["text"] == "attempt 2" + assert [item["text"] for item in observed_progress] == ["attempt 1", "attempt 2"] + assert "tool.invocation.retrying" in [event.type for event in events] + + +def test_runtime_marks_unfinished_tool_invocation_interrupted(tmp_path): + runtime = _runtime(tmp_path) + tool = runtime.services.get_tool("system.echo") + invocation = runtime.services.create_invocation( + tool=tool, + provider_key="builtin:diagnostics", + caller_id="agent:restart-fixture", + session_id=None, + trace_id="run_interrupted", + arguments={"value": "pending"}, + timeout_ms=5_000, + ) + + restarted = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + restarted.start() + recovered = restarted.services.get_invocation(invocation.id) + + assert recovered.status is ToolInvocationStatus.INTERRUPTED + assert recovered.error == {"code": "runtime_restarted"} + event = restarted.events.latest_for_subject( + tool.id, event_type="tool.invocation.interrupted" + ) + assert event.payload["invocation_id"] == invocation.id + + +class FakeEnginePool: + def __init__(self): + self.entry = SimpleNamespace(engine=None) + self.loaded = [] + self.unloaded = [] + + def get_status(self): + return {"models": [{"id": "tiny", "loaded": self.entry.engine is not None}]} + + def get_entry(self, model_id): + return self.entry if model_id == "tiny" else None + + async def get_engine(self, model_id): + self.loaded.append(model_id) + self.entry.engine = object() + return self.entry.engine + + async def _unload_engine(self, model_id): + self.unloaded.append(model_id) + self.entry.engine = None + + +@pytest.mark.asyncio +async def test_model_runtime_adapter_invokes_the_existing_engine_pool(tmp_path): + runtime = _runtime(tmp_path) + pool = FakeEnginePool() + OmlxModelServiceAdapter(lambda: pool).bind( + runtime.services, runtime.service_registry + ) + context = ToolCallContext( + caller_id="system:test", + granted_capabilities=frozenset({"model.manage"}), + ) + + status = await runtime.tools.execute("model.status", {}, context=context) + loaded = await runtime.tools.execute( + "model.load", {"model_id": "tiny"}, context=context + ) + unloaded = await runtime.tools.execute( + "model.unload", {"model_id": "tiny"}, context=context + ) + + assert status.output["models"][0]["id"] == "tiny" + assert loaded.output == {"status": "ok", "model_id": "tiny"} + assert unloaded.output == {"status": "ok", "model_id": "tiny"} + assert pool.loaded == ["tiny"] + assert pool.unloaded == ["tiny"] + service = runtime.services.get_service("ai2apps.model-runtime") + assert service.config["inference_contract"] == "openai-compatible" + assert "/v1/chat/completions" in service.config["compatibility_endpoints"] + + +class FakeMCPManager: + def __init__(self): + self.calls = [] + + def get_server_status(self): + return [ + SimpleNamespace(to_dict=lambda: {"name": "files", "state": "connected"}) + ] + + def get_all_tools(self): + return [ + SimpleNamespace( + full_name="files__read", + name="read", + description="Read a test resource", + input_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + ) + ] + + async def execute_tool(self, name, arguments): + self.calls.append((name, arguments)) + return SimpleNamespace( + is_error=False, + error_message=None, + content={"text": f"read:{arguments['path']}"}, + ) + + +@pytest.mark.asyncio +async def test_mcp_adapter_discovers_and_invokes_existing_manager_tools(tmp_path): + runtime = _runtime(tmp_path) + manager = FakeMCPManager() + MCPServiceAdapter(lambda: manager).bind(runtime.services, runtime.service_registry) + + result = await runtime.tools.execute( + "mcp.files__read", + {"path": "/sandbox/readme.txt"}, + context=ToolCallContext(caller_id="agent:test"), + ) + + assert result.output == { + "content": {"text": "read:/sandbox/readme.txt"}, + "is_error": False, + } + assert manager.calls == [("files__read", {"path": "/sandbox/readme.txt"})] + + +def test_provider_identity_cannot_be_spoofed(tmp_path): + runtime = _runtime(tmp_path) + + with pytest.raises(ToolGatewayError) as error: + runtime.service_registry.bind_tool( + "system.echo", + provider_key="installed:malicious-provider", + handler=lambda arguments, context: arguments, + ) + + assert error.value.code == "provider_identity_mismatch" + + +def test_invalid_schema_and_missing_service_are_rejected(tmp_path): + runtime = _runtime(tmp_path) + diagnostics = runtime.services.get_service("ai2apps.diagnostics") + + with pytest.raises(SchemaError, match="not valid"): + runtime.services.ensure_tool( + service_id=runtime.services.get_service("ai2apps.diagnostics").id, + qualified_name="invalid.schema", + display_name="Invalid", + description="Invalid test schema", + input_schema={"type": "definitely-not-a-json-type"}, + output_schema={"type": "object"}, + ) + with pytest.raises(ResourceConflictError): + runtime.services.ensure_tool( + service_id="svc_" + "f" * 32, + qualified_name="missing.service", + display_name="Missing", + description="Missing Service test", + input_schema={"type": "object"}, + output_schema={"type": "object"}, + ) + with pytest.raises(ValueError, match="allow_effect_replay"): + runtime.services.ensure_tool( + service_id=diagnostics.id, + qualified_name="invalid.effect-retry", + display_name="Unsafe retry", + description="Effectful retries require an explicit declaration.", + input_schema={"type": "object"}, + output_schema={"type": "object"}, + effects=("write",), + retry_policy={ + "max_attempts": 2, + "retry_codes": ["provider_error"], + }, + ) From 12237374992ae4e9a572d33a5212d7bcbdd4bb98 Mon Sep 17 00:00:00 2001 From: Avdpro Pang <38308119+Avdpro@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:03:40 +0800 Subject: [PATCH 02/11] feat(security): add sandbox and secret management --- ai2apps/api/capabilities.py | 155 +++++ ai2apps/api/secrets.py | 132 +++++ ai2apps/api/workspace.py | 295 ++++++++++ ai2apps/capabilities/__init__.py | 30 + ai2apps/capabilities/models.py | 109 ++++ ai2apps/capabilities/policy.py | 131 +++++ ai2apps/capabilities/repository.py | 842 ++++++++++++++++++++++++++++ ai2apps/capabilities/risk.py | 99 ++++ ai2apps/processes/__init__.py | 41 ++ ai2apps/processes/authority.py | 84 +++ ai2apps/processes/manager.py | 672 ++++++++++++++++++++++ ai2apps/processes/models.py | 85 +++ ai2apps/processes/repository.py | 370 ++++++++++++ ai2apps/processes/sandbox.py | 152 +++++ ai2apps/processes/service.py | 360 ++++++++++++ ai2apps/secrets/__init__.py | 25 + ai2apps/secrets/backends.py | 339 +++++++++++ ai2apps/secrets/factory.py | 73 +++ ai2apps/secrets/models.py | 31 + ai2apps/secrets/repository.py | 185 ++++++ ai2apps/terminal/__init__.py | 11 + ai2apps/terminal/child.py | 37 ++ ai2apps/terminal/manager.py | 415 ++++++++++++++ ai2apps/terminal/service.py | 39 ++ ai2apps/workspace/__init__.py | 26 + ai2apps/workspace/broker.py | 40 ++ ai2apps/workspace/models.py | 81 +++ ai2apps/workspace/repository.py | 792 ++++++++++++++++++++++++++ ai2apps/workspace/service.py | 369 ++++++++++++ docs/security-authority-baseline.md | 48 ++ tests/test_ai2apps_capabilities.py | 273 +++++++++ tests/test_ai2apps_processes.py | 402 +++++++++++++ tests/test_ai2apps_secrets.py | 129 +++++ tests/test_ai2apps_shell.py | 740 ++++++++++++++++++++++++ tests/test_ai2apps_terminal.py | 138 +++++ tests/test_ai2apps_workspace.py | 284 ++++++++++ 36 files changed, 8034 insertions(+) create mode 100644 ai2apps/api/capabilities.py create mode 100644 ai2apps/api/secrets.py create mode 100644 ai2apps/api/workspace.py create mode 100644 ai2apps/capabilities/__init__.py create mode 100644 ai2apps/capabilities/models.py create mode 100644 ai2apps/capabilities/policy.py create mode 100644 ai2apps/capabilities/repository.py create mode 100644 ai2apps/capabilities/risk.py create mode 100644 ai2apps/processes/__init__.py create mode 100644 ai2apps/processes/authority.py create mode 100644 ai2apps/processes/manager.py create mode 100644 ai2apps/processes/models.py create mode 100644 ai2apps/processes/repository.py create mode 100644 ai2apps/processes/sandbox.py create mode 100644 ai2apps/processes/service.py create mode 100644 ai2apps/secrets/__init__.py create mode 100644 ai2apps/secrets/backends.py create mode 100644 ai2apps/secrets/factory.py create mode 100644 ai2apps/secrets/models.py create mode 100644 ai2apps/secrets/repository.py create mode 100644 ai2apps/terminal/__init__.py create mode 100644 ai2apps/terminal/child.py create mode 100644 ai2apps/terminal/manager.py create mode 100644 ai2apps/terminal/service.py create mode 100644 ai2apps/workspace/__init__.py create mode 100644 ai2apps/workspace/broker.py create mode 100644 ai2apps/workspace/models.py create mode 100644 ai2apps/workspace/repository.py create mode 100644 ai2apps/workspace/service.py create mode 100644 docs/security-authority-baseline.md create mode 100644 tests/test_ai2apps_capabilities.py create mode 100644 tests/test_ai2apps_processes.py create mode 100644 tests/test_ai2apps_secrets.py create mode 100644 tests/test_ai2apps_shell.py create mode 100644 tests/test_ai2apps_terminal.py create mode 100644 tests/test_ai2apps_workspace.py diff --git a/ai2apps/api/capabilities.py b/ai2apps/api/capabilities.py new file mode 100644 index 00000000..ac770adc --- /dev/null +++ b/ai2apps/api/capabilities.py @@ -0,0 +1,155 @@ +"""Capability policy and revocable GrantLease management APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.capabilities import ( + CapabilityPolicyRecord, + GrantLeaseRecord, + PolicyEffect, +) +from ai2apps.core import RepositoryError + + +class PolicyResponse(BaseModel): + id: str + policy_key: str + effect: str + capability_pattern: str + agent_pattern: str + tool_pattern: str + priority: int + enabled: bool + source: str + conditions: dict[str, Any] + revision: int + + @classmethod + def from_record(cls, value: CapabilityPolicyRecord): + return cls(**{key: getattr(value, key) for key in cls.model_fields}) + + +class PolicyListResponse(BaseModel): + items: list[PolicyResponse] + + +class PolicyPutRequest(BaseModel): + effect: PolicyEffect + capability_pattern: str = Field(min_length=1) + agent_pattern: str = Field(default="*", min_length=1) + tool_pattern: str = Field(default="*", min_length=1) + priority: int = Field(default=0, ge=-10_000, le=10_000) + conditions: dict[str, Any] = Field(default_factory=dict) + + +class GrantLeaseResponse(BaseModel): + id: str + scope: str + scope_id: str + agent_definition_id: str | None + session_id: str + app_instance_id: str + capabilities: list[str] + tool_pattern: str + tool_service_digest: str | None + resource_selector: dict[str, Any] + issued_by: str + evidence: dict[str, Any] + expires_at: datetime | None + revoked_at: datetime | None + revoke_reason: str | None + created_at: datetime + + @classmethod + def from_record(cls, value: GrantLeaseRecord): + data = {key: getattr(value, key) for key in cls.model_fields} + data["capabilities"] = list(value.capabilities) + return cls(**data) + + +class GrantLeaseListResponse(BaseModel): + items: list[GrantLeaseResponse] + + +class RevokeRequest(BaseModel): + reason: str = Field(min_length=1, max_length=500) + + +def create_capability_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def repository_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.capabilities is None: + return platform_error_response( + status_code=503, + code="capability_runtime_not_ready", + message="AI2Apps Capability Runtime is not ready.", + retryable=True, + ) + return runtime.capabilities + + @router.get("/capability-policies", response_model=PolicyListResponse) + def list_policies(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return PolicyListResponse( + items=[PolicyResponse.from_record(x) for x in repository.list_policies()] + ) + + @router.put("/capability-policies/{policy_key}", response_model=PolicyResponse) + def put_policy(policy_key: str, request: PolicyPutRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.upsert_policy( + policy_key=policy_key, + effect=request.effect, + capability_pattern=request.capability_pattern, + agent_pattern=request.agent_pattern, + tool_pattern=request.tool_pattern, + priority=request.priority, + source="local", + conditions=request.conditions, + ) + return PolicyResponse.from_record(record) + except ValueError as error: + return platform_error_response( + status_code=422, code="invalid_capability_policy", message=str(error) + ) + + @router.get("/grant-leases", response_model=GrantLeaseListResponse) + def list_grants(include_inactive: bool = Query(default=False)): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return GrantLeaseListResponse( + items=[ + GrantLeaseResponse.from_record(x) + for x in repository.list_leases(include_inactive=include_inactive) + ] + ) + + @router.post("/grant-leases/{lease_id}/revoke", response_model=GrantLeaseResponse) + def revoke_grant(lease_id: str, request: RevokeRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return GrantLeaseResponse.from_record( + repository.revoke_lease(lease_id, reason=request.reason) + ) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/secrets.py b/ai2apps/api/secrets.py new file mode 100644 index 00000000..9f23f256 --- /dev/null +++ b/ai2apps/api/secrets.py @@ -0,0 +1,132 @@ +"""Secret metadata API. No endpoint ever returns a secret value.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, SecretStr + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.secrets import SecretRecord + + +class SecretResponse(BaseModel): + id: str + uri: str + name: str + purpose: str + allowed_tools: list[str] + status: str + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + @classmethod + def from_record(cls, record: SecretRecord): + return cls( + id=record.id, uri=record.uri, name=record.name, + purpose=record.purpose, allowed_tools=list(record.allowed_tools), + status=record.status, metadata=record.metadata, + created_at=record.created_at, updated_at=record.updated_at, + deleted_at=record.deleted_at, + ) + + +class SecretListResponse(BaseModel): + items: list[SecretResponse] + + +class SecretBackendResponse(BaseModel): + provider: str + portable: bool + + +class SecretCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + value: SecretStr + purpose: str = Field(default="", max_length=500) + allowed_tools: list[str] = Field(min_length=1) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class SecretReplaceRequest(BaseModel): + value: SecretStr + + +def create_secret_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def repository_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.secrets is None: + return platform_error_response( + status_code=503, code="secret_runtime_not_ready", + message="AI2Apps Secret Store is not ready.", retryable=True, + ) + return runtime.secrets + + @router.get("/secrets/backend", response_model=SecretBackendResponse) + def get_secret_backend(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + provider = repository.backend.provider_name + return SecretBackendResponse( + provider=provider, + portable=provider == "encrypted-file", + ) + + @router.get("/secrets", response_model=SecretListResponse) + def list_secrets(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return SecretListResponse( + items=[SecretResponse.from_record(item) for item in repository.list()] + ) + + @router.post("/secrets", response_model=SecretResponse, status_code=201) + def create_secret(request: SecretCreateRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record(repository.create( + name=request.name, value=request.value.get_secret_value(), + purpose=request.purpose, allowed_tools=tuple(request.allowed_tools), + metadata=request.metadata, + )) + except (ValueError, RuntimeError) as error: + return platform_error_response( + status_code=422, code="secret_create_failed", message=str(error) + ) + + @router.put("/secrets/{secret_id}/value", response_model=SecretResponse) + def replace_secret(secret_id: str, request: SecretReplaceRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record( + repository.replace(secret_id, request.value.get_secret_value()) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/secrets/{secret_id}", response_model=SecretResponse) + def delete_secret(secret_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record(repository.delete(secret_id)) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/workspace.py b/ai2apps/api/workspace.py new file mode 100644 index 00000000..0fde752f --- /dev/null +++ b/ai2apps/api/workspace.py @@ -0,0 +1,295 @@ +"""Session Workspace, ResourceHandle, and Artifact user APIs.""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Any + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.workspace import ArtifactRecord, ResourceHandleRecord, WorkspaceError + + +class ResourceImportRequest(BaseModel): + filename: str = Field(min_length=1, max_length=255) + content_base64: str = Field(min_length=1) + media_type: str | None = None + + +class ResourceHandleResponse(BaseModel): + id: str + uri: str + kind: str + display_name: str + capabilities: list[str] + media_type: str | None + size_bytes: int | None + content_hash: str | None + source: str + + @classmethod + def from_record(cls, value: ResourceHandleRecord): + return cls( + id=value.id, + uri=value.uri, + kind=value.kind.value, + display_name=value.display_name, + capabilities=list(value.capabilities), + media_type=value.media_type, + size_bytes=value.size_bytes, + content_hash=value.content_hash, + source=value.source, + ) + + +class ResourceHandleListResponse(BaseModel): + items: list[ResourceHandleResponse] + + +class WorkspaceWriteRequest(BaseModel): + path: str = Field(min_length=1) + content: str + encoding: str = Field(default="utf-8", pattern="^(utf-8|base64)$") + + +class ArtifactCreateRequest(BaseModel): + path: str = Field(min_length=1) + name: str | None = None + media_type: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ArtifactResponse(BaseModel): + id: str + uri: str + name: str + media_type: str + content_hash: str + size_bytes: int + metadata: dict[str, Any] + + @classmethod + def from_record(cls, value: ArtifactRecord): + return cls( + id=value.id, + uri=value.uri, + name=value.name, + media_type=value.media_type, + content_hash=value.content_hash, + size_bytes=value.size_bytes, + metadata=value.metadata, + ) + + +class ArtifactListResponse(BaseModel): + items: list[ArtifactResponse] + + +def create_workspace_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def workspace_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.workspace is None: + return platform_error_response( + status_code=503, + code="workspace_runtime_not_ready", + message="AI2Apps Workspace Runtime is not ready.", + retryable=True, + ) + return runtime.workspace + + def workspace_error(error: WorkspaceError): + status = ( + 413 + if error.code in {"resource_too_large", "workspace_quota_exceeded"} + else 422 + ) + return platform_error_response( + status_code=status, code=error.code, message=str(error) + ) + + @router.get("/sessions/{session_id}/workspace") + def list_workspace( + session_id: str, + path: str = ".", + offset: int = Query(0, ge=0), + limit: int = Query(200, ge=1, le=1000), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.list(session_id, path, offset=offset, limit=limit) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.get("/sessions/{session_id}/workspace/read") + def read_workspace( + session_id: str, + path: str, + offset: int = Query(0, ge=0), + limit: int = Query(1024 * 1024, ge=1, le=1024 * 1024), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.read(session_id, path, offset=offset, limit=limit) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.put("/sessions/{session_id}/workspace") + def write_workspace(session_id: str, request: WorkspaceWriteRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.write( + session_id, request.path, request.content, encoding=request.encoding + ) + except RepositoryError as error: + return repository_error_response(error) + except (WorkspaceError, binascii.Error, ValueError) as error: + if isinstance(error, WorkspaceError): + return workspace_error(error) + return platform_error_response( + status_code=422, code="invalid_content_encoding", message=str(error) + ) + + @router.post( + "/sessions/{session_id}/resource-handles/import", + response_model=ResourceHandleResponse, + status_code=201, + ) + def import_resource(session_id: str, request: ResourceImportRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + data = base64.b64decode(request.content_base64, validate=True) + return ResourceHandleResponse.from_record( + workspace.import_bytes( + session_id, request.filename, data, media_type=request.media_type + ) + ) + except RepositoryError as error: + return repository_error_response(error) + except binascii.Error: + return platform_error_response( + status_code=422, + code="invalid_base64", + message="content_base64 is invalid.", + ) + except WorkspaceError as error: + return workspace_error(error) + + @router.get( + "/sessions/{session_id}/resource-handles", + response_model=ResourceHandleListResponse, + ) + def list_handles(session_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + return ResourceHandleListResponse( + items=[ + ResourceHandleResponse.from_record(item) + for item in workspace.list_handles(session_id) + ] + ) + + @router.delete( + "/sessions/{session_id}/resource-handles/{handle_id}", status_code=204 + ) + def revoke_handle(session_id: str, handle_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + workspace.revoke_handle(session_id, handle_id) + return Response(status_code=204) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/sessions/{session_id}/artifacts", + response_model=ArtifactResponse, + status_code=201, + ) + def create_artifact(session_id: str, request: ArtifactCreateRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return ArtifactResponse.from_record( + workspace.create_artifact( + session_id, + request.path, + request.name, + media_type=request.media_type, + metadata=request.metadata, + ) + ) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.get("/sessions/{session_id}/artifacts", response_model=ArtifactListResponse) + def list_artifacts(session_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + return ArtifactListResponse( + items=[ + ArtifactResponse.from_record(item) + for item in workspace.list_artifacts(session_id) + ] + ) + + @router.get("/sessions/{session_id}/artifacts/{artifact_id}/preview") + def preview_artifact( + session_id: str, + artifact_id: str, + limit: int = Query(256 * 1024, ge=1, le=1024 * 1024), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.preview_artifact(session_id, artifact_id, limit) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/sessions/{session_id}/artifacts/{artifact_id}/download") + def download_artifact(session_id: str, artifact_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + artifact = workspace.get_artifact(session_id, artifact_id) + data = workspace.artifact_path(artifact).read_bytes() + safe = artifact.name.replace('"', "") + return Response( + data, + media_type=artifact.media_type, + headers={ + "Content-Disposition": f'attachment; filename="{safe}"', + "ETag": artifact.content_hash, + }, + ) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/capabilities/__init__.py b/ai2apps/capabilities/__init__.py new file mode 100644 index 00000000..cdadc38a --- /dev/null +++ b/ai2apps/capabilities/__init__.py @@ -0,0 +1,30 @@ +"""Capability policy, approval, and GrantLease subsystem.""" + +from .models import ( + CapabilityDecision, + CapabilityPolicyRecord, + CapabilityRequestRecord, + CapabilityRequestStatus, + GrantLeaseRecord, + GrantScope, + PolicyEffect, +) +from .policy import CapabilityPolicyEngine +from .repository import CapabilityRepository +from .risk import action_preview, operation_class, risk_level, sanitize_value + +__all__ = [ + "CapabilityDecision", + "CapabilityPolicyEngine", + "CapabilityPolicyRecord", + "CapabilityRequestRecord", + "CapabilityRequestStatus", + "CapabilityRepository", + "action_preview", + "operation_class", + "risk_level", + "sanitize_value", + "GrantLeaseRecord", + "GrantScope", + "PolicyEffect", +] diff --git a/ai2apps/capabilities/models.py b/ai2apps/capabilities/models.py new file mode 100644 index 00000000..15fd115b --- /dev/null +++ b/ai2apps/capabilities/models.py @@ -0,0 +1,109 @@ +"""Durable capability policy and GrantLease contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + + +class PolicyEffect(StrEnum): + ALLOW = "allow" + DENY = "deny" + REQUIRE_APPROVAL = "require_approval" + + +class GrantScope(StrEnum): + RUN = "run" + SESSION = "session" + AGENT = "agent" + APP = "app" + + +class CapabilityRequestStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +@dataclass(frozen=True, slots=True) +class CapabilityPolicyRecord: + id: str + policy_key: str + effect: PolicyEffect + capability_pattern: str + agent_pattern: str + tool_pattern: str + priority: int + enabled: bool + source: str + conditions: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class GrantLeaseRecord: + id: str + scope: GrantScope + scope_id: str + agent_definition_id: str | None + session_id: str + app_instance_id: str + capabilities: tuple[str, ...] + tool_pattern: str + tool_service_digest: str | None + resource_selector: dict[str, Any] + issued_by: str + evidence: dict[str, Any] + expires_at: datetime | None + revoked_at: datetime | None + revoke_reason: str | None + created_at: datetime + updated_at: datetime + + @property + def active(self) -> bool: + return self.revoked_at is None and ( + self.expires_at is None or self.expires_at > datetime.now(self.expires_at.tzinfo) + ) + + +@dataclass(frozen=True, slots=True) +class CapabilityRequestRecord: + id: str + subject_kind: str + app_instance_id: str + session_id: str + run_id: str | None + capabilities: tuple[str, ...] + tool_name: str + effects: tuple[str, ...] + resource_selector: dict[str, Any] + reason: str + risk_level: str + status: CapabilityRequestStatus + requested_by: str + decision_scope: str | None + decision_evidence: dict[str, Any] + grant_lease_id: str | None + deadline_at: datetime + revision: int + created_at: datetime + updated_at: datetime + resolved_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class CapabilityDecision: + effect: PolicyEffect + source: str + capabilities: tuple[str, ...] + allowed_capabilities: tuple[str, ...] + matched_policy_ids: tuple[str, ...] = () + matched_lease_ids: tuple[str, ...] = () + evidence: dict[str, Any] | None = None diff --git a/ai2apps/capabilities/policy.py b/ai2apps/capabilities/policy.py new file mode 100644 index 00000000..bfb8046f --- /dev/null +++ b/ai2apps/capabilities/policy.py @@ -0,0 +1,131 @@ +"""Fail-closed capability policy evaluation with an optional AI auditor.""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from typing import Any + +from .models import CapabilityDecision, PolicyEffect +from .repository import CapabilityRepository +from .risk import sanitize_value + +Auditor = Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] + + +class CapabilityPolicyEngine: + def __init__(self, repository: CapabilityRepository) -> None: + self.repository = repository + self._auditor: Auditor | None = None + + def bind_ai_auditor(self, auditor: Auditor | None) -> None: + """Bind an independent auditor; invalid/error results fail closed to approval.""" + self._auditor = auditor + + async def evaluate( + self, + *, + run_id: str, + agent_key: str, + tool_name: str, + capabilities: tuple[str, ...], + effects: tuple[str, ...], + arguments: dict[str, Any], + ) -> CapabilityDecision: + leases = self.repository.active_leases_for_run(run_id, tool_name, arguments) + leased = { + capability + for lease in leases + for capability in lease.capabilities + if capability in capabilities + } + unresolved = tuple(sorted(set(capabilities) - leased)) + if not unresolved: + return CapabilityDecision( + PolicyEffect.ALLOW, + "grant_lease", + capabilities, + tuple(sorted(leased)), + matched_lease_ids=tuple(x.id for x in leases), + evidence={"lease_count": len(leases)}, + ) + + matched = [] + effects_by_capability: dict[str, PolicyEffect] = {} + for capability in unresolved: + policies = self.repository.matching_policies( + agent_key=agent_key, tool_name=tool_name, capability=capability + ) + if policies: + top_priority = policies[0].priority + top = tuple(p for p in policies if p.priority == top_priority) + matched.extend(top) + # A deny wins ties; otherwise explicit approval wins allow ties. + values = {p.effect for p in top} + effects_by_capability[capability] = ( + PolicyEffect.DENY + if PolicyEffect.DENY in values + else PolicyEffect.REQUIRE_APPROVAL + if PolicyEffect.REQUIRE_APPROVAL in values + else PolicyEffect.ALLOW + ) + else: + effects_by_capability[capability] = PolicyEffect.REQUIRE_APPROVAL + if PolicyEffect.DENY in effects_by_capability.values(): + effect = PolicyEffect.DENY + elif PolicyEffect.REQUIRE_APPROVAL in effects_by_capability.values(): + effect = PolicyEffect.REQUIRE_APPROVAL + else: + effect = PolicyEffect.ALLOW + evidence: dict[str, Any] = { + "policy_effects": { + key: value.value for key, value in effects_by_capability.items() + } + } + source = "policy" + + if effect is PolicyEffect.REQUIRE_APPROVAL and self._auditor is not None: + request = { + "run_id": run_id, + "agent_key": agent_key, + "tool_name": tool_name, + "capabilities": unresolved, + "effects": effects, + "arguments": sanitize_value(arguments), + } + try: + result = self._auditor(request) + if inspect.isawaitable(result): + result = await result + auditor_effect = PolicyEffect( + result.get("decision", "require_approval") + ) + # AI may narrow approval to deny or allow, never override explicit deny. + effect = auditor_effect + source = "ai_auditor" + evidence["ai_auditor"] = { + "decision": auditor_effect.value, + "reason": str(result.get("reason", "")), + "evidence": result.get("evidence", {}), + } + except Exception as exc: + evidence["ai_auditor"] = { + "decision": "require_approval", + "error": type(exc).__name__, + } + effect = PolicyEffect.REQUIRE_APPROVAL + + return CapabilityDecision( + effect, + source, + capabilities, + tuple( + sorted( + leased + | (set(unresolved) if effect is PolicyEffect.ALLOW else set()) + ) + ), + matched_policy_ids=tuple(dict.fromkeys(p.id for p in matched)), + matched_lease_ids=tuple(x.id for x in leases), + evidence=evidence, + ) diff --git a/ai2apps/capabilities/repository.py b/ai2apps/capabilities/repository.py new file mode 100644 index 00000000..18f0087f --- /dev/null +++ b/ai2apps/capabilities/repository.py @@ -0,0 +1,842 @@ +"""Persistence and matching for capability policy and GrantLeases.""" + +from __future__ import annotations + +import fnmatch +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .models import ( + CapabilityPolicyRecord, + CapabilityRequestRecord, + CapabilityRequestStatus, + GrantLeaseRecord, + GrantScope, + PolicyEffect, +) +from .risk import risk_level as classify_risk_level + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _time(value: str | None): + return None if value is None else parse_utc(value) + + +class CapabilityRepository: + def __init__(self, database: PlatformDatabase, events: EventStore) -> None: + self.database = database + self.events = events + + @staticmethod + def _policy(row) -> CapabilityPolicyRecord: + return CapabilityPolicyRecord( + id=row["id"], + policy_key=row["policy_key"], + effect=PolicyEffect(row["effect"]), + capability_pattern=row["capability_pattern"], + agent_pattern=row["agent_pattern"], + tool_pattern=row["tool_pattern"], + priority=row["priority"], + enabled=bool(row["enabled"]), + source=row["source"], + conditions=json.loads(row["conditions_json"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _lease(row) -> GrantLeaseRecord: + return GrantLeaseRecord( + id=row["id"], + scope=GrantScope(row["scope"]), + scope_id=row["scope_id"], + agent_definition_id=row["agent_definition_id"], + session_id=row["session_id"], + app_instance_id=row["app_instance_id"], + capabilities=tuple(json.loads(row["capabilities_json"])), + tool_pattern=row["tool_pattern"], + tool_service_digest=row["tool_service_digest"], + resource_selector=json.loads(row["resource_selector_json"]), + issued_by=row["issued_by"], + evidence=json.loads(row["evidence_json"]), + expires_at=_time(row["expires_at"]), + revoked_at=_time(row["revoked_at"]), + revoke_reason=row["revoke_reason"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _request(row) -> CapabilityRequestRecord: + return CapabilityRequestRecord( + id=row["id"], + subject_kind=row["subject_kind"], + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + run_id=row["run_id"], + capabilities=tuple(json.loads(row["capabilities_json"])), + tool_name=row["tool_name"], + effects=tuple(json.loads(row["effects_json"])), + resource_selector=json.loads(row["resource_selector_json"]), + reason=row["reason"], + risk_level=row["risk_level"], + status=CapabilityRequestStatus(row["status"]), + requested_by=row["requested_by"], + decision_scope=row["decision_scope"], + decision_evidence=json.loads(row["decision_evidence_json"]), + grant_lease_id=row["grant_lease_id"], + deadline_at=parse_utc(row["deadline_at"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + resolved_at=_time(row["resolved_at"]), + ) + + def ensure_builtin_defaults(self) -> None: + self.upsert_policy( + policy_key="builtin.default-require-approval", + effect=PolicyEffect.REQUIRE_APPROVAL, + capability_pattern="*", + agent_pattern="*", + tool_pattern="*", + priority=-1000, + source="builtin", + ) + + def upsert_policy( + self, + *, + policy_key: str, + effect: PolicyEffect, + capability_pattern: str, + agent_pattern: str = "*", + tool_pattern: str = "*", + priority: int = 0, + source: str = "local", + conditions: dict[str, Any] | None = None, + ) -> CapabilityPolicyRecord: + if not all((policy_key, capability_pattern, agent_pattern, tool_pattern)): + raise ValueError("Policy keys and patterns cannot be empty") + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM capability_policies WHERE policy_key = ?", (policy_key,) + ).fetchone() + if row is None: + policy_id = new_entity_id(EntityIdKind.CAPABILITY_POLICY) + connection.execute( + """INSERT INTO capability_policies( + id, policy_key, effect, capability_pattern, agent_pattern, + tool_pattern, priority, source, conditions_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + policy_id, + policy_key, + effect.value, + capability_pattern, + agent_pattern, + tool_pattern, + priority, + source, + _json(conditions or {}), + now, + now, + ), + ) + else: + policy_id = row["id"] + connection.execute( + """UPDATE capability_policies SET effect = ?, capability_pattern = ?, + agent_pattern = ?, tool_pattern = ?, priority = ?, source = ?, + conditions_json = ?, revision = revision + 1, updated_at = ? + WHERE id = ?""", + ( + effect.value, + capability_pattern, + agent_pattern, + tool_pattern, + priority, + source, + _json(conditions or {}), + now, + policy_id, + ), + ) + result = connection.execute( + "SELECT * FROM capability_policies WHERE id = ?", (policy_id,) + ).fetchone() + assert result is not None + return self._policy(result) + + def list_policies(self) -> tuple[CapabilityPolicyRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM capability_policies ORDER BY priority DESC, policy_key" + ).fetchall() + return tuple(self._policy(row) for row in rows) + + def matching_policies( + self, *, agent_key: str, tool_name: str, capability: str + ) -> tuple[CapabilityPolicyRecord, ...]: + return tuple( + policy + for policy in self.list_policies() + if policy.enabled + and fnmatch.fnmatchcase(agent_key, policy.agent_pattern) + and fnmatch.fnmatchcase(tool_name, policy.tool_pattern) + and fnmatch.fnmatchcase(capability, policy.capability_pattern) + ) + + def create_lease( + self, + *, + run_id: str, + scope: GrantScope, + capabilities: tuple[str, ...], + tool_pattern: str, + issued_by: str, + evidence: dict[str, Any], + expires_at: datetime | None = None, + resource_selector: dict[str, Any] | None = None, + ) -> GrantLeaseRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + run = connection.execute( + """SELECT r.*, s.app_instance_id FROM agent_runs r + JOIN sessions s ON s.id = r.session_id WHERE r.id = ?""", + (run_id,), + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + scope_id = { + GrantScope.RUN: run_id, + GrantScope.SESSION: run["session_id"], + GrantScope.AGENT: run["agent_definition_id"], + GrantScope.APP: run["app_instance_id"], + }[scope] + lease_id = new_entity_id(EntityIdKind.GRANT_LEASE) + tool_row = connection.execute( + """SELECT s.active_package_digest FROM tool_descriptors t + JOIN service_descriptors s ON s.id = t.service_id + WHERE t.qualified_name = ?""", + (tool_pattern,), + ).fetchone() + tool_service_digest = None if tool_row is None else tool_row[0] + connection.execute( + """INSERT INTO grant_leases( + id, scope, scope_id, agent_definition_id, session_id, + app_instance_id, capabilities_json, tool_pattern, tool_service_digest, + resource_selector_json, issued_by, evidence_json, expires_at, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + lease_id, + scope.value, + scope_id, + run["agent_definition_id"], + run["session_id"], + run["app_instance_id"], + _json(sorted(set(capabilities))), + tool_pattern, + tool_service_digest, + _json(resource_selector or {}), + issued_by, + _json(evidence), + None + if expires_at is None + else expires_at.isoformat().replace("+00:00", "Z"), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.created", + subject_id=lease_id, + app_instance_id=run["app_instance_id"], + session_id=run["session_id"], + trace_id=run_id, + payload={ + "run_id": run_id, + "scope": scope.value, + "capabilities": sorted(set(capabilities)), + "tool_pattern": tool_pattern, + "issued_by": issued_by, + "evidence": evidence, + }, + ) + row = connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", (lease_id,) + ).fetchone() + assert row is not None + return self._lease(row) + + def active_leases_for_run( + self, run_id: str, tool_name: str, arguments: dict[str, Any] | None = None + ) -> tuple[GrantLeaseRecord, ...]: + now = utc_now_text() + with self.database.transaction() as connection: + run = connection.execute( + """SELECT r.id, r.agent_definition_id, r.session_id, s.app_instance_id + FROM agent_runs r JOIN sessions s ON s.id = r.session_id + WHERE r.id = ?""", + (run_id,), + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + rows = connection.execute( + """SELECT * FROM grant_leases WHERE revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + AND agent_definition_id = ? + AND ((scope = 'run' AND scope_id = ?) + OR (scope = 'session' AND scope_id = ?) + OR (scope = 'agent' AND scope_id = ?) + OR (scope = 'app' AND scope_id = ?)) + ORDER BY created_at""", + ( + now, + run["agent_definition_id"], + run["id"], + run["session_id"], + run["agent_definition_id"], + run["app_instance_id"], + ), + ).fetchall() + with self.database.transaction() as connection: + tool_row = connection.execute( + """SELECT s.active_package_digest FROM tool_descriptors t + JOIN service_descriptors s ON s.id = t.service_id + WHERE t.qualified_name = ?""", + (tool_name,), + ).fetchone() + current_digest = None if tool_row is None else tool_row[0] + stale_ids = [ + row["id"] for row in rows + if row["tool_service_digest"] is not None + and row["tool_service_digest"] != current_digest + ] + for lease_id in stale_ids: + self.revoke_lease(lease_id, reason="tool-version-changed") + + def resource_matches(row) -> bool: + selector = json.loads(row["resource_selector_json"]) + exact = selector.get("arguments", {}) + if not exact: + return True + supplied = arguments or {} + return all(supplied.get(key) == value for key, value in exact.items()) + + return tuple( + self._lease(row) + for row in rows + if row["id"] not in stale_ids + and fnmatch.fnmatchcase(tool_name, row["tool_pattern"]) + and resource_matches(row) + ) + + def consume_single_use_leases(self, lease_ids: tuple[str, ...]) -> tuple[str, ...]: + """Atomically consume approval leases issued for exactly one invocation.""" + + if not lease_ids: + return () + now = utc_now_text() + consumed: list[str] = [] + with self.database.transaction(write=True) as connection: + for lease_id in lease_ids: + row = connection.execute( + "SELECT * FROM grant_leases WHERE id = ? AND revoked_at IS NULL", + (lease_id,), + ).fetchone() + if row is None or not json.loads(row["evidence_json"]).get("single_use"): + continue + connection.execute( + """UPDATE grant_leases SET revoked_at = ?, revoke_reason = 'consumed', + updated_at = ? WHERE id = ?""", + (now, now, lease_id), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.consumed", + subject_id=lease_id, + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload={"reason": "single-use"}, + ) + consumed.append(lease_id) + return tuple(consumed) + + def expire_leases(self) -> int: + """Materialize time-based expiry so it is visible in the audit trail.""" + + now = utc_now_text() + with self.database.transaction(write=True) as connection: + rows = connection.execute( + """SELECT * FROM grant_leases WHERE revoked_at IS NULL + AND expires_at IS NOT NULL AND expires_at <= ?""", + (now,), + ).fetchall() + for row in rows: + connection.execute( + """UPDATE grant_leases SET revoked_at = ?, revoke_reason = 'expired', + updated_at = ? WHERE id = ?""", + (now, now, row["id"]), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.expired", + subject_id=row["id"], + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload={"reason": "expired"}, + ) + return len(rows) + + def list_leases( + self, *, include_inactive: bool = False + ) -> tuple[GrantLeaseRecord, ...]: + where = ( + "" + if include_inactive + else "WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)" + ) + params = () if include_inactive else (utc_now_text(),) + with self.database.transaction() as connection: + rows = connection.execute( + f"SELECT * FROM grant_leases {where} ORDER BY created_at DESC", params + ).fetchall() + return tuple(self._lease(row) for row in rows) + + def revoke_lease(self, lease_id: str, *, reason: str) -> GrantLeaseRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", (lease_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("grant_lease", lease_id) + if row["revoked_at"] is not None: + if row["revoke_reason"] == reason: + return self._lease(row) + raise ResourceConflictError("GrantLease is already revoked") + connection.execute( + """UPDATE grant_leases SET revoked_at = ?, revoke_reason = ?, + updated_at = ? WHERE id = ?""", + (now, reason, now, lease_id), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.revoked", + subject_id=lease_id, + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload={"reason": reason}, + ) + result = connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", (lease_id,) + ).fetchone() + assert result is not None + return self._lease(result) + + def revoke_all(self, *, reason: str) -> tuple[GrantLeaseRecord, ...]: + """Revoke every currently active lease as one auditable recovery action.""" + now = utc_now_text() + revoked_ids: list[str] = [] + with self.database.transaction(write=True) as connection: + rows = connection.execute( + """SELECT * FROM grant_leases WHERE revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?)""", + (now,), + ).fetchall() + for row in rows: + connection.execute( + """UPDATE grant_leases SET revoked_at = ?, revoke_reason = ?, + updated_at = ? WHERE id = ?""", + (now, reason, now, row["id"]), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.revoked", + subject_id=row["id"], + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload={"reason": reason, "recovery": True}, + ) + revoked_ids.append(row["id"]) + return tuple( + self._lease( + connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", (lease_id,) + ).fetchone() + ) + for lease_id in revoked_ids + ) + + @staticmethod + def risk_level(effects: tuple[str, ...]) -> str: + return classify_risk_level(effects) + + def create_app_request( + self, + *, + app_instance_id: str, + session_id: str, + capabilities: tuple[str, ...], + tool_name: str = "*", + effects: tuple[str, ...] = (), + resource_selector: dict[str, Any] | None = None, + reason: str, + requested_by: str = "app-bridge", + timeout_seconds: int = 600, + ) -> CapabilityRequestRecord: + capabilities = tuple(sorted({item.strip() for item in capabilities if item.strip()})) + if not capabilities: + raise ValueError("At least one capability is required") + if not reason.strip(): + raise ValueError("Capability reason cannot be empty") + if timeout_seconds < 30 or timeout_seconds > 3600: + raise ValueError("Capability request timeout must be between 30 and 3600 seconds") + request_id = new_entity_id(EntityIdKind.CAPABILITY_REQUEST) + now_dt = datetime.now(UTC) + now = now_dt.isoformat().replace("+00:00", "Z") + deadline = (now_dt + timedelta(seconds=timeout_seconds)).isoformat().replace( + "+00:00", "Z" + ) + selector = resource_selector or {} + with self.database.transaction(write=True) as connection: + scope = connection.execute( + """SELECT s.app_instance_id AS session_owner, + d.package_id, d.display_name + FROM app_instances i + JOIN app_definitions d ON d.id = i.app_definition_id + JOIN sessions s ON s.id = ? + WHERE i.id = ? AND i.status != 'closed' AND s.status = 'active'""", + (session_id, app_instance_id), + ).fetchone() + if scope is None: + raise ResourceNotFoundError("app_instance_or_session", app_instance_id) + allowed = scope["session_owner"] == app_instance_id or connection.execute( + """SELECT 1 FROM app_mounts WHERE app_instance_id = ? + AND interaction_session_id = ? AND status = 'mounted'""", + (app_instance_id, session_id), + ).fetchone() + if not allowed: + raise ResourceConflictError("Session is outside App scope") + connection.execute( + """INSERT INTO capability_requests( + id, subject_kind, app_instance_id, session_id, + capabilities_json, tool_name, effects_json, + resource_selector_json, reason, risk_level, requested_by, + deadline_at, created_at, updated_at + ) VALUES (?, 'app', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + request_id, + app_instance_id, + session_id, + _json(capabilities), + tool_name or "*", + _json(sorted(set(effects))), + _json(selector), + reason.strip(), + self.risk_level(effects), + requested_by, + deadline, + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="capability.request.created", + subject_id=request_id, + app_instance_id=app_instance_id, + session_id=session_id, + payload={ + "subject_kind": "app", + "capabilities": capabilities, + "tool_name": tool_name or "*", + "effects": sorted(set(effects)), + "resource_selector": selector, + "reason": reason.strip(), + "risk_level": self.risk_level(effects), + "requested_by": requested_by, + "deadline_at": deadline, + }, + ) + row = connection.execute( + "SELECT * FROM capability_requests WHERE id = ?", (request_id,) + ).fetchone() + assert row is not None + return self._request(row) + + def get_request(self, request_id: str) -> CapabilityRequestRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM capability_requests WHERE id = ?", (request_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("capability_request", request_id) + return self._request(row) + + def list_requests( + self, *, include_resolved: bool = False + ) -> tuple[CapabilityRequestRecord, ...]: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + expired = connection.execute( + """SELECT * FROM capability_requests + WHERE status = 'pending' AND deadline_at <= ?""", + (now,), + ).fetchall() + for row in expired: + connection.execute( + """UPDATE capability_requests SET status = 'expired', + revision = revision + 1, updated_at = ?, resolved_at = ? + WHERE id = ?""", + (now, now, row["id"]), + ) + self.events.append_in_transaction( + connection, + event_type="capability.request.expired", + subject_id=row["id"], + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload={"deadline_at": row["deadline_at"]}, + ) + query = "SELECT * FROM capability_requests" + if not include_resolved: + query += " WHERE status = 'pending'" + query += " ORDER BY created_at DESC" + rows = connection.execute(query).fetchall() + return tuple(self._request(row) for row in rows) + + def decide_app_request( + self, + request_id: str, + *, + decision: str, + scope: str = "once", + issued_by: str = "user", + duration_seconds: int | None = None, + resource_selector: dict[str, Any] | None = None, + ) -> tuple[CapabilityRequestRecord, GrantLeaseRecord | None]: + if decision not in {"approve", "deny"}: + raise ValueError("Decision must be approve or deny") + if scope not in {"once", "session", "app"}: + raise ValueError("App approval scope must be once, session, or app") + if duration_seconds is not None and not 60 <= duration_seconds <= 86400: + raise ValueError("Grant duration must be between 60 and 86400 seconds") + now_dt = datetime.now(UTC) + now = now_dt.isoformat().replace("+00:00", "Z") + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM capability_requests WHERE id = ?", (request_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("capability_request", request_id) + if row["subject_kind"] != "app": + raise ResourceConflictError("CapabilityRequest is not App-owned") + if row["status"] != "pending": + lease = None + if row["grant_lease_id"] is not None: + lease_row = connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", + (row["grant_lease_id"],), + ).fetchone() + lease = None if lease_row is None else self._lease(lease_row) + return self._request(row), lease + if row["deadline_at"] <= now: + connection.execute( + """UPDATE capability_requests SET status='expired', resolved_at=?, + updated_at=?, revision=revision+1 WHERE id=?""", + (now, now, request_id), + ) + raise ResourceConflictError("CapabilityRequest has expired") + evidence = { + "decision": decision, + "scope": scope, + "issued_by": issued_by, + "requested_resource": json.loads(row["resource_selector_json"]), + } + if decision == "deny": + connection.execute( + """UPDATE capability_requests SET status='denied', + decision_scope=?, decision_evidence_json=?, resolved_at=?, + updated_at=?, revision=revision+1 WHERE id=?""", + (scope, _json(evidence), now, now, request_id), + ) + self.events.append_in_transaction( + connection, + event_type="capability.decision.deny", + subject_id=request_id, + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload=evidence, + ) + resolved = connection.execute( + "SELECT * FROM capability_requests WHERE id = ?", (request_id,) + ).fetchone() + assert resolved is not None + return self._request(resolved), None + + lease_id = new_entity_id(EntityIdKind.GRANT_LEASE) + lease_scope = GrantScope.SESSION if scope == "session" else GrantScope.APP + scope_id = ( + row["session_id"] + if lease_scope is GrantScope.SESSION + else row["app_instance_id"] + ) + lifetime = 300 if scope == "once" and duration_seconds is None else duration_seconds + expires_at = None + if lifetime is not None: + expires_at = (now_dt + timedelta(seconds=lifetime)).isoformat().replace( + "+00:00", "Z" + ) + selector = resource_selector or json.loads(row["resource_selector_json"]) + tool_row = connection.execute( + """SELECT s.active_package_digest FROM tool_descriptors t + JOIN service_descriptors s ON s.id = t.service_id + WHERE t.qualified_name = ?""", + (row["tool_name"],), + ).fetchone() + tool_service_digest = None if tool_row is None else tool_row[0] + evidence["expires_at"] = expires_at + evidence["single_use"] = scope == "once" + connection.execute( + """INSERT INTO grant_leases( + id, scope, scope_id, agent_definition_id, session_id, + app_instance_id, capabilities_json, tool_pattern, + tool_service_digest, resource_selector_json, issued_by, + evidence_json, request_id, expires_at, created_at, updated_at + ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + lease_id, + lease_scope.value, + scope_id, + row["session_id"], + row["app_instance_id"], + row["capabilities_json"], + row["tool_name"], + tool_service_digest, + _json(selector), + issued_by, + _json(evidence), + request_id, + expires_at, + now, + now, + ), + ) + connection.execute( + """UPDATE capability_requests SET status='approved', + decision_scope=?, decision_evidence_json=?, grant_lease_id=?, + resolved_at=?, updated_at=?, revision=revision+1 WHERE id=?""", + (scope, _json(evidence), lease_id, now, now, request_id), + ) + for event_type, subject_id, payload in ( + ("capability.decision.allow", request_id, evidence), + ( + "capability.grant.created", + lease_id, + { + "request_id": request_id, + "scope": lease_scope.value, + "approval_mode": scope, + "capabilities": json.loads(row["capabilities_json"]), + "tool_pattern": row["tool_name"], + "issued_by": issued_by, + "evidence": evidence, + }, + ), + ): + self.events.append_in_transaction( + connection, + event_type=event_type, + subject_id=subject_id, + app_instance_id=row["app_instance_id"], + session_id=row["session_id"], + payload=payload, + ) + resolved = connection.execute( + "SELECT * FROM capability_requests WHERE id = ?", (request_id,) + ).fetchone() + lease = connection.execute( + "SELECT * FROM grant_leases WHERE id = ?", (lease_id,) + ).fetchone() + assert resolved is not None and lease is not None + return self._request(resolved), self._lease(lease) + + def record_decision( + self, + *, + run_id: str, + interaction_id: str | None, + decision: PolicyEffect, + source: str, + capabilities: tuple[str, ...], + tool_name: str, + effects: tuple[str, ...], + matched_policy_ids: tuple[str, ...] = (), + evidence: dict[str, Any] | None = None, + ) -> None: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + decision_id = new_entity_id(EntityIdKind.CAPABILITY_DECISION) + run = connection.execute( + """SELECT r.session_id, s.app_instance_id FROM agent_runs r + JOIN sessions s ON s.id = r.session_id WHERE r.id = ?""", + (run_id,), + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + connection.execute( + """INSERT INTO capability_decisions( + id, run_id, interaction_id, decision, decision_source, + capabilities_json, tool_name, effects_json, + matched_policy_ids_json, evidence_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + decision_id, + run_id, + interaction_id, + decision.value, + source, + _json(capabilities), + tool_name, + _json(effects), + _json(matched_policy_ids), + _json(evidence or {}), + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type=f"capability.decision.{decision.value}", + subject_id=run_id, + app_instance_id=run["app_instance_id"], + session_id=run["session_id"], + trace_id=run_id, + payload={ + "decision_id": decision_id, + "source": source, + "capabilities": capabilities, + "tool_name": tool_name, + "matched_policy_ids": matched_policy_ids, + "evidence": evidence or {}, + }, + ) diff --git a/ai2apps/capabilities/risk.py b/ai2apps/capabilities/risk.py new file mode 100644 index 00000000..3c030e4c --- /dev/null +++ b/ai2apps/capabilities/risk.py @@ -0,0 +1,99 @@ +"""Deterministic risk classification and user-facing Tool action previews.""" + +from __future__ import annotations + +from typing import Any + +_SENSITIVE_FRAGMENTS = ( + "password", "passwd", "secret", "token", "api_key", "apikey", + "authorization", "cookie", "credential", "private_key", +) +_RESOURCE_KEYS = ( + "path", "url", "target", "selector", "name", "id", "destination", + "repository", "branch", "recipient", "channel", +) + + +def is_sensitive_key(key: str) -> bool: + normalized = key.lower().replace("-", "_") + return any(fragment in normalized for fragment in _SENSITIVE_FRAGMENTS) + + +def sanitize_value(value: Any, *, key: str = "") -> Any: + """Produce a bounded value safe for approval UI, events, and audit prompts.""" + + if key and is_sensitive_key(key): + return "[secret]" + if isinstance(value, dict): + return { + str(k): sanitize_value(v, key=str(k)) + for k, v in list(value.items())[:40] + } + if isinstance(value, (list, tuple)): + return [sanitize_value(item) for item in list(value)[:40]] + if isinstance(value, str): + if value.startswith("secret://"): + return value + return value if len(value) <= 240 else value[:237] + "..." + return value + + +def operation_class(effects: tuple[str, ...]) -> str: + values = {value.lower() for value in effects} + if values & {"destructive", "delete", "purchase", "payment"}: + return "destructive" + if values & {"external", "network", "export", "upload", "send", "publish"}: + return "external" + if values & { + "write", "write-host", "execute", "process", "clipboard", + "host-control", "privileged", "credential", + }: + return "write" + return "read" + + +def risk_level(effects: tuple[str, ...]) -> str: + category = operation_class(effects) + values = {value.lower() for value in effects} + if category == "destructive" or values & {"privileged", "credential"}: + return "critical" + if values & {"network", "send", "publish", "execute", "process", "host-control"}: + return "high" + if category in {"write", "external"}: + return "medium" + return "low" + + +def resource_selector(arguments: dict[str, Any]) -> dict[str, Any]: + """Bind reusable consent to the concrete resources shown to the user.""" + + selected = { + key: sanitize_value(arguments[key], key=key) + for key in _RESOURCE_KEYS + if key in arguments and not is_sensitive_key(key) + } + return {} if not selected else {"arguments": selected} + + +def action_preview( + tool_name: str, + effects: tuple[str, ...], + arguments: dict[str, Any], +) -> dict[str, Any]: + safe_arguments = sanitize_value(arguments) + resources = [ + f"{key}={safe_arguments[key]}" + for key in _RESOURCE_KEYS + if key in safe_arguments + ][:3] + summary = tool_name + (f" ({', '.join(resources)})" if resources else "") + category = operation_class(effects) + return { + "tool_name": tool_name, + "operation_class": category, + "risk_level": risk_level(effects), + "reversible": category not in {"destructive", "external"}, + "summary": summary, + "arguments": safe_arguments, + "resource_selector": resource_selector(arguments), + } diff --git a/ai2apps/processes/__init__.py b/ai2apps/processes/__init__.py new file mode 100644 index 00000000..cfadcd1e --- /dev/null +++ b/ai2apps/processes/__init__.py @@ -0,0 +1,41 @@ +"""Sandboxed Process Service and Host Broker authority.""" + +from .authority import BrokerAuthority, BrokerEnvelope +from .manager import ProcessManager, SecretProvider +from .models import ( + ProcessLimits, + ProcessLogRecord, + ProcessRecord, + ProcessServiceError, + ProcessStatus, +) +from .repository import ProcessRepository +from .sandbox import ( + LinuxBubblewrapAdapter, + MacOSSandboxAdapter, + ProcessSandboxAdapter, + SandboxLaunch, + TestSandboxAdapter, + default_sandbox_adapter, +) +from .service import install_process_service + +__all__ = [ + "BrokerAuthority", + "BrokerEnvelope", + "LinuxBubblewrapAdapter", + "MacOSSandboxAdapter", + "ProcessLimits", + "ProcessLogRecord", + "ProcessManager", + "ProcessRecord", + "ProcessRepository", + "ProcessSandboxAdapter", + "ProcessServiceError", + "ProcessStatus", + "SandboxLaunch", + "SecretProvider", + "TestSandboxAdapter", + "default_sandbox_adapter", + "install_process_service", +] diff --git a/ai2apps/processes/authority.py b/ai2apps/processes/authority.py new file mode 100644 index 00000000..ccc1f428 --- /dev/null +++ b/ai2apps/processes/authority.py @@ -0,0 +1,84 @@ +"""Short-lived authenticated Host Broker request envelopes.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + + +@dataclass(frozen=True, slots=True) +class BrokerEnvelope: + token: str + nonce: str + expires_at: datetime + token_digest: str + + +class BrokerAuthority: + def __init__(self, secret: bytes | None = None) -> None: + self._secret = secret or secrets.token_bytes(32) + + def issue( + self, + *, + request_id: str, + session_id: str, + run_id: str | None, + operation: str, + ttl_seconds: int = 30, + ) -> BrokerEnvelope: + nonce = secrets.token_hex(16) + expires = datetime.now(UTC) + timedelta(seconds=ttl_seconds) + payload = { + "request_id": request_id, + "session_id": session_id, + "run_id": run_id, + "operation": operation, + "nonce": nonce, + "expires_at": expires.isoformat(), + } + encoded = ( + base64.urlsafe_b64encode( + json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + ) + .decode() + .rstrip("=") + ) + signature = hmac.new(self._secret, encoded.encode(), hashlib.sha256).hexdigest() + token = f"{encoded}.{signature}" + return BrokerEnvelope( + token, + nonce, + expires, + f"sha256:{hashlib.sha256(token.encode()).hexdigest()}", + ) + + def verify( + self, token: str, *, session_id: str, run_id: str | None, operation: str + ) -> dict: + try: + encoded, signature = token.split(".", 1) + expected = hmac.new( + self._secret, encoded.encode(), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(signature, expected): + raise ValueError("signature") + padded = encoded + "=" * (-len(encoded) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + expires = datetime.fromisoformat(payload["expires_at"]) + except Exception as exc: + raise PermissionError("Invalid Host Broker authorization") from exc + if datetime.now(UTC) >= expires: + raise PermissionError("Expired Host Broker authorization") + if ( + payload.get("session_id") != session_id + or payload.get("run_id") != run_id + or payload.get("operation") != operation + ): + raise PermissionError("Host Broker scope mismatch") + return payload diff --git a/ai2apps/processes/manager.py b/ai2apps/processes/manager.py new file mode 100644 index 00000000..70465829 --- /dev/null +++ b/ai2apps/processes/manager.py @@ -0,0 +1,672 @@ +"""Asynchronous, Session-owned, resource-bounded process execution.""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import platform +import resource +import shutil +import signal +from collections.abc import Mapping +from contextlib import suppress +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Protocol + +import psutil + +from ai2apps.config import ( + DEFAULT_PROCESS_CPU_TIME_SECONDS, + DEFAULT_PROCESS_IDLE_TIME_SECONDS, + DEFAULT_PROCESS_MEMORY_LIMIT_BYTES, + DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES, + DEFAULT_PROCESS_WALL_TIME_SECONDS, + DEFAULT_SESSION_PROCESS_LIMIT, +) +from ai2apps.core import EntityIdKind, new_entity_id +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase +from ai2apps.workspace import WorkspaceRepository + +from .authority import BrokerAuthority +from .models import ProcessLimits, ProcessRecord, ProcessServiceError, ProcessStatus +from .repository import ProcessRepository +from .sandbox import ProcessSandboxAdapter, default_sandbox_adapter + +_MAX_ARGV_ITEMS = 64 +_MAX_ARG_BYTES = 32 * 1024 +_MAX_STDIN_BYTES = 64 * 1024 +_READ_CHUNK_BYTES = 16 * 1024 +_LITERAL_ENV_KEYS = frozenset({"LANG", "LC_ALL", "TZ", "TERM"}) +_TERMINATION_GRACE_SECONDS = 1.0 + + +class SecretProvider(Protocol): + """Resolve an opaque secret reference without storing its value in SQLite.""" + + def resolve( + self, reference: str, *, session_id: str, run_id: str | None + ) -> str: ... + + +@dataclass(slots=True) +class _LiveProcess: + process: asyncio.subprocess.Process + record: ProcessRecord + tasks: tuple[asyncio.Task[None], ...] + + +class ProcessManager: + def __init__( + self, + database: PlatformDatabase, + events: EventStore, + workspace: WorkspaceRepository, + *, + sandbox: ProcessSandboxAdapter | None = None, + broker: BrokerAuthority | None = None, + secrets: SecretProvider | None = None, + session_limit: int = DEFAULT_SESSION_PROCESS_LIMIT, + ) -> None: + if session_limit <= 0: + raise ValueError("session_limit must be positive") + self.repository = ProcessRepository(database, events) + self.workspace = workspace + self.sandbox = sandbox or default_sandbox_adapter() + self.broker = broker or BrokerAuthority() + self.secrets = secrets + self.session_limit = session_limit + self._live: dict[str, _LiveProcess] = {} + self._output_locks: dict[str, asyncio.Lock] = {} + self._loop: asyncio.AbstractEventLoop | None = None + self._stopping = False + + async def startup(self) -> int: + self._loop = asyncio.get_running_loop() + self._stopping = False + await asyncio.to_thread(self._reap_previous_runtime) + return await asyncio.to_thread(self.repository.recover_orphans) + + def _reap_previous_runtime(self) -> None: + """Kill only stale process groups whose PID birth time matches our record.""" + + for record in self.repository.active(): + if record.pid is None or record.started_at is None: + continue + try: + process = psutil.Process(record.pid) + same_process = ( + abs(process.create_time() - record.started_at.timestamp()) < 5.0 + ) + if same_process and os.getpgid(record.pid) == record.pid: + os.killpg(record.pid, signal.SIGKILL) + except (psutil.Error, ProcessLookupError, PermissionError, OSError): + continue + + async def shutdown(self) -> None: + self._stopping = True + await asyncio.gather( + *( + self.cancel( + item.record.id, + session_id=item.record.session_id, + run_id=item.record.run_id, + ) + for item in tuple(self._live.values()) + ), + return_exceptions=True, + ) + self._loop = None + + @staticmethod + def _limits(values: Mapping[str, object] | None) -> ProcessLimits: + raw = values or {} + + def bounded(name: str, default: int, maximum: int) -> int: + value = raw.get(name, default) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ProcessServiceError("invalid_limits", f"{name} must be positive") + return min(value, maximum) + + return ProcessLimits( + wall_time_seconds=bounded( + "wall_time_seconds", + DEFAULT_PROCESS_WALL_TIME_SECONDS, + DEFAULT_PROCESS_WALL_TIME_SECONDS, + ), + idle_time_seconds=bounded( + "idle_time_seconds", + DEFAULT_PROCESS_IDLE_TIME_SECONDS, + DEFAULT_PROCESS_IDLE_TIME_SECONDS, + ), + cpu_time_seconds=bounded( + "cpu_time_seconds", + DEFAULT_PROCESS_CPU_TIME_SECONDS, + DEFAULT_PROCESS_CPU_TIME_SECONDS, + ), + memory_bytes=bounded( + "memory_bytes", + DEFAULT_PROCESS_MEMORY_LIMIT_BYTES, + DEFAULT_PROCESS_MEMORY_LIMIT_BYTES, + ), + output_bytes=bounded( + "output_bytes", + DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES, + DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES, + ), + ) + + @staticmethod + def _argv(argv: object) -> tuple[str, ...]: + if ( + not isinstance(argv, list) + or not 1 <= len(argv) <= _MAX_ARGV_ITEMS + or not all( + isinstance(item, str) and item and "\x00" not in item for item in argv + ) + or sum(len(item.encode("utf-8")) for item in argv) > _MAX_ARG_BYTES + ): + raise ProcessServiceError( + "invalid_argv", "argv must be a bounded non-empty string array" + ) + return tuple(argv) + + def _environment( + self, + values: Mapping[str, object] | None, + *, + session_id: str, + run_id: str | None, + workspace: Path, + temporary: Path, + ) -> dict[str, str]: + search_roots = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] + for root in ("/opt/homebrew/bin", "/usr/local/bin"): + if Path(root).is_dir(): + search_roots.append(root) + result = { + "PATH": ":".join(search_roots), + "TMPDIR": str(temporary), + "HOME": str(workspace), + "AI2APPS_SESSION_ID": session_id, + "AI2APPS_WORKSPACE": str(workspace), + } + for key, value in (values or {}).items(): + if ( + not isinstance(key, str) + or not key + or "\x00" in key + or not (key in _LITERAL_ENV_KEYS or key.startswith("AI2APPS_")) + ): + raise ProcessServiceError( + "environment_denied", f"Environment key is not allowed: {key!r}" + ) + if isinstance(value, str): + resolved = value + elif isinstance(value, dict) and set(value) == {"secret_ref"}: + reference = value["secret_ref"] + if ( + not isinstance(reference, str) + or not reference + or self.secrets is None + ): + raise ProcessServiceError( + "secret_unavailable", + f"Secret reference for {key} cannot be resolved", + ) + resolved = self.secrets.resolve( + reference, session_id=session_id, run_id=run_id + ) + else: + raise ProcessServiceError( + "invalid_environment", f"Environment value for {key} is invalid" + ) + if "\x00" in resolved or len(resolved.encode("utf-8")) > 16 * 1024: + raise ProcessServiceError( + "invalid_environment", f"Environment value for {key} is invalid" + ) + result[key] = resolved + return result + + @staticmethod + def _resolve_executable( + argv: tuple[str, ...], environment: Mapping[str, str], workspace: Path + ) -> tuple[str, ...]: + executable = argv[0] + if "/" not in executable: + executable = shutil.which(executable, path=environment["PATH"]) or "" + path = Path(executable) + if not executable or not path.is_absolute() or not path.is_file(): + raise ProcessServiceError( + "executable_not_found", f"Executable not found: {argv[0]}" + ) + resolved = path.resolve() + allowed = any( + resolved == root or root in resolved.parents + for root in ( + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/opt/homebrew"), + Path("/usr/local"), + workspace.resolve(), + ) + ) + if not allowed: + raise ProcessServiceError( + "executable_denied", + "Executable must be system-provided or in the Session workspace", + ) + if not os.access(resolved, os.X_OK): + raise ProcessServiceError( + "executable_denied", "Executable is not executable" + ) + return (str(resolved), *argv[1:]) + + @staticmethod + def _resource_limiter(limits: ProcessLimits): + def apply() -> None: + def set_limit(kind: int, value: int) -> None: + try: + _soft, hard = resource.getrlimit(kind) + bounded = ( + value if hard == resource.RLIM_INFINITY else min(value, hard) + ) + resource.setrlimit(kind, (bounded, bounded)) + except (OSError, ValueError): + # Some kernels expose but do not implement every RLIMIT. + pass + + set_limit(resource.RLIMIT_CPU, limits.cpu_time_seconds) + set_limit(resource.RLIMIT_FSIZE, limits.output_bytes) + if platform.system() == "Darwin": + set_limit(resource.RLIMIT_RSS, limits.memory_bytes) + else: + set_limit(resource.RLIMIT_AS, limits.memory_bytes) + + return apply + + async def start( + self, + *, + session_id: str, + run_id: str | None, + caller_id: str, + argv: object, + cwd: str = ".", + environment: Mapping[str, object] | None = None, + network_enabled: bool = False, + limits: Mapping[str, object] | None = None, + ) -> ProcessRecord: + if self._stopping: + raise ProcessServiceError("service_stopping", "Process Service is stopping") + if self.repository.active_count(session_id) >= self.session_limit: + raise ProcessServiceError( + "session_process_limit", "Session concurrent Process limit reached" + ) + process_argv = self._argv(argv) + process_limits = self._limits(limits) + self.workspace.ensure_sandbox(session_id) + workspace = self.workspace._root(session_id).resolve(strict=True) + temporary = self.workspace._temporary_root(session_id).resolve(strict=True) + process_cwd = self.workspace._resolve(session_id, cwd) + if not process_cwd.is_dir(): + raise ProcessServiceError( + "invalid_cwd", "cwd must be a workspace directory" + ) + process_environment = self._environment( + environment, + session_id=session_id, + run_id=run_id, + workspace=workspace, + temporary=temporary, + ) + process_argv = self._resolve_executable( + process_argv, process_environment, workspace + ) + launch = self.sandbox.wrap( + process_argv, + workspace, + temporary, + process_cwd, + network_enabled=bool(network_enabled), + ) + if not launch.enforced: + raise ProcessServiceError( + "sandbox_unavailable", "Process sandbox is not enforced" + ) + record = self.repository.create( + session_id=session_id, + run_id=run_id, + caller_id=caller_id, + argv=process_argv, + cwd=cwd, + environment_keys=tuple(sorted(process_environment)), + sandbox_backend=launch.backend, + network_enabled=bool(network_enabled), + limits=process_limits, + ) + request_id = new_entity_id(EntityIdKind.BROKER_REQUEST) + envelope = self.broker.issue( + request_id=request_id, + session_id=session_id, + run_id=run_id, + operation="process.spawn", + ) + self.repository.issue_broker_request( + request_id=request_id, + process_id=record.id, + session_id=session_id, + run_id=run_id, + operation="process.spawn", + nonce=envelope.nonce, + token_digest=envelope.token_digest, + expires_at=envelope.expires_at, + evidence={"sandbox": launch.backend, "argv0": process_argv[0]}, + ) + try: + self.broker.verify( + envelope.token, + session_id=session_id, + run_id=run_id, + operation="process.spawn", + ) + child = await asyncio.create_subprocess_exec( + *launch.argv, + cwd=launch.cwd, + env=process_environment, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + preexec_fn=self._resource_limiter(process_limits), + ) + except BaseException as error: + self.repository.resolve_broker_request(request_id, "denied") + self.repository.settle( + record.id, + ProcessStatus.FAILED, + exit_code=None, + error={"code": "spawn_failed", "message": str(error)}, + ) + if isinstance(error, asyncio.CancelledError): + raise + if isinstance(error, ProcessServiceError): + raise + raise ProcessServiceError("spawn_failed", str(error)) from error + self.repository.resolve_broker_request(request_id, "accepted") + record = self.repository.mark_running(record.id, child.pid) + readers = ( + asyncio.create_task( + self._read_output(record.id, "stdout", child.stdout), + name=f"{record.id}-stdout", + ), + asyncio.create_task( + self._read_output(record.id, "stderr", child.stderr), + name=f"{record.id}-stderr", + ), + ) + watchdog = asyncio.create_task( + self._watchdog(record.id), name=f"{record.id}-watchdog" + ) + waiter = asyncio.create_task(self._wait(record.id), name=f"{record.id}-wait") + self._live[record.id] = _LiveProcess( + child, record, (*readers, watchdog, waiter) + ) + self._output_locks[record.id] = asyncio.Lock() + return record + + def _owned( + self, process_id: str, *, session_id: str, run_id: str | None + ) -> ProcessRecord: + record = self.repository.get(process_id, session_id=session_id) + if record.run_id is not None and record.run_id != run_id: + # Do not reveal whether a different Run owns this Process. + raise ProcessServiceError("process_not_found", "Process not found") + return record + + async def _read_output( + self, process_id: str, stream: str, reader: asyncio.StreamReader | None + ) -> None: + if reader is None: + return + try: + while data := await reader.read(_READ_CHUNK_BYTES): + lock = self._output_locks.setdefault(process_id, asyncio.Lock()) + async with lock: + record = await asyncio.to_thread(self.repository.get, process_id) + remaining = max( + 0, record.limits["output_bytes"] - record.output_bytes + ) + captured = data[:remaining] + try: + content = captured.decode("utf-8") + encoding = "utf-8" + except UnicodeDecodeError: + content = base64.b64encode(captured).decode("ascii") + encoding = "base64" + if captured: + await asyncio.to_thread( + self.repository.append_log, + process_id, + stream, + encoding, + content, + len(captured), + ) + limit_reached = len(captured) < len(data) or not remaining + if limit_reached: + await self._terminate( + process_id, + ProcessStatus.OUTPUT_LIMIT, + {"code": "output_limit_exceeded"}, + ) + return + except asyncio.CancelledError: + raise + except Exception: + await self._terminate( + process_id, + ProcessStatus.FAILED, + {"code": "output_capture_failed"}, + ) + + async def _watchdog(self, process_id: str) -> None: + while process_id in self._live: + await asyncio.sleep(0.1) + record = await asyncio.to_thread(self.repository.get, process_id) + if record.status.terminal: + return + now = datetime.now(UTC) + live = self._live.get(process_id) + if live is not None: + try: + rss = psutil.Process(live.process.pid).memory_info().rss + except psutil.Error: + rss = 0 + if rss > record.limits["memory_bytes"]: + await self._terminate( + process_id, + ProcessStatus.FAILED, + {"code": "memory_limit_exceeded"}, + ) + return + if (now - record.created_at).total_seconds() >= record.limits[ + "wall_time_seconds" + ]: + await self._terminate( + process_id, ProcessStatus.TIMED_OUT, {"code": "wall_time_exceeded"} + ) + return + if (now - record.last_activity_at).total_seconds() >= record.limits[ + "idle_time_seconds" + ]: + await self._terminate( + process_id, + ProcessStatus.IDLE_TIMEOUT, + {"code": "idle_time_exceeded"}, + ) + return + + async def _wait(self, process_id: str) -> None: + live = self._live.get(process_id) + if live is None: + # start() installs the map immediately after creating this task. + await asyncio.sleep(0) + live = self._live.get(process_id) + if live is None: + return + return_code = await live.process.wait() + for task in live.tasks[:2]: + with suppress(asyncio.CancelledError): + await task + record = await asyncio.to_thread(self.repository.get, process_id) + if not record.status.terminal: + status = ProcessStatus.EXITED if return_code == 0 else ProcessStatus.FAILED + await asyncio.to_thread( + self.repository.settle, + process_id, + status, + exit_code=return_code, + error=None if return_code == 0 else {"code": "nonzero_exit"}, + ) + current = asyncio.current_task() + for task in live.tasks: + if task is not current and not task.done(): + task.cancel() + self._live.pop(process_id, None) + self._output_locks.pop(process_id, None) + + async def _terminate( + self, process_id: str, status: ProcessStatus, error: dict[str, str] + ) -> ProcessRecord: + live = self._live.get(process_id) + settled = await asyncio.to_thread( + self.repository.settle, + process_id, + status, + exit_code=None, + error=error, + ) + if live is not None and live.process.returncode is None: + with suppress(ProcessLookupError): + os.killpg(live.process.pid, signal.SIGTERM) + try: + await asyncio.wait_for(live.process.wait(), _TERMINATION_GRACE_SECONDS) + except TimeoutError: + with suppress(ProcessLookupError): + os.killpg(live.process.pid, signal.SIGKILL) + await live.process.wait() + return settled + + async def write_stdin( + self, + process_id: str, + data: str, + *, + session_id: str, + run_id: str | None, + close: bool = False, + ) -> ProcessRecord: + record = self._owned(process_id, session_id=session_id, run_id=run_id) + encoded = data.encode("utf-8") + if len(encoded) > _MAX_STDIN_BYTES: + raise ProcessServiceError("stdin_limit", "stdin write exceeds 64 KiB") + live = self._live.get(process_id) + if ( + record.status is not ProcessStatus.RUNNING + or live is None + or live.process.stdin is None + ): + raise ProcessServiceError("process_not_running", "Process is not running") + if not record.stdin_open: + raise ProcessServiceError("stdin_closed", "Process stdin is closed") + if encoded: + live.process.stdin.write(encoded) + await live.process.stdin.drain() + if close: + live.process.stdin.close() + with suppress(BrokenPipeError, ConnectionResetError): + await live.process.stdin.wait_closed() + await asyncio.to_thread(self.repository.touch, process_id, stdin_open=not close) + return await asyncio.to_thread(self.repository.get, process_id) + + async def cancel( + self, process_id: str, *, session_id: str, run_id: str | None + ) -> ProcessRecord: + record = self._owned(process_id, session_id=session_id, run_id=run_id) + if record.status.terminal: + return record + return await self._terminate( + process_id, ProcessStatus.CANCELLED, {"code": "cancelled"} + ) + + def status( + self, process_id: str, *, session_id: str, run_id: str | None + ) -> ProcessRecord: + return self._owned(process_id, session_id=session_id, run_id=run_id) + + async def wait( + self, + process_id: str, + *, + session_id: str, + run_id: str | None, + timeout_ms: int = 30_000, + ) -> ProcessRecord: + if timeout_ms <= 0 or timeout_ms > 300_000: + raise ProcessServiceError( + "invalid_wait_timeout", "timeout_ms must be between 1 and 300000" + ) + + async def terminal() -> ProcessRecord: + while True: + record = await asyncio.to_thread( + self._owned, + process_id, + session_id=session_id, + run_id=run_id, + ) + if record.status.terminal: + return record + await asyncio.sleep(0.05) + + try: + async with asyncio.timeout(timeout_ms / 1_000): + return await terminal() + except TimeoutError as error: + raise ProcessServiceError( + "process_wait_timeout", "Process did not finish before timeout" + ) from error + + def logs( + self, + process_id: str, + *, + session_id: str, + run_id: str | None, + after: int = 0, + limit: int = 200, + ): + self._owned(process_id, session_id=session_id, run_id=run_id) + return self.repository.logs(process_id, after=after, limit=limit) + + async def cancel_run(self, run_id: str) -> None: + records = await asyncio.to_thread(self.repository.active_for_run, run_id) + await asyncio.gather( + *( + self.cancel(record.id, session_id=record.session_id, run_id=run_id) + for record in records + ), + return_exceptions=True, + ) + + def schedule_cancel_by_run(self, run_id: str) -> None: + if self._loop is None or self._loop.is_closed(): + return + self._loop.call_soon_threadsafe( + lambda: asyncio.create_task(self.cancel_run(run_id)) + ) diff --git a/ai2apps/processes/models.py b/ai2apps/processes/models.py new file mode 100644 index 00000000..b69fac64 --- /dev/null +++ b/ai2apps/processes/models.py @@ -0,0 +1,85 @@ +"""Sandboxed Process Service contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + + +class ProcessStatus(StrEnum): + STARTING = "starting" + RUNNING = "running" + EXITED = "exited" + FAILED = "failed" + CANCELLED = "cancelled" + TIMED_OUT = "timed_out" + IDLE_TIMEOUT = "idle_timeout" + OUTPUT_LIMIT = "output_limit" + ORPHANED = "orphaned" + + @property + def terminal(self) -> bool: + return self not in {ProcessStatus.STARTING, ProcessStatus.RUNNING} + + +@dataclass(frozen=True, slots=True) +class ProcessLimits: + wall_time_seconds: int + idle_time_seconds: int + cpu_time_seconds: int + memory_bytes: int + output_bytes: int + + def to_json(self) -> dict[str, int]: + return { + "wall_time_seconds": self.wall_time_seconds, + "idle_time_seconds": self.idle_time_seconds, + "cpu_time_seconds": self.cpu_time_seconds, + "memory_bytes": self.memory_bytes, + "output_bytes": self.output_bytes, + } + + +@dataclass(frozen=True, slots=True) +class ProcessRecord: + id: str + session_id: str + run_id: str | None + caller_id: str + status: ProcessStatus + argv: tuple[str, ...] + cwd: str + environment_keys: tuple[str, ...] + sandbox_backend: str + network_enabled: bool + pid: int | None + exit_code: int | None + limits: dict[str, int] + stdin_open: bool + output_bytes: int + last_activity_at: datetime + error: dict[str, Any] | None + created_at: datetime + updated_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class ProcessLogRecord: + id: str + process_id: str + sequence: int + stream: str + encoding: str + content: str + byte_count: int + created_at: datetime + + +class ProcessServiceError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + self.code = code + super().__init__(message) diff --git a/ai2apps/processes/repository.py b/ai2apps/processes/repository.py new file mode 100644 index 00000000..01b2157d --- /dev/null +++ b/ai2apps/processes/repository.py @@ -0,0 +1,370 @@ +"""Durable Process execution, bounded logs, and broker audit records.""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + ResourceNotFoundError, + format_utc, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .models import ProcessLimits, ProcessLogRecord, ProcessRecord, ProcessStatus + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _time(value: str | None): + return None if value is None else parse_utc(value) + + +class ProcessRepository: + def __init__(self, database: PlatformDatabase, events: EventStore) -> None: + self.database = database + self.events = events + + @staticmethod + def _record(row) -> ProcessRecord: + return ProcessRecord( + id=row["id"], + session_id=row["session_id"], + run_id=row["run_id"], + caller_id=row["caller_id"], + status=ProcessStatus(row["status"]), + argv=tuple(json.loads(row["argv_json"])), + cwd=row["cwd"], + environment_keys=tuple(json.loads(row["environment_keys_json"])), + sandbox_backend=row["sandbox_backend"], + network_enabled=bool(row["network_enabled"]), + pid=row["pid"], + exit_code=row["exit_code"], + limits=json.loads(row["limits_json"]), + stdin_open=bool(row["stdin_open"]), + output_bytes=row["output_bytes"], + last_activity_at=parse_utc(row["last_activity_at"]), + error=None if row["error_json"] is None else json.loads(row["error_json"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + started_at=_time(row["started_at"]), + finished_at=_time(row["finished_at"]), + ) + + @staticmethod + def _log(row) -> ProcessLogRecord: + return ProcessLogRecord( + id=row["id"], + process_id=row["process_id"], + sequence=row["sequence"], + stream=row["stream"], + encoding=row["encoding"], + content=row["content"], + byte_count=row["byte_count"], + created_at=parse_utc(row["created_at"]), + ) + + def create( + self, + *, + session_id: str, + run_id: str | None, + caller_id: str, + argv: tuple[str, ...], + cwd: str, + environment_keys: tuple[str, ...], + sandbox_backend: str, + network_enabled: bool, + limits: ProcessLimits, + ) -> ProcessRecord: + process_id = new_entity_id(EntityIdKind.PROCESS_EXECUTION) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ? AND status = 'active'", + (session_id,), + ).fetchone() + if session is None: + raise ResourceNotFoundError("session", session_id) + connection.execute( + """INSERT INTO process_executions( + id, session_id, run_id, caller_id, status, argv_json, cwd, + environment_keys_json, sandbox_backend, network_enabled, + limits_json, last_activity_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + process_id, + session_id, + run_id, + caller_id, + _json(argv), + cwd, + _json(environment_keys), + sandbox_backend, + int(network_enabled), + _json(limits.to_json()), + now, + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="process.starting", + subject_id=process_id, + app_instance_id=session["app_instance_id"], + session_id=session_id, + trace_id=run_id, + payload={ + "argv": argv, + "cwd": cwd, + "sandbox": sandbox_backend, + "network_enabled": network_enabled, + "limits": limits.to_json(), + }, + ) + row = connection.execute( + "SELECT * FROM process_executions WHERE id = ?", (process_id,) + ).fetchone() + assert row is not None + return self._record(row) + + def mark_running(self, process_id: str, pid: int) -> ProcessRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE process_executions SET status = 'running', pid = ?, + started_at = ?, last_activity_at = ?, updated_at = ? WHERE id = ?""", + (pid, now, now, now, process_id), + ) + return self._get_in_transaction(connection, process_id) + + def get( + self, + process_id: str, + *, + session_id: str | None = None, + run_id: str | None = None, + ) -> ProcessRecord: + query = "SELECT * FROM process_executions WHERE id = ?" + params: list[Any] = [process_id] + if session_id is not None: + query += " AND session_id = ?" + params.append(session_id) + if run_id is not None: + query += " AND run_id = ?" + params.append(run_id) + with self.database.transaction() as connection: + row = connection.execute(query, params).fetchone() + if row is None: + raise ResourceNotFoundError("process", process_id) + return self._record(row) + + def _get_in_transaction(self, connection, process_id: str) -> ProcessRecord: + row = connection.execute( + "SELECT * FROM process_executions WHERE id = ?", (process_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("process", process_id) + return self._record(row) + + def active_count(self, session_id: str) -> int: + with self.database.transaction() as connection: + return int( + connection.execute( + """SELECT COUNT(*) FROM process_executions + WHERE session_id = ? AND status IN ('starting', 'running')""", + (session_id,), + ).fetchone()[0] + ) + + def append_log( + self, process_id: str, stream: str, encoding: str, content: str, byte_count: int + ) -> int: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT session_id, run_id, output_bytes FROM process_executions WHERE id = ?", + (process_id,), + ).fetchone() + if row is None: + raise ResourceNotFoundError("process", process_id) + sequence = int( + connection.execute( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM process_log_chunks WHERE process_id = ?", + (process_id,), + ).fetchone()[0] + ) + connection.execute( + """INSERT INTO process_log_chunks( + id, process_id, sequence, stream, encoding, content, + byte_count, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + new_entity_id(EntityIdKind.PROCESS_LOG), + process_id, + sequence, + stream, + encoding, + content, + byte_count, + now, + ), + ) + connection.execute( + """UPDATE process_executions SET output_bytes = output_bytes + ?, + last_activity_at = ?, updated_at = ? WHERE id = ?""", + (byte_count, now, now, process_id), + ) + return row["output_bytes"] + byte_count + + def logs(self, process_id: str, *, after: int = 0, limit: int = 200): + with self.database.transaction() as connection: + rows = connection.execute( + """SELECT * FROM process_log_chunks WHERE process_id = ? AND sequence > ? + ORDER BY sequence LIMIT ?""", + (process_id, after, limit), + ).fetchall() + return tuple(self._log(row) for row in rows) + + def touch(self, process_id: str, *, stdin_open: bool | None = None) -> None: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + if stdin_open is None: + connection.execute( + "UPDATE process_executions SET last_activity_at = ?, updated_at = ? WHERE id = ?", + (now, now, process_id), + ) + else: + connection.execute( + """UPDATE process_executions SET stdin_open = ?, last_activity_at = ?, + updated_at = ? WHERE id = ?""", + (int(stdin_open), now, now, process_id), + ) + + def settle( + self, + process_id: str, + status: ProcessStatus, + *, + exit_code: int | None, + error: dict[str, Any] | None = None, + ) -> ProcessRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + current = self._get_in_transaction(connection, process_id) + if current.status.terminal: + return current + connection.execute( + """UPDATE process_executions SET status = ?, exit_code = ?, + stdin_open = 0, error_json = ?, finished_at = ?, updated_at = ? + WHERE id = ?""", + ( + status.value, + exit_code, + None if error is None else _json(error), + now, + now, + process_id, + ), + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (current.session_id,), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type=f"process.{status.value}", + subject_id=process_id, + app_instance_id=session["app_instance_id"], + session_id=current.session_id, + trace_id=current.run_id, + payload={ + "exit_code": exit_code, + "error": error, + "output_bytes": current.output_bytes, + }, + ) + return self._get_in_transaction(connection, process_id) + + def active_for_run(self, run_id: str) -> tuple[ProcessRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + """SELECT * FROM process_executions WHERE run_id = ? + AND status IN ('starting', 'running')""", + (run_id,), + ).fetchall() + return tuple(self._record(row) for row in rows) + + def active(self) -> tuple[ProcessRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + """SELECT * FROM process_executions + WHERE status IN ('starting', 'running')""" + ).fetchall() + return tuple(self._record(row) for row in rows) + + def recover_orphans(self) -> int: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + rows = connection.execute( + """SELECT id FROM process_executions + WHERE status IN ('starting', 'running')""" + ).fetchall() + for row in rows: + connection.execute( + """UPDATE process_executions SET status = 'orphaned', stdin_open = 0, + error_json = ?, finished_at = ?, updated_at = ? WHERE id = ?""", + (_json({"code": "runtime_restarted"}), now, now, row["id"]), + ) + return len(rows) + + def issue_broker_request( + self, + *, + request_id: str, + process_id: str | None, + session_id: str, + run_id: str | None, + operation: str, + nonce: str, + token_digest: str, + expires_at: datetime, + evidence: dict[str, Any], + ) -> None: + with self.database.transaction(write=True) as connection: + connection.execute( + """INSERT INTO host_broker_requests( + id, process_id, session_id, run_id, operation, nonce, + token_digest, status, expires_at, evidence_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)""", + ( + request_id, + process_id, + session_id, + run_id, + operation, + nonce, + token_digest, + format_utc(expires_at), + _json(evidence), + utc_now_text(), + ), + ) + + def resolve_broker_request(self, request_id: str, status: str) -> None: + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE host_broker_requests SET status = ?, resolved_at = ? + WHERE id = ? AND status = 'issued'""", + (status, utc_now_text(), request_id), + ) diff --git a/ai2apps/processes/sandbox.py b/ai2apps/processes/sandbox.py new file mode 100644 index 00000000..430d9987 --- /dev/null +++ b/ai2apps/processes/sandbox.py @@ -0,0 +1,152 @@ +"""Portable command wrapping for enforced macOS and Linux process sandboxes.""" + +from __future__ import annotations + +import json +import platform +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from .models import ProcessServiceError + + +@dataclass(frozen=True, slots=True) +class SandboxLaunch: + argv: tuple[str, ...] + cwd: Path + backend: str + enforced: bool + + +class ProcessSandboxAdapter(Protocol): + name: str + + def wrap( + self, + argv: tuple[str, ...], + workspace: Path, + temporary: Path, + cwd: Path, + *, + network_enabled: bool, + ) -> SandboxLaunch: ... + + +class MacOSSandboxAdapter: + name = "macos-seatbelt" + + def __init__(self, sandbox_exec: str = "/usr/bin/sandbox-exec") -> None: + self.sandbox_exec = sandbox_exec + + def wrap(self, argv, workspace, temporary, cwd, *, network_enabled): + if not Path(self.sandbox_exec).is_file(): + raise ProcessServiceError( + "sandbox_unavailable", "macOS sandbox-exec is unavailable" + ) + policy_directory = workspace.parent / "policy" + policy_directory.mkdir(parents=True, exist_ok=True) + policy = policy_directory / "process.sb" + writable = [workspace.resolve(), temporary.resolve()] + readable = [ + Path("/System"), + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/Library"), + Path("/private/var/db"), + Path("/dev"), + Path("/opt/homebrew"), + Path("/usr/local"), + ] + lines = [ + "(version 1)", + "(deny default)", + '(import "system.sb")', + "(allow process*)", + "(allow sysctl-read)", + "(allow mach-lookup)", + "(allow file-read-metadata)", + ] + for path in readable + writable: + if path.exists(): + lines.append(f"(allow file-read* (subpath {json.dumps(str(path))}))") + for path in writable: + lines.append(f"(allow file-write* (subpath {json.dumps(str(path))}))") + lines.append("(allow network*)" if network_enabled else "(deny network*)") + policy.write_text("\n".join(lines) + "\n", encoding="utf-8") + return SandboxLaunch( + (self.sandbox_exec, "-f", str(policy), "--", *argv), + cwd, + self.name, + True, + ) + + +class LinuxBubblewrapAdapter: + name = "linux-bubblewrap" + + def __init__(self, executable: str | None = None) -> None: + self.executable = executable or shutil.which("bwrap") + + def wrap(self, argv, workspace, temporary, cwd, *, network_enabled): + if self.executable is None: + raise ProcessServiceError( + "sandbox_unavailable", "Linux Process Service requires bubblewrap" + ) + command = [ + self.executable, + "--die-with-parent", + "--new-session", + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ] + if not network_enabled: + command.append("--unshare-net") + for root in ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"): + if Path(root).exists(): + command.extend(("--ro-bind", root, root)) + command.extend( + ( + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--bind", + str(workspace), + str(workspace), + "--bind", + str(temporary), + str(temporary), + "--chdir", + str(cwd), + "--", + *argv, + ) + ) + return SandboxLaunch(tuple(command), cwd, self.name, True) + + +class TestSandboxAdapter: + """Explicit test double; production selection never chooses this adapter.""" + + name = "test-sandbox" + + def wrap(self, argv, workspace, temporary, cwd, *, network_enabled): + return SandboxLaunch(tuple(argv), cwd, self.name, True) + + +def default_sandbox_adapter() -> ProcessSandboxAdapter: + system = platform.system() + if system == "Darwin": + return MacOSSandboxAdapter() + if system == "Linux": + return LinuxBubblewrapAdapter() + raise ProcessServiceError( + "sandbox_unavailable", f"No enforced Process sandbox for {system}" + ) diff --git a/ai2apps/processes/service.py b/ai2apps/processes/service.py new file mode 100644 index 00000000..ea43a6bf --- /dev/null +++ b/ai2apps/processes/service.py @@ -0,0 +1,360 @@ +"""Built-in Process Service Tool descriptors and provider bindings.""" + +from __future__ import annotations + +from typing import Any + +from ai2apps.services import ( + ServiceInstanceStatus, + ServiceRegistry, + ServiceRepository, + ServiceRuntimeMode, + ToolCallContext, + ToolProviderError, +) + +from .manager import ProcessManager +from .models import ProcessServiceError + +OBJECT = {"type": "object"} + + +def _scope(context: ToolCallContext) -> tuple[str, str | None]: + if context.session_id is None: + raise ToolProviderError("Process Tools require a Session") + run_id = ( + context.trace_id + if context.trace_id and context.trace_id.startswith("run_") + else None + ) + return context.session_id, run_id + + +def _record(record) -> dict[str, Any]: + return { + "id": record.id, + "session_id": record.session_id, + "run_id": record.run_id, + "status": record.status.value, + "argv": list(record.argv), + "cwd": record.cwd, + "sandbox_backend": record.sandbox_backend, + "network_enabled": record.network_enabled, + "pid": record.pid, + "exit_code": record.exit_code, + "limits": record.limits, + "stdin_open": record.stdin_open, + "output_bytes": record.output_bytes, + "error": record.error, + "created_at": record.created_at.isoformat(), + "started_at": None + if record.started_at is None + else record.started_at.isoformat(), + "finished_at": None + if record.finished_at is None + else record.finished_at.isoformat(), + } + + +def install_process_service( + manager: ProcessManager, + repository: ServiceRepository, + registry: ServiceRegistry, +) -> None: + service = repository.ensure_service( + service_key="ai2apps.process", + package_id="ai2apps.process", + package_version="1.0.0", + display_name="AI2Apps Sandboxed Process Service", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("process", "sandbox", "host-broker"), + ) + instance = repository.ensure_instance( + service_id=service.id, + provider_key="builtin:process", + status=ServiceInstanceStatus.RUNNING, + endpoint="/v1/platform/sessions/{session_id}/processes", + health={"status": "ok", "sandbox": manager.sandbox.name}, + ) + + async def call(operation, *args, **kwargs): + try: + return await operation(*args, **kwargs) + except ProcessServiceError as error: + raise ToolProviderError(f"{error.code}: {error}") from error + + async def process_start(arguments, context): + session_id, run_id = _scope(context) + await context.report_progress("Starting sandboxed process", progress=0.25) + record = await call( + manager.start, + session_id=session_id, + run_id=run_id, + caller_id=context.caller_id, + argv=arguments["argv"], + cwd=arguments.get("cwd", "."), + environment=arguments.get("environment"), + network_enabled=arguments.get("network", False), + limits=arguments.get("limits"), + ) + await context.report_progress( + "Sandboxed process started", + progress=1.0, + content={"process_id": record.id, "status": record.status.value}, + ) + return _record(record) + + async def process_write_stdin(arguments, context): + session_id, run_id = _scope(context) + return _record( + await call( + manager.write_stdin, + arguments["process_id"], + arguments.get("data", ""), + session_id=session_id, + run_id=run_id, + close=arguments.get("close", False), + ) + ) + + async def process_status(arguments, context): + session_id, run_id = _scope(context) + try: + return _record( + manager.status( + arguments["process_id"], session_id=session_id, run_id=run_id + ) + ) + except ProcessServiceError as error: + raise ToolProviderError(f"{error.code}: {error}") from error + + async def process_logs(arguments, context): + session_id, run_id = _scope(context) + try: + logs = manager.logs( + arguments["process_id"], + session_id=session_id, + run_id=run_id, + after=arguments.get("after", 0), + limit=arguments.get("limit", 200), + ) + except ProcessServiceError as error: + raise ToolProviderError(f"{error.code}: {error}") from error + return { + "items": [ + { + "sequence": item.sequence, + "stream": item.stream, + "encoding": item.encoding, + "content": item.content, + "byte_count": item.byte_count, + "created_at": item.created_at.isoformat(), + } + for item in logs + ] + } + + async def process_wait(arguments, context): + session_id, run_id = _scope(context) + await context.report_progress("Waiting for sandboxed process") + record = await call( + manager.wait, + arguments["process_id"], + session_id=session_id, + run_id=run_id, + timeout_ms=arguments.get("timeout_ms", 30_000), + ) + await context.report_progress( + f"Sandboxed process finished: {record.status.value}", + progress=1.0, + content={"process_id": record.id, "status": record.status.value}, + ) + return _record(record) + + async def process_cancel(arguments, context): + session_id, run_id = _scope(context) + await context.report_progress("Cancelling sandboxed process", progress=0.5) + result = _record( + await call( + manager.cancel, + arguments["process_id"], + session_id=session_id, + run_id=run_id, + ) + ) + await context.report_progress("Sandboxed process cancelled", progress=1.0) + return result + + process_id_schema = { + "type": "object", + "properties": {"process_id": {"type": "string", "minLength": 1}}, + "required": ["process_id"], + "additionalProperties": False, + } + limits_properties = { + "wall_time_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "idle_time_seconds": {"type": "integer", "minimum": 1, "maximum": 60}, + "cpu_time_seconds": {"type": "integer", "minimum": 1, "maximum": 120}, + "memory_bytes": {"type": "integer", "minimum": 16777216, "maximum": 1073741824}, + "output_bytes": {"type": "integer", "minimum": 1024, "maximum": 4194304}, + } + start_schema = { + "type": "object", + "properties": { + "argv": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": {"type": "string", "minLength": 1}, + }, + "cwd": {"type": "string"}, + "environment": { + "type": "object", + "propertyNames": {"type": "string", "minLength": 1}, + "additionalProperties": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "secret_ref": {"type": "string", "minLength": 1} + }, + "required": ["secret_ref"], + "additionalProperties": False, + }, + ] + }, + }, + "network": {"type": "boolean"}, + "limits": { + "type": "object", + "properties": limits_properties, + "additionalProperties": False, + }, + }, + "required": ["argv"], + "additionalProperties": False, + } + write_schema = { + "type": "object", + "properties": { + "process_id": {"type": "string", "minLength": 1}, + "data": {"type": "string", "maxLength": 65536}, + "close": {"type": "boolean"}, + }, + "required": ["process_id"], + "additionalProperties": False, + } + logs_schema = { + "type": "object", + "properties": { + "process_id": {"type": "string", "minLength": 1}, + "after": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + }, + "required": ["process_id"], + "additionalProperties": False, + } + wait_schema = { + "type": "object", + "properties": { + "process_id": {"type": "string", "minLength": 1}, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "maximum": 300000, + }, + }, + "required": ["process_id"], + "additionalProperties": False, + } + tools = ( + ( + "process.start", + "Start sandboxed process", + "Start an argv-only Process in the Session workspace sandbox.", + start_schema, + ("process",), + ("process.execute",), + ( + { + "when": {"property": "network", "equals": True}, + "require": ["network.outbound"], + }, + ), + process_start, + ), + ( + "process.write_stdin", + "Write process stdin", + "Write bounded UTF-8 input to a Process owned by this Session and Run.", + write_schema, + ("process",), + ("process.execute",), + (), + process_write_stdin, + ), + ( + "process.status", + "Get process status", + "Read status for a Process owned by this Session and Run.", + process_id_schema, + (), + ("process.execute",), + (), + process_status, + ), + ( + "process.logs", + "Read process logs", + "Read bounded stdout/stderr chunks for a Process.", + logs_schema, + (), + ("process.execute",), + (), + process_logs, + ), + ( + "process.wait", + "Wait for process", + "Wait for a Process to finish without unbounded polling.", + wait_schema, + (), + ("process.execute",), + (), + process_wait, + ), + ( + "process.cancel", + "Cancel process", + "Terminate the complete Process group owned by this Session and Run.", + process_id_schema, + ("process",), + ("process.execute",), + (), + process_cancel, + ), + ) + for ( + name, + title, + description, + schema, + effects, + capabilities, + rules, + handler, + ) in tools: + repository.ensure_tool( + service_id=service.id, + qualified_name=name, + display_name=title, + description=description, + input_schema=schema, + output_schema=OBJECT, + effects=effects, + required_capabilities=capabilities, + capability_rules=rules, + timeout_ms=300_000 if name == "process.wait" else 30_000, + ) + registry.bind_tool(name, provider_key=instance.provider_key, handler=handler) diff --git a/ai2apps/secrets/__init__.py b/ai2apps/secrets/__init__.py new file mode 100644 index 00000000..f6827afd --- /dev/null +++ b/ai2apps/secrets/__init__.py @@ -0,0 +1,25 @@ +"""Keychain-backed secret management.""" + +from .backends import ( + EncryptedFileSecretBackend, + LinuxSecretServiceBackend, + MacOSKeychainBackend, + MemorySecretBackend, + SecretBackend, + SecretBackendError, +) +from .factory import ( + create_secret_backend, + register_secret_backend, + select_secret_backend_name, +) +from .models import SecretInjection, SecretRecord +from .repository import SecretRepository + +__all__ = [ + "EncryptedFileSecretBackend", "LinuxSecretServiceBackend", + "MacOSKeychainBackend", "MemorySecretBackend", "SecretBackend", + "SecretBackendError", "create_secret_backend", "register_secret_backend", + "select_secret_backend_name", + "SecretInjection", "SecretRecord", "SecretRepository", +] diff --git a/ai2apps/secrets/backends.py b/ai2apps/secrets/backends.py new file mode 100644 index 00000000..1096f8ad --- /dev/null +++ b/ai2apps/secrets/backends.py @@ -0,0 +1,339 @@ +"""Platform-independent secret-value backend contract and built-in providers.""" + +from __future__ import annotations + +import base64 +import ctypes +import json +import os +import platform +import secrets +import shutil +import subprocess +import threading +from hashlib import sha256 +from pathlib import Path +from typing import Protocol + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class SecretBackendError(RuntimeError): + """A provider is unavailable, locked, or cannot complete an operation.""" + + +class SecretBackend(Protocol): + provider_name: str + + def store(self, key: str, value: str) -> None: ... + def load(self, key: str) -> str: ... + def delete(self, key: str) -> None: ... + + +class MacOSKeychainBackend: + provider_name = "macos-keychain" + + _ERR_SEC_ITEM_NOT_FOUND = -25300 + _ERR_SEC_DUPLICATE_ITEM = -25299 + + def __init__(self, *, service: str = "AI2Apps Secret Store") -> None: + self.service = service + if platform.system() != "Darwin": + raise SecretBackendError("macOS Keychain is only available on Darwin") + self._security = ctypes.CDLL( + "/System/Library/Frameworks/Security.framework/Security" + ) + self._core_foundation = ctypes.CDLL( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + self._configure_functions() + + def _configure_functions(self) -> None: + void_p = ctypes.c_void_p + uint32 = ctypes.c_uint32 + self._security.SecKeychainAddGenericPassword.argtypes = [ + void_p, uint32, void_p, uint32, void_p, uint32, void_p, + ctypes.POINTER(void_p), + ] + self._security.SecKeychainAddGenericPassword.restype = ctypes.c_int32 + self._security.SecKeychainFindGenericPassword.argtypes = [ + void_p, uint32, void_p, uint32, void_p, + ctypes.POINTER(uint32), ctypes.POINTER(void_p), ctypes.POINTER(void_p), + ] + self._security.SecKeychainFindGenericPassword.restype = ctypes.c_int32 + self._security.SecKeychainItemModifyAttributesAndData.argtypes = [ + void_p, void_p, uint32, void_p, + ] + self._security.SecKeychainItemModifyAttributesAndData.restype = ctypes.c_int32 + self._security.SecKeychainItemDelete.argtypes = [void_p] + self._security.SecKeychainItemDelete.restype = ctypes.c_int32 + self._security.SecKeychainItemFreeContent.argtypes = [void_p, void_p] + self._security.SecKeychainItemFreeContent.restype = ctypes.c_int32 + self._core_foundation.CFRelease.argtypes = [void_p] + self._core_foundation.CFRelease.restype = None + + @staticmethod + def _buffer(value: str) -> tuple[bytes, ctypes.Array[ctypes.c_char]]: + encoded = value.encode("utf-8") + return encoded, ctypes.create_string_buffer(encoded) + + def _find_item( + self, key: str, *, include_password: bool = False + ) -> tuple[int, ctypes.c_void_p, bytes | None]: + service, service_buffer = self._buffer(self.service) + account, account_buffer = self._buffer(key) + item = ctypes.c_void_p() + password_length = ctypes.c_uint32() + password_data = ctypes.c_void_p() + status = self._security.SecKeychainFindGenericPassword( + None, + len(service), ctypes.cast(service_buffer, ctypes.c_void_p), + len(account), ctypes.cast(account_buffer, ctypes.c_void_p), + ctypes.byref(password_length) if include_password else None, + ctypes.byref(password_data) if include_password else None, + ctypes.byref(item), + ) + password = None + if status == 0 and include_password: + try: + password = ctypes.string_at(password_data, password_length.value) + finally: + self._security.SecKeychainItemFreeContent(None, password_data) + return status, item, password + + def _release(self, item: ctypes.c_void_p) -> None: + if item.value: + self._core_foundation.CFRelease(item) + + def store(self, key: str, value: str) -> None: + status, item, _ = self._find_item(key) + secret, secret_buffer = self._buffer(value) + if status == 0: + try: + status = self._security.SecKeychainItemModifyAttributesAndData( + item, None, len(secret), + ctypes.cast(secret_buffer, ctypes.c_void_p), + ) + finally: + self._release(item) + elif status == self._ERR_SEC_ITEM_NOT_FOUND: + service, service_buffer = self._buffer(self.service) + account, account_buffer = self._buffer(key) + created_item = ctypes.c_void_p() + status = self._security.SecKeychainAddGenericPassword( + None, + len(service), ctypes.cast(service_buffer, ctypes.c_void_p), + len(account), ctypes.cast(account_buffer, ctypes.c_void_p), + len(secret), ctypes.cast(secret_buffer, ctypes.c_void_p), + ctypes.byref(created_item), + ) + self._release(created_item) + else: + self._release(item) + if status == self._ERR_SEC_DUPLICATE_ITEM: + # Another process may have created the item between find and add. + return self.store(key, value) + if status: + raise SecretBackendError( + f"Unable to store secret in macOS Keychain (OSStatus {status})" + ) + + def load(self, key: str) -> str: + status, item, password = self._find_item(key, include_password=True) + self._release(item) + if status == self._ERR_SEC_ITEM_NOT_FOUND: + raise KeyError("Secret value is unavailable in macOS Keychain") + if status or password is None: + raise SecretBackendError( + f"Unable to load secret from macOS Keychain (OSStatus {status})" + ) + return password.decode("utf-8") + + def delete(self, key: str) -> None: + status, item, _ = self._find_item(key) + if status == self._ERR_SEC_ITEM_NOT_FOUND: + return + if status: + self._release(item) + raise SecretBackendError( + f"Unable to find secret in macOS Keychain (OSStatus {status})" + ) + try: + status = self._security.SecKeychainItemDelete(item) + finally: + self._release(item) + if status: + raise SecretBackendError( + f"Unable to delete secret from macOS Keychain (OSStatus {status})" + ) + + +class LinuxSecretServiceBackend: + """Freedesktop Secret Service adapter using the standard secret-tool CLI.""" + + provider_name = "linux-secret-service" + + def __init__(self, *, service: str = "ai2apps") -> None: + executable = shutil.which("secret-tool") + if executable is None: + raise SecretBackendError("secret-tool is not installed") + self.executable = executable + self.service = service + + def _run( + self, arguments: list[str], *, input_text: str | None = None + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [self.executable, *arguments], input=input_text, + capture_output=True, text=True, check=False, + ) + + def store(self, key: str, value: str) -> None: + result = self._run( + ["store", "--label", "AI2Apps Secret", "service", self.service, + "account", key], + input_text=value + "\n", + ) + if result.returncode: + raise SecretBackendError("Unable to store secret using Secret Service") + + def load(self, key: str) -> str: + result = self._run( + ["lookup", "service", self.service, "account", key] + ) + if result.returncode: + raise KeyError("Secret value is unavailable in Secret Service") + return result.stdout.rstrip("\n") + + def delete(self, key: str) -> None: + result = self._run( + ["clear", "service", self.service, "account", key] + ) + if result.returncode: + raise SecretBackendError("Unable to delete secret from Secret Service") + + +class EncryptedFileSecretBackend: + """AES-GCM vault for headless systems such as NVIDIA DGX Spark. + + A deployment may inject ``AI2APPS_SECRET_VAULT_KEY``. When absent, a + random machine-local key is generated with mode 0600. TPM/systemd/KMS + integrations can provide the environment key without changing this class. + """ + + provider_name = "encrypted-file" + _AAD = b"AI2Apps Secret Vault v1" + + def __init__( + self, + directory: str | Path, + *, + key_material: str | bytes | None = None, + ) -> None: + self.directory = Path(directory).expanduser().resolve() + self.vault_path = self.directory / "vault.aesgcm" + self.key_path = self.directory / "vault.key" + self._provided_key = key_material + self._lock = threading.RLock() + + @staticmethod + def _normalize_key(value: str | bytes) -> bytes: + raw = value.encode("utf-8") if isinstance(value, str) else value + try: + decoded = base64.urlsafe_b64decode(raw + b"=" * (-len(raw) % 4)) + if len(decoded) == 32: + return decoded + except ValueError: + pass + return sha256(raw).digest() + + def _key(self) -> bytes: + supplied = self._provided_key or os.environ.get("AI2APPS_SECRET_VAULT_KEY") + if supplied: + return self._normalize_key(supplied) + self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if self.key_path.exists(): + return base64.urlsafe_b64decode(self.key_path.read_bytes()) + key = AESGCM.generate_key(bit_length=256) + descriptor = os.open( + self.key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + with os.fdopen(descriptor, "wb") as stream: + stream.write(base64.urlsafe_b64encode(key)) + return key + + def _read(self) -> dict[str, str]: + if not self.vault_path.exists(): + return {} + payload = self.vault_path.read_bytes() + if len(payload) < 13 or payload[:1] != b"1": + raise SecretBackendError("Secret vault has an unsupported format") + try: + plaintext = AESGCM(self._key()).decrypt( + payload[1:13], payload[13:], self._AAD + ) + values = json.loads(plaintext) + except Exception as exc: + raise SecretBackendError("Secret vault is locked or corrupted") from exc + if not isinstance(values, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in values.items() + ): + raise SecretBackendError("Secret vault contains invalid data") + return values + + def _write(self, values: dict[str, str]) -> None: + self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + nonce = secrets.token_bytes(12) + plaintext = json.dumps( + values, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + payload = b"1" + nonce + AESGCM(self._key()).encrypt( + nonce, plaintext, self._AAD + ) + temporary = self.vault_path.with_suffix(".tmp") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 + ) + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.vault_path) + + def store(self, key: str, value: str) -> None: + with self._lock: + values = self._read() + values[key] = value + self._write(values) + + def load(self, key: str) -> str: + with self._lock: + try: + return self._read()[key] + except KeyError as exc: + raise KeyError("Secret value is unavailable in encrypted vault") from exc + + def delete(self, key: str) -> None: + with self._lock: + values = self._read() + if key in values: + del values[key] + self._write(values) + + +class MemorySecretBackend: + """Test backend; never selected by the production composition root.""" + + provider_name = "memory" + + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def store(self, key: str, value: str) -> None: + self.values[key] = value + + def load(self, key: str) -> str: + return self.values[key] + + def delete(self, key: str) -> None: + self.values.pop(key, None) diff --git a/ai2apps/secrets/factory.py b/ai2apps/secrets/factory.py new file mode 100644 index 00000000..f1ac7468 --- /dev/null +++ b/ai2apps/secrets/factory.py @@ -0,0 +1,73 @@ +"""Provider registry and platform-aware SecretBackend selection.""" + +from __future__ import annotations + +import os +import platform +import shutil +from collections.abc import Callable, Mapping +from pathlib import Path + +from .backends import ( + EncryptedFileSecretBackend, + LinuxSecretServiceBackend, + MacOSKeychainBackend, + SecretBackend, + SecretBackendError, +) + +BackendFactory = Callable[[Path], SecretBackend] +_PROVIDERS: dict[str, BackendFactory] = { + "macos-keychain": lambda _: MacOSKeychainBackend(), + "linux-secret-service": lambda _: LinuxSecretServiceBackend(), + "encrypted-file": lambda path: EncryptedFileSecretBackend(path), +} + + +def register_secret_backend( + name: str, factory: BackendFactory, *, replace: bool = False +) -> None: + """Register a Credential Manager, TPM, KMS, or plugin-owned provider.""" + + normalized = name.strip().lower() + if not normalized: + raise ValueError("Secret backend name cannot be empty") + if normalized in _PROVIDERS and not replace: + raise ValueError(f"Secret backend is already registered: {normalized}") + _PROVIDERS[normalized] = factory + + +def select_secret_backend_name( + configured: str = "auto", + *, + system: str | None = None, + environ: Mapping[str, str] | None = None, + executable_lookup: Callable[[str], str | None] = shutil.which, +) -> str: + environment = os.environ if environ is None else environ + requested = environment.get("AI2APPS_SECRET_BACKEND", configured).strip().lower() + if requested != "auto": + return requested + host = (system or platform.system()).lower() + if host == "darwin": + return "macos-keychain" + if host == "linux": + has_session_bus = bool(environment.get("DBUS_SESSION_BUS_ADDRESS")) + if has_session_bus and executable_lookup("secret-tool"): + return "linux-secret-service" + return "encrypted-file" + + +def create_secret_backend( + directory: str | Path, + *, + configured: str = "auto", +) -> SecretBackend: + name = select_secret_backend_name(configured) + factory = _PROVIDERS.get(name) + if factory is None: + available = ", ".join(sorted(_PROVIDERS)) + raise SecretBackendError( + f"Unknown Secret backend '{name}'. Available providers: {available}" + ) + return factory(Path(directory).expanduser().resolve()) diff --git a/ai2apps/secrets/models.py b/ai2apps/secrets/models.py new file mode 100644 index 00000000..fd948aee --- /dev/null +++ b/ai2apps/secrets/models.py @@ -0,0 +1,31 @@ +"""Public metadata records for secrets; secret values are deliberately absent.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True, slots=True) +class SecretRecord: + id: str + name: str + purpose: str + allowed_tools: tuple[str, ...] + status: str + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + @property + def uri(self) -> str: + return f"secret://{self.id}" + + +@dataclass(frozen=True, slots=True) +class SecretInjection: + arguments: dict[str, Any] + sensitive_values: tuple[str, ...] + secret_ids: tuple[str, ...] diff --git a/ai2apps/secrets/repository.py b/ai2apps/secrets/repository.py new file mode 100644 index 00000000..119bc502 --- /dev/null +++ b/ai2apps/secrets/repository.py @@ -0,0 +1,185 @@ +"""Metadata persistence, Tool scoping, injection, and redaction for secrets.""" + +from __future__ import annotations + +import fnmatch +import json +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .backends import SecretBackend +from .models import SecretInjection, SecretRecord + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +class SecretRepository: + def __init__( + self, database: PlatformDatabase, events: EventStore, backend: SecretBackend + ) -> None: + self.database = database + self.events = events + self.backend = backend + + @staticmethod + def _record(row) -> SecretRecord: + return SecretRecord( + id=row["id"], name=row["name"], purpose=row["purpose"], + allowed_tools=tuple(json.loads(row["allowed_tools_json"])), + status=row["status"], metadata=json.loads(row["metadata_json"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + deleted_at=None if row["deleted_at"] is None else parse_utc(row["deleted_at"]), + ) + + def create( + self, *, name: str, value: str, purpose: str = "", + allowed_tools: tuple[str, ...] = (), metadata: dict[str, Any] | None = None, + ) -> SecretRecord: + if not name.strip() or not value: + raise ValueError("Secret name and value cannot be empty") + secret_id = new_entity_id(EntityIdKind.SECRET) + backend_key = secret_id + now = utc_now_text() + self.backend.store(backend_key, value) + try: + with self.database.transaction(write=True) as connection: + connection.execute( + """INSERT INTO secret_records( + id, name, backend_key, purpose, allowed_tools_json, + metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (secret_id, name.strip(), backend_key, purpose.strip(), + _json(sorted(set(allowed_tools))), _json(metadata or {}), now, now), + ) + self.events.append_in_transaction( + connection, event_type="secret.created", subject_id=secret_id, + payload={"name": name.strip(), "allowed_tools": sorted(set(allowed_tools))}, + ) + row = connection.execute( + "SELECT * FROM secret_records WHERE id = ?", (secret_id,) + ).fetchone() + except Exception: + self.backend.delete(backend_key) + raise + assert row is not None + return self._record(row) + + def list(self, *, include_deleted: bool = False) -> tuple[SecretRecord, ...]: + where = "" if include_deleted else "WHERE status = 'active'" + with self.database.transaction() as connection: + rows = connection.execute( + f"SELECT * FROM secret_records {where} ORDER BY name" + ).fetchall() + return tuple(self._record(row) for row in rows) + + def get(self, secret_id: str) -> SecretRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM secret_records WHERE id = ?", (secret_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("secret", secret_id) + return self._record(row) + + def replace(self, secret_id: str, value: str) -> SecretRecord: + if not value: + raise ValueError("Secret value cannot be empty") + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM secret_records WHERE id = ? AND status = 'active'", + (secret_id,), + ).fetchone() + if row is None: + raise ResourceNotFoundError("secret", secret_id) + self.backend.store(row["backend_key"], value) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + "UPDATE secret_records SET updated_at = ? WHERE id = ?", (now, secret_id) + ) + self.events.append_in_transaction( + connection, event_type="secret.replaced", subject_id=secret_id, + payload={"name": row["name"]}, + ) + return self.get(secret_id) + + def delete(self, secret_id: str) -> SecretRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM secret_records WHERE id = ?", (secret_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("secret", secret_id) + if row["status"] == "deleted": + return self._record(row) + self.backend.delete(row["backend_key"]) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE secret_records SET status = 'deleted', deleted_at = ?, + updated_at = ? WHERE id = ?""", (now, now, secret_id) + ) + self.events.append_in_transaction( + connection, event_type="secret.deleted", subject_id=secret_id, + payload={"name": row["name"]}, + ) + return self.get(secret_id) + + def _resolve(self, secret_id: str, tool_name: str) -> str: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM secret_records WHERE id = ? AND status = 'active'", + (secret_id,), + ).fetchone() + if row is None: + raise ResourceNotFoundError("secret", secret_id) + allowed = tuple(json.loads(row["allowed_tools_json"])) + if not allowed or not any(fnmatch.fnmatchcase(tool_name, item) for item in allowed): + raise ResourceConflictError(f"Secret {secret_id} is not allowed for {tool_name}") + return self.backend.load(row["backend_key"]) + + def inject_arguments(self, arguments: dict[str, Any], tool_name: str) -> SecretInjection: + values: list[str] = [] + ids: list[str] = [] + + def inject(value: Any) -> Any: + if isinstance(value, str) and value.startswith("secret://sec_"): + secret_id = value.removeprefix("secret://") + resolved = self._resolve(secret_id, tool_name) + values.append(resolved) + ids.append(secret_id) + return resolved + if isinstance(value, dict): + return {key: inject(item) for key, item in value.items()} + if isinstance(value, list): + return [inject(item) for item in value] + return value + + return SecretInjection(inject(arguments), tuple(values), tuple(ids)) + + @staticmethod + def redact(value: Any, sensitive_values: tuple[str, ...] = ()) -> Any: + if isinstance(value, dict): + return {key: SecretRepository.redact(item, sensitive_values) for key, item in value.items()} + if isinstance(value, list): + return [SecretRepository.redact(item, sensitive_values) for item in value] + if isinstance(value, str): + result = value + for secret in sensitive_values: + if secret: + result = result.replace(secret, "[secret]") + return result + return value diff --git a/ai2apps/terminal/__init__.py b/ai2apps/terminal/__init__.py new file mode 100644 index 00000000..a147dd6b --- /dev/null +++ b/ai2apps/terminal/__init__.py @@ -0,0 +1,11 @@ +"""System-owned interactive PTY terminal service.""" + +from .manager import TerminalManager, TerminalServiceError, TerminalSession +from .service import install_terminal_service + +__all__ = [ + "TerminalManager", + "TerminalServiceError", + "TerminalSession", + "install_terminal_service", +] diff --git a/ai2apps/terminal/child.py b/ai2apps/terminal/child.py new file mode 100644 index 00000000..e48f46a0 --- /dev/null +++ b/ai2apps/terminal/child.py @@ -0,0 +1,37 @@ +"""Small PTY child bootstrap used by :mod:`ai2apps.terminal.manager`. + +Keeping controlling-terminal setup in a freshly spawned interpreter avoids +using ``preexec_fn`` or ``fork()`` in the multi-threaded API server process. +""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import sys +import termios + + +def main() -> None: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--cwd", required=True) + parser.add_argument("--shell", required=True) + parser.add_argument("--exec", dest="command", nargs=argparse.REMAINDER) + arguments = parser.parse_args() + + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + os.chdir(arguments.cwd) + if arguments.command: + os.execvpe(arguments.command[0], arguments.command, os.environ) + os.execve(arguments.shell, [arguments.shell, "-l"], os.environ) + + +if __name__ == "__main__": + try: + main() + except BaseException as error: + message = f"ai2apps terminal bootstrap failed: {error}\r\n" + os.write(2, message.encode("utf-8", "replace")) + sys.exit(126) diff --git a/ai2apps/terminal/manager.py b/ai2apps/terminal/manager.py new file mode 100644 index 00000000..65c7064d --- /dev/null +++ b/ai2apps/terminal/manager.py @@ -0,0 +1,415 @@ +"""Lifecycle and byte transport for system-owned interactive PTY sessions.""" + +from __future__ import annotations + +import asyncio +import errno +import fcntl +import os +import pty +import secrets +import shutil +import signal +import struct +import subprocess +import sys +import termios +from collections import deque +from contextlib import suppress +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +_READ_BYTES = 64 * 1024 +_BACKLOG_BYTES = 2 * 1024 * 1024 +_MAX_SESSIONS = 12 +_QUEUE_CHUNKS = 256 +_INPUT_BUFFER_BYTES = 256 * 1024 +_EXITED_HISTORY = 24 +_TERMINATION_GRACE_SECONDS = 1.0 + + +class TerminalServiceError(RuntimeError): + """A stable error suitable for an HTTP API response.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(slots=True) +class TerminalSession: + id: str + title: str + cwd: str + shell: str + pid: int + cols: int + rows: int + created_at: datetime + owner: str = "terminal" + owner_id: str | None = None + status: str = "running" + exit_code: int | None = None + finished_at: datetime | None = None + master_fd: int = field(default=-1, repr=False) + process: subprocess.Popen[bytes] | None = field(default=None, repr=False) + backlog: deque[bytes] = field(default_factory=deque, repr=False) + backlog_bytes: int = field(default=0, repr=False) + subscribers: dict[str, asyncio.Queue[bytes | dict[str, Any]]] = field( + default_factory=dict, repr=False + ) + input_buffer: bytearray = field(default_factory=bytearray, repr=False) + wait_task: asyncio.Task[None] | None = field(default=None, repr=False) + + def public(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "cwd": self.cwd, + "shell": self.shell, + "pid": self.pid, + "cols": self.cols, + "rows": self.rows, + "owner": self.owner, + "owner_id": self.owner_id, + "status": self.status, + "exit_code": self.exit_code, + "created_at": self.created_at.isoformat(), + "finished_at": ( + None if self.finished_at is None else self.finished_at.isoformat() + ), + } + + +class TerminalManager: + """Own PTYs independently from browser connections.""" + + def __init__( + self, + *, + default_cwd: str | Path | None = None, + max_sessions: int = _MAX_SESSIONS, + backlog_bytes: int = _BACKLOG_BYTES, + ) -> None: + if max_sessions <= 0 or backlog_bytes <= 0: + raise ValueError("terminal limits must be positive") + self.default_cwd = Path(default_cwd or os.getcwd()).resolve() + self.max_sessions = max_sessions + self.backlog_limit = backlog_bytes + self._sessions: dict[str, TerminalSession] = {} + self._loop: asyncio.AbstractEventLoop | None = None + self._stopping = False + + async def startup(self) -> None: + self._loop = asyncio.get_running_loop() + self._stopping = False + + async def shutdown(self) -> None: + self._stopping = True + await asyncio.gather( + *(self.close(item.id) for item in tuple(self._sessions.values())), + return_exceptions=True, + ) + self._loop = None + + def list(self, *, owner: str | None = None) -> list[dict[str, Any]]: + return [ + item.public() + for item in sorted( + self._sessions.values(), key=lambda value: value.created_at + ) + if owner is None or item.owner == owner + ] + + def get(self, session_id: str) -> TerminalSession: + session = self._sessions.get(session_id) + if session is None: + raise TerminalServiceError("not_found", "Terminal session not found") + return session + + @staticmethod + def _shell() -> str: + candidate = os.environ.get("SHELL", "") + if candidate and Path(candidate).is_absolute() and os.access(candidate, os.X_OK): + return str(Path(candidate).resolve()) + for name in ("zsh", "bash", "sh"): + resolved = shutil.which(name) + if resolved: + return str(Path(resolved).resolve()) + raise TerminalServiceError("shell_unavailable", "No interactive shell found") + + def _cwd(self, value: str | None) -> Path: + if value is None or not value.strip(): + path = self.default_cwd + else: + if "\x00" in value or len(value.encode("utf-8")) > 4096: + raise TerminalServiceError("invalid_cwd", "Invalid working directory") + path = Path(value).expanduser().resolve() + if not path.is_dir(): + raise TerminalServiceError("invalid_cwd", "Working directory does not exist") + return path + + async def create( + self, + *, + title: str | None = None, + cwd: str | None = None, + cols: int = 100, + rows: int = 30, + command: list[str] | tuple[str, ...] | None = None, + environment: dict[str, str] | None = None, + owner: str = "terminal", + owner_id: str | None = None, + ) -> TerminalSession: + if self._stopping: + raise TerminalServiceError("stopping", "Terminal service is stopping") + self._prune_exited() + active = sum(item.status == "running" for item in self._sessions.values()) + if active >= self.max_sessions: + raise TerminalServiceError( + "session_limit", f"At most {self.max_sessions} terminals may run" + ) + loop = asyncio.get_running_loop() + self._loop = loop + if owner not in {"terminal", "coder", "system"}: + raise TerminalServiceError("invalid_owner", "Invalid terminal owner") + if owner_id is not None and ( + not owner_id or "\x00" in owner_id or len(owner_id.encode("utf-8")) > 200 + ): + raise TerminalServiceError("invalid_owner", "Invalid terminal owner ID") + workdir = self._cwd(cwd) + shell = self._shell() + cols, rows = self._dimensions(cols, rows) + master_fd, slave_fd = pty.openpty() + try: + self._set_winsize(slave_fd, cols, rows) + child_environment = dict(os.environ) + child_environment.update( + { + "TERM": child_environment.get("TERM", "xterm-256color"), + "COLORTERM": "truecolor", + "TERM_PROGRAM": "AI2Apps", + } + ) + for key, value in (environment or {}).items(): + if not key or "\x00" in key or "=" in key or "\x00" in value: + raise TerminalServiceError( + "invalid_environment", "Invalid terminal environment" + ) + child_environment[key] = value + child_argv = [ + sys.executable, + "-m", + "ai2apps.terminal.child", + "--cwd", + str(workdir), + "--shell", + shell, + ] + if command: + if any(not isinstance(item, str) or not item or "\x00" in item for item in command): + raise TerminalServiceError("invalid_command", "Invalid command") + child_argv.extend(("--exec", *command)) + process = subprocess.Popen( + child_argv, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + env=child_environment, + ) + except BaseException: + os.close(master_fd) + raise + finally: + os.close(slave_fd) + + os.set_blocking(master_fd, False) + session_id = f"term_{secrets.token_hex(16)}" + label = (title or "Terminal").strip()[:80] or "Terminal" + session = TerminalSession( + id=session_id, + title=label, + cwd=str(workdir), + shell=shell, + pid=process.pid, + cols=cols, + rows=rows, + created_at=datetime.now(UTC), + owner=owner, + owner_id=owner_id, + master_fd=master_fd, + process=process, + ) + self._sessions[session_id] = session + loop.add_reader(master_fd, self._read_ready, session_id) + session.wait_task = asyncio.create_task( + self._wait(session_id), name=f"terminal-wait-{session_id}" + ) + return session + + def _prune_exited(self) -> None: + exited = sorted( + (item for item in self._sessions.values() if item.status != "running"), + key=lambda item: item.finished_at or item.created_at, + reverse=True, + ) + for session in exited[_EXITED_HISTORY:]: + session.subscribers.clear() + self._sessions.pop(session.id, None) + + @staticmethod + def _dimensions(cols: int, rows: int) -> tuple[int, int]: + if not 20 <= cols <= 1000 or not 5 <= rows <= 500: + raise TerminalServiceError("invalid_size", "Invalid terminal dimensions") + return int(cols), int(rows) + + @staticmethod + def _set_winsize(fd: int, cols: int, rows: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + def resize(self, session_id: str, cols: int, rows: int) -> None: + session = self.get(session_id) + if session.status != "running": + return + cols, rows = self._dimensions(cols, rows) + try: + self._set_winsize(session.master_fd, cols, rows) + except OSError as error: + raise TerminalServiceError("closed", "Terminal is closed") from error + session.cols = cols + session.rows = rows + + def write(self, session_id: str, data: str | bytes) -> None: + session = self.get(session_id) + if session.status != "running" or session.master_fd < 0: + raise TerminalServiceError("closed", "Terminal is closed") + payload = data.encode("utf-8") if isinstance(data, str) else data + if len(payload) > 64 * 1024: + raise TerminalServiceError("input_too_large", "Terminal input is too large") + if len(session.input_buffer) + len(payload) > _INPUT_BUFFER_BYTES: + raise TerminalServiceError("input_backpressure", "Terminal input is busy") + session.input_buffer.extend(payload) + self._flush_input(session_id) + + def _flush_input(self, session_id: str) -> None: + session = self._sessions.get(session_id) + if session is None or session.master_fd < 0: + return + while session.input_buffer: + try: + written = os.write(session.master_fd, session.input_buffer) + except BlockingIOError: + if self._loop is not None: + self._loop.add_writer( + session.master_fd, self._flush_input, session_id + ) + return + except OSError: + session.input_buffer.clear() + return + del session.input_buffer[:written] + if self._loop is not None: + self._loop.remove_writer(session.master_fd) + + def subscribe( + self, session_id: str + ) -> tuple[str, asyncio.Queue[bytes | dict[str, Any]], bytes]: + session = self.get(session_id) + subscriber_id = secrets.token_hex(12) + queue: asyncio.Queue[bytes | dict[str, Any]] = asyncio.Queue(_QUEUE_CHUNKS) + session.subscribers[subscriber_id] = queue + return subscriber_id, queue, b"".join(session.backlog) + + def unsubscribe(self, session_id: str, subscriber_id: str) -> None: + session = self._sessions.get(session_id) + if session is not None: + session.subscribers.pop(subscriber_id, None) + + def _append_output(self, session: TerminalSession, data: bytes) -> None: + session.backlog.append(data) + session.backlog_bytes += len(data) + while session.backlog_bytes > self.backlog_limit and session.backlog: + removed = session.backlog.popleft() + session.backlog_bytes -= len(removed) + for queue in tuple(session.subscribers.values()): + if queue.full(): + with suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(data) + + def _broadcast_event(self, session: TerminalSession, event: dict[str, Any]) -> None: + for queue in tuple(session.subscribers.values()): + if queue.full(): + with suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(event) + + def _read_ready(self, session_id: str) -> None: + session = self._sessions.get(session_id) + if session is None or session.master_fd < 0: + return + while True: + try: + data = os.read(session.master_fd, _READ_BYTES) + except BlockingIOError: + return + except OSError as error: + if error.errno in (errno.EIO, errno.EBADF): + self._remove_reader(session) + return + raise + if not data: + self._remove_reader(session) + return + self._append_output(session, data) + + def _remove_reader(self, session: TerminalSession) -> None: + if self._loop is not None and session.master_fd >= 0: + self._loop.remove_reader(session.master_fd) + self._loop.remove_writer(session.master_fd) + + async def _wait(self, session_id: str) -> None: + session = self._sessions.get(session_id) + if session is None or session.process is None: + return + exit_code = await asyncio.to_thread(session.process.wait) + self._read_ready(session_id) + self._remove_reader(session) + if session.master_fd >= 0: + with suppress(OSError): + os.close(session.master_fd) + session.master_fd = -1 + session.status = "exited" + session.exit_code = exit_code + session.finished_at = datetime.now(UTC) + self._broadcast_event( + session, + {"type": "exit", "exit_code": exit_code, "session": session.public()}, + ) + + async def close(self, session_id: str) -> None: + session = self.get(session_id) + process = session.process + if process is not None and process.poll() is None: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGHUP) + try: + await asyncio.wait_for( + asyncio.shield(session.wait_task), + timeout=_TERMINATION_GRACE_SECONDS, + ) + except TimeoutError: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + if session.wait_task is not None: + await session.wait_task + self._remove_reader(session) + if session.master_fd >= 0: + with suppress(OSError): + os.close(session.master_fd) + session.master_fd = -1 + session.subscribers.clear() + self._sessions.pop(session_id, None) diff --git a/ai2apps/terminal/service.py b/ai2apps/terminal/service.py new file mode 100644 index 00000000..d1091bf1 --- /dev/null +++ b/ai2apps/terminal/service.py @@ -0,0 +1,39 @@ +"""Register the interactive terminal as a built-in AI2Apps Service.""" + +from __future__ import annotations + +from ai2apps.services import ( + ServiceInstanceStatus, + ServiceRegistry, + ServiceRepository, + ServiceRuntimeMode, +) + +from .manager import TerminalManager + + +def install_terminal_service( + manager: TerminalManager, + repository: ServiceRepository, + registry: ServiceRegistry, +) -> None: + service = repository.ensure_service( + service_key="ai2apps.terminal", + package_id="ai2apps.terminal", + package_version="1.0.0", + display_name="AI2Apps Terminal Service", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("terminal", "pty", "host-shell"), + config={ + "transport": "websocket", + "session_limit": manager.max_sessions, + "backlog_bytes": manager.backlog_limit, + }, + ) + repository.ensure_instance( + service_id=service.id, + provider_key="builtin:terminal", + status=ServiceInstanceStatus.RUNNING, + endpoint="/admin/api/terminal/sessions", + health={"status": "ok", "pty": True}, + ) diff --git a/ai2apps/workspace/__init__.py b/ai2apps/workspace/__init__.py new file mode 100644 index 00000000..00e3e048 --- /dev/null +++ b/ai2apps/workspace/__init__.py @@ -0,0 +1,26 @@ +"""Session Workspace, ResourceHandle, Artifact, and Host Broker subsystem.""" + +from .broker import HostExportBroker, LocalHostExportBroker +from .models import ( + ArtifactRecord, + LocatorKind, + ResourceHandleRecord, + ResourceKind, + SandboxRecord, + WorkspaceError, +) +from .repository import WorkspaceRepository +from .service import install_workspace_service + +__all__ = [ + "ArtifactRecord", + "HostExportBroker", + "LocalHostExportBroker", + "LocatorKind", + "ResourceHandleRecord", + "ResourceKind", + "SandboxRecord", + "WorkspaceError", + "WorkspaceRepository", + "install_workspace_service", +] diff --git a/ai2apps/workspace/broker.py b/ai2apps/workspace/broker.py new file mode 100644 index 00000000..347e9ebf --- /dev/null +++ b/ai2apps/workspace/broker.py @@ -0,0 +1,40 @@ +"""Narrow host export broker abstraction with atomic replacement.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from contextlib import suppress +from pathlib import Path +from typing import Protocol + + +class HostExportBroker(Protocol): + def export(self, source: Path, destination_directory: Path, name: str) -> Path: ... + + +class LocalHostExportBroker: + """Trusted built-in broker; callers must resolve authority before invoking it.""" + + def export(self, source: Path, destination_directory: Path, name: str) -> Path: + directory = destination_directory.resolve(strict=True) + if not directory.is_dir(): + raise NotADirectoryError(str(directory)) + if Path(name).name != name or name in {"", ".", ".."}: + raise ValueError("Export name must be a single safe filename") + destination = directory / name + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{name}.", suffix=".ai2apps-export", dir=directory + ) + try: + with os.fdopen(descriptor, "wb") as output, source.open("rb") as input_file: + shutil.copyfileobj(input_file, output) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary_name, destination) + except BaseException: + with suppress(FileNotFoundError): + os.unlink(temporary_name) + raise + return destination diff --git a/ai2apps/workspace/models.py b/ai2apps/workspace/models.py new file mode 100644 index 00000000..e38d24e6 --- /dev/null +++ b/ai2apps/workspace/models.py @@ -0,0 +1,81 @@ +"""Workspace, opaque ResourceHandle, and immutable Artifact contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + + +class ResourceKind(StrEnum): + FILE = "file" + DIRECTORY = "directory" + ARTIFACT = "artifact" + + +class LocatorKind(StrEnum): + WORKSPACE = "workspace" + ARTIFACT = "artifact" + EXTERNAL = "external" + + +@dataclass(frozen=True, slots=True) +class SandboxRecord: + id: str + session_id: str + quota_bytes: int + used_bytes: int + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class ResourceHandleRecord: + id: str + session_id: str + artifact_id: str | None + kind: ResourceKind + display_name: str + locator_kind: LocatorKind + locator: str + capabilities: tuple[str, ...] + media_type: str | None + size_bytes: int | None + content_hash: str | None + source: str + expires_at: datetime | None + revoked_at: datetime | None + created_at: datetime + updated_at: datetime + + @property + def uri(self) -> str: + return f"resource://{self.id}" + + +@dataclass(frozen=True, slots=True) +class ArtifactRecord: + id: str + session_id: str + run_id: str | None + name: str + media_type: str + content_hash: str + size_bytes: int + storage_key: str + status: str + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + + @property + def uri(self) -> str: + return f"artifact://{self.id}" + + +class WorkspaceError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + self.code = code + super().__init__(message) diff --git a/ai2apps/workspace/repository.py b/ai2apps/workspace/repository.py new file mode 100644 index 00000000..9a45ade4 --- /dev/null +++ b/ai2apps/workspace/repository.py @@ -0,0 +1,792 @@ +"""Session-isolated filesystem, ResourceHandle, and Artifact persistence.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import mimetypes +import os +import tempfile +from contextlib import suppress +from pathlib import Path, PurePosixPath +from typing import Any + +from ai2apps.config import ( + DEFAULT_RESOURCE_IMPORT_LIMIT_BYTES, + DEFAULT_SESSION_WORKSPACE_QUOTA_BYTES, + DEFAULT_WORKSPACE_READ_LIMIT_BYTES, + PlatformPaths, +) +from ai2apps.core import ( + EntityIdKind, + ResourceNotFoundError, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .broker import HostExportBroker, LocalHostExportBroker +from .models import ( + ArtifactRecord, + LocatorKind, + ResourceHandleRecord, + ResourceKind, + SandboxRecord, + WorkspaceError, +) + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _time(value: str | None): + return None if value is None else parse_utc(value) + + +def _safe_name(value: str) -> str: + name = Path(value).name.strip().replace("\x00", "") + if not name or name in {".", ".."}: + raise WorkspaceError("invalid_name", "A safe filename is required") + return name[:255] + + +class WorkspaceRepository: + def __init__( + self, + database: PlatformDatabase, + events: EventStore, + paths: PlatformPaths, + broker: HostExportBroker | None = None, + ) -> None: + self.database = database + self.events = events + self.paths = paths + self.broker = broker or LocalHostExportBroker() + + @staticmethod + def _sandbox(row) -> SandboxRecord: + return SandboxRecord( + id=row["id"], + session_id=row["session_id"], + quota_bytes=row["quota_bytes"], + used_bytes=row["used_bytes"], + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _handle(row) -> ResourceHandleRecord: + return ResourceHandleRecord( + id=row["id"], + session_id=row["session_id"], + artifact_id=row["artifact_id"], + kind=ResourceKind(row["kind"]), + display_name=row["display_name"], + locator_kind=LocatorKind(row["locator_kind"]), + locator=row["locator"], + capabilities=tuple(json.loads(row["capabilities_json"])), + media_type=row["media_type"], + size_bytes=row["size_bytes"], + content_hash=row["content_hash"], + source=row["source"], + expires_at=_time(row["expires_at"]), + revoked_at=_time(row["revoked_at"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _artifact(row) -> ArtifactRecord: + return ArtifactRecord( + id=row["id"], + session_id=row["session_id"], + run_id=row["run_id"], + name=row["name"], + media_type=row["media_type"], + content_hash=row["content_hash"], + size_bytes=row["size_bytes"], + storage_key=row["storage_key"], + status=row["status"], + metadata=json.loads(row["metadata_json"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + def _root(self, session_id: str) -> Path: + return self.paths.sandboxes_path / session_id / "workspace" + + def _temporary_root(self, session_id: str) -> Path: + return self.paths.sandboxes_path / session_id / "temporary" + + def resolve_browser_upload(self, session_id: str, relative_path: str) -> Path: + """Resolve an Agent-selected upload strictly inside its Session workspace.""" + + path = self._resolve(session_id, relative_path) + if not path.is_file() or path.is_symlink(): + raise WorkspaceError("not_file", f"Not an uploadable file: {relative_path}") + return path + + def browser_download_directory(self, session_id: str) -> Path: + """Return the isolated temporary Chrome download directory for a Session.""" + + self.ensure_sandbox(session_id) + directory = self._temporary_root(session_id) / "browser-downloads" + directory.mkdir(parents=True, exist_ok=True) + return directory.resolve(strict=True) + + def adopt_browser_download(self, session_id: str, filename: str) -> dict[str, Any]: + """Move a completed Chrome download into the durable Session workspace.""" + + safe_name = _safe_name(filename) + source_root = self.browser_download_directory(session_id) + source = (source_root / safe_name).resolve(strict=True) + try: + source.relative_to(source_root) + except ValueError as exc: + raise WorkspaceError("path_escape", "Download escaped its staging root") from exc + if not source.is_file() or source.is_symlink() or source.name.endswith(".crdownload"): + raise WorkspaceError("download_incomplete", f"Incomplete download: {safe_name}") + destination_dir = self._resolve(session_id, "downloads", missing=True) + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / safe_name + stem, suffix = destination.stem, destination.suffix + serial = 2 + while destination.exists(): + destination = destination_dir / f"{stem}-{serial}{suffix}" + serial += 1 + size = source.stat().st_size + self._check_quota(session_id, 0, size) + os.replace(source, destination) + self._sync_usage(session_id) + return { + "name": destination.name, + "path": destination.relative_to(self._root(session_id)).as_posix(), + "size_bytes": size, + "media_type": mimetypes.guess_type(destination.name)[0] + or "application/octet-stream", + } + + def ensure_sandbox(self, session_id: str) -> SandboxRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + session = connection.execute( + "SELECT id FROM sessions WHERE id = ? AND status != 'deleted'", + (session_id,), + ).fetchone() + if session is None: + raise ResourceNotFoundError("session", session_id) + row = connection.execute( + "SELECT * FROM session_sandboxes WHERE session_id = ?", (session_id,) + ).fetchone() + if row is None: + sandbox_id = new_entity_id(EntityIdKind.SESSION_SANDBOX) + connection.execute( + """INSERT INTO session_sandboxes( + id, session_id, quota_bytes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + sandbox_id, + session_id, + DEFAULT_SESSION_WORKSPACE_QUOTA_BYTES, + now, + now, + ), + ) + row = connection.execute( + "SELECT * FROM session_sandboxes WHERE id = ?", (sandbox_id,) + ).fetchone() + assert row is not None + self._root(session_id).mkdir(parents=True, exist_ok=True) + self._temporary_root(session_id).mkdir(parents=True, exist_ok=True) + return self._sandbox(row) + + def _resolve( + self, session_id: str, relative_path: str, *, missing: bool = False + ) -> Path: + self.ensure_sandbox(session_id) + raw = PurePosixPath(relative_path) + if raw.is_absolute() or ".." in raw.parts or "\x00" in relative_path: + raise WorkspaceError( + "path_escape", "Workspace paths must be safe and relative" + ) + root = self._root(session_id).resolve(strict=True) + candidate = root.joinpath(*raw.parts) + resolved = candidate.resolve(strict=not missing) + try: + resolved.relative_to(root) + except ValueError as exc: + raise WorkspaceError( + "path_escape", "Path escapes the Session workspace" + ) from exc + return resolved + + def _used_bytes(self, session_id: str) -> int: + total = 0 + for path in self._root(session_id).rglob("*"): + if path.is_file() and not path.is_symlink(): + total += path.stat().st_size + return total + + def _check_quota(self, session_id: str, replaced: int, incoming: int) -> None: + sandbox = self.ensure_sandbox(session_id) + used = self._used_bytes(session_id) + if used - replaced + incoming > sandbox.quota_bytes: + raise WorkspaceError( + "workspace_quota_exceeded", "Session workspace quota exceeded" + ) + + def _sync_usage(self, session_id: str) -> None: + now = utc_now_text() + used = self._used_bytes(session_id) + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE session_sandboxes SET used_bytes = ?, revision = revision + 1, + updated_at = ? WHERE session_id = ?""", + (used, now, session_id), + ) + + def list( + self, session_id: str, path: str = ".", *, offset: int = 0, limit: int = 200 + ): + directory = self._resolve(session_id, path) + if not directory.is_dir(): + raise WorkspaceError("not_directory", f"Not a directory: {path}") + entries = sorted( + directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()) + ) + root = self._root(session_id).resolve() + result = [] + for item in entries[offset : offset + limit]: + stat = item.lstat() + result.append( + { + "name": item.name, + "path": item.relative_to(root).as_posix(), + "kind": "symlink" + if item.is_symlink() + else "directory" + if item.is_dir() + else "file", + "size_bytes": stat.st_size if item.is_file() else None, + "modified_at": stat.st_mtime, + } + ) + return { + "items": result, + "offset": offset, + "limit": limit, + "has_more": offset + limit < len(entries), + } + + def stat(self, session_id: str, path: str): + item = self._resolve(session_id, path) + stat = item.stat() + return { + "path": path, + "kind": "directory" if item.is_dir() else "file", + "size_bytes": stat.st_size, + "modified_at": stat.st_mtime, + } + + def read( + self, + session_id: str, + path: str, + *, + offset: int = 0, + limit: int = DEFAULT_WORKSPACE_READ_LIMIT_BYTES, + ): + item = self._resolve(session_id, path) + if not item.is_file(): + raise WorkspaceError("not_file", f"Not a file: {path}") + with item.open("rb") as file: + file.seek(offset) + content = file.read(limit + 1) + truncated = len(content) > limit + content = content[:limit] + try: + text = content.decode("utf-8") + encoding = "utf-8" + except UnicodeDecodeError: + text = base64.b64encode(content).decode("ascii") + encoding = "base64" + return { + "path": path, + "content": text, + "encoding": encoding, + "offset": offset, + "bytes_returned": len(content), + "truncated": truncated, + } + + def write( + self, session_id: str, path: str, content: str, *, encoding: str = "utf-8" + ): + destination = self._resolve(session_id, path, missing=True) + destination.parent.mkdir(parents=True, exist_ok=True) + # Re-resolve after mkdir to catch a concurrently introduced symlink. + destination = self._resolve(session_id, path, missing=True) + data = ( + content.encode("utf-8") + if encoding == "utf-8" + else base64.b64decode(content, validate=True) + ) + replaced = ( + destination.stat().st_size + if destination.exists() and destination.is_file() + else 0 + ) + self._check_quota(session_id, replaced, len(data)) + descriptor, temporary = tempfile.mkstemp( + prefix=".ai2apps-write-", dir=destination.parent + ) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, destination) + except BaseException: + with suppress(FileNotFoundError): + os.unlink(temporary) + raise + self._sync_usage(session_id) + digest = hashlib.sha256(data).hexdigest() + self._event( + session_id, + "workspace.file.written", + path, + {"path": path, "size_bytes": len(data), "content_hash": f"sha256:{digest}"}, + ) + return { + "path": path, + "size_bytes": len(data), + "content_hash": f"sha256:{digest}", + } + + def apply_patch( + self, session_id: str, path: str, replacements: list[dict[str, Any]] + ): + current = self.read(session_id, path, limit=DEFAULT_WORKSPACE_READ_LIMIT_BYTES) + if current["encoding"] != "utf-8" or current["truncated"]: + raise WorkspaceError( + "patch_target_unsupported", "Patch target must be bounded UTF-8 text" + ) + text = current["content"] + applied = 0 + for replacement in replacements: + old = replacement.get("old") + new = replacement.get("new") + count = int(replacement.get("count", 1)) + if not isinstance(old, str) or not isinstance(new, str) or not old: + raise WorkspaceError( + "invalid_patch", + "Each replacement needs non-empty old and string new", + ) + occurrences = text.count(old) + if occurrences < count: + raise WorkspaceError( + "patch_conflict", + f"Expected {count} occurrence(s), found {occurrences}", + ) + text = text.replace(old, new, count) + applied += count + result = self.write(session_id, path, text) + return {**result, "replacements_applied": applied} + + def search(self, session_id: str, query: str, *, path: str = ".", limit: int = 100): + if not query: + raise WorkspaceError("invalid_query", "Search query cannot be empty") + root = self._resolve(session_id, path) + files = [root] if root.is_file() else sorted(root.rglob("*")) + matches = [] + workspace_root = self._root(session_id).resolve() + for file in files: + if len(matches) >= limit or not file.is_file() or file.is_symlink(): + continue + try: + text = file.read_text("utf-8") + except (UnicodeDecodeError, OSError): + continue + for number, line in enumerate(text.splitlines(), 1): + if query.lower() in line.lower(): + matches.append( + { + "path": file.relative_to(workspace_root).as_posix(), + "line": number, + "text": line[:500], + } + ) + if len(matches) >= limit: + break + return {"matches": matches, "truncated": len(matches) >= limit} + + def import_bytes( + self, + session_id: str, + filename: str, + data: bytes, + *, + media_type: str | None = None, + source: str = "user_picker", + ): + if len(data) > DEFAULT_RESOURCE_IMPORT_LIMIT_BYTES: + raise WorkspaceError( + "resource_too_large", "Selected resource exceeds import limit" + ) + handle_id = new_entity_id(EntityIdKind.RESOURCE_HANDLE) + name = _safe_name(filename) + relative = f"imports/{handle_id}/{name}" + encoded = base64.b64encode(data).decode("ascii") + written = self.write(session_id, relative, encoded, encoding="base64") + return self._insert_handle( + handle_id=handle_id, + session_id=session_id, + kind=ResourceKind.FILE, + display_name=name, + locator_kind=LocatorKind.WORKSPACE, + locator=relative, + capabilities=("read",), + media_type=media_type or mimetypes.guess_type(name)[0], + size_bytes=len(data), + content_hash=written["content_hash"], + source=source, + ) + + def _insert_handle( + self, + *, + handle_id: str, + session_id: str, + kind: ResourceKind, + display_name: str, + locator_kind: LocatorKind, + locator: str, + capabilities: tuple[str, ...], + source: str, + artifact_id: str | None = None, + media_type: str | None = None, + size_bytes: int | None = None, + content_hash: str | None = None, + ): + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """INSERT INTO resource_handles( + id, session_id, artifact_id, kind, display_name, locator_kind, + locator, capabilities_json, media_type, size_bytes, content_hash, + source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + handle_id, + session_id, + artifact_id, + kind.value, + display_name, + locator_kind.value, + locator, + _json(capabilities), + media_type, + size_bytes, + content_hash, + source, + now, + now, + ), + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="resource.handle.created", + subject_id=handle_id, + app_instance_id=session["app_instance_id"], + session_id=session_id, + payload={ + "kind": kind.value, + "display_name": display_name, + "capabilities": capabilities, + "source": source, + }, + ) + row = connection.execute( + "SELECT * FROM resource_handles WHERE id = ?", (handle_id,) + ).fetchone() + assert row is not None + return self._handle(row) + + def get_handle( + self, session_id: str, handle_or_uri: str, *, capability: str | None = None + ): + handle_id = handle_or_uri.removeprefix("resource://") + now = utc_now_text() + with self.database.transaction() as connection: + row = connection.execute( + """SELECT * FROM resource_handles WHERE id = ? AND session_id = ? + AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)""", + (handle_id, session_id, now), + ).fetchone() + if row is None: + raise ResourceNotFoundError("resource_handle", handle_id) + record = self._handle(row) + if capability is not None and capability not in record.capabilities: + raise WorkspaceError( + "resource_capability_denied", f"Handle lacks {capability}" + ) + return record + + def list_handles(self, session_id: str): + self.ensure_sandbox(session_id) + now = utc_now_text() + with self.database.transaction() as connection: + rows = connection.execute( + """SELECT * FROM resource_handles WHERE session_id = ? + AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) + ORDER BY created_at DESC""", + (session_id, now), + ).fetchall() + return tuple(self._handle(row) for row in rows) + + def revoke_handle(self, session_id: str, handle_id: str): + now = utc_now_text() + with self.database.transaction(write=True) as connection: + changed = connection.execute( + """UPDATE resource_handles SET revoked_at = ?, updated_at = ? + WHERE id = ? AND session_id = ? AND revoked_at IS NULL""", + (now, now, handle_id, session_id), + ).rowcount + if not changed: + raise ResourceNotFoundError("resource_handle", handle_id) + + def create_artifact( + self, + session_id: str, + source_path: str, + name: str | None = None, + *, + run_id: str | None = None, + media_type: str | None = None, + metadata: dict[str, Any] | None = None, + ): + source = self._resolve(session_id, source_path) + if not source.is_file(): + raise WorkspaceError("not_file", "Artifact source must be a file") + data = source.read_bytes() + digest = hashlib.sha256(data).hexdigest() + artifact_name = _safe_name(name or source.name) + storage_key = f"sha256/{digest[:2]}/{digest}" + destination = self.paths.artifacts_path / storage_key + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists(): + descriptor, temporary = tempfile.mkstemp( + prefix=".artifact-", dir=destination.parent + ) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, destination) + except BaseException: + with suppress(FileNotFoundError): + os.unlink(temporary) + raise + now = utc_now_text() + with self.database.transaction(write=True) as connection: + existing = connection.execute( + """SELECT * FROM artifacts WHERE session_id = ? AND content_hash = ? + AND name = ?""", + (session_id, f"sha256:{digest}", artifact_name), + ).fetchone() + if existing is not None: + return self._artifact(existing) + artifact_id = new_entity_id(EntityIdKind.ARTIFACT) + connection.execute( + """INSERT INTO artifacts( + id, session_id, run_id, name, media_type, content_hash, + size_bytes, storage_key, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + artifact_id, + session_id, + run_id, + artifact_name, + media_type + or mimetypes.guess_type(artifact_name)[0] + or "application/octet-stream", + f"sha256:{digest}", + len(data), + storage_key, + _json(metadata or {}), + now, + now, + ), + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="artifact.created", + subject_id=artifact_id, + app_instance_id=session["app_instance_id"], + session_id=session_id, + trace_id=run_id, + payload={ + "name": artifact_name, + "media_type": media_type, + "size_bytes": len(data), + "content_hash": f"sha256:{digest}", + }, + ) + row = connection.execute( + "SELECT * FROM artifacts WHERE id = ?", (artifact_id,) + ).fetchone() + assert row is not None + return self._artifact(row) + + def get_artifact(self, session_id: str, artifact_id: str): + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM artifacts WHERE id = ? AND session_id = ? AND status = 'active'", + (artifact_id, session_id), + ).fetchone() + if row is None: + raise ResourceNotFoundError("artifact", artifact_id) + return self._artifact(row) + + def list_artifacts(self, session_id: str): + self.ensure_sandbox(session_id) + with self.database.transaction() as connection: + rows = connection.execute( + """SELECT * FROM artifacts WHERE session_id = ? AND status = 'active' + ORDER BY created_at DESC""", + (session_id,), + ).fetchall() + return tuple(self._artifact(row) for row in rows) + + def artifact_path(self, artifact: ArtifactRecord) -> Path: + path = (self.paths.artifacts_path / artifact.storage_key).resolve(strict=True) + path.relative_to(self.paths.artifacts_path.resolve(strict=True)) + return path + + def preview_artifact( + self, session_id: str, artifact_id: str, limit: int = 256 * 1024 + ): + artifact = self.get_artifact(session_id, artifact_id) + data = self.artifact_path(artifact).read_bytes()[: limit + 1] + truncated = len(data) > limit + data = data[:limit] + if artifact.media_type.startswith("text/") or artifact.media_type in { + "application/json", + "image/svg+xml", + }: + content, encoding = data.decode("utf-8", errors="replace"), "utf-8" + else: + content, encoding = base64.b64encode(data).decode("ascii"), "base64" + return { + "artifact_id": artifact.id, + "media_type": artifact.media_type, + "content": content, + "encoding": encoding, + "truncated": truncated, + } + + def register_external_directory( + self, session_id: str, directory: Path, *, display_name: str | None = None + ): + resolved = directory.expanduser().resolve(strict=True) + if not resolved.is_dir(): + raise NotADirectoryError(str(resolved)) + return self._insert_handle( + handle_id=new_entity_id(EntityIdKind.RESOURCE_HANDLE), + session_id=session_id, + kind=ResourceKind.DIRECTORY, + display_name=display_name or resolved.name, + locator_kind=LocatorKind.EXTERNAL, + locator=str(resolved), + capabilities=("export",), + source="trusted_host_picker", + ) + + def export_artifact( + self, + session_id: str, + artifact_id: str, + destination_handle: str, + name: str | None = None, + ): + artifact = self.get_artifact(session_id, artifact_id) + handle = self.get_handle(session_id, destination_handle, capability="export") + if ( + handle.kind is not ResourceKind.DIRECTORY + or handle.locator_kind is not LocatorKind.EXTERNAL + ): + raise WorkspaceError( + "invalid_export_target", "Export needs an external directory handle" + ) + export_id = new_entity_id(EntityIdKind.ARTIFACT_EXPORT) + export_name = _safe_name(name or artifact.name) + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """INSERT INTO artifact_exports( + id, artifact_id, session_id, destination_handle_id, + destination_name, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + (export_id, artifact.id, session_id, handle.id, export_name, now, now), + ) + try: + destination = self.broker.export( + self.artifact_path(artifact), Path(handle.locator), export_name + ) + except BaseException as exc: + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE artifact_exports SET status = 'failed', error_json = ?, + updated_at = ? WHERE id = ?""", + ( + _json({"type": type(exc).__name__, "message": str(exc)}), + utc_now_text(), + export_id, + ), + ) + raise + completed = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE artifact_exports SET status = 'completed', content_hash = ?, + completed_at = ?, updated_at = ? WHERE id = ?""", + (artifact.content_hash, completed, completed, export_id), + ) + return { + "export_id": export_id, + "name": export_name, + "content_hash": artifact.content_hash, + "destination": destination.name, + } + + def _event( + self, session_id: str, event_type: str, subject_id: str, payload: dict[str, Any] + ): + with self.database.transaction(write=True) as connection: + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", (session_id,) + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type=event_type, + subject_id=subject_id, + app_instance_id=session["app_instance_id"], + session_id=session_id, + payload=payload, + ) diff --git a/ai2apps/workspace/service.py b/ai2apps/workspace/service.py new file mode 100644 index 00000000..7e74b7da --- /dev/null +++ b/ai2apps/workspace/service.py @@ -0,0 +1,369 @@ +"""Built-in Workspace and Artifact Service Tool descriptors and handlers.""" + +from __future__ import annotations + +from typing import Any + +from ai2apps.services import ( + ServiceInstanceStatus, + ServiceRegistry, + ServiceRepository, + ServiceRuntimeMode, + ToolCallContext, + ToolProviderError, +) + +from .models import LocatorKind +from .repository import WorkspaceRepository + +OBJECT = {"type": "object"} + + +def install_workspace_service( + workspace: WorkspaceRepository, + repository: ServiceRepository, + registry: ServiceRegistry, +) -> None: + service = repository.ensure_service( + service_key="ai2apps.workspace", + package_id="ai2apps.workspace", + package_version="1.0.0", + display_name="AI2Apps Workspace & Artifacts", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("workspace", "resources", "artifacts"), + ) + instance = repository.ensure_instance( + service_id=service.id, + provider_key="builtin:workspace", + status=ServiceInstanceStatus.RUNNING, + endpoint="/v1/platform/sessions/{session_id}/workspace", + health={"status": "ok"}, + ) + + def session(context: ToolCallContext) -> str: + if context.session_id is None: + raise ToolProviderError("Workspace Tools require a Session") + return context.session_id + + async def workspace_list(arguments, context): + return workspace.list( + session(context), + arguments.get("path", "."), + offset=arguments.get("offset", 0), + limit=arguments.get("limit", 200), + ) + + async def workspace_stat(arguments, context): + return workspace.stat(session(context), arguments["path"]) + + async def workspace_read(arguments, context): + return workspace.read( + session(context), + arguments["path"], + offset=arguments.get("offset", 0), + limit=arguments.get("limit", 1024 * 1024), + ) + + async def workspace_search(arguments, context): + return workspace.search( + session(context), + arguments["query"], + path=arguments.get("path", "."), + limit=arguments.get("limit", 100), + ) + + async def workspace_write(arguments, context): + await context.report_progress("Writing workspace file", progress=0.25) + result = workspace.write( + session(context), + arguments["path"], + arguments["content"], + encoding=arguments.get("encoding", "utf-8"), + ) + await context.report_progress("Workspace file written", progress=1.0) + return result + + async def workspace_patch(arguments, context): + await context.report_progress("Applying workspace patch", progress=0.25) + result = workspace.apply_patch( + session(context), arguments["path"], arguments["replacements"] + ) + await context.report_progress("Workspace patch applied", progress=1.0) + return result + + async def resource_read(arguments, context): + session_id = session(context) + handle = workspace.get_handle( + session_id, arguments["resource"], capability="read" + ) + if handle.locator_kind is not LocatorKind.WORKSPACE: + raise ToolProviderError( + "This ResourceHandle is not readable through Workspace" + ) + result = workspace.read( + session_id, + handle.locator, + offset=arguments.get("offset", 0), + limit=arguments.get("limit", 1024 * 1024), + ) + return {**result, "resource": handle.uri, "display_name": handle.display_name} + + async def artifact_create(arguments, context): + artifact = workspace.create_artifact( + session(context), + arguments["path"], + arguments.get("name"), + run_id=( + context.trace_id + if context.trace_id and context.trace_id.startswith("run_") + else None + ), + media_type=arguments.get("media_type"), + metadata=arguments.get("metadata"), + ) + return _artifact_json(artifact) + + async def artifact_list(_arguments, context): + return { + "items": [ + _artifact_json(item) + for item in workspace.list_artifacts(session(context)) + ] + } + + async def artifact_preview(arguments, context): + return workspace.preview_artifact( + session(context), + arguments["artifact_id"], + arguments.get("limit", 256 * 1024), + ) + + async def artifact_export(arguments, context): + return workspace.export_artifact( + session(context), + arguments["artifact_id"], + arguments["destination_handle"], + arguments.get("name"), + ) + + common_path = { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + } + tools: tuple[ + tuple[str, str, str, dict[str, Any], tuple[str, ...], tuple[str, ...], Any], ... + ] = ( + ( + "workspace.list", + "List workspace", + "List a Session workspace directory with pagination.", + { + "type": "object", + "properties": { + "path": {"type": "string"}, + "offset": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + }, + "additionalProperties": False, + }, + (), + (), + workspace_list, + ), + ( + "workspace.stat", + "Stat workspace path", + "Inspect a Session workspace path.", + common_path, + (), + (), + workspace_stat, + ), + ( + "workspace.read", + "Read workspace file", + "Read bounded file content from the Session workspace.", + _read_schema("path"), + (), + (), + workspace_read, + ), + ( + "workspace.search", + "Search workspace", + "Search bounded UTF-8 files in the Session workspace.", + { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": 1}, + "path": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + }, + "required": ["query"], + "additionalProperties": False, + }, + (), + (), + workspace_search, + ), + ( + "workspace.write", + "Write workspace file", + "Atomically write within the Session workspace quota.", + { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + "encoding": {"enum": ["utf-8", "base64"]}, + }, + "required": ["path", "content"], + "additionalProperties": False, + }, + ("write",), + ("workspace.write",), + workspace_write, + ), + ( + "workspace.apply_patch", + "Patch workspace file", + "Atomically apply exact text replacements.", + { + "type": "object", + "properties": { + "path": {"type": "string"}, + "replacements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "old": {"type": "string", "minLength": 1}, + "new": {"type": "string"}, + "count": {"type": "integer", "minimum": 1}, + }, + "required": ["old", "new"], + "additionalProperties": False, + }, + }, + }, + "required": ["path", "replacements"], + "additionalProperties": False, + }, + ("write",), + ("workspace.write",), + workspace_patch, + ), + ( + "resource.read", + "Read selected resource", + "Read a user-selected opaque ResourceHandle.", + _read_schema("resource"), + (), + (), + resource_read, + ), + ( + "artifact.create", + "Create artifact", + "Create an immutable Artifact from a workspace file.", + { + "type": "object", + "properties": { + "path": {"type": "string"}, + "name": {"type": "string"}, + "media_type": {"type": "string"}, + "metadata": {"type": "object"}, + }, + "required": ["path"], + "additionalProperties": False, + }, + ("write",), + ("artifact.create",), + artifact_create, + ), + ( + "artifact.list", + "List artifacts", + "List active Artifacts owned by the Session.", + {"type": "object", "additionalProperties": False}, + (), + (), + artifact_list, + ), + ( + "artifact.preview", + "Preview artifact", + "Read a bounded Artifact preview.", + { + "type": "object", + "properties": { + "artifact_id": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1048576}, + }, + "required": ["artifact_id"], + "additionalProperties": False, + }, + (), + (), + artifact_preview, + ), + ( + "artifact.export", + "Export artifact", + "Atomically export through an authorized directory handle.", + { + "type": "object", + "properties": { + "artifact_id": {"type": "string"}, + "destination_handle": {"type": "string"}, + "name": {"type": "string"}, + }, + "required": ["artifact_id", "destination_handle"], + "additionalProperties": False, + }, + ("external_write",), + ("artifact.export",), + artifact_export, + ), + ) + for name, title, description, schema, effects, capabilities, handler in tools: + repository.ensure_tool( + service_id=service.id, + qualified_name=name, + display_name=title, + description=description, + input_schema=schema, + output_schema=OBJECT, + effects=effects, + required_capabilities=capabilities, + timeout_ms=30_000, + ) + registry.bind_tool(name, provider_key=instance.provider_key, handler=handler) + + +def _read_schema(field: str) -> dict[str, Any]: + return { + "type": "object", + "properties": { + field: {"type": "string", "minLength": 1}, + "offset": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1048576}, + }, + "required": [field], + "additionalProperties": False, + } + + +def _artifact_json(artifact) -> dict[str, Any]: + return { + "id": artifact.id, + "uri": artifact.uri, + "name": artifact.name, + "media_type": artifact.media_type, + "content_hash": artifact.content_hash, + "size_bytes": artifact.size_bytes, + "metadata": artifact.metadata, + } diff --git a/docs/security-authority-baseline.md b/docs/security-authority-baseline.md new file mode 100644 index 00000000..3394bd86 --- /dev/null +++ b/docs/security-authority-baseline.md @@ -0,0 +1,48 @@ +# AI2Apps Authority and Secret Baseline + +AI2Apps uses one deterministic authority path for Agent Tool calls. + +## Action risk + +Before a Tool is dispatched, its declared effects are classified as `read`, +`write`, `external`, or `destructive`. Approval requests contain a bounded, +redacted action preview with the Tool name, concrete target, reversibility, and +`low`/`medium`/`high`/`critical` risk. Sensitive argument names and +`secret://...` references never expand in the preview or AI auditor request. + +## Grant leases + +User approval can be scoped to: + +- `once`: consumed atomically before one Tool dispatch; +- `run`: the current Agent run; +- `session`: the current conversation Session; +- `agent`: the Agent definition; +- `app`: the current App instance. + +Leases may be bound to displayed resource arguments. They expire automatically, +are revocable, stop matching after a Tool service digest changes, and are all +revoked when Safe Mode is enabled. + +## Secret Store + +Secret metadata is available under `/v1/platform/secrets`. Values are never +persisted in SQLite or returned by an API. A Tool receives a value only when an argument +contains `secret://sec_` and its qualified name matches the secret's +`allowed_tools` patterns. + +The runtime selects a provider through `SecretBackendFactory`: + +- macOS uses Keychain; +- Linux with a desktop Secret Service uses `secret-tool`; +- headless Linux (including DGX Spark) and other hosts use an AES-256-GCM vault. + +`AI2APPS_SECRET_BACKEND` overrides automatic selection. The encrypted provider +accepts `AI2APPS_SECRET_VAULT_KEY`; otherwise it creates a machine-local key with +mode `0600`. TPM, systemd credential, Windows Credential Manager, and KMS +providers can register without changing Secret URIs or Tool code. The selected +provider is visible at `GET /v1/platform/secrets/backend`. + +Tool invocation records retain the opaque reference. Injection occurs after +authorization and input validation. Progress, output, and error strings are +redacted against values injected for that invocation. diff --git a/tests/test_ai2apps_capabilities.py b/tests/test_ai2apps_capabilities.py new file mode 100644 index 00000000..e4d30282 --- /dev/null +++ b/tests/test_ai2apps_capabilities.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: Apache-2.0 +"""M4 capability policy, GrantLease, audit, and management contracts.""" + +from __future__ import annotations + +import httpx +import pytest +from fastapi import FastAPI + +from ai2apps.api.router import create_ai2apps_router +from ai2apps.capabilities import GrantScope, PolicyEffect +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.agents is not None + assert runtime.capabilities is not None + assert runtime.capability_policy is not None + return runtime + + +def _session(runtime): + thread, _ = ChatRepository(runtime.database, runtime.events).create_thread( + title="Capability test" + ) + return thread.session.id + + +def _run(runtime, session_id): + return runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={}, + )[0] + + +@pytest.mark.asyncio +async def test_policy_is_ordered_deterministic_and_deny_wins_ties(tmp_path): + runtime = _runtime(tmp_path) + run = _run(runtime, _session(runtime)) + engine = runtime.capability_policy + + initial = await engine.evaluate( + run_id=run.id, + agent_key="ai2apps.diagnostic-agent", + tool_name="secure.write", + capabilities=("secure.write",), + effects=("write",), + arguments={"path": "a"}, + ) + assert initial.effect is PolicyEffect.REQUIRE_APPROVAL + + runtime.capabilities.upsert_policy( + policy_key="local.allow-write", + effect=PolicyEffect.ALLOW, + capability_pattern="secure.*", + tool_pattern="secure.*", + priority=100, + ) + allowed = await engine.evaluate( + run_id=run.id, + agent_key="ai2apps.diagnostic-agent", + tool_name="secure.write", + capabilities=("secure.write",), + effects=("write",), + arguments={}, + ) + assert allowed.effect is PolicyEffect.ALLOW + + runtime.capabilities.upsert_policy( + policy_key="local.deny-write", + effect=PolicyEffect.DENY, + capability_pattern="secure.write", + tool_pattern="secure.write", + priority=100, + ) + denied = await engine.evaluate( + run_id=run.id, + agent_key="ai2apps.diagnostic-agent", + tool_name="secure.write", + capabilities=("secure.write",), + effects=("write",), + arguments={}, + ) + assert denied.effect is PolicyEffect.DENY + assert len(denied.matched_policy_ids) == 2 + + +def test_session_lease_is_agent_bound_expiring_and_revocable(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + first = _run(runtime, session_id) + lease = runtime.capabilities.create_lease( + run_id=first.id, + scope=GrantScope.SESSION, + capabilities=("secure.write",), + tool_pattern="secure.*", + issued_by="test", + evidence={"case": "session"}, + ) + second = _run(runtime, session_id) + + active = runtime.capabilities.active_leases_for_run(second.id, "secure.write") + assert [item.id for item in active] == [lease.id] + assert active[0].evidence == {"case": "session"} + + revoked = runtime.capabilities.revoke_lease(lease.id, reason="user changed mind") + assert revoked.revoked_at is not None + assert runtime.capabilities.active_leases_for_run(second.id, "secure.write") == () + events = runtime.events.list_after(subject_id=lease.id, limit=20) + assert [event.type for event in events] == [ + "capability.grant.created", + "capability.grant.revoked", + ] + + +@pytest.mark.asyncio +async def test_grant_resource_selector_cannot_authorize_a_different_target(tmp_path): + runtime = _runtime(tmp_path) + run = _run(runtime, _session(runtime)) + runtime.capabilities.create_lease( + run_id=run.id, scope=GrantScope.RUN, + capabilities=("workspace.write",), tool_pattern="workspace.write", + issued_by="test", evidence={"case": "resource-bound"}, + resource_selector={"arguments": {"path": "allowed.txt"}}, + ) + allowed = await runtime.capability_policy.evaluate( + run_id=run.id, agent_key="ai2apps.diagnostic-agent", + tool_name="workspace.write", capabilities=("workspace.write",), + effects=("write",), arguments={"path": "allowed.txt"}, + ) + denied = await runtime.capability_policy.evaluate( + run_id=run.id, agent_key="ai2apps.diagnostic-agent", + tool_name="workspace.write", capabilities=("workspace.write",), + effects=("write",), arguments={"path": "different.txt"}, + ) + assert allowed.effect is PolicyEffect.ALLOW + assert allowed.source == "grant_lease" + assert denied.effect is PolicyEffect.REQUIRE_APPROVAL + + +@pytest.mark.asyncio +async def test_ai_auditor_is_bounded_and_records_evidence(tmp_path): + runtime = _runtime(tmp_path) + run = _run(runtime, _session(runtime)) + + async def auditor(request): + assert request["tool_name"] == "secure.read" + return { + "decision": "allow", + "reason": "read-only fixture", + "evidence": {"review": "unit-test"}, + } + + runtime.bind_ai_capability_auditor(auditor) + decision = await runtime.capability_policy.evaluate( + run_id=run.id, + agent_key="ai2apps.diagnostic-agent", + tool_name="secure.read", + capabilities=("secure.read",), + effects=("read",), + arguments={}, + ) + assert decision.effect is PolicyEffect.ALLOW + assert decision.source == "ai_auditor" + assert decision.evidence["ai_auditor"]["evidence"] == {"review": "unit-test"} + + runtime.bind_ai_capability_auditor(lambda _request: {"decision": "bogus"}) + failed_closed = await runtime.capability_policy.evaluate( + run_id=run.id, + agent_key="ai2apps.diagnostic-agent", + tool_name="secure.read", + capabilities=("secure.read",), + effects=("read",), + arguments={}, + ) + assert failed_closed.effect is PolicyEffect.REQUIRE_APPROVAL + assert failed_closed.evidence["ai_auditor"]["error"] == "ValueError" + + +@pytest.mark.asyncio +async def test_policy_and_grant_management_api(tmp_path): + runtime = _runtime(tmp_path) + run = _run(runtime, _session(runtime)) + lease = runtime.capabilities.create_lease( + run_id=run.id, + scope=GrantScope.RUN, + capabilities=("secure.write",), + tool_pattern="secure.write", + issued_by="test", + evidence={}, + ) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + put = await client.put( + "/v1/platform/capability-policies/local.read", + json={"effect": "allow", "capability_pattern": "secure.read"}, + ) + assert put.status_code == 200 + policies = await client.get("/v1/platform/capability-policies") + assert {item["policy_key"] for item in policies.json()["items"]} >= { + "builtin.default-require-approval", + "local.read", + } + grants = await client.get("/v1/platform/grant-leases") + assert [item["id"] for item in grants.json()["items"]] == [lease.id] + revoked = await client.post( + f"/v1/platform/grant-leases/{lease.id}/revoke", + json={"reason": "API test"}, + ) + assert revoked.status_code == 200 + assert revoked.json()["revoke_reason"] == "API test" + + +@pytest.mark.asyncio +async def test_app_capability_request_approval_grant_and_safe_mode_recovery(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + with runtime.database.transaction() as connection: + app_instance_id = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id=?", (session_id,) + ).fetchone()[0] + + request = runtime.capabilities.create_app_request( + app_instance_id=app_instance_id, + session_id=session_id, + capabilities=("workspace.export",), + tool_name="artifact.export", + effects=("export", "write"), + resource_selector={"artifact_id": "art_example"}, + reason="Export the selected artifact", + ) + assert request.status.value == "pending" + assert request.risk_level == "medium" + assert runtime.capabilities.list_requests() == (request,) + + resolved, lease = runtime.capabilities.decide_app_request( + request.id, + decision="approve", + scope="once", + ) + assert resolved.status.value == "approved" + assert lease is not None + assert lease.agent_definition_id is None + assert lease.scope.value == "app" + assert lease.expires_at is not None + assert lease.resource_selector == {"artifact_id": "art_example"} + + recovery = await runtime.set_safe_mode(True, "capability-test") + assert recovery["revoked_grants"] == 1 + assert runtime.capabilities.list_leases() == () + repeated = await runtime.set_safe_mode(True, "repeated-request") + assert repeated["active"] is True + assert repeated["reason"] == "capability-test" + assert repeated["revoked_grants"] == 0 + restored = await runtime.set_safe_mode(False, "capability-test-complete") + assert restored["active"] is False + assert runtime.capabilities.list_leases(include_inactive=False) == () + audit_types = [ + event.type for event in runtime.events.list_after(subject_id=request.id, limit=20) + ] + assert audit_types == [ + "capability.request.created", + "capability.decision.allow", + ] diff --git a/tests/test_ai2apps_processes.py b/tests/test_ai2apps_processes.py new file mode 100644 index 00000000..149dbf20 --- /dev/null +++ b/tests/test_ai2apps_processes.py @@ -0,0 +1,402 @@ +# SPDX-License-Identifier: Apache-2.0 +"""M7 sandboxed Process Service, ownership, limits, and broker contracts.""" + +from __future__ import annotations + +import asyncio +import os +import platform + +import pytest + +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.core import ResourceNotFoundError +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.processes import ( + BrokerAuthority, + LinuxBubblewrapAdapter, + MacOSSandboxAdapter, + ProcessManager, + ProcessServiceError, + ProcessStatus, +) +from ai2apps.processes import ( + TestSandboxAdapter as ProcessTestSandboxAdapter, +) +from ai2apps.services import ToolCallContext, ToolGatewayError + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path / "data")) + runtime.start() + assert runtime.workspace is not None + assert runtime.processes is not None + return runtime + + +def _session(runtime, title="Process"): + return ( + ChatRepository(runtime.database, runtime.events) + .create_thread(title=title)[0] + .session.id + ) + + +async def _manager(runtime, *, session_limit=4): + manager = ProcessManager( + runtime.database, + runtime.events, + runtime.workspace, + sandbox=ProcessTestSandboxAdapter(), + session_limit=session_limit, + ) + await manager.startup() + return manager + + +async def _terminal(manager, process_id, session_id, run_id=None, timeout=3): + async def wait(): + while True: + record = manager.status(process_id, session_id=session_id, run_id=run_id) + if record.status.terminal: + return record + await asyncio.sleep(0.01) + + return await asyncio.wait_for(wait(), timeout) + + +@pytest.mark.asyncio +async def test_process_argv_output_status_and_broker_audit(tmp_path): + runtime = _runtime(tmp_path) + session = _session(runtime) + manager = await _manager(runtime) + try: + started = await manager.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/bin/echo", "hello process"], + ) + finished = await manager.wait( + started.id, session_id=session, run_id=None, timeout_ms=3_000 + ) + logs = manager.logs(started.id, session_id=session, run_id=None) + + assert finished.status is ProcessStatus.EXITED + assert finished.exit_code == 0 + assert "".join(item.content for item in logs) == "hello process\n" + with runtime.database.connect() as connection: + broker = connection.execute( + "SELECT operation, status, token_digest FROM host_broker_requests" + ).fetchone() + assert broker[0:2] == ("process.spawn", "accepted") + assert broker[2].startswith("sha256:") + finally: + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_process_stdin_and_environment_are_bounded(tmp_path, monkeypatch): + runtime = _runtime(tmp_path) + session = _session(runtime) + manager = await _manager(runtime) + monkeypatch.setenv("AI2APPS_HOST_SECRET", "must-not-leak") + try: + started = await manager.start( + session_id=session, run_id=None, caller_id="test", argv=["/bin/cat"] + ) + await manager.write_stdin( + started.id, "ping\n", session_id=session, run_id=None, close=True + ) + finished = await _terminal(manager, started.id, session) + assert finished.status is ProcessStatus.EXITED + assert ( + manager.logs(started.id, session_id=session, run_id=None)[0].content + == "ping\n" + ) + assert "AI2APPS_HOST_SECRET" not in finished.environment_keys + + with pytest.raises(ProcessServiceError, match="not allowed"): + await manager.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/bin/echo", "x"], + environment={"LD_PRELOAD": "bad"}, + ) + finally: + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_process_output_wall_idle_and_session_limits(tmp_path): + runtime = _runtime(tmp_path) + session = _session(runtime) + manager = await _manager(runtime, session_limit=1) + try: + noisy = await manager.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/usr/bin/yes"], + limits={"output_bytes": 2048}, + ) + noisy_done = await _terminal(manager, noisy.id, session) + assert noisy_done.status is ProcessStatus.OUTPUT_LIMIT + assert noisy_done.output_bytes == 2048 + + sleeping = await manager.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/bin/sleep", "5"], + limits={"idle_time_seconds": 1}, + ) + with pytest.raises(ProcessServiceError, match="limit reached"): + await manager.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/bin/echo", "blocked"], + ) + sleeping_done = await _terminal(manager, sleeping.id, session, timeout=2) + assert sleeping_done.status is ProcessStatus.IDLE_TIMEOUT + finally: + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_process_is_scoped_to_session_and_originating_run(tmp_path): + runtime = _runtime(tmp_path) + first = _session(runtime, "First") + second = _session(runtime, "Second") + first_run = runtime.agents.create_run( + session_id=first, agent_key="ai2apps.diagnostic-agent", input={} + )[0] + second_run = runtime.agents.create_run( + session_id=first, agent_key="ai2apps.diagnostic-agent", input={} + )[0] + manager = await _manager(runtime) + try: + started = await manager.start( + session_id=first, + run_id=first_run.id, + caller_id="agent:test", + argv=["/bin/sleep", "5"], + ) + with pytest.raises(ResourceNotFoundError): + manager.status(started.id, session_id=second, run_id=first_run.id) + with pytest.raises(ProcessServiceError, match="Process not found"): + manager.status(started.id, session_id=first, run_id=second_run.id) + await manager.cancel_run(first_run.id) + assert ( + await _terminal(manager, started.id, first, first_run.id) + ).status is ProcessStatus.CANCELLED + finally: + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_agent_run_cancel_terminates_process_group(tmp_path): + runtime = _runtime(tmp_path) + session = _session(runtime) + run = runtime.agents.create_run( + session_id=session, agent_key="ai2apps.diagnostic-agent", input={} + )[0] + manager = await _manager(runtime) + runtime.agent_runtime.bind_run_terminal_handler(manager.schedule_cancel_by_run) + try: + script = ( + "import subprocess,time; " + "p=subprocess.Popen(['/bin/sleep','30']); " + "print(p.pid,flush=True); time.sleep(30)" + ) + started = await manager.start( + session_id=session, + run_id=run.id, + caller_id="agent:test", + argv=["/usr/bin/python3", "-c", script], + ) + child_pid = None + for _ in range(100): + logs = manager.logs(started.id, session_id=session, run_id=run.id) + for item in logs: + candidate = item.content.strip() + if item.stream == "stdout" and candidate.isdigit(): + child_pid = int(candidate) + break + if child_pid is not None: + break + await asyncio.sleep(0.01) + assert child_pid is not None + + runtime.agent_runtime.cancel(run.id) + assert ( + await _terminal(manager, started.id, session, run.id) + ).status is ProcessStatus.CANCELLED + alive = True + for _ in range(100): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + alive = False + break + await asyncio.sleep(0.01) + assert alive is False + finally: + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_startup_reaps_verified_orphan_process_group(tmp_path): + runtime = _runtime(tmp_path) + session = _session(runtime) + original = await _manager(runtime) + recovered = await _manager(runtime) + try: + started = await original.start( + session_id=session, + run_id=None, + caller_id="test", + argv=["/bin/sleep", "30"], + ) + live = original._live.pop(started.id) + for task in live.tasks: + task.cancel() + await asyncio.gather(*live.tasks, return_exceptions=True) + # Simulate a fresh runtime observing durable state while the old child + # remains. PID birth-time matching prevents killing a reused PID. + orphan_count = await recovered.startup() + assert orphan_count == 1 + await asyncio.wait_for(live.process.wait(), 1) + record = recovered.status(started.id, session_id=session, run_id=None) + assert record.status is ProcessStatus.ORPHANED + with pytest.raises(ProcessLookupError): + os.kill(started.pid, 0) + finally: + await original.shutdown() + await recovered.shutdown() + + +def test_broker_tokens_are_signed_scoped_and_expiring(monkeypatch): + authority = BrokerAuthority(b"x" * 32) + envelope = authority.issue( + request_id="brq_test", + session_id="ses_one", + run_id="run_one", + operation="process.spawn", + ) + assert ( + authority.verify( + envelope.token, + session_id="ses_one", + run_id="run_one", + operation="process.spawn", + )["nonce"] + == envelope.nonce + ) + with pytest.raises(PermissionError, match="scope mismatch"): + authority.verify( + envelope.token, + session_id="ses_two", + run_id="run_one", + operation="process.spawn", + ) + tampered = envelope.token[:-1] + ("0" if envelope.token[-1] != "0" else "1") + with pytest.raises(PermissionError, match="Invalid"): + authority.verify( + tampered, + session_id="ses_one", + run_id="run_one", + operation="process.spawn", + ) + + +def test_linux_bubblewrap_contract_denies_network_by_default(tmp_path): + root = tmp_path / "workspace" + temporary = tmp_path / "temporary" + root.mkdir() + temporary.mkdir() + adapter = LinuxBubblewrapAdapter("/usr/bin/true") + launch = adapter.wrap( + ("/bin/echo", "ok"), root, temporary, root, network_enabled=False + ) + assert "--die-with-parent" in launch.argv + assert "--unshare-net" in launch.argv + assert launch.enforced is True + + +@pytest.mark.asyncio +async def test_process_tools_require_capabilities_and_network_is_dynamic(tmp_path): + runtime = _runtime(tmp_path) + session = _session(runtime) + tool = runtime.services.get_tool("process.start") + assert runtime.tools.required_capabilities( + tool, {"argv": ["/bin/echo", "x"]} + ) == frozenset({"process.execute"}) + assert runtime.tools.required_capabilities( + tool, {"argv": ["/bin/echo", "x"], "network": True} + ) == frozenset({"process.execute", "network.outbound"}) + with pytest.raises(ToolGatewayError) as denied: + await runtime.tools.execute( + "process.start", + {"argv": ["/bin/echo", "x"]}, + context=ToolCallContext(caller_id="test", session_id=session), + ) + assert denied.value.code == "capability_denied" + + +@pytest.mark.asyncio +@pytest.mark.skipif(platform.system() != "Darwin", reason="macOS Seatbelt contract") +async def test_macos_sandbox_allows_own_workspace_and_denies_another_session(tmp_path): + if not os.path.isfile("/usr/bin/sandbox-exec"): + pytest.skip("sandbox-exec unavailable") + runtime = _runtime(tmp_path) + first = _session(runtime, "Sandbox owner") + second = _session(runtime, "Sandbox foreign") + runtime.workspace.write(first, "own.txt", "own") + runtime.workspace.write(second, "foreign.txt", "foreign") + manager = ProcessManager( + runtime.database, + runtime.events, + runtime.workspace, + sandbox=MacOSSandboxAdapter(), + ) + await manager.startup() + try: + own_path = str(runtime.workspace._resolve(first, "own.txt")) + own = await manager.start( + session_id=first, + run_id=None, + caller_id="test", + argv=["/bin/cat", own_path], + ) + own_done = await _terminal(manager, own.id, first) + if own_done.status is ProcessStatus.FAILED: + diagnostic = "".join( + item.content + for item in manager.logs(own.id, session_id=first, run_id=None) + ) + if "sandbox_apply: Operation not permitted" in diagnostic: + pytest.skip( + "test runner itself forbids applying a nested Seatbelt profile" + ) + assert own_done.status is ProcessStatus.EXITED + + foreign_path = str(runtime.workspace._resolve(second, "foreign.txt")) + foreign = await manager.start( + session_id=first, + run_id=None, + caller_id="test", + argv=["/bin/cat", foreign_path], + ) + denied = await _terminal(manager, foreign.id, first) + assert denied.status is ProcessStatus.FAILED + assert "foreign" not in "".join( + item.content + for item in manager.logs(foreign.id, session_id=first, run_id=None) + if item.stream == "stdout" + ) + finally: + await manager.shutdown() diff --git a/tests/test_ai2apps_secrets.py b/tests/test_ai2apps_secrets.py new file mode 100644 index 00000000..e308278a --- /dev/null +++ b/tests/test_ai2apps_secrets.py @@ -0,0 +1,129 @@ +"""Secret Store value isolation, Tool scoping, injection, and redaction.""" + +from __future__ import annotations + +import httpx +import pytest +from fastapi import FastAPI + +from ai2apps.api.router import create_ai2apps_router +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.secrets import ( + EncryptedFileSecretBackend, + MemorySecretBackend, + SecretBackendError, + SecretRepository, + select_secret_backend_name, +) +from ai2apps.services import ToolCallContext + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + backend = MemorySecretBackend() + runtime.secrets = SecretRepository(runtime.database, runtime.events, backend) + runtime.tools.bind_secret_resolver(runtime.secrets.inject_arguments) + return runtime, backend + + +@pytest.mark.asyncio +async def test_secret_metadata_never_persists_value_and_tool_output_is_redacted(tmp_path): + runtime, backend = _runtime(tmp_path) + secret = runtime.secrets.create( + name="demo", value="never-log-this", purpose="test", + allowed_tools=("system.echo",), + ) + assert secret.uri == f"secret://{secret.id}" + assert backend.values[secret.id] == "never-log-this" + + with runtime.database.transaction() as connection: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(secret_records)") + } + stored = connection.execute( + "SELECT * FROM secret_records WHERE id = ?", (secret.id,) + ).fetchone() + assert "value" not in columns + assert "never-log-this" not in str(tuple(stored)) + + result = await runtime.tools.execute( + "system.echo", {"value": secret.uri}, + context=ToolCallContext(caller_id="agent:test"), + ) + assert result.output == {"value": "[secret]"} + invocation = runtime.services.get_invocation(result.invocation_id) + assert invocation.arguments == {"value": secret.uri} + assert invocation.output == {"value": "[secret]"} + + +def test_secret_is_rejected_outside_allowed_tool_scope(tmp_path): + runtime, _ = _runtime(tmp_path) + secret = runtime.secrets.create( + name="scoped", value="credential", allowed_tools=("browser.*",) + ) + with pytest.raises(Exception, match="not allowed"): + runtime.secrets.inject_arguments({"value": secret.uri}, "system.echo") + + +@pytest.mark.asyncio +async def test_secret_api_returns_metadata_only(tmp_path): + runtime, backend = _runtime(tmp_path) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post("/v1/platform/secrets", json={ + "name": "api", "value": "api-value", + "purpose": "API test", "allowed_tools": ["system.echo"], + }) + assert response.status_code == 201 + body = response.json() + assert "value" not in body + assert body["uri"].startswith("secret://sec_") + listed = (await client.get("/v1/platform/secrets")).json()["items"] + assert listed == [body] + assert "api-value" not in str(body) + assert backend.values[body["id"]] == "api-value" + provider = (await client.get("/v1/platform/secrets/backend")).json() + assert provider == {"provider": "memory", "portable": False} + + +def test_platform_backend_selection_is_deterministic(): + assert select_secret_backend_name(system="Darwin", environ={}) == "macos-keychain" + assert select_secret_backend_name( + system="Linux", environ={}, executable_lookup=lambda _: "/usr/bin/secret-tool" + ) == "encrypted-file" + assert select_secret_backend_name( + system="Linux", + environ={"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1/bus"}, + executable_lookup=lambda _: "/usr/bin/secret-tool", + ) == "linux-secret-service" + assert select_secret_backend_name( + system="Darwin", environ={"AI2APPS_SECRET_BACKEND": "encrypted-file"} + ) == "encrypted-file" + + +def test_encrypted_file_backend_persists_ciphertext_and_rejects_wrong_key(tmp_path): + directory = tmp_path / "secrets" + backend = EncryptedFileSecretBackend(directory, key_material="correct-key") + backend.store("sec_test", "not-plaintext") + assert backend.load("sec_test") == "not-plaintext" + assert b"not-plaintext" not in backend.vault_path.read_bytes() + assert backend.vault_path.stat().st_mode & 0o777 == 0o600 + + reopened = EncryptedFileSecretBackend(directory, key_material="correct-key") + assert reopened.load("sec_test") == "not-plaintext" + wrong = EncryptedFileSecretBackend(directory, key_material="wrong-key") + with pytest.raises(SecretBackendError, match="locked or corrupted"): + wrong.load("sec_test") + + +def test_runtime_can_explicitly_use_portable_vault(tmp_path): + runtime = PlatformRuntime( + PlatformConfig.from_base_path(tmp_path, secret_backend="encrypted-file") + ) + runtime.start() + assert runtime.secrets.backend.provider_name == "encrypted-file" diff --git a/tests/test_ai2apps_shell.py b/tests/test_ai2apps_shell.py new file mode 100644 index 00000000..486b64a2 --- /dev/null +++ b/tests/test_ai2apps_shell.py @@ -0,0 +1,740 @@ +"""Contract tests for the AI2Apps WebUI Shell migration slice.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime +from omlx.admin import routes as admin_routes + +WEB_ROOT = Path(__file__).parents[1] / "ai2apps" / "web" + + +def test_system_app_catalog_covers_legacy_omlx_surfaces(): + assert {app["id"] for app in admin_routes.SYSTEM_APPS} == { + "ai2apps.dashboard", + "ai2apps.account", + "ai2apps.models", + "ai2apps.discover", + "ai2apps.agents", + "ai2apps.general-chat", + "ai2apps.trust-center", + "ai2apps.settings", + "ai2apps.logs", + "ai2apps.terminal", + "ai2apps.coder", + "ai2apps.benchmark", + } + assert all(app["singleton"] for app in admin_routes.SYSTEM_APPS) + + +def test_shell_router_exposes_singleton_and_instance_urls(): + paths = {route.path for route in admin_routes.shell_router.routes} + assert "/apps/{app_id}" in paths + assert "/apps/{app_id}/instances/{instance_id}" in paths + assert "/mobile" in paths + assert "/mobile/complete" in paths + assert "/mobile/static/{path:path}" in paths + assert "/v1/mobile/session/exchange" in paths + assert "/v1/mobile/apps" in paths + assert "/v1/mobile/models" in paths + assert "/v1/mobile/chat/completions" in paths + assert "/v1/mobile/chat/state" in paths + assert "/v1/mobile/chat/threads" in paths + assert "/v1/mobile/chat/threads/{thread_id}/content" in paths + assert "/v1/mobile/chat/threads/{thread_id}/attachments" in paths + assert "/v1/mobile/agents" in paths + assert "/v1/mobile/chat/threads/{thread_id}/agent-runs" in paths + assert "/v1/mobile/agent-runs/{run_id}" in paths + assert "/mobile/chat" in paths + + +def test_mobile_shell_contract_exposes_dock_launcher_and_switcher(): + template = (WEB_ROOT / "templates" / "mobile.html").read_text() + script = (WEB_ROOT / "static" / "js" / "mobile.js").read_text() + styles = (WEB_ROOT / "static" / "css" / "mobile.css").read_text() + base = (WEB_ROOT / "templates" / "mobile_base.html").read_text() + + assert "mobile-dock" in template + assert 'data-mobile-overlay="launcher"' in template + assert 'data-mobile-overlay="switcher"' in template + assert "/v1/mobile/apps" in script + assert "/v1/mobile/mounts" in script + assert "/v1/mobile/session/exchange" in script + assert "warmFrameLimit = 2" in script + assert "env(safe-area-inset-bottom)" in styles + assert "/admin/static/" not in template + assert "/admin/static/" not in base + assert "mobile_static('css/mobile.css')" in template + assert "mobile_static('js/lucide.min.js')" in base + mobile_chat = (WEB_ROOT / "templates" / "mobile_chat.html").read_text() + mobile_chat_script = (WEB_ROOT / "static" / "js" / "mobile_chat.js").read_text() + assert "Your local API key is never sent to the phone" in mobile_chat + assert '{% extends "mobile_base.html" %}' in mobile_chat + assert "/admin/static/" not in mobile_chat + assert "mobile_static('css/mobile_chat.css')" in mobile_chat + assert "mobile_static('js/mobile_chat.js')" in mobile_chat + assert "" + b"" + b"

Browser runtime works

" + b"

Visible evidence introduces a substantial local article used to verify reader mode. " + b"The managed browser extracts rendered content without modifying the page that the user sees.

" + b"

Reader mode should retain paragraphs, links, headings, code examples, and useful tables. " + b"It should discard navigation, forms, advertising, and unrelated recommendations around the story.

" + b"

Safe extraction

" + b"

Hidden page content is removed using computed browser styles before article scoring begins. " + b"This matters because invisible text can waste context or attempt to influence an AI agent.

" + b"

Relative source link becomes absolute, while unsafe active content is removed. " + b"The result can be returned as semantic HTML or compact Markdown for model consumption.

" + b"
print('reader mode')
" + b"

The final paragraph makes the fixture long enough for strict Readability extraction. " + b"Metadata and extraction diagnostics remain separate from the canonical article content.

" + b"

HIDDEN-DISPLAY

" + b"

HIDDEN-VISIBILITY

" + b"

HIDDEN-OPACITY

" + b"" + b"
Hover target revealed
" + b"" + b"" + b"Download fixture" + b"" + b"" + b"
" + b"" + ) + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +async def run(profile_path: str) -> dict: + backend = ChromeBrowserBackend(BrowserRuntimeConfig(profile_path=profile_path)) + test_root = Path(profile_path).parent + backend.set_download_directory(test_root / "downloads") + upload_source = test_root / "upload-fixture.txt" + upload_source.write_text("browser upload fixture", encoding="utf-8") + manager = BrowserManager(backend) + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_port}" + try: + started = await manager.start(session_id="browser-smoke") + await manager.navigate(base_url, session_id="browser-smoke") + snapshot = await manager.snapshot(session_id="browser-smoke") + repeated_snapshot = await manager.snapshot(session_id="browser-smoke") + full_snapshot = await manager.snapshot( + session_id="browser-smoke", html_mode="full" + ) + shadow_input_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Shadow input" + ) + shadow_action_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Shadow action" + ) + frame_input_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Frame input" + ) + frame_action_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Frame action" + ) + await manager.type_text( + shadow_input_ref, + "inside shadow", + session_id="browser-smoke", + clear=True, + input_mode="instant", + ) + await manager.click( + shadow_action_ref, session_id="browser-smoke", commit=False + ) + await manager.type_text( + frame_input_ref, + "inside frame", + session_id="browser-smoke", + clear=True, + input_mode="instant", + ) + await manager.click(frame_action_ref, session_id="browser-smoke", commit=False) + backend.driver.switch_to.frame( + backend.driver.find_element("css selector", "#fixture-frame") + ) + frame_state = backend.driver.execute_script( + "return {value:document.querySelector('#frame-input').value," + "result:document.querySelector('#frame-result').textContent}" + ) + backend.driver.switch_to.default_content() + shadow_state = backend.driver.execute_script( + "const r=document.querySelector('#shadow-host').shadowRoot;" + "return {value:r.querySelector('input').value," + "result:r.querySelector('output').textContent}" + ) + continue_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Continue" + ) + backend.driver.execute_script( + "document.querySelector('#continue').outerHTML=''" + ) + relocated_wait = await manager.wait_for( + session_id="browser-smoke", + condition="element", + target=continue_ref, + state="clickable", + timeout_ms=2_000, + ) + async_wait = await manager.wait_for( + session_id="browser-smoke", + condition="text", + text="async ready", + timeout_ms=2_000, + ) + stable_wait = await manager.wait_for( + session_id="browser-smoke", + condition="page_stable", + timeout_ms=2_000, + stable_ms=200, + ) + backend.upload_file("#file-input", upload_source) + uploaded_name = backend.driver.execute_script( + "return document.querySelector('#file-result').textContent" + ) + download_ref = next( + item["ref"] + for item in snapshot["snapshot"]["items"] + if item["text"] == "Download fixture" + ) + await manager.click( + download_ref, session_id="browser-smoke", commit=False, duration_ms=180 + ) + staged_downloads = backend.staged_downloads(wait_ms=3_000) + original_tab = (await manager.list_tabs(session_id="browser-smoke"))["tabs"][0]["id"] + opened_tab = await manager.open_tab( + session_id="browser-smoke", url=base_url + "?tab=second" + ) + tab_count = len((await manager.list_tabs(session_id="browser-smoke"))["tabs"]) + await manager.switch_tab(original_tab, session_id="browser-smoke") + await manager.close_tab(opened_tab["opened_tab"], session_id="browser-smoke") + article = await manager.read_article( + session_id="browser-smoke", output_format="both" + ) + hovered = await manager.hover( + "#hover-target", session_id="browser-smoke", duration_ms=280 + ) + hover_visible = backend.driver.execute_script( + "return getComputedStyle(document.querySelector('#hover-result')).display !== 'none'" + ) + await manager.type_text( + "#typing-target", + "hello", + session_id="browser-smoke", + clear=True, + input_mode="natural", + delay_ms=2, + ) + await manager.key_press( + "ARROW_DOWN", session_id="browser-smoke", target="#typing-target" + ) + await manager.click( + continue_ref, session_id="browser-smoke", commit=False, duration_ms=220 + ) + interaction_state = backend.driver.execute_script( + "return {value:document.querySelector('#typing-target').value," + "key:document.querySelector('#key-result').textContent," + "click:document.querySelector('#click-result').textContent}" + ) + relocated_ref = backend.driver.execute_script( + "return document.querySelector('#continue').getAttribute('data-ai2apps-ref')" + ) + observation = await manager.observe_changes(session_id="browser-smoke") + challenge = await manager.navigate( + base_url + "/login-frame", session_id="browser-smoke" + ) + return { + "started": started, + "snapshot_title": snapshot["snapshot"]["title"], + "snapshot_items": snapshot["snapshot"]["items"], + "snapshot_refs_stable": [ + item["ref"] for item in snapshot["snapshot"]["items"] + ] + == [item["ref"] for item in repeated_snapshot["snapshot"]["items"]], + "snapshot_text": snapshot["snapshot"]["text"], + "snapshot_html": snapshot["snapshot"]["html"], + "snapshot_has_layout": "data-ai2apps-rect=" in snapshot["snapshot"]["html"], + "hidden_text_removed": not any( + marker in snapshot["snapshot"]["text"] + for marker in ( + "HIDDEN-DISPLAY", + "HIDDEN-VISIBILITY", + "HIDDEN-OPACITY", + "HIDDEN-ARIA", + ) + ), + "full_html_has_hidden": "HIDDEN-DISPLAY" + in full_snapshot["snapshot"]["html"], + "article": article["article"], + "natural_interactions": { + "hover": hovered, + "hover_visible": hover_visible, + "relocated_wait": relocated_wait["wait"], + "relocated_ref": relocated_ref, + "async_wait": async_wait["wait"], + "stable_wait": stable_wait["wait"], + "uploaded_name": uploaded_name, + "staged_downloads": staged_downloads, + "tab_count_during_test": tab_count, + "observation": observation["observation"], + **interaction_state, + }, + "nested_contexts": { + "frame_ref": frame_action_ref, + "frame": frame_state, + "shadow_ref": shadow_action_ref, + "shadow": shadow_state, + "shadow_serialized": "data-ai2apps-shadow-root" + in snapshot["snapshot"]["html"], + "frame_serialized": "data-ai2apps-frame-context" + in snapshot["snapshot"]["html"], + }, + "challenge": challenge, + } + finally: + await manager.close() + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def verify(result: dict) -> None: + interactions = result["natural_interactions"] + nested = result["nested_contexts"] + checks = { + "stable_refs": result["snapshot_refs_stable"], + "hidden_content_filtered": result["hidden_text_removed"], + "stale_ref_relocated": interactions["relocated_wait"]["satisfied"], + "page_wait": interactions["stable_wait"]["satisfied"], + "upload": interactions["uploaded_name"] == "upload-fixture.txt", + "download": bool(interactions["staged_downloads"]["complete"]), + "tabs": interactions["tab_count_during_test"] == 2, + "shadow_input": nested["shadow"]["value"] == "inside shadow", + "shadow_click": nested["shadow"]["result"] == "shadow clicked", + "frame_input": nested["frame"]["value"] == "inside frame", + "frame_click": nested["frame"]["result"] == "frame clicked", + "nested_serialization": nested["shadow_serialized"] + and nested["frame_serialized"], + "framed_login_handoff": result["challenge"]["state"] == "user_required", + } + failed = [name for name, passed in checks.items() if not passed] + if failed: + raise RuntimeError("Browser smoke checks failed: " + ", ".join(failed)) + result["checks"] = checks + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--profile") + args = parser.parse_args() + if args.profile: + result = asyncio.run(run(args.profile)) + verify(result) + print(json.dumps(result, indent=2)) + return + with tempfile.TemporaryDirectory(prefix="ai2apps-browser-smoke-") as temp: + result = asyncio.run(run(str(Path(temp) / "profile"))) + verify(result) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ai2apps_agent_model_stream.py b/tests/test_ai2apps_agent_model_stream.py new file mode 100644 index 00000000..922d701d --- /dev/null +++ b/tests/test_ai2apps_agent_model_stream.py @@ -0,0 +1,183 @@ +"""Agent model-stream reconstruction and durable progress tests.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from ai2apps.agents import AgentRunStatus +from ai2apps.agents.model_stream import ChatCompletionStreamAccumulator +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime + + +def test_chat_completion_stream_accumulates_content_reasoning_tools_and_usage(): + stream = ChatCompletionStreamAccumulator() + stream.add( + { + "id": "chat-1", + "created": 12, + "model": "agent-model", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": "check ", + "tool_calls": [{ + "index": 0, "id": "call_1", "type": "function", + "function": {"name": "web.", "arguments": '{"query":"ML'}, + }], + }, + }], + } + ) + stream.add( + { + "id": "chat-1", + "model": "agent-model", + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": "sources", + "tool_calls": [{ + "index": 0, + "function": {"name": "search", "arguments": 'X"}'}, + }], + }, + "finish_reason": "tool_calls", + }], + } + ) + stream.add( + { + "choices": [], + "usage": {"prompt_tokens": 20, "completion_tokens": 4, "total_tokens": 24}, + } + ) + + result = stream.result() + message = result["choices"][0]["message"] + assert result["object"] == "chat.completion" + assert message["reasoning_content"] == "check sources" + assert message["tool_calls"] == [{ + "id": "call_1", + "type": "function", + "function": {"name": "web.search", "arguments": '{"query":"MLX"}'}, + }] + assert result["choices"][0]["finish_reason"] == "tool_calls" + assert result["usage"]["completion_tokens"] == 4 + assert stream.has_tool_calls is True + + +def test_chat_completion_stream_preserves_cloud_settlement_and_rejects_failure(): + completed = ChatCompletionStreamAccumulator() + completed.add( + { + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "content": "done", + "ai2apps_cloud": { + "phase": "completed", + "requestId": "req-agent", + "charged": "4", + "balance": "996", + }, + }, + "finish_reason": "stop", + }], + } + ) + result = completed.result() + assert result["ai2apps_cloud"][0]["requestId"] == "req-agent" + assert result["ai2apps_cloud"][0]["charged"] == "4" + + failed = ChatCompletionStreamAccumulator() + failed.add( + { + "choices": [{ + "index": 0, + "delta": { + "ai2apps_cloud": { + "phase": "failed", + "requestId": "req-failed", + "error": { + "code": "INSUFFICIENT_POINTS", + "message": "Not enough points", + }, + } + }, + "finish_reason": "stop", + }], + } + ) + with pytest.raises(ValueError, match="INSUFFICIENT_POINTS"): + failed.result() + + +@pytest.mark.asyncio +async def test_progress_aware_model_provider_updates_durable_agent_status(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.agent_runtime is not None + assert runtime.agents is not None + progress_visible = asyncio.Event() + release = asyncio.Event() + + async def provider(request, report_progress): + assert request["model"] == "progress-model" + await report_progress( + { + "phase": "model_streaming", + "text": "Receiving the model plan", + "presentation": "indeterminate", + "content": { + "detail": "Streaming model output · 128 characters", + "output_characters": 128, + }, + } + ) + progress_visible.set() + await release.wait() + return {"choices": [{"message": {"role": "assistant", "content": "finished"}}]} + + runtime.agent_runtime.bind_model_provider(provider) + thread, _ = ChatRepository(runtime.database, runtime.events).create_thread( + title="Model progress" + ) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=thread.session.id, + agent_key="ai2apps.diagnostic-agent", + input={ + "mode": "model", + "request": { + "model": "progress-model", + "messages": [{"role": "user", "content": "hello"}], + }, + }, + ) + runtime.agent_runtime.wake() + await asyncio.wait_for(progress_visible.wait(), timeout=2) + + status = runtime.agents.get_status_line(run.id) + assert status.phase == "model_streaming" + assert status.text == "Receiving the model plan" + assert status.presentation == "indeterminate" + assert status.content["output_characters"] == 128 + assert status.content["model"] == "progress-model" + assert status.content["step"] == 1 + assert "agent.status" in [ + event.type for event in runtime.events.list_after(subject_id=run.id, limit=100) + ] + + release.set() + await runtime.agent_runtime.wait_for_terminal(run.id, timeout=2) + assert runtime.agents.get_run(run.id).status is AgentRunStatus.COMPLETED + finally: + release.set() + await runtime.stop_background_tasks() diff --git a/tests/test_ai2apps_agents.py b/tests/test_ai2apps_agents.py new file mode 100644 index 00000000..5a46ed42 --- /dev/null +++ b/tests/test_ai2apps_agents.py @@ -0,0 +1,1806 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Asynchronous Agent scheduling, interaction, recovery, and API contracts.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import httpx +import pytest +from fastapi import FastAPI + +from ai2apps.agents import ( + AgentRunStatus, + CompleteAction, + InteractionAction, + InteractionKind, + RunStepStatus, + ToolCallAction, +) +from ai2apps.agents.general import _provider_tool_schema +from ai2apps.api.router import create_ai2apps_router +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.core import MessageRole, ResourceConflictError, format_utc +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ServiceInstanceStatus, ServiceRuntimeMode +from ai2apps.storage import MessagePartInput +from ai2apps.storage.repositories import MessageRepository + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + assert runtime.agents is not None + assert runtime.agent_runtime is not None + assert runtime.services is not None + assert runtime.service_registry is not None + assert runtime.tools is not None + return runtime + + +def test_provider_tool_schema_removes_only_root_composition_keywords(): + source = { + "type": "object", + "properties": { + "target": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "x": {"type": "integer"}, + }, + "anyOf": [{"required": ["target"]}, {"required": ["x"]}], + "additionalProperties": False, + } + + normalized = _provider_tool_schema(source) + + assert normalized["type"] == "object" + assert "anyOf" not in normalized + assert "anyOf" in normalized["properties"]["target"] + assert source["anyOf"] == [{"required": ["target"]}, {"required": ["x"]}] + + +def _session(runtime, title="Agent test"): + thread, _ = ChatRepository(runtime.database, runtime.events).create_thread( + title=title + ) + return thread.session.id + + +async def _wait_status(runtime, run_id, statuses, timeout=3.0): + statuses = {statuses} if isinstance(statuses, AgentRunStatus) else set(statuses) + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + run = runtime.agents.get_run(run_id) + if run.status in statuses: + return run + await asyncio.sleep(0.01) + raise AssertionError( + f"Run {run_id} did not reach {sorted(item.value for item in statuses)}; " + f"current={runtime.agents.get_run(run_id).status.value}" + ) + + +def test_run_creation_is_idempotent_and_always_has_status_line(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + + first, created = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"hello": "world"}, + idempotency_key="request-1", + ) + replay, replay_created = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"hello": "world"}, + idempotency_key="request-1", + ) + + assert created is True + assert replay_created is False + assert replay.id == first.id + status = runtime.agents.get_status_line(first.id) + assert status.phase == "queued" + assert status.text + with pytest.raises(ResourceConflictError): + runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"different": True}, + idempotency_key="request-1", + ) + + +def test_delegated_runs_persist_tree_and_enforce_depth(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + parent, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"value": "parent"}, + ) + runtime.agents.transition( + parent.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + parent.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + child, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"value": "child"}, + parent_run_id=parent.id, + delegation={ + "request_key": "child-one", + "task": "Run the child", + "parameters": {}, + "context": {}, + "budget": {"max_steps": 2, "timeout_seconds": 30}, + }, + ) + + assert child.parent_run_id == parent.id + assert child.root_run_id == parent.id + assert child.depth == 1 + assert runtime.agents.list_children(parent.id) == (child,) + assert runtime.agents.get_delegated_child(parent.id, "child-one") == child + + runtime.agents.transition( + child.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + child.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + grandchild, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"value": "grandchild"}, + parent_run_id=child.id, + delegation={ + "request_key": "grandchild-one", + "task": "Run the grandchild", + "parameters": {}, + "context": {}, + "budget": {}, + }, + ) + assert grandchild.root_run_id == parent.id + assert grandchild.depth == 2 + runtime.agents.transition( + grandchild.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + grandchild.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + with pytest.raises(ResourceConflictError, match="depth"): + runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"value": "too deep"}, + parent_run_id=grandchild.id, + delegation={ + "request_key": "too-deep", + "task": "This must be rejected", + "parameters": {}, + "context": {}, + "budget": {}, + }, + ) + + +@pytest.mark.asyncio +async def test_agent_delegate_tool_runs_child_and_returns_output(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + runtime.agents.ensure_definition( + agent_key="test.delegate-parent", + package_version="1.0.0", + display_name="Delegate parent", + executor_key="test:delegate-parent", + ) + runtime.agents.ensure_definition( + agent_key="test.delegate-child", + package_version="1.0.0", + display_name="Delegate child", + executor_key="test:delegate-child", + ) + + def parent_executor(context): + step = context.step("delegate:one") + if step is None: + return ToolCallAction( + call_id="delegate:one", + tool_name="agent.delegate", + arguments={ + "agent": "test.delegate-child", + "task": "Return a bounded result", + "request_key": "one", + "parameters": {}, + "budget": {"max_steps": 2, "timeout_seconds": 30}, + }, + ) + return CompleteAction({"delegation_result": step.output}) + + runtime.agent_runtime.bind_executor("test:delegate-parent", parent_executor) + runtime.agent_runtime.bind_executor( + "test:delegate-child", lambda context: CompleteAction({"child": context.run.id}) + ) + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="test.delegate-parent", + input={}, + ) + await runtime.agent_runtime.start() + try: + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + finally: + await runtime.agent_runtime.stop() + + children = runtime.agents.list_children(run.id) + assert len(children) == 1 + assert children[0].status is AgentRunStatus.COMPLETED + result = completed.output["delegation_result"] + assert result["child_run_id"] == children[0].id + assert result["output"] == {"child": children[0].id} + + +def test_parent_cancel_cascades_to_active_children(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + parent, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={}, + ) + runtime.agents.transition( + parent.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + parent.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + child, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={}, + parent_run_id=parent.id, + delegation={ + "request_key": "cancel-child", + "task": "Wait", + "parameters": {}, + "context": {}, + "budget": {}, + }, + ) + + runtime.agent_runtime.cancel(parent.id) + + assert runtime.agents.get_run(parent.id).status is AgentRunStatus.CANCELLED + assert runtime.agents.get_run(child.id).status is AgentRunStatus.CANCELLED + + +def test_agent_invocation_schema_validates_parameters_and_snapshots_identity(tmp_path): + runtime = _runtime(tmp_path) + runtime.agents.ensure_definition( + agent_key="test.parameterized", + package_version="2.3.4", + display_name="Parameterized", + executor_key="test:parameterized", + manifest={ + "invocation_schema": { + "type": "object", + "properties": {"tone": {"type": "string", "enum": ["brief"]}}, + "required": ["tone"], + "additionalProperties": False, + } + }, + ) + session_id = _session(runtime) + + with pytest.raises(ValueError, match="tone.*required"): + runtime.agents.create_run( + session_id=session_id, + agent_key="test.parameterized", + input={"prompt": "Hello", "parameters": {}}, + ) + + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="test.parameterized", + input={ + "prompt": "Hello", + "parameters": {"tone": "brief"}, + "invocation": {"source": "mention", "package_version": "forged"}, + }, + ) + + assert run.input["parameters"] == {"tone": "brief"} + assert run.input["invocation"] == { + "agent_definition_id": run.agent_definition_id, + "agent_key": "test.parameterized", + "package_version": "2.3.4", + "source": "mention", + } + + +def test_builtin_agent_metadata_refreshes_without_replacing_definition(tmp_path): + runtime = _runtime(tmp_path) + first = runtime.agents.ensure_definition( + agent_key="test.refreshable-builtin", + package_version="1.0.0", + display_name="Old name", + executor_key="test:refreshable", + manifest={"discoverable": False}, + ) + second = runtime.agents.ensure_definition( + agent_key="test.refreshable-builtin", + package_version="1.1.0", + display_name="New name", + executor_key="test:refreshable", + manifest={"discoverable": True, "aliases": ["fresh"]}, + ) + + assert second.id == first.id + assert second.package_version == "1.1.0" + assert second.display_name == "New name" + assert second.manifest["aliases"] == ["fresh"] + + +@pytest.mark.asyncio +async def test_diagnostic_agent_completes_asynchronously_with_replayable_status( + tmp_path, +): + runtime = _runtime(tmp_path) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={"message": "hello"}, + ) + runtime.agent_runtime.wake() + + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + status = runtime.agents.get_status_line(run.id) + events = runtime.events.list_after(subject_id=run.id, limit=100) + + assert completed.output == {"echo": {"message": "hello"}} + assert status.phase == "completed" + assert status.text == "Completed" + assert "agent.run.queued" in [event.type for event in events] + assert "agent.status" in [event.type for event in events] + assert "agent.run.completed" in [event.type for event in events] + assert all(event.session_id == run.session_id for event in events) + finally: + await runtime.stop_background_tasks() + + +def test_waiting_and_paused_time_do_not_consume_run_deadline(tmp_path): + runtime = _runtime(tmp_path) + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={"mode": "text"}, + ) + runtime.agents.claim_next() + runtime.agents.transition( + run.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + interaction = runtime.agents.create_interaction( + run.id, + request_key="deadline-input", + kind=InteractionKind.TEXT, + prompt="Continue", + response_schema={"type": "object"}, + ) + now = datetime.now(UTC) + with runtime.database.transaction(write=True) as connection: + connection.execute( + "UPDATE agent_runs SET deadline_at=?, updated_at=? WHERE id=?", + ( + format_utc(now + timedelta(seconds=10)), + format_utc(now - timedelta(seconds=90)), + run.id, + ), + ) + runtime.agents.respond_interaction( + run.id, + interaction.id, + response={}, + response_id="deadline-response", + ) + resumed = runtime.agents.get_run(run.id) + assert resumed.deadline_at > now + timedelta(seconds=95) + + paused = runtime.agents.request_pause(run.id) + with runtime.database.transaction(write=True) as connection: + connection.execute( + "UPDATE agent_runs SET updated_at=? WHERE id=?", + (format_utc(now - timedelta(seconds=60)), paused.id), + ) + resumed_again = runtime.agents.resume_interrupted(paused.id) + assert resumed_again.deadline_at > resumed.deadline_at + timedelta(seconds=55) + + +@pytest.mark.asyncio +async def test_shutdown_freezes_queued_deadline_and_startup_recovers(tmp_path): + runtime = _runtime(tmp_path) + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={"message": "survive restart"}, + ) + original_deadline = run.deadline_at + await runtime.agent_runtime.stop() + suspended = runtime.agents.get_run(run.id) + assert suspended.error == {"code": "runtime_stopped"} + with runtime.database.transaction(write=True) as connection: + connection.execute( + "UPDATE agent_runs SET updated_at=? WHERE id=?", + (format_utc(datetime.now(UTC) - timedelta(seconds=120)), run.id), + ) + + recovery = runtime.agents.recover_interrupted() + recovered = runtime.agents.get_run(run.id) + assert recovery["recovered"] == 1 + assert recovered.error is None + assert recovered.deadline_at > original_deadline + timedelta(seconds=115) + + +def test_failed_run_retry_is_fresh_bounded_and_auditable(tmp_path): + runtime = _runtime(tmp_path) + original, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={"message": "retry me"}, + ) + runtime.agents.transition( + original.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + original.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.FAILED, + error={"code": "transient"}, + ) + + retried, created = runtime.agents.retry_run( + original.id, idempotency_key="retry-attempt-1" + ) + replay, replay_created = runtime.agents.retry_run( + original.id, idempotency_key="retry-attempt-1" + ) + assert created is True + assert replay_created is False + assert replay.id == retried.id + assert retried.status is AgentRunStatus.QUEUED + assert retried.input["retry"] == { + "attempt": 1, + "retry_of_run_id": original.id, + "root_attempt_run_id": original.id, + } + with pytest.raises(ResourceConflictError, match="failed or cancelled"): + runtime.agents.retry_run(retried.id) + current = retried + for expected_attempt in (2, 3): + runtime.agents.transition( + current.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + current.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.FAILED, + error={"code": "again"}, + ) + current, _ = runtime.agents.retry_run(current.id) + assert current.input["retry"]["attempt"] == expected_attempt + runtime.agents.transition( + current.id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + current.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.FAILED, + error={"code": "limit"}, + ) + with pytest.raises(ResourceConflictError, match="retry limit"): + runtime.agents.retry_run(current.id) + + +def test_general_agent_is_serialized_for_single_user_runtime(tmp_path): + runtime = _runtime(tmp_path) + definition = runtime.agents.get_definition("ai2apps.general-agent") + assert definition.concurrency_group == "model:foreground" + assert definition.concurrency_limit == 1 + + +def test_per_run_budget_is_bounded_by_agent_definition(tmp_path): + runtime = _runtime(tmp_path) + before = datetime.now(UTC) + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.general-agent", + input={"content": "bounded"}, + budget={ + "max_steps": 2, + "timeout_seconds": 30, + "max_model_tokens": 500, + }, + ) + assert run.input["run_budget"] == { + "max_steps": 2, + "timeout_seconds": 30, + "max_model_tokens": 500, + } + assert runtime.agent_runtime._step_budget( + run, runtime.agents.get_definition(run.agent_definition_id) + ) == 2 + assert before + timedelta(seconds=29) < run.deadline_at + assert run.deadline_at <= before + timedelta(seconds=31) + + +@pytest.mark.asyncio +async def test_agent_api_exposes_budget_usage_and_retry_endpoint(tmp_path): + runtime = _runtime(tmp_path) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + session_id = _session(runtime) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + created = await client.post( + f"/v1/platform/sessions/{session_id}/agent-runs", + json={ + "agent": "ai2apps.diagnostic-agent", + "input": {"message": "retry API"}, + "budget": {"max_steps": 2, "timeout_seconds": 20}, + }, + ) + assert created.status_code == 202 + payload = created.json() + assert payload["budget"]["max_steps"] == 2 + assert payload["budget"]["timeout_seconds"] == 20 + assert payload["usage"] == {"steps": 0, "model_tokens": 0} + run_id = payload["id"] + runtime.agents.transition( + run_id, + expected={AgentRunStatus.QUEUED}, + status=AgentRunStatus.PLANNING, + ) + runtime.agents.transition( + run_id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.FAILED, + error={"code": "test_failure"}, + ) + retried = await client.post( + f"/v1/platform/agent-runs/{run_id}/retry", + json={"idempotency_key": "api-retry-1"}, + ) + assert retried.status_code == 202 + assert retried.json()["input"]["retry"]["retry_of_run_id"] == run_id + + +@pytest.mark.asyncio +async def test_waiting_interaction_releases_serial_resource_slot(tmp_path): + runtime = _runtime(tmp_path) + observed = [] + + async def executor(context): + if context.run.input.get("wait"): + interaction = context.interaction("choice") + if interaction is None: + return InteractionAction( + request_key="choice", + kind=InteractionKind.MENU, + prompt="Choose", + response_schema={ + "type": "object", + "properties": { + "choice": {"type": "string", "enum": ["yes", "no"]} + }, + "required": ["choice"], + }, + ui_hints={"control": "menu"}, + ) + return CompleteAction({"choice": interaction.response["choice"]}) + observed.append(context.run.id) + return CompleteAction({"second": True}) + + runtime.agents.ensure_definition( + agent_key="test.serial-agent", + package_version="1", + display_name="Serial Agent", + executor_key="test:serial", + concurrency_group="hardware:exclusive-test", + concurrency_limit=1, + ) + runtime.agent_runtime.bind_executor("test:serial", executor) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + first, _ = runtime.agents.create_run( + session_id=_session(runtime, "first"), + agent_key="test.serial-agent", + input={"wait": True}, + ) + second, _ = runtime.agents.create_run( + session_id=_session(runtime, "second"), + agent_key="test.serial-agent", + input={"wait": False}, + ) + runtime.agent_runtime.wake() + + waiting = await _wait_status(runtime, first.id, AgentRunStatus.WAITING_INPUT) + completed_second = await _wait_status( + runtime, second.id, AgentRunStatus.COMPLETED + ) + interaction = runtime.agents.list_interactions(first.id)[0] + + assert waiting.status is AgentRunStatus.WAITING_INPUT + assert completed_second.output == {"second": True} + assert observed == [second.id] + runtime.agents.respond_interaction( + first.id, + interaction.id, + response={"choice": "yes"}, + response_id="answer-1", + ) + runtime.agent_runtime.wake() + completed_first = await _wait_status( + runtime, first.id, AgentRunStatus.COMPLETED + ) + assert completed_first.output == {"choice": "yes"} + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_shared_resource_group_serializes_and_unbounded_agent_runs_parallel( + tmp_path, +): + runtime = _runtime(tmp_path) + active = 0 + max_active = 0 + + async def measured(context): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.04) + active -= 1 + return CompleteAction({"run": context.run.id}) + + for agent_key, executor_key in ( + ("test.hw-a", "test:hw-a"), + ("test.hw-b", "test:hw-b"), + ): + runtime.agents.ensure_definition( + agent_key=agent_key, + package_version="1", + display_name=agent_key, + executor_key=executor_key, + concurrency_group="hardware:gpu-test", + concurrency_limit=1, + ) + runtime.agent_runtime.bind_executor(executor_key, measured) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + runs = [ + runtime.agents.create_run( + session_id=_session(runtime, key), agent_key=key, input={} + )[0] + for key in ("test.hw-a", "test.hw-b") + ] + runtime.agent_runtime.wake() + for run in runs: + await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + assert max_active == 1 + + max_active = 0 + runtime.agents.ensure_definition( + agent_key="test.parallel", + package_version="1", + display_name="Parallel", + executor_key="test:parallel", + ) + runtime.agent_runtime.bind_executor("test:parallel", measured) + parallel = [ + runtime.agents.create_run( + session_id=_session(runtime, f"parallel-{index}"), + agent_key="test.parallel", + input={}, + )[0] + for index in range(2) + ] + runtime.agent_runtime.wake() + for run in parallel: + await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + assert max_active == 2 + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_tool_capability_creates_approval_before_side_effect(tmp_path): + runtime = _runtime(tmp_path) + calls = [] + service = runtime.services.ensure_service( + service_key="test.secure-service", + package_id="test.secure-service", + package_version="1", + display_name="Secure Service", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + ) + instance = runtime.services.ensure_instance( + service_id=service.id, + provider_key="test:secure-provider", + status=ServiceInstanceStatus.RUNNING, + ) + runtime.services.ensure_tool( + service_id=service.id, + qualified_name="secure.mutate", + display_name="Secure Mutate", + description="Approval test", + input_schema={"type": "object"}, + output_schema={"type": "object"}, + effects=("write",), + required_capabilities=("secure.write",), + ) + + async def secure(arguments, context): + calls.append(arguments) + return {"ok": True} + + runtime.service_registry.bind_tool( + "secure.mutate", provider_key=instance.provider_key, handler=secure + ) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={ + "mode": "tool", + "tool_name": "secure.mutate", + "arguments": {"value": 7}, + }, + ) + runtime.agent_runtime.wake() + await _wait_status(runtime, run.id, AgentRunStatus.WAITING_CAPABILITY) + interaction = runtime.agents.list_interactions(run.id)[0] + + assert calls == [] + assert interaction.kind is InteractionKind.APPROVAL + assert interaction.request["effects"] == ["write"] + assert interaction.ui_hints["default_scope"] == "once" + assert interaction.ui_hints["operation_class"] == "write" + assert interaction.ui_hints["risk_level"] == "medium" + assert interaction.request["action_preview"]["summary"] == "secure.mutate" + runtime.agents.respond_interaction( + run.id, + interaction.id, + response={"decision": "approve"}, + response_id="approve-1", + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + + assert calls == [{"value": 7}] + assert completed.output == {"tool_output": {"ok": True}} + assert runtime.agents.get_run(run.id).granted_capabilities == ("secure.write",) + leases = runtime.capabilities.list_leases(include_inactive=True) + once = next(item for item in leases if item.tool_pattern == "secure.mutate") + assert once.evidence["requested_scope"] == "once" + assert once.revoke_reason == "consumed" + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_model_action_uses_bound_model_runtime_and_persists_step(tmp_path): + runtime = _runtime(tmp_path) + requests = [] + + async def model_provider(request): + requests.append(request) + return {"choices": [{"message": {"role": "assistant", "content": "hello"}}]} + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={ + "mode": "model", + "request": { + "model": "test-model", + "messages": [{"role": "user", "content": "hello"}], + }, + }, + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + steps = runtime.agents.list_steps(run.id) + + assert requests[0]["model"] == "test-model" + assert steps[0].kind == "model" + assert steps[0].status is RunStepStatus.COMPLETED + assert ( + completed.output["model_output"]["choices"][0]["message"]["content"] + == "hello" + ) + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_cancel_stops_running_executor_and_is_terminal(tmp_path): + runtime = _runtime(tmp_path) + cancelled = asyncio.Event() + started = asyncio.Event() + + async def slow(context): + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + return CompleteAction({}) + + runtime.agents.ensure_definition( + agent_key="test.slow", + package_version="1", + display_name="Slow", + executor_key="test:slow", + ) + runtime.agent_runtime.bind_executor("test:slow", slow) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), agent_key="test.slow", input={} + ) + runtime.agent_runtime.wake() + await _wait_status(runtime, run.id, AgentRunStatus.RUNNING) + await asyncio.wait_for(started.wait(), timeout=1) + runtime.agent_runtime.cancel(run.id) + + final = await _wait_status(runtime, run.id, AgentRunStatus.CANCELLED) + await asyncio.wait_for(cancelled.wait(), timeout=1) + assert final.finished_at is not None + assert runtime.agents.get_status_line(run.id).phase == "cancelled" + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_pause_stops_running_executor_and_resume_requeues_it(tmp_path): + runtime = _runtime(tmp_path) + stopped = asyncio.Event() + started = asyncio.Event() + + async def slow(context): + started.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + return CompleteAction({}) + + runtime.agents.ensure_definition( + agent_key="test.pausable", + package_version="1", + display_name="Pausable", + executor_key="test:pausable", + ) + runtime.agent_runtime.bind_executor("test:pausable", slow) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), agent_key="test.pausable", input={} + ) + runtime.agent_runtime.wake() + await asyncio.wait_for(started.wait(), timeout=1) + + paused = runtime.agent_runtime.pause(run.id) + await asyncio.wait_for(stopped.wait(), timeout=1) + assert paused.status is AgentRunStatus.INTERRUPTED + assert paused.error == {"code": "user_paused"} + + async def finish(context): + return CompleteAction({"resumed": True}) + + runtime.agent_runtime.bind_executor("test:pausable", finish) + resumed = runtime.agents.resume_interrupted(run.id) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + + assert resumed.status is AgentRunStatus.QUEUED + assert completed.output == {"resumed": True} + finally: + await runtime.stop_background_tasks() + + +def test_uncertain_tool_recovery_requires_explicit_resolution(tmp_path): + runtime = _runtime(tmp_path) + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="ai2apps.diagnostic-agent", + input={"mode": "tool"}, + ) + claimed = runtime.agents.claim_next() + assert claimed.id == run.id + runtime.agents.transition( + run.id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + step, _ = runtime.agents.create_step( + run.id, + action_key="diagnostic-tool", + kind="tool", + input={"value": "agent"}, + tool_name="system.echo", + ) + + recovery = runtime.agents.recover_interrupted() + + assert recovery == {"recovered": 0, "interrupted": 1, "failed": 0} + assert runtime.agents.get_run(run.id).status is AgentRunStatus.INTERRUPTED + assert runtime.agents.list_steps(run.id)[0].status is RunStepStatus.UNCERTAIN + with pytest.raises(ResourceConflictError, match="require"): + runtime.agents.resume_interrupted(run.id) + resumed = runtime.agents.resume_interrupted(run.id, uncertain_resolution="retry") + assert resumed.status is AgentRunStatus.QUEUED + assert runtime.agents.list_steps(run.id)[0].id == step.id + + +@pytest.mark.asyncio +async def test_agent_api_exposes_status_menu_response_and_event_stream_url(tmp_path): + runtime = _runtime(tmp_path) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + catalog = await client.get("/v1/platform/agents") + created = await client.post( + f"/v1/platform/sessions/{_session(runtime)}/agent-runs", + json={ + "agent": "ai2apps.diagnostic-agent", + "input": {"mode": "menu"}, + "idempotency_key": "api-menu-1", + }, + ) + run_id = created.json()["id"] + await _wait_status(runtime, run_id, AgentRunStatus.WAITING_INPUT) + waiting = await client.get(f"/v1/platform/agent-runs/{run_id}") + interaction = waiting.json()["interactions"][0] + answered = await client.post( + f"/v1/platform/agent-runs/{run_id}/interactions/{interaction['id']}/respond", + json={"response": {"choice": "beta"}, "response_id": "menu-answer-1"}, + ) + replay = await client.post( + f"/v1/platform/agent-runs/{run_id}/interactions/{interaction['id']}/respond", + json={"response": {"choice": "beta"}, "response_id": "menu-answer-1"}, + ) + completed = await _wait_status(runtime, run_id, AgentRunStatus.COMPLETED) + + assert created.status_code == 202 + assert catalog.status_code == 200 + assert "ai2apps.diagnostic-agent" in { + item["agent_key"] for item in catalog.json()["items"] + } + assert created.json()["agent_key"] == "ai2apps.diagnostic-agent" + assert created.json()["agent_display_name"] == "Diagnostic Agent" + assert created.json()["agent_package_version"] == "1.0.0" + diagnostic = next( + item + for item in catalog.json()["items"] + if item["agent_key"] == "ai2apps.diagnostic-agent" + ) + general = next( + item + for item in catalog.json()["items"] + if item["agent_key"] == "ai2apps.general-agent" + ) + assert diagnostic["discoverable"] is False + assert "general" in general["aliases"] + assert general["invocation_schema"]["type"] == "object" + assert waiting.json()["status_line"]["phase"] == "waiting_input" + assert interaction["ui_hints"]["control"] == "menu" + assert waiting.json()["event_stream_url"].endswith(f"/{run_id}/events") + assert answered.status_code == 200 + assert replay.status_code == 200 + assert completed.output["response"] == {"choice": "beta"} + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_agent_manager_api_exposes_lifecycle_runs_and_provenance(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + first, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.diagnostic-agent", + input={"value": "one"}, + ) + runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"prompt": "two"}, + ) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + management = await client.get( + "/v1/platform/agents/ai2apps.diagnostic-agent/management" + ) + filtered = await client.get( + "/v1/platform/agent-runs", + params={"agent": "ai2apps.diagnostic-agent", "limit": 20}, + ) + disabled = await client.post( + "/v1/platform/agents/ai2apps.diagnostic-agent/disable" + ) + enabled = await client.post( + "/v1/platform/agents/ai2apps.diagnostic-agent/enable" + ) + + assert management.status_code == 200 + assert management.json()["definition"]["source"] == "builtin" + assert ( + management.json()["definition"]["executor_key"] + == "builtin:diagnostic-agent" + ) + assert management.json()["run_counts"]["total"] == 1 + assert management.json()["packages"] == [] + assert management.json()["patches"] == [] + assert management.json()["effective_definition"] is None + assert [item["id"] for item in filtered.json()["items"]] == [first.id] + assert disabled.json()["status"] == "disabled" + assert enabled.json()["status"] == "enabled" + + +@pytest.mark.asyncio +async def test_agent_api_pauses_and_resumes_queued_run(tmp_path): + runtime = _runtime(tmp_path) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + created = await client.post( + f"/v1/platform/sessions/{_session(runtime)}/agent-runs", + json={"agent": "ai2apps.diagnostic-agent", "input": {}}, + ) + run_id = created.json()["id"] + paused = await client.post(f"/v1/platform/agent-runs/{run_id}/pause") + resumed = await client.post( + f"/v1/platform/agent-runs/{run_id}/resume", + json={"uncertain_resolution": None}, + ) + + assert paused.status_code == 200 + assert paused.json()["status"] == "interrupted" + assert paused.json()["error"] == {"code": "user_paused"} + assert paused.json()["status_line"]["text"] == "Paused" + assert resumed.status_code == 200 + assert resumed.json()["status"] == "queued" + events = runtime.events.list_after(subject_id=run_id, limit=20) + assert "agent.run.paused" in [event.type for event in events] + + +@pytest.mark.asyncio +async def test_unanswered_interaction_expires_fail_closed(tmp_path): + runtime = _runtime(tmp_path) + + async def executor(context): + return InteractionAction( + request_key="short-lived-choice", + kind=InteractionKind.MENU, + prompt="Choose before the deadline", + response_schema={ + "type": "object", + "properties": {"choice": {"type": "string"}}, + "required": ["choice"], + }, + timeout_seconds=1, + ) + + runtime.agents.ensure_definition( + agent_key="test.expiring-interaction", + package_version="1", + display_name="Expiring interaction", + executor_key="test:expiring-interaction", + ) + runtime.agent_runtime.bind_executor("test:expiring-interaction", executor) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=_session(runtime), + agent_key="test.expiring-interaction", + input={}, + ) + runtime.agent_runtime.wake() + await _wait_status(runtime, run.id, AgentRunStatus.WAITING_INPUT) + failed = await _wait_status( + runtime, + run.id, + AgentRunStatus.FAILED, + timeout=2.5, + ) + + interaction = runtime.agents.list_interactions(run.id)[0] + status = runtime.agents.get_status_line(run.id) + assert failed.error["code"] == "interaction_expired" + assert interaction.status.value == "expired" + assert status.phase == "failed" + finally: + await runtime.stop_background_tasks() + + +def _user_message(runtime, session_id, text="Use the available tool"): + return ( + MessageRepository(runtime.database, runtime.events) + .append( + session_id=session_id, + role=MessageRole.USER, + parts=(MessagePartInput(kind="text", content={"text": text}),), + idempotency_key=f"user:{session_id}:{text}", + ) + .value + ) + + +@pytest.mark.asyncio +async def test_general_agent_persists_final_model_reply_in_session(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + user = _user_message(runtime, session_id, "Hello Agent") + requests = [] + + async def model_provider(request): + requests.append(request) + return { + "choices": [ + { + "message": {"role": "assistant", "content": "Hello user"}, + "finish_reason": "stop", + } + ], + "usage": {"total_tokens": 9}, + "ai2apps_cloud": [ + { + "phase": "completed", + "requestId": "req-agent-test", + "charged": "2", + "balance": "998", + } + ], + } + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + messages = MessageRepository(runtime.database, runtime.events).list_for_session( + session_id + ) + + assert requests[0]["messages"][-1] == { + "role": "user", + "content": "Hello Agent", + } + assert requests[0]["ai2apps_idempotency_key"] == ( + f"agent-{run.id}-model-1" + ) + assert any( + tool["function"]["name"].startswith("system__echo_") + for tool in requests[0]["tools"] + ) + assert len(messages) == 2 + assert messages[-1].message.role is MessageRole.ASSISTANT + assert messages[-1].parts[0].content == {"text": "Hello user"} + assert messages[-1].message.metadata["agent_run_id"] == run.id + assert messages[-1].message.metadata["ai2apps_cloud"][0]["charged"] == "2" + assert completed.output["message_id"] == messages[-1].message.id + assert completed.output["usage"] == {"total_tokens": 9} + assert completed.output["ai2apps_cloud"][0]["requestId"] == "req-agent-test" + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_runs_model_tool_model_loop_durably(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + user = _user_message(runtime, session_id) + requests = [] + + async def model_provider(request): + requests.append(request) + if len(requests) == 1: + echo_alias = next( + item["function"]["name"] + for item in request["tools"] + if item["function"]["name"].startswith("system__echo_") + ) + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_echo_1", + "type": "function", + "function": { + "name": echo_alias, + "arguments": '{"value":"durable"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": "Tool returned durable", + }, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + steps = runtime.agents.list_steps(run.id) + invocations = runtime.services.list_invocations(trace_id=run.id) + + assert [step.kind for step in steps] == ["model", "tool", "model"] + assert steps[1].tool_name == "system.echo" + assert steps[1].output == {"value": "durable"} + assert len(invocations) == 1 + assert invocations[0].qualified_name == "system.echo" + assert invocations[0].output == {"value": "durable"} + assert requests[1]["messages"][-2]["tool_calls"][0]["id"] == "call_echo_1" + assert requests[1]["messages"][-1] == { + "role": "tool", + "tool_call_id": "call_echo_1", + "content": '{"value": "durable"}', + } + assert completed.output["content"] == "Tool returned durable" + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_retries_non_effectful_tool_after_runtime_restart(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + user = _user_message(runtime, session_id, "Survive a restart") + tool_started = asyncio.Event() + + async def interrupted_echo(arguments, _context): + tool_started.set() + await asyncio.Event().wait() + return {"value": arguments["value"]} + + runtime.service_registry.bind_tool( + "system.echo", + provider_key="builtin:diagnostics", + handler=interrupted_echo, + ) + model_calls = 0 + + async def model_provider(request): + nonlocal model_calls + model_calls += 1 + if model_calls == 1: + alias = next( + item["function"]["name"] + for item in request["tools"] + if item["function"]["name"].startswith("system__echo_") + ) + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "restart-echo", + "type": "function", + "function": { + "name": alias, + "arguments": '{"value":"resumed"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + return { + "choices": [ + { + "message": {"role": "assistant", "content": "Recovered"}, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + await asyncio.wait_for(tool_started.wait(), timeout=2) + await runtime.stop_background_tasks() + + interrupted = runtime.agents.get_run(run.id) + first_invocation = runtime.services.list_invocations(trace_id=run.id)[0] + assert interrupted.status is AgentRunStatus.QUEUED + assert first_invocation.status.value == "cancelled" + assert runtime.agents.list_steps(run.id)[1].action_key.startswith( + "tool:1:0:" + ) + assert ":retry:" in runtime.agents.list_steps(run.id)[1].action_key + + async def resumed_echo(arguments, _context): + return {"value": arguments["value"]} + + runtime.service_registry.bind_tool( + "system.echo", provider_key="builtin:diagnostics", handler=resumed_echo + ) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + invocations = runtime.services.list_invocations(trace_id=run.id) + + assert completed.output["content"] == "Recovered" + assert [item.status.value for item in invocations] == [ + "completed", + "cancelled", + ] + assert model_calls == 2 + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_stops_repeated_identical_tool_loop(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + user = _user_message(runtime, session_id, "Do not loop") + calls = 0 + + async def looping_model(request): + nonlocal calls + calls += 1 + echo_alias = next( + item["function"]["name"] + for item in request["tools"] + if item["function"]["name"].startswith("system__echo_") + ) + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": f"repeat-{calls}", + "type": "function", + "function": { + "name": echo_alias, + "arguments": '{"value":"same"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + + runtime.agent_runtime.bind_model_provider(looping_model) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + failed = await _wait_status(runtime, run.id, AgentRunStatus.FAILED) + tool_steps = [ + step for step in runtime.agents.list_steps(run.id) if step.kind == "tool" + ] + + assert failed.error["code"] == "repeated_tool_call" + assert len(tool_steps) == 3 + assert calls == 4 + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_waits_for_capability_before_model_selected_effect( + tmp_path, +): + runtime = _runtime(tmp_path) + effects = [] + service = runtime.services.ensure_service( + service_key="test.general-secure-service", + package_id="test.general-secure-service", + package_version="1", + display_name="General secure service", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + ) + instance = runtime.services.ensure_instance( + service_id=service.id, + provider_key="test:general-secure-provider", + status=ServiceInstanceStatus.RUNNING, + ) + runtime.services.ensure_tool( + service_id=service.id, + qualified_name="secure.write_value", + display_name="Write value", + description="Write a value after approval", + input_schema={ + "type": "object", + "properties": {"value": {"type": "integer"}}, + "required": ["value"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + effects=("write",), + required_capabilities=("secure.write",), + ) + + async def write_value(arguments, context): + effects.append(arguments["value"]) + return {"written": arguments["value"]} + + runtime.service_registry.bind_tool( + "secure.write_value", + provider_key=instance.provider_key, + handler=write_value, + ) + model_calls = 0 + + async def model_provider(request): + nonlocal model_calls + model_calls += 1 + if model_calls == 1: + secure_alias = next( + item["function"]["name"] + for item in request["tools"] + if item["function"]["name"].startswith("secure__write_value_") + ) + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "secure-call", + "type": "function", + "function": { + "name": secure_alias, + "arguments": '{"value":7}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ] + } + return { + "choices": [ + { + "message": {"role": "assistant", "content": "Written"}, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + session_id = _session(runtime) + user = _user_message(runtime, session_id, "Write seven") + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + await _wait_status(runtime, run.id, AgentRunStatus.WAITING_CAPABILITY) + interaction = runtime.agents.list_interactions(run.id)[0] + + assert effects == [] + assert interaction.request["capabilities"] == ["secure.write"] + runtime.agents.respond_interaction( + run.id, + interaction.id, + response={"decision": "approve"}, + response_id="approve-general-secure-call", + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + + assert effects == [7] + assert completed.output["content"] == "Written" + assert model_calls == 2 + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_agent_api_defaults_to_general_agent_and_accepts_direct_prompt(tmp_path): + runtime = _runtime(tmp_path) + + async def model_provider(request): + assert request["messages"][-1] == {"role": "user", "content": "Direct prompt"} + return { + "choices": [ + { + "message": {"role": "assistant", "content": "Direct reply"}, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + app = FastAPI() + app.include_router(create_ai2apps_router(runtime_provider=lambda: runtime)) + session_id = _session(runtime) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + created = await client.post( + f"/v1/platform/sessions/{session_id}/agent-runs", + json={"input": {"model": "test-model", "prompt": "Direct prompt"}}, + ) + completed = await _wait_status( + runtime, created.json()["id"], AgentRunStatus.COMPLETED + ) + messages = MessageRepository(runtime.database, runtime.events).list_for_session( + session_id + ) + + assert created.status_code == 202 + assert completed.output["content"] == "Direct reply" + assert [item.message.role for item in messages] == [ + MessageRole.USER, + MessageRole.ASSISTANT, + ] + assert messages[0].message.metadata["agent_input"] is True + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_can_complete_at_exact_step_budget(tmp_path): + runtime = _runtime(tmp_path) + runtime.agents.ensure_definition( + agent_key="test.one-step-general", + package_version="1", + display_name="One step general Agent", + executor_key="builtin:general-agent", + max_steps=1, + manifest={"allowed_tools": []}, + ) + + async def model_provider(request): + return { + "choices": [ + { + "message": {"role": "assistant", "content": "One step"}, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + session_id = _session(runtime) + user = _user_message(runtime, session_id, "Finish in one") + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="test.one-step-general", + input={"model": "test-model", "message_id": user.message.id}, + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + + assert completed.current_step == 1 + assert completed.output["content"] == "One step" + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_applies_context_and_token_budgets_before_effects( + tmp_path, +): + runtime = _runtime(tmp_path) + runtime.agents.ensure_definition( + agent_key="test.budgeted-general", + package_version="1", + display_name="Budgeted general Agent", + executor_key="builtin:general-agent", + manifest={ + "allowed_tools": ["system.echo"], + "context_message_limit": 2, + "max_total_model_tokens": 1, + }, + ) + session_id = _session(runtime) + _user_message(runtime, session_id, "Old one") + _user_message(runtime, session_id, "Old two") + current = _user_message(runtime, session_id, "Current") + + async def model_provider(request): + assert request["messages"][-2:] == [ + {"role": "user", "content": "Old two"}, + {"role": "user", "content": "Current"}, + ] + assert ( + "1 earlier Session messages were omitted" + in request["messages"][0]["content"] + ) + echo_alias = next( + item["function"]["name"] + for item in request["tools"] + if item["function"]["name"].startswith("system__echo_") + ) + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "over-budget-effect", + "type": "function", + "function": { + "name": echo_alias, + "arguments": '{"value":"must-not-run"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"total_tokens": 1}, + } + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="test.budgeted-general", + input={"model": "test-model", "message_id": current.message.id}, + ) + runtime.agent_runtime.wake() + failed = await _wait_status(runtime, run.id, AgentRunStatus.FAILED) + + assert failed.error["code"] == "model_token_budget_exceeded" + assert [step.kind for step in runtime.agents.list_steps(run.id)] == ["model"] + finally: + await runtime.stop_background_tasks() + + +@pytest.mark.asyncio +async def test_general_agent_preserves_rich_input_and_per_run_instructions(tmp_path): + runtime = _runtime(tmp_path) + session_id = _session(runtime) + rich_content = [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}, + {"type": "text", "text": "Describe this"}, + ] + + async def model_provider(request): + assert request["messages"][0] == { + "role": "system", + "content": "Be concise", + } + assert request["messages"][-1] == { + "role": "user", + "content": rich_content, + } + return { + "choices": [ + { + "message": {"role": "assistant", "content": "An image"}, + "finish_reason": "stop", + } + ] + } + + runtime.agent_runtime.bind_model_provider(model_provider) + await runtime.start_background_tasks(retention_interval_seconds=60) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key="ai2apps.general-agent", + input={ + "model": "test-model", + "content": rich_content, + "instructions": "Be concise", + }, + ) + runtime.agent_runtime.wake() + completed = await _wait_status(runtime, run.id, AgentRunStatus.COMPLETED) + + assert completed.output["content"] == "An image" + finally: + await runtime.stop_background_tasks() diff --git a/tests/test_ai2apps_browser.py b/tests/test_ai2apps_browser.py new file mode 100644 index 00000000..d6e1280a --- /dev/null +++ b/tests/test_ai2apps_browser.py @@ -0,0 +1,564 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Managed Chrome contracts, authentication handoff, and Tool registration.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ai2apps.browser import ( + AuthenticationChallenge, + BrowserArticle, + BrowserControlState, + BrowserError, + BrowserManager, + BrowserSnapshot, +) +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime + + +class FakeBrowserBackend: + def __init__(self): + self.started = False + self.url = "about:blank" + self.title = "" + self.challenge = None + self.info = { + "tag": "button", + "type": "", + "autocomplete": "", + "text": "Continue", + "submits": False, + } + self.typed = [] + self.clicked = [] + self.hovered = [] + self.pointer_moves = [] + self.keys = [] + self.clipboard = [] + self.wait_result = { + "satisfied": True, + "condition": "element", + "elapsed_ms": 5, + "detail": {"state": "visible"}, + } + self.tab_items = [ + {"id": "tab-1", "url": self.url, "title": self.title, "active": True} + ] + self.download_directory = None + self.uploads = [] + self.snapshot_text = "Safe page text" + self.snapshot_items = [ + {"ref": "e1", "tag": "button", "text": "Continue"} + ] + self.bidi_connected = True + + def start(self): + self.started = True + + def set_download_directory(self, path): + self.download_directory = Path(path) + + def stop(self): + self.started = False + + def current(self): + return self.url, self.title + + def recent_events(self): + return [{"method": "browsingContext.load", "url": self.url}] + + def navigate(self, url): + self.url = url + self.title = "Test" + self.tab_items[0].update(url=url, title=self.title) + + def tabs(self): + return [dict(item) for item in self.tab_items] + + def open_tab(self, url=None): + for item in self.tab_items: + item["active"] = False + tab_id = f"tab-{len(self.tab_items) + 1}" + self.tab_items.append( + {"id": tab_id, "url": url or "about:blank", "title": "", "active": True} + ) + self.url = url or "about:blank" + self.title = "" + return tab_id + + def switch_tab(self, tab_id): + for item in self.tab_items: + item["active"] = item["id"] == tab_id + if item["active"]: + self.url, self.title = item["url"], item["title"] + + def close_tab(self, tab_id): + self.tab_items = [item for item in self.tab_items if item["id"] != tab_id] + self.tab_items[-1]["active"] = True + self.url, self.title = self.tab_items[-1]["url"], self.tab_items[-1]["title"] + return self.tab_items[-1]["id"] + + def detect_authentication(self): + return self.challenge + + def snapshot(self, *, max_items, max_text, html_mode, max_html): + return BrowserSnapshot( + self.url, + self.title, + tuple(self.snapshot_items), + self.snapshot_text, + '', + html_mode, + False, + ) + + def read_article( + self, + *, + mode, + selector, + include_images, + include_links, + max_chars, + char_threshold, + max_elements, + ): + return BrowserArticle( + url=self.url, + canonical_url="https://example.test/article", + title="Reader title", + byline="Example Author", + site_name="Example", + published_at="2026-08-13", + language="en", + direction="ltr", + excerpt="Opening paragraph.", + html=( + '

Opening paragraph.

' + '
print("hello")
' + ), + text='Opening paragraph. print("hello")', + text_length=33, + reading_time_minutes=1, + extraction_method="readability", + confidence="high", + hidden_nodes_removed=3, + ) + + def target_info(self, target): + return dict(self.info) + + def click(self, target, *, duration_ms=None): + self.clicked.append((target, duration_ms)) + + def hover(self, target, *, duration_ms=None): + self.hovered.append((target, duration_ms)) + return {"x": 10, "y": 20, "duration_ms": duration_ms or 300} + + def move_pointer(self, *, target, x, y, duration_ms): + self.pointer_moves.append((target, x, y, duration_ms)) + return {"x": x or 10, "y": y or 20, "duration_ms": duration_ms or 300} + + def type_text( + self, target, text, *, clear, input_mode="natural", delay_ms=None + ): + self.typed.append((target, text, clear, input_mode, delay_ms)) + + def key_press(self, *, key, modifiers, target, repeat): + self.keys.append((key, modifiers, target, repeat)) + + def clipboard_action(self, action, *, target): + self.clipboard.append((action, target)) + + def upload_file(self, target, path): + self.uploads.append((target, Path(path))) + + def staged_downloads(self, *, wait_ms=0): + return { + "complete": [{"name": "result.pdf", "size_bytes": 10}], + "in_progress": [], + } + + def wait_for(self, **_kwargs): + return dict(self.wait_result) + + def scroll(self, delta_y): + pass + + def screenshot(self): + return "cG5n" + + +class FakeWorkspace: + def __init__(self, root: Path): + self.root = root + + def browser_download_directory(self, session_id): + path = self.root / session_id / "temporary" / "browser-downloads" + path.mkdir(parents=True, exist_ok=True) + return path + + def resolve_browser_upload(self, session_id, path): + return self.root / session_id / "workspace" / path + + def adopt_browser_download(self, session_id, filename): + return { + "name": filename, + "path": f"downloads/{filename}", + "size_bytes": 10, + "media_type": "application/pdf", + } + + +@pytest.mark.asyncio +async def test_authentication_requires_user_and_blocks_agent_observation(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + await manager.start(session_id="session-1") + backend.challenge = AuthenticationChallenge("login", "Password field") + + result = await manager.navigate( + "https://example.test/login", session_id="session-1" + ) + assert result["state"] == "user_required" + assert result["user_action_required"] is True + + with pytest.raises(BrowserError, match="must be completed by the user"): + await manager.snapshot(session_id="session-1") + + handoff = await manager.begin_user_control() + assert handoff["state"] == "user_control" + still_blocked = await manager.complete_user_control() + assert still_blocked["completed"] is False + assert still_blocked["state"] == "user_required" + + await manager.begin_user_control() + backend.challenge = None + completed = await manager.complete_user_control() + assert completed["completed"] is True + assert completed["state"] == "agent_control" + + +@pytest.mark.asyncio +async def test_password_and_otp_fields_can_never_be_typed_by_agent(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + await manager.start(session_id="session-1") + backend.info = { + "tag": "input", + "type": "password", + "autocomplete": "current-password", + "text": "", + } + + result = await manager.type_text( + "e1", "must-not-be-recorded", session_id="session-1", clear=True + ) + assert result["state"] == BrowserControlState.USER_REQUIRED.value + assert backend.typed == [] + + +@pytest.mark.asyncio +async def test_consequential_click_requires_explicit_commit_flag(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + await manager.start(session_id="session-1") + backend.info["text"] = "Publish post" + + with pytest.raises(BrowserError, match="commit=true"): + await manager.click("e1", session_id="session-1", commit=False) + result = await manager.click("e1", session_id="session-1", commit=True) + assert result["commit"] is True + assert backend.clicked == [("e1", None)] + + +@pytest.mark.asyncio +async def test_pointer_hover_keyboard_and_clipboard_actions(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + + hovered = await manager.hover("e1", session_id="session-1", duration_ms=420) + assert hovered["pointer"] == {"x": 10, "y": 20, "duration_ms": 420} + moved = await manager.move_pointer( + session_id="session-1", x=30, y=40, duration_ms=250 + ) + assert moved["pointer"]["x"] == 30 + + await manager.key_press( + "ARROW_DOWN", + session_id="session-1", + modifiers=("SHIFT",), + target="e1", + repeat=2, + ) + assert backend.keys == [("ARROW_DOWN", ("SHIFT",), "e1", 2)] + + copied = await manager.clipboard_action( + "copy", session_id="session-1", target="e1" + ) + assert copied["content_returned"] is False + assert backend.clipboard == [("copy", "e1")] + + +@pytest.mark.asyncio +async def test_wait_returns_success_or_diagnostic_snapshot_on_timeout(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + success = await manager.wait_for( + session_id="session-1", condition="element", target="e1" + ) + assert success["wait"]["satisfied"] is True + + backend.wait_result = { + "satisfied": False, + "condition": "text", + "elapsed_ms": 20, + "detail": {"text": "never appears"}, + } + timeout = await manager.wait_for( + session_id="session-1", + condition="text", + text="never appears", + timeout_ms=0, + ) + diagnostic = timeout["wait"]["diagnostic_snapshot"] + assert diagnostic["text"] == "Safe page text" + assert diagnostic["items"][0]["ref"] == "e1" + + +@pytest.mark.asyncio +async def test_observe_returns_compact_changes_since_snapshot(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + await manager.snapshot(session_id="session-1") + backend.snapshot_text = "Safe page text New result" + backend.snapshot_items[0] = { + "ref": "e1", + "tag": "button", + "text": "Completed", + } + backend.snapshot_items.append( + {"ref": "e2", "tag": "a", "text": "Open result", "href": "/result"} + ) + observed = await manager.observe_changes(session_id="session-1") + change = observed["observation"] + assert change["counts"] == { + "added": 1, + "removed": 0, + "changed": 1, + "text_changes": 1, + } + assert change["added"][0]["ref"] == "e2" + assert change["changed"][0]["fields"]["text"]["after"] == "Completed" + + +@pytest.mark.asyncio +async def test_enter_on_form_requires_commit_and_natural_typing_is_configurable(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + backend.info["submits"] = True + with pytest.raises(BrowserError, match="commit=true"): + await manager.key_press("ENTER", session_id="session-1", target="e1") + await manager.key_press( + "ENTER", session_id="session-1", target="e1", commit=True + ) + + backend.info["submits"] = False + await manager.type_text( + "e1", + "hello", + session_id="session-1", + clear=True, + input_mode="instant", + ) + assert backend.typed[-1] == ("e1", "hello", True, "instant", None) + + with pytest.raises(BrowserError, match="browser.clipboard"): + await manager.key_press( + "v", session_id="session-1", modifiers=("META",), target="e1" + ) + + +@pytest.mark.asyncio +async def test_browser_is_owned_by_one_session_until_closed(): + manager = BrowserManager(FakeBrowserBackend()) + await manager.start(session_id="session-1") + with pytest.raises(BrowserError, match="another active Session"): + await manager.snapshot(session_id="session-2") + await manager.close() + result = await manager.start(session_id="session-2") + assert result["owner_session_id"] == "session-2" + + +@pytest.mark.asyncio +async def test_tab_lifecycle_and_click_popup_detection(): + backend = FakeBrowserBackend() + manager = BrowserManager(backend) + opened = await manager.open_tab( + session_id="session-1", url="https://example.test/new" + ) + assert opened["opened_tab"] == "tab-2" + listed = await manager.list_tabs(session_id="session-1") + assert len(listed["tabs"]) == 2 + await manager.switch_tab("tab-1", session_id="session-1") + closed = await manager.close_tab("tab-2", session_id="session-1") + assert closed["active_tab"] == "tab-1" + + original_click = backend.click + + def popup_click(target, *, duration_ms=None): + original_click(target, duration_ms=duration_ms) + backend.open_tab("https://example.test/popup") + + backend.click = popup_click + result = await manager.click( + "e1", session_id="session-1", commit=False + ) + assert result["new_tabs"][0]["url"] == "https://example.test/popup" + + +@pytest.mark.asyncio +async def test_upload_and_download_are_scoped_to_session_workspace(tmp_path): + backend = FakeBrowserBackend() + manager = BrowserManager(backend, workspace=FakeWorkspace(tmp_path)) + await manager.start(session_id="session-1") + assert backend.download_directory == ( + tmp_path / "session-1" / "temporary" / "browser-downloads" + ) + + backend.info["type"] = "file" + uploaded = await manager.upload_file( + "e1", "attachments/input.txt", session_id="session-1" + ) + assert uploaded["filename"] == "input.txt" + assert backend.uploads[0][1] == ( + tmp_path / "session-1" / "workspace" / "attachments" / "input.txt" + ) + + downloads = await manager.collect_downloads( + session_id="session-1", wait_ms=100 + ) + assert downloads["downloads"][0]["path"] == "downloads/result.pdf" + + +@pytest.mark.asyncio +async def test_snapshot_returns_visible_html_layout_and_requested_mode(): + manager = BrowserManager(FakeBrowserBackend()) + result = await manager.snapshot(session_id="session-1") + snapshot = result["snapshot"] + assert snapshot["html_mode"] == "visible" + assert snapshot["html_truncated"] is False + assert 'data-ai2apps-ref="e1"' in snapshot["html"] + assert 'data-ai2apps-rect="1,2,3,4"' in snapshot["html"] + + full = await manager.snapshot(session_id="session-1", html_mode="full") + assert full["snapshot"]["html_mode"] == "full" + + +@pytest.mark.asyncio +async def test_read_article_returns_markdown_html_and_metadata(): + manager = BrowserManager(FakeBrowserBackend()) + + markdown = await manager.read_article(session_id="session-1") + article = markdown["article"] + assert article["format"] == "markdown" + assert article["title"] == "Reader title" + assert article["canonical_url"] == "https://example.test/article" + assert article["content"].startswith("# Reader title") + assert "**paragraph**" in article["content"] + assert "```python" in article["content"] + assert article["hidden_nodes_removed"] == 3 + + both = await manager.read_article( + session_id="session-1", output_format="both" + ) + assert '

DEV

', + encoding="utf-8", + ) + (root / "app/ui/style.css").write_text("h1 { color: green; }", encoding="utf-8") + (root / "app/ui/mini.json").write_text('{"type":"text"}', encoding="utf-8") + (root / "agent/agent.yaml").write_text( + yaml.safe_dump( + { + "schema": "ai2apps.agent/v1", + "id": "example.helper", + "name": "Helper", + "version": "1.0.0", + "publisher": {"id": "example.publisher"}, + "executor": {"key": "builtin:diagnostic-agent"}, + } + ), + encoding="utf-8", + ) + (root / "service/service.yaml").write_text( + yaml.safe_dump( + { + "schema": "ai2apps.service/v1", + "id": "example.echo", + "name": "Echo", + "version": "1.0.0", + "publisher": {"id": "example.publisher"}, + "runtime": {"mode": "external", "endpoint": "http://127.0.0.1:9999"}, + "capabilities": [], + "requires": {"services": []}, + "permissions": {}, + "compatibility": {}, + "health": {}, + "restart": {}, + "tools": [], + } + ), + encoding="utf-8", + ) + + project = manager.create_project( + name="Multi", root_path=str(root), kind="ai2apps" + ) + assert {item["kind"] for item in project["components"]} == { + "app", + "mini-app", + "agent", + "service", + } + report = manager.validate_project(project["id"]) + assert report["valid"] is True + + session = manager.start_dev_session(project["id"], "example.game") + assert session["preview_url"].endswith("/preview") + path, media = manager.resolve_dev_resource(session["id"], "ui/style.css") + assert path == root / "app/ui/style.css" + assert media == "text/css" + with pytest.raises(CoderError, match="Unsafe development resource"): + manager.resolve_dev_resource(session["id"], "../service/service.yaml") + + bundle = Path(manager.build_project(project["id"])["path"]) + with zipfile.ZipFile(bundle) as archive: + package = json.loads(archive.read("META/package.json")) + names = archive.namelist() + assert package["development"] is True + assert package["installable"] is False + assert {item["type"] for item in package["components"]} == { + "app", + "mini-app", + "agent", + "service", + } + assert str(root) not in json.dumps(package) + assert any(name.endswith("ui/index.html") for name in names) + assert len(names) == len(set(names)) + + flight = manager.submit_project_testflight(project["id"]) + assert flight["channel"] == "testflight" + assert flight["apps"][0]["id"] == "testflight.example.game" + assert flight["apps"][0]["entry_url"] == "/apps/testflight.example.game" + assert manager.submit_project_testflight(project["id"])["apps"] == flight["apps"] + with manager.database.transaction() as connection: + definition = connection.execute( + "SELECT source,manifest_json FROM app_definitions WHERE package_id=? " + "AND status='enabled'", + ("testflight.example.game",), + ).fetchone() + assert definition["source"] == "local" + flight_manifest = json.loads(definition["manifest_json"]) + assert flight_manifest["navigation"]["category"] == "TestFlight" + assert flight_manifest["testflight"]["signed"] is False + + +def test_coder_project_file_editor_is_bounded_and_cannot_escape(coder, tmp_path): + manager, _terminal = coder + root = tmp_path / "editable" + (root / "config").mkdir(parents=True) + (root / ".git").mkdir() + (root / "config/settings.yaml").write_text("enabled: false\n", encoding="utf-8") + project = manager.create_project(name="Editable", root_path=str(root)) + + listing = manager.list_project_files(project["id"]) + assert [item["name"] for item in listing["items"]] == ["config"] + assert manager.read_project_file(project["id"], "config/settings.yaml")[ + "content" + ] == "enabled: false\n" + + saved = manager.write_project_file( + project["id"], "config/settings.yaml", "enabled: true\n" + ) + assert saved["content"] == "enabled: true\n" + assert (root / "config/settings.yaml").read_text() == "enabled: true\n" + with pytest.raises(CoderError, match="safe and relative"): + manager.read_project_file(project["id"], "../outside.txt") + with pytest.raises(CoderError, match="not editable"): + manager.list_project_files(project["id"], ".git") + + +@pytest.mark.asyncio +async def test_coder_deletes_thread_and_removes_project_without_deleting_directory( + coder, tmp_path +): + manager, _terminal = coder + root = tmp_path / "kept-source" + root.mkdir() + project = manager.create_project(name="Keep", root_path=str(root)) + thread = manager.create_thread( + project_id=project["id"], title="Disposable", agent="codex" + ) + + assert (await manager.delete_thread(thread["id"]))["deleted"] is True + assert manager.snapshot()["threads"] == [] + result = await manager.remove_project(project["id"]) + assert result == { + "id": project["id"], + "deleted": True, + "directory_deleted": False, + } + assert root.is_dir() + assert manager.snapshot()["projects"] == [] + + +@pytest.mark.asyncio +async def test_coder_thread_owns_a_real_terminal_session(coder, tmp_path): + manager, terminal = coder + await terminal.startup() + project = manager.create_project(name="Example", root_path=str(tmp_path)) + thread = manager.create_thread( + project_id=project["id"], title="CLI", agent="opencode" + ) + try: + with patch("ai2apps.coder.manager.shutil.which", return_value="/bin/sh"): + running = await manager.start_thread(thread["id"]) + assert running["status"] == "running" + session = terminal.get(running["terminal_session_id"]) + assert session.cwd == str(tmp_path) + assert session.owner == "coder" + assert session.owner_id == thread["id"] + assert terminal.list(owner="terminal") == [] + + stopped = await manager.stop_thread(thread["id"]) + assert stopped["status"] == "stopped" + assert stopped["terminal_session_id"] is None + finally: + await terminal.shutdown() diff --git a/tests/test_ai2apps_documents.py b/tests/test_ai2apps_documents.py new file mode 100644 index 00000000..93afadc4 --- /dev/null +++ b/tests/test_ai2apps_documents.py @@ -0,0 +1,145 @@ +"""Durable attachment, parsing, and Session isolation contracts.""" + +from __future__ import annotations + +import pytest + +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.documents import DocumentRepository, DocumentStatus, PdfGenerator +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ToolCallContext + + +def _runtime_and_sessions(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + chat = ChatRepository(runtime.database, runtime.events) + first, _ = chat.create_thread(title="first") + second, _ = chat.create_thread(title="second") + return runtime, first.session.id, second.session.id + + +def test_attachment_blob_is_deduplicated_but_access_is_session_scoped(tmp_path): + runtime, first, second = _runtime_and_sessions(tmp_path) + documents = runtime.documents + assert isinstance(documents, DocumentRepository) + + one = documents.create( + first, + filename="notes.md", + media_type="text/markdown", + data=b"# Plan\n\nalpha beta", + ) + two = documents.create( + second, + filename="copy.md", + media_type="text/markdown", + data=b"# Plan\n\nalpha beta", + ) + + assert one.blob_id == two.blob_id + assert one.id != two.id + with runtime.database.connect() as connection: + assert ( + connection.execute("SELECT count(*) FROM document_blobs").fetchone()[0] == 1 + ) + try: + documents.get(first, two.id) + except Exception as exc: + assert "attachment not found" in str(exc) + else: + raise AssertionError("cross-Session attachment access was allowed") + + +def test_text_document_parses_into_source_blocks_and_is_searchable(tmp_path): + runtime, session_id, _ = _runtime_and_sessions(tmp_path) + record = runtime.documents.create( + session_id, + filename="research.txt", + media_type="text/plain", + data=b"First paragraph.\n\nSecond paragraph has tornado data.", + ) + parsed = runtime.documents.parse(session_id, record.id) + assert parsed.status is DocumentStatus.READY + blocks = runtime.documents.blocks(session_id, record.id) + assert [item.ordinal for item in blocks] == [0, 1] + matches = runtime.documents.search(session_id, record.id, "TORNADO") + assert len(matches) == 1 + assert "tornado" in matches[0].text + + +def test_xlsx_document_preserves_sheet_and_cell_coordinates(tmp_path): + from openpyxl import Workbook + + runtime, session_id, _ = _runtime_and_sessions(tmp_path) + source = tmp_path / "metrics.xlsx" + workbook = Workbook() + sheet = workbook.active + sheet.title = "Decode" + sheet.append(["scope", "tps"]) + sheet.append(["code", 31.5]) + workbook.save(source) + workbook.close() + + record = runtime.documents.create( + session_id, + filename=source.name, + media_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + data=source.read_bytes(), + ) + parsed = runtime.documents.parse(session_id, record.id) + assert parsed.status is DocumentStatus.READY + blocks = runtime.documents.blocks(session_id, record.id) + assert blocks[1].sheet == "Decode" + assert blocks[1].cell_range == "A2:B2" + assert "31.5" in blocks[1].text + + +def test_pdf_generator_supports_multilingual_markdown_and_verifies_output(): + result = PdfGenerator().generate( + """# 中文报告 + +This is **bold** text. + +- 第一项 +- Second item + +| 指标 | 数值 | +|---|---| +| TPS | 31.5 | +""", + title="AI2Apps 测试报告", + header="Local document engine", + ) + assert result.data.startswith(b"%PDF-") + assert result.pages == 1 + assert result.extracted_chars > 20 + assert result.font + + +@pytest.mark.asyncio +async def test_create_pdf_tool_writes_workspace_and_registers_artifact(tmp_path): + runtime, session_id, _ = _runtime_and_sessions(tmp_path) + result = await runtime.tools.execute( + "document.create_pdf", + { + "content": "# Benchmark\n\n| Engine | TPS |\n|---|---|\n| Arena | 31.5 |", + "title": "Qwen Report", + "output_path": "output/pdf/qwen-report.pdf", + }, + context=ToolCallContext( + caller_id="agent:ai2apps.general-agent", + session_id=session_id, + granted_capabilities=frozenset({"workspace.write", "artifact.create"}), + ), + ) + assert result.output["pages"] == 1 + assert result.output["artifact"]["media_type"] == "application/pdf" + workspace_pdf = runtime.workspace.read( + session_id, "output/pdf/qwen-report.pdf", limit=1024 * 1024 + ) + assert workspace_pdf["encoding"] == "base64" + assert workspace_pdf["bytes_returned"] > 1000 diff --git a/tests/test_ai2apps_images.py b/tests/test_ai2apps_images.py new file mode 100644 index 00000000..0aee78e1 --- /dev/null +++ b/tests/test_ai2apps_images.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Image generation service and Agent Tool contracts.""" + +from __future__ import annotations + +import pytest + +from ai2apps.chat import ChatRepository +from ai2apps.config import PlatformConfig +from ai2apps.model_manager import ModelManagerStore +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ToolCallContext + + +@pytest.mark.asyncio +async def test_image_tool_uses_default_model_and_creates_session_artifact( + tmp_path, monkeypatch +): + async def fake_image_request(payload, **_kwargs): + assert payload["model"] == "cloud/ai2apps/openai/gpt-image-2" + assert payload["prompt"] == "a tiny blue robot" + assert payload["idempotencyKey"].startswith("agent-image-tinv_") + return { + "requestId": "req-image-settlement", + "model": "openai/gpt-image-2", + "status": "completed", + "usage": {"imageOutputTokens": 196}, + "points": {"reserved": "7", "charged": "6"}, + "pointsReleased": "1", + "balance": "994", + "pricingVersion": "image-v1", + "image": { + "dataUrl": "data:image/png;base64,aW1hZ2U=", + "size": "1024x1024", + "quality": "auto", + "format": "png", + } + } + + monkeypatch.setattr("ai2apps.images.service.request_cloud_image", fake_image_request) + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + ModelManagerStore(tmp_path).put_default_models( + {"image_generation": "cloud/ai2apps/openai/gpt-image-2"} + ) + session_id = ( + ChatRepository(runtime.database, runtime.events) + .create_thread(title="Image tool")[0] + .session.id + ) + + result = await runtime.tools.execute( + "image.generate", + {"prompt": "a tiny blue robot"}, + context=ToolCallContext( + caller_id="agent:ai2apps.general-agent", + session_id=session_id, + trace_id="run-image", + granted_capabilities=frozenset( + {"image.generate", "workspace.write", "artifact.create"} + ), + ), + ) + + artifact = result.output["artifact"] + assert result.output["ai2apps_cloud"] == [{ + "requestId": "req-image-settlement", + "model": "openai/gpt-image-2", + "status": "completed", + "usage": {"imageOutputTokens": 196}, + "points": {"reserved": "7", "charged": "6"}, + "pointsReleased": "1", + "balance": "994", + "pricingVersion": "image-v1", + "phase": "completed", + }] + assert artifact["media_type"] == "image/png" + assert artifact["download_url"].endswith(f"/{artifact['id']}/download") + stored = runtime.workspace.read(session_id, f"generated-images/{artifact['name']}") + assert stored["content"] == "image" + record = runtime.workspace.get_artifact(session_id, artifact["id"]) + assert "prompt" not in record.metadata diff --git a/tests/test_ai2apps_research.py b/tests/test_ai2apps_research.py new file mode 100644 index 00000000..a2f56c97 --- /dev/null +++ b/tests/test_ai2apps_research.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: Apache-2.0 +"""First-party web research Service and Agent contracts.""" + +from __future__ import annotations + +import socket + +import pytest + +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.research.provider import ( + BingWebProvider, + HttpResponse, + SafeHttpClient, + WebProviderError, +) +from ai2apps.services import ToolCallContext + + +class _FakeClient: + def __init__(self, responses): + self.responses = list(responses) + self.urls = [] + + def get(self, url): + self.urls.append(url) + return self.responses.pop(0) + + +def _runtime(tmp_path): + runtime = PlatformRuntime(PlatformConfig.from_base_path(tmp_path)) + runtime.start() + return runtime + + +def test_web_provider_search_fetch_and_cache_are_structured(): + search_html = b""" +
+
  • Example Guide

    +

    A useful primary source.

  • +
    + """ + page_html = b""" + Example Guide +

    Reliable heading

    Evidence from the page.

    + + """ + client = _FakeClient( + [ + HttpResponse( + "https://www.bing.com/search?q=test", + "text/html", + search_html, + False, + ), + HttpResponse( + "https://example.com/guide", "text/html", page_html, False + ), + ] + ) + provider = BingWebProvider(client, cache_ttl=60) + + search = provider.search(" test ", limit=3) + fetched = provider.fetch(search["results"][0]["url"]) + cached = provider.fetch(search["results"][0]["url"]) + + assert search["results"] == [ + { + "title": "Example Guide", + "url": "https://example.com/guide", + "snippet": "A useful primary source.", + "source_id": search["results"][0]["source_id"], + } + ] + assert fetched["source_id"] == search["results"][0]["source_id"] + assert fetched["title"] == "Example Guide" + assert "Reliable heading" in fetched["text"] + assert "Evidence from the page." in fetched["text"] + assert "ignore me" not in fetched["text"] + assert cached["cached"] is True + assert len(client.urls) == 2 + + +def test_safe_http_client_rejects_private_and_credentialed_targets(): + def resolver(_host, port, **_kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))] + + client = SafeHttpClient(resolver=resolver) + + with pytest.raises(WebProviderError, match="private"): + client._validate_url("http://example.test/secret") + with pytest.raises(WebProviderError, match="no credentials"): + client._validate_url("https://user:pass@example.test/") + with pytest.raises(WebProviderError, match="HTTP"): + client._validate_url("file:///etc/passwd") + + +@pytest.mark.asyncio +async def test_platform_registers_web_tools_and_research_agent(tmp_path): + runtime = _runtime(tmp_path) + provider = BingWebProvider( + _FakeClient( + [ + HttpResponse( + "https://www.bing.com/search?q=ai2apps", + "text/html", + b'
  • Example

  • ', + False, + ) + ] + ) + ) + # Replace only the bound handler through an isolated service reinstall. + from ai2apps.research import install_web_research_service + + install_web_research_service(runtime.services, runtime.service_registry, provider) + agent = runtime.agents.get_definition("ai2apps.research-agent") + search_tool = runtime.services.get_tool("web.search") + + assert agent.executor_key == "builtin:general-agent" + assert agent.manifest["discoverable"] is True + assert agent.manifest["allowed_tools"][:2] == ["web.search", "web.fetch"] + assert search_tool.required_capabilities == ("network.outbound",) + + result = await runtime.tools.execute( + "web.search", + {"query": "ai2apps", "limit": 1}, + context=ToolCallContext( + caller_id="agent:ai2apps.research-agent", + granted_capabilities=frozenset({"network.outbound"}), + ), + ) + assert result.output["count"] == 1 + assert result.output["results"][0]["url"] == "https://example.com/" + + +def test_research_agent_is_read_only_by_tool_policy(tmp_path): + runtime = _runtime(tmp_path) + agent = runtime.agents.get_definition("ai2apps.research-agent") + allowed = set(agent.manifest["allowed_tools"]) + + assert "workspace.write" not in allowed + assert "workspace.apply_patch" not in allowed + assert "process.start" not in allowed + assert "agent.delegate" not in allowed From 3bf1da68a5603cac4bb8f05ac0fde49c9b6d43fd Mon Sep 17 00:00:00 2001 From: Avdpro Pang <38308119+Avdpro@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:05:31 +0800 Subject: [PATCH 05/11] feat(remote): add cloud gateway and mobile access --- ai2apps/api/cloud.py | 276 +++++++++++ ai2apps/api/remote.py | 132 +++++ ai2apps/api/router.py | 52 ++ ai2apps/cloud_client.py | 154 ++++++ ai2apps/cloud_gateway.py | 672 ++++++++++++++++++++++++++ ai2apps/remote/__init__.py | 14 + ai2apps/remote/frp-ca-2026.pem | 25 + ai2apps/remote/frpc-device.toml | 31 ++ ai2apps/remote/frpc.py | 214 ++++++++ ai2apps/remote/manager.py | 360 ++++++++++++++ ai2apps/remote/models.py | 42 ++ ai2apps/remote/repository.py | 119 +++++ ai2apps/remote/security.py | 125 +++++ docs/ai2apps-cloud-sse-bridge.md | 605 +++++++++++++++++++++++ docs/ai2apps-mobile-entry.md | 484 +++++++++++++++++++ docs/ai2apps-multi-user-gateway.md | 655 +++++++++++++++++++++++++ scripts/accept_ai2apps_cloud.py | 305 ++++++++++++ tests/test_ai2apps_cloud_client.py | 244 ++++++++++ tests/test_ai2apps_cloud_gateway.py | 454 +++++++++++++++++ tests/test_ai2apps_platform_api.py | 214 ++++++++ tests/test_ai2apps_platform_health.py | 162 +++++++ tests/test_ai2apps_remote.py | 340 +++++++++++++ tools/AI2AppsTray-Info.plist | 28 ++ tools/AI2AppsTray.swift | 431 +++++++++++++++++ 24 files changed, 6138 insertions(+) create mode 100644 ai2apps/api/cloud.py create mode 100644 ai2apps/api/remote.py create mode 100644 ai2apps/api/router.py create mode 100644 ai2apps/cloud_client.py create mode 100644 ai2apps/cloud_gateway.py create mode 100644 ai2apps/remote/__init__.py create mode 100644 ai2apps/remote/frp-ca-2026.pem create mode 100644 ai2apps/remote/frpc-device.toml create mode 100644 ai2apps/remote/frpc.py create mode 100644 ai2apps/remote/manager.py create mode 100644 ai2apps/remote/models.py create mode 100644 ai2apps/remote/repository.py create mode 100644 ai2apps/remote/security.py create mode 100644 docs/ai2apps-cloud-sse-bridge.md create mode 100644 docs/ai2apps-mobile-entry.md create mode 100644 docs/ai2apps-multi-user-gateway.md create mode 100644 scripts/accept_ai2apps_cloud.py create mode 100644 tests/test_ai2apps_cloud_client.py create mode 100644 tests/test_ai2apps_cloud_gateway.py create mode 100644 tests/test_ai2apps_platform_api.py create mode 100644 tests/test_ai2apps_platform_health.py create mode 100644 tests/test_ai2apps_remote.py create mode 100644 tools/AI2AppsTray-Info.plist create mode 100644 tools/AI2AppsTray.swift diff --git a/ai2apps/api/cloud.py b/ai2apps/api/cloud.py new file mode 100644 index 00000000..0c977f56 --- /dev/null +++ b/ai2apps/api/cloud.py @@ -0,0 +1,276 @@ +"""Local native-network facade for the AI2Apps Cloud v1 API.""" + +from __future__ import annotations + +from typing import Any + +import httpx +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse, Response, StreamingResponse +from pydantic import BaseModel, ConfigDict, Field + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider + + +class RegisterRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + display_name: str = Field(alias="displayName", min_length=1, max_length=120) + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=12, max_length=128) + + +class LoginRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=12, max_length=128) + + +class AdminReauthRequest(BaseModel): + password: str = Field(min_length=12, max_length=128) + + +class EmailRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class EmailCodeRequest(EmailRequest): + code: str = Field(pattern=r"^[0-9]{8}$") + + +class PasswordResetRequest(EmailCodeRequest): + model_config = ConfigDict(populate_by_name=True) + + new_password: str = Field(alias="newPassword", min_length=12, max_length=128) + + +def _cloud_or_error(runtime_provider: PlatformRuntimeProvider): + runtime = runtime_provider() + cloud = None if runtime is None else getattr(runtime, "cloud", None) + if cloud is None: + return platform_error_response( + status_code=503, + code="cloud_client_not_ready", + message="AI2Apps Cloud client is not ready.", + retryable=True, + ) + return cloud + + +def _forward_response(response: httpx.Response) -> Response: + headers = {} + content_type = response.headers.get("content-type") + retry_after = response.headers.get("retry-after") + if content_type: + headers["content-type"] = content_type + if retry_after: + headers["retry-after"] = retry_after + return Response(content=response.content, status_code=response.status_code, headers=headers) + + +def _transport_error(error: httpx.HTTPError) -> JSONResponse: + if isinstance(error, httpx.TimeoutException): + return platform_error_response( + status_code=504, + code="cloud_timeout", + message="AI2Apps Cloud did not respond in time.", + retryable=True, + ) + return platform_error_response( + status_code=502, + code="cloud_unavailable", + message="AI2Apps Cloud is unavailable.", + retryable=True, + ) + + +def create_cloud_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/cloud", tags=["platform-cloud"]) + + async def call( + method: str, + path: str, + *, + payload: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Response: + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + try: + response = await cloud.request( + method, path, json=payload, params=params, headers=headers + ) + except httpx.HTTPError as error: + return _transport_error(error) + try: + return _forward_response(response) + finally: + await response.aclose() + + @router.post("/auth/register") + async def register(request: RegisterRequest): + return await call( + "POST", "/v1/auth/register", payload=request.model_dump(by_alias=True) + ) + + @router.post("/auth/email/verify") + async def verify_email(request: EmailCodeRequest): + return await call("POST", "/v1/auth/email/verify", payload=request.model_dump()) + + @router.post("/auth/email/resend") + async def resend_email(request: EmailRequest): + return await call("POST", "/v1/auth/email/resend", payload=request.model_dump()) + + @router.post("/auth/login") + async def login(request: LoginRequest): + return await call("POST", "/v1/auth/login", payload=request.model_dump()) + + @router.post("/auth/logout") + async def logout(): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + response = await call("POST", "/v1/auth/logout") + if response.status_code < 400: + await cloud.clear_session() + return response + + @router.get("/auth/me") + async def auth_me(): + return await call("GET", "/v1/auth/me") + + @router.post("/admin/reauth") + async def admin_reauth(request: AdminReauthRequest): + return await call( + "POST", "/v1/admin/reauth", payload=request.model_dump() + ) + + @router.post("/auth/password/reset-request") + async def request_password_reset(request: EmailRequest): + return await call( + "POST", "/v1/auth/password/reset-request", payload=request.model_dump() + ) + + @router.post("/auth/password/reset") + async def reset_password(request: PasswordResetRequest): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + response = await call( + "POST", + "/v1/auth/password/reset", + payload=request.model_dump(by_alias=True), + ) + if response.status_code < 400: + await cloud.clear_session() + return response + + @router.get("/levels") + async def levels(): + return await call("GET", "/v1/levels") + + @router.get("/points") + async def points(): + return await call("GET", "/v1/points") + + @router.get("/points/ledger") + async def point_ledger(limit: int = Query(default=50, ge=1, le=100)): + return await call("GET", "/v1/points/ledger", params={"limit": limit}) + + @router.post("/points/daily-claim") + async def daily_claim(): + return await call("POST", "/v1/points/daily-claim") + + @router.get("/account/entitlements") + async def entitlements(): + return await call("GET", "/v1/account/entitlements") + + @router.get("/ai/models") + async def ai_models(): + return await call("GET", "/v1/ai/models") + + @router.post("/ai/responses") + async def ai_response( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + wants_stream = payload.get("stream", True) is not False + try: + response = await cloud.request( + "POST", + "/v1/ai/responses", + json=payload, + headers={"Idempotency-Key": idempotency_key}, + stream=wants_stream, + ) + except httpx.HTTPError as error: + return _transport_error(error) + if not wants_stream or response.status_code >= 400: + try: + await response.aread() + return _forward_response(response) + finally: + await response.aclose() + + async def body(): + try: + async for chunk in response.aiter_bytes(): + yield chunk + finally: + await response.aclose() + + return StreamingResponse( + body(), + status_code=response.status_code, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + async def ai_image( + endpoint: str, + payload: dict[str, Any], + idempotency_key: str, + ) -> Response: + """Forward synchronous image calls without retaining image Data URLs.""" + + return await call( + "POST", + f"/v1/ai/images/{endpoint}", + payload=payload, + headers={"Idempotency-Key": idempotency_key}, + ) + + @router.post("/ai/images/generations") + async def ai_image_generation( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + return await ai_image("generations", payload, idempotency_key) + + @router.post("/ai/images/edits") + async def ai_image_edit( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + return await ai_image("edits", payload, idempotency_key) + + @router.get("/ai/requests/{request_id}") + async def ai_request(request_id: str): + return await call("GET", f"/v1/ai/requests/{request_id}") + + @router.post("/ai/requests/{request_id}/cancel") + async def cancel_ai_request(request_id: str): + return await call("POST", f"/v1/ai/requests/{request_id}/cancel") + + return router diff --git a/ai2apps/api/remote.py b/ai2apps/api/remote.py new file mode 100644 index 00000000..843fc053 --- /dev/null +++ b/ai2apps/api/remote.py @@ -0,0 +1,132 @@ +"""Local control API for AI2Apps Remote Access v1.""" + +from __future__ import annotations + +import base64 +import io +from dataclasses import asdict + +import httpx +import qrcode +import qrcode.image.svg +from fastapi import APIRouter +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.remote import RemoteAccessError + + +class RegisterRemoteDeviceRequest(BaseModel): + display_name: str = Field(alias="displayName", min_length=1, max_length=120) + + +def _pairing_qr_data_url(value: str) -> str: + qr = qrcode.QRCode( + error_correction=qrcode.constants.ERROR_CORRECT_Q, + box_size=8, + border=4, + ) + qr.add_data(value) + qr.make(fit=True) + image = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage) + output = io.BytesIO() + image.save(output) + encoded = base64.b64encode(output.getvalue()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def _device(value) -> dict: + result = asdict(value) + return { + "deviceId": result["device_id"], "displayName": result["display_name"], + "platform": result["platform"], "clientVersion": result["client_version"], + "status": result["status"], "suspensionReason": result["suspension_reason"], + "accessEpoch": result["access_epoch"], "publicOrigin": result["public_origin"], + "credentialVersion": result["credential_version"], + "credentialExpiresAt": result["credential_expires_at"].isoformat(), + "serverAddr": result["server_addr"], "serverPort": result["server_port"], + "proxyName": result["proxy_name"], "subdomain": result["subdomain"], + "enabled": result["enabled"], "online": result["online"], + "proxyConnected": result["proxy_connected"], + "lastSeenAt": None if result["last_seen_at"] is None else result["last_seen_at"].isoformat(), + "createdAt": result["created_at"].isoformat(), "updatedAt": result["updated_at"].isoformat(), + } + + +def create_remote_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/remote", tags=["platform-remote"]) + + def manager(): + runtime = runtime_provider() + value = None if runtime is None else getattr(runtime, "remote", None) + if value is None: + raise RemoteAccessError(503, "remote_not_ready", "Remote Access is not ready") + return value + + async def run(operation): + try: + return await operation + except RemoteAccessError as error: + return platform_error_response( + status_code=error.status_code, code=error.code.lower(), message=str(error), + retryable=error.status_code >= 500 or error.status_code == 429, + ) + except httpx.TimeoutException: + return platform_error_response(status_code=504, code="cloud_timeout", message="AI2Apps Cloud did not respond in time", retryable=True) + except httpx.HTTPError: + return platform_error_response(status_code=502, code="cloud_unavailable", message="AI2Apps Cloud is unavailable", retryable=True) + + @router.get("/status") + async def status(): + value = manager() + return {"devices": [_device(item) for item in value.repository.list()], + "connector": value.frpc.status()} + + @router.post("/devices") + async def register(request: RegisterRemoteDeviceRequest): + result = await run(manager().register(display_name=request.display_name)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/reconcile") + async def reconcile(): + result = await run(manager().reconcile()) + return result if isinstance(result, Response) else {"devices": [_device(item) for item in result]} + + @router.post("/devices/{device_id}/credentials/rotate") + async def rotate(device_id: str): + result = await run(manager().rotate(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/{device_id}/pairing-challenges") + async def pairing(device_id: str): + result = await run(manager().pairing_challenge(device_id)) + if isinstance(result, Response): + return result + return {**result, "pairingQrDataUrl": _pairing_qr_data_url(result["pairingUrl"])} + + @router.post("/devices/{device_id}/revoke") + async def revoke(device_id: str): + return await run(manager().revoke(device_id)) + + @router.post("/devices/{device_id}/start") + async def start(device_id: str): + result = await run(manager().start(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/{device_id}/stop") + async def stop(device_id: str): + result = await run(manager().stop(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.delete("/devices/{device_id}", status_code=204) + async def redact(device_id: str): + result = await run(manager().redact(device_id)) + return result if isinstance(result, Response) else Response(status_code=204) + + @router.get("/usage") + async def usage(): + return await run(manager().usage()) + + return router diff --git a/ai2apps/api/router.py b/ai2apps/api/router.py new file mode 100644 index 00000000..fd0d0e0d --- /dev/null +++ b/ai2apps/api/router.py @@ -0,0 +1,52 @@ +"""Composition root for AI2Apps platform resource APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from ai2apps.api.agents import create_agent_router +from ai2apps.api.browser import create_browser_router +from ai2apps.api.capabilities import create_capability_router +from ai2apps.api.chat import create_chat_router +from ai2apps.api.cloud import create_cloud_router +from ai2apps.api.documents import create_document_router +from ai2apps.api.event_stream import create_event_stream_router +from ai2apps.api.extensions import create_extension_router +from ai2apps.api.health import ( + PlatformConfigProvider, + PlatformRuntimeProvider, + create_health_router, +) +from ai2apps.api.packages import create_package_router +from ai2apps.api.resources import create_resource_router +from ai2apps.api.remote import create_remote_router +from ai2apps.api.secrets import create_secret_router +from ai2apps.api.services import create_service_router +from ai2apps.api.workspace import create_workspace_router + + +def create_ai2apps_router( + *, + config_provider: PlatformConfigProvider | None = None, + runtime_provider: PlatformRuntimeProvider | None = None, +) -> APIRouter: + """Create the versioned AI2Apps platform router.""" + + router = APIRouter(prefix="/v1/platform", tags=["platform"]) + router.include_router(create_health_router(config_provider, runtime_provider)) + if runtime_provider is not None: + router.include_router(create_cloud_router(runtime_provider)) + router.include_router(create_chat_router(runtime_provider)) + router.include_router(create_resource_router(runtime_provider)) + router.include_router(create_event_stream_router(runtime_provider)) + router.include_router(create_extension_router(runtime_provider)) + router.include_router(create_service_router(runtime_provider)) + router.include_router(create_agent_router(runtime_provider)) + router.include_router(create_capability_router(runtime_provider)) + router.include_router(create_workspace_router(runtime_provider)) + router.include_router(create_package_router(runtime_provider)) + router.include_router(create_document_router(runtime_provider)) + router.include_router(create_browser_router(runtime_provider)) + router.include_router(create_secret_router(runtime_provider)) + router.include_router(create_remote_router(runtime_provider)) + return router diff --git a/ai2apps/cloud_client.py b/ai2apps/cloud_client.py new file mode 100644 index 00000000..bf387b9f --- /dev/null +++ b/ai2apps/cloud_client.py @@ -0,0 +1,154 @@ +"""Native AI2Apps Cloud client with a private, persistent session cookie.""" + +from __future__ import annotations + +import hashlib +import os +from collections.abc import Mapping +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ai2apps.secrets import SecretBackend + +DEFAULT_AI2APPS_CLOUD_BASE_URL = "https://coder.ai2apps.com" +AI2APPS_SESSION_COOKIE = "ai2apps_session" + + +def resolve_cloud_base_url(value: str | None = None) -> str: + """Return the configured Cloud origin, rejecting paths and unsafe schemes.""" + + candidate = ( + value + if value is not None + else os.environ.get("AI2APPS_CLOUD_BASE_URL", DEFAULT_AI2APPS_CLOUD_BASE_URL) + ).strip() + parsed = urlparse(candidate) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("AI2Apps Cloud base URL must be an HTTP(S) origin") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("AI2Apps Cloud base URL must not contain credentials or metadata") + if parsed.path.rstrip("/"): + raise ValueError("AI2Apps Cloud base URL must not contain a path") + if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost", "::1"}: + raise ValueError("Remote AI2Apps Cloud origins must use HTTPS") + return f"{parsed.scheme}://{parsed.netloc}" + + +class CloudSessionStore: + """Persist only the opaque Cloud session value in the platform SecretBackend.""" + + def __init__(self, backend: SecretBackend, base_url: str) -> None: + self.backend = backend + origin = resolve_cloud_base_url(base_url) + origin_id = hashlib.sha256(origin.encode("utf-8")).hexdigest()[:16] + self.key = f"ai2apps-cloud-session-{origin_id}" + + def load(self) -> str | None: + try: + value = self.backend.load(self.key) + except KeyError: + return None + return value or None + + def save(self, value: str) -> None: + self.backend.store(self.key, value) + + def clear(self) -> None: + self.backend.delete(self.key) + + +class AI2AppsCloudClient: + """Call the versioned Cloud API without exposing its Cookie to UI code.""" + + def __init__( + self, + *, + session_store: CloudSessionStore, + base_url: str | None = None, + transport: httpx.AsyncBaseTransport | None = None, + timeout: httpx.Timeout | None = None, + ) -> None: + self.base_url = resolve_cloud_base_url(base_url) + self.session_store = session_store + self.transport = transport + self.timeout = timeout or httpx.Timeout( + connect=15.0, read=3600.0, write=120.0, pool=30.0 + ) + self._client: httpx.AsyncClient | None = None + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + cookies = httpx.Cookies() + session = self.session_store.load() + if session: + parsed = urlparse(self.base_url) + cookies.set( + AI2APPS_SESSION_COOKIE, + session, + domain=parsed.hostname, + path="/", + ) + self._client = httpx.AsyncClient( + base_url=self.base_url, + cookies=cookies, + follow_redirects=False, + timeout=self.timeout, + transport=self.transport, + headers={"Accept": "application/json"}, + ) + return self._client + + def _persist_response_session(self, response: httpx.Response) -> None: + try: + value = response.cookies.get(AI2APPS_SESSION_COOKIE) + except httpx.CookieConflict: + value = None + if not value and self._client is not None: + try: + value = self._client.cookies.get(AI2APPS_SESSION_COOKIE) + except httpx.CookieConflict: + value = None + if value: + self.session_store.save(value) + + async def request( + self, + method: str, + path: str, + *, + json: Any | None = None, + content: bytes | str | None = None, + data: Mapping[str, Any] | None = None, + files: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + stream: bool = False, + ) -> httpx.Response: + if not path.startswith("/v1/"): + raise ValueError("Cloud API requests must use a /v1/ path") + client = self._get_client() + request = client.build_request( + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + ) + response = await client.send(request, stream=stream) + self._persist_response_session(response) + return response + + async def clear_session(self) -> None: + self.session_store.clear() + if self._client is not None: + self._client.cookies.delete(AI2APPS_SESSION_COOKIE) + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None diff --git a/ai2apps/cloud_gateway.py b/ai2apps/cloud_gateway.py new file mode 100644 index 00000000..e39cfed2 --- /dev/null +++ b/ai2apps/cloud_gateway.py @@ -0,0 +1,672 @@ +"""OpenAI-compatible gateway for user-enabled cloud models.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +import time +import uuid +from typing import Any + +import httpx +from fastapi import HTTPException, Response +from fastapi.responses import StreamingResponse + +from .model_manager import ModelManagerStore + +_OPENAI_CHAT_FIELDS = { + "model", + "messages", + "temperature", + "top_p", + "max_tokens", + "stream", + "stream_options", + "stop", + "presence_penalty", + "frequency_penalty", + "tools", + "tool_choice", + "response_format", + "seed", +} + +AI2APPS_CLOUD_PROVIDER_ID = "ai2apps" +AI2APPS_CLOUD_MODEL_PREFIX = f"cloud/{AI2APPS_CLOUD_PROVIDER_ID}/" +_IMAGE_DATA_URL = re.compile( + r"^data:(image/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=\s]+)$" +) +_MODERN_TOKEN_PARAMETER_MODELS: set[tuple[str, str]] = set() + + +def _chat_url(provider: dict[str, Any]) -> str: + root = provider["base_url"].rstrip("/") + if provider["protocol"] == "anthropic" and not root.endswith("/v1"): + root = f"{root}/v1" + return f"{root}/chat/completions" + + +def _redacted_error(response: httpx.Response, secret: str) -> str: + """Return a bounded upstream error without ever reflecting credentials.""" + + try: + payload = response.json() + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + message = error.get("message") + else: + message = payload.get("detail") or error + if isinstance(message, str) and message: + return message.replace(secret, "[redacted]")[:1000] + except (ValueError, TypeError): + pass + return f"Cloud provider returned HTTP {response.status_code}" + + +def _needs_max_completion_tokens(status: int, detail: str, body: dict[str, Any]) -> bool: + """Recognize OpenAI's validation error for modern completion limits.""" + + message = detail.lower() + return ( + status == 400 + and "max_tokens" in body + and "max_tokens" in message + and "max_completion_tokens" in message + ) + + +def _modern_openai_chat_body(body: dict[str, Any]) -> dict[str, Any]: + modern = dict(body) + token_limit = modern.pop("max_tokens", None) + if token_limit is not None: + modern["max_completion_tokens"] = token_limit + # GPT-5-class Chat Completions models also reject non-default sampling + # temperature. Let the provider apply its model default. + modern.pop("temperature", None) + return modern + + +def _decode_image_data_url(value: Any, label: str) -> tuple[bytes, str, str]: + match = _IMAGE_DATA_URL.fullmatch(str(value or "")) + if match is None: + raise HTTPException(status_code=400, detail=f"{label} must be a PNG, JPEG, or WebP Data URL") + try: + data = base64.b64decode("".join(match.group(2).split()), validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail=f"{label} contains invalid base64") from exc + extension = "jpg" if match.group(1) == "image/jpeg" else match.group(1).split("/", 1)[1] + return data, match.group(1), extension + + +def _managed_model_id(model: str) -> str | None: + if not model.startswith("cloud/"): + return None + value = model[len("cloud/") :] + if value.startswith(f"{AI2APPS_CLOUD_PROVIDER_ID}/"): + value = value[len(AI2APPS_CLOUD_PROVIDER_ID) + 1 :] + return value if "/" in value else None + + +def _local_image_url(provider: dict[str, Any], edit: bool) -> str: + root = provider["base_url"].rstrip("/") + return f"{root}/images/{'edits' if edit else 'generations'}" + + +async def request_cloud_image( + payload: dict[str, Any], + *, + edit: bool, + base_path: Any, + cloud_client: Any | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> dict[str, Any]: + """Generate or edit one image through the selected local/account route.""" + + model = str(payload.get("model") or "").strip() + if not model: + raise HTTPException(status_code=400, detail="Image model is required") + store = ModelManagerStore(base_path) + provider = store.resolve_cloud_model(model) + managed_model = _managed_model_id(model) + if provider is None and managed_model is not None: + provider_id = managed_model.split("/", 1)[0] + local_provider = next( + (item for item in store.list_cloud() if item["id"] == provider_id), None + ) + if model.startswith(AI2APPS_CLOUD_MODEL_PREFIX) or local_provider is None or not local_provider["configured"]: + if cloud_client is None: + raise HTTPException(status_code=503, detail="AI2Apps Cloud client is not ready") + cloud_body = { + key: value + for key, value in payload.items() + if key + in { + "prompt", + "size", + "quality", + "outputFormat", + "outputCompression", + "n", + "imageDataUrls", + "maskDataUrl", + } + } + cloud_body["model"] = managed_model + try: + upstream = await cloud_client.request( + "POST", + f"/v1/ai/images/{'edits' if edit else 'generations'}", + json=cloud_body, + headers={ + "Idempotency-Key": str( + payload.get("idempotencyKey") or f"local-image-{uuid.uuid4()}" + ) + }, + ) + except httpx.TimeoutException as exc: + raise HTTPException(status_code=504, detail="AI2Apps Cloud timed out") from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="AI2Apps Cloud is unavailable") from exc + try: + if upstream.status_code >= 400: + try: + error_payload = upstream.json() + except ValueError: + error_payload = {} + error = ( + error_payload.get("error", {}) + if isinstance(error_payload, dict) + else {} + ) + raise HTTPException( + status_code=upstream.status_code, + detail={ + "code": str( + error.get("code") + or "AI2APPS_CLOUD_IMAGE_REQUEST_FAILED" + ), + "message": str( + error.get("message") + or "AI2Apps Cloud image request failed" + )[:1000], + "retryable": bool(error.get("retryable", False)), + }, + ) + result = upstream.json() + if not isinstance(result, dict) or not isinstance(result.get("image"), dict): + raise HTTPException(status_code=502, detail="AI2Apps Cloud returned an invalid image response") + return result + finally: + await upstream.aclose() + if provider is None: + raise HTTPException(status_code=404, detail="Image model is not enabled") + if provider.get("protocol") != "openai": + raise HTTPException(status_code=400, detail="Selected provider does not support the image API") + + model_id = str(provider["model_id"]) + common = { + "model": model_id, + "prompt": str(payload.get("prompt") or ""), + "size": str(payload.get("size") or "1024x1024"), + "quality": str(payload.get("quality") or "auto"), + "n": 1, + } + output_format = str(payload.get("outputFormat") or "png") + normalized_model_id = model_id.lower() + is_gpt_image = normalized_model_id.startswith("gpt-image-") or normalized_model_id == "chatgpt-image-latest" + if is_gpt_image: + common["output_format"] = output_format + if payload.get("outputCompression") is not None: + common["output_compression"] = payload["outputCompression"] + else: + # DALL-E endpoints still use the legacy response_format switch. GPT Image + # models return b64_json without it and reject the parameter entirely. + common["response_format"] = "b64_json" + headers = {"Authorization": f"Bearer {provider['api_key']}"} + client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=15.0, read=3600.0, write=120.0, pool=30.0), + transport=transport, + ) + try: + if edit: + values = payload.get("imageDataUrls") + if not isinstance(values, list) or not 1 <= len(values) <= 4: + raise HTTPException(status_code=400, detail="imageDataUrls must contain 1 to 4 images") + files = [] + for index, value in enumerate(values): + data, media_type, extension = _decode_image_data_url(value, f"imageDataUrls[{index}]") + files.append(("image[]", (f"image-{index + 1}.{extension}", data, media_type))) + if payload.get("maskDataUrl") is not None: + data, media_type, extension = _decode_image_data_url(payload["maskDataUrl"], "maskDataUrl") + files.append(("mask", (f"mask.{extension}", data, media_type))) + request = client.build_request( + "POST", _local_image_url(provider, True), headers=headers, data=common, files=files + ) + else: + request = client.build_request( + "POST", _local_image_url(provider, False), headers={**headers, "Content-Type": "application/json"}, json=common + ) + upstream = await client.send(request) + if upstream.status_code >= 400: + detail = _redacted_error(upstream, provider["api_key"]) + raise HTTPException(status_code=upstream.status_code, detail=detail) + result = upstream.json() + images = result.get("data") if isinstance(result, dict) else None + image = images[0] if isinstance(images, list) and images else None + if not isinstance(image, dict): + raise HTTPException(status_code=502, detail="Image provider returned no image") + value = image.get("b64_json") + data_url = ( + f"data:image/{'jpeg' if output_format == 'jpeg' else output_format};base64,{value}" + if value + else image.get("url") + ) + if not data_url: + raise HTTPException(status_code=502, detail="Image provider returned an invalid image") + return { + "image": { + "dataUrl": data_url, + "size": common["size"], + "quality": common["quality"], + "format": output_format, + }, + "usage": result.get("usage"), + "provider": "local", + "model": model, + } + except httpx.TimeoutException as exc: + raise HTTPException(status_code=504, detail="Image provider timed out") from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Image provider is unavailable") from exc + finally: + await client.aclose() + + +async def proxy_cloud_image_request(payload: dict[str, Any], **kwargs: Any) -> Response: + result = await request_cloud_image(payload, **kwargs) + return Response(content=json.dumps(result), media_type="application/json") + + +def _content_parts(content: Any) -> list[dict[str, str]]: + if isinstance(content, str): + return [{"type": "input_text", "text": content}] if content else [] + result: list[dict[str, str]] = [] + for part in content or []: + value = part.model_dump(exclude_none=True) if hasattr(part, "model_dump") else part + if not isinstance(value, dict): + continue + if value.get("type") in {"text", "input_text"} and value.get("text"): + result.append({"type": "input_text", "text": str(value["text"])}) + elif value.get("type") == "image_url": + image = value.get("image_url") + url = image.get("url") if isinstance(image, dict) else None + if url: + result.append({"type": "input_image", "imageUrl": str(url)}) + return result + + +def _ai2apps_request_body(request: Any) -> dict[str, Any]: + system: list[str] = [] + messages: list[dict[str, Any]] = [] + for message in request.messages: + role = str(message.role) + parts = _content_parts(message.content) + if role in {"system", "developer"}: + system.extend(part["text"] for part in parts if part["type"] == "input_text") + continue + item: dict[str, Any] = {"role": role, "content": parts} + if role == "assistant" and message.tool_calls: + item["toolCalls"] = [ + { + "callId": str(call.get("id") or ""), + "name": str((call.get("function") or {}).get("name") or ""), + "arguments": str((call.get("function") or {}).get("arguments") or "{}"), + } + for call in message.tool_calls + ] + if role == "tool": + item["toolCallId"] = str(message.tool_call_id or "") + messages.append(item) + body: dict[str, Any] = { + "model": request.model[len(AI2APPS_CLOUD_MODEL_PREFIX) :], + "input": messages, + "maxOutputTokens": request.max_tokens or 1024, + "stream": bool(request.stream), + } + if system: + body["system"] = "\n\n".join(system) + if request.temperature is not None: + body["temperature"] = request.temperature + if request.tools: + body["tools"] = [ + { + "name": tool.function.get("name"), + "description": tool.function.get("description", ""), + "parameters": tool.function.get("parameters", {"type": "object"}), + } + for tool in request.tools + ] + return body + + +def _openai_usage(value: Any) -> dict[str, int]: + usage = value if isinstance(value, dict) else {} + prompt = int(usage.get("inputTokens") or 0) + completion = int(usage.get("outputTokens") or 0) + return { + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": prompt + completion, + } + + +def _finish_reason(value: Any) -> str: + return { + "tool_calls": "tool_calls", + "length": "length", + "content_filter": "content_filter", + }.get(str(value), "stop") + + +def _chat_chunk(model: str, request_id: str, delta: dict[str, Any], finish: str | None = None, usage: Any = None) -> bytes: + payload: dict[str, Any] = { + "id": request_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + if usage is not None: + payload["usage"] = _openai_usage(usage) + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode() + + +async def _proxy_ai2apps_chat_completion(request: Any, cloud_client: Any) -> Response: + if cloud_client is None: + raise HTTPException(status_code=503, detail="AI2Apps Cloud client is not ready") + body = _ai2apps_request_body(request) + try: + upstream = await cloud_client.request( + "POST", + "/v1/ai/responses", + json=body, + headers={ + "Idempotency-Key": str( + getattr(request, "ai2apps_idempotency_key", None) + or f"local-chat-{uuid.uuid4()}" + ) + }, + stream=bool(request.stream), + ) + except httpx.TimeoutException as exc: + raise HTTPException(status_code=504, detail="AI2Apps Cloud timed out") from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="AI2Apps Cloud is unavailable") from exc + + if upstream.status_code >= 400: + await upstream.aread() + try: + payload = upstream.json() + error = payload.get("error", {}) if isinstance(payload, dict) else {} + detail = { + "code": str(error.get("code") or "AI2APPS_CLOUD_REQUEST_FAILED"), + "message": str( + error.get("message") or "AI2Apps Cloud request failed" + )[:1000], + "retryable": bool(error.get("retryable", False)), + } + except (TypeError, ValueError): + detail = { + "code": "AI2APPS_CLOUD_REQUEST_FAILED", + "message": f"AI2Apps Cloud returned HTTP {upstream.status_code}", + "retryable": False, + } + status = upstream.status_code + await upstream.aclose() + raise HTTPException(status_code=status, detail=detail) + + gateway_model = request.model + if not request.stream: + try: + payload = upstream.json() + finally: + await upstream.aclose() + output = payload.get("output", []) if isinstance(payload, dict) else [] + text = "".join( + str(item.get("text") or "") + for item in output + if isinstance(item, dict) and item.get("type") == "output_text" + ) + tool_calls = [ + { + "id": str(item.get("callId") or ""), + "type": "function", + "function": { + "name": str(item.get("name") or ""), + "arguments": str(item.get("arguments") or "{}"), + }, + } + for item in output + if isinstance(item, dict) and item.get("type") == "tool_call" + ] + response = { + "id": str(payload.get("requestId") or f"chatcmpl-{uuid.uuid4().hex}"), + "object": "chat.completion", + "created": int(time.time()), + "model": gateway_model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": text or None, **({"tool_calls": tool_calls} if tool_calls else {})}, + "finish_reason": _finish_reason(payload.get("stopReason")), + }], + "usage": _openai_usage(payload.get("usage")), + "ai2apps_cloud": { + key: payload.get(key) + for key in ( + "requestId", + "charged", + "released", + "balance", + "pricingVersion", + "stopReason", + ) + if payload.get(key) is not None + }, + } + return Response(content=json.dumps(response), media_type="application/json") + + async def stream_body(): + buffer = "" + request_id = f"chatcmpl-{uuid.uuid4().hex}" + tool_indices: dict[str, int] = {} + try: + async for chunk in upstream.aiter_text(): + buffer += chunk.replace("\r\n", "\n") + while "\n\n" in buffer: + frame, buffer = buffer.split("\n\n", 1) + event = "message" + data_lines: list[str] = [] + for line in frame.split("\n"): + if line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + if not data_lines: + continue + try: + data = json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + continue + if event == "response.created" and data.get("requestId"): + request_id = str(data["requestId"]) + yield _chat_chunk( + gateway_model, + request_id, + { + "role": "assistant", + "content": "", + "ai2apps_cloud": { + "phase": "created", + **data, + }, + }, + ) + elif event == "output_text.delta": + yield _chat_chunk(gateway_model, request_id, {"content": str(data.get("delta") or "")}) + elif event == "tool_call.delta": + call_id = str(data.get("callId") or "") + index = tool_indices.setdefault(call_id, len(tool_indices)) + function: dict[str, Any] = {"arguments": str(data.get("argumentsDelta") or "")} + if data.get("name"): + function["name"] = str(data["name"]) + yield _chat_chunk(gateway_model, request_id, {"tool_calls": [{"index": index, "id": call_id, "type": "function", "function": function}]}) + elif event == "response.completed": + yield _chat_chunk( + gateway_model, + request_id, + {"ai2apps_cloud": {"phase": "completed", **data}}, + _finish_reason(data.get("stopReason")), + data.get("usage"), + ) + elif event == "response.failed": + error = data.get("error") if isinstance(data.get("error"), dict) else {} + yield _chat_chunk( + gateway_model, + request_id, + { + "ai2apps_cloud": { + "phase": "failed", + **data, + "error": error, + } + }, + "stop", + ) + yield b"data: [DONE]\n\n" + finally: + await upstream.aclose() + + return StreamingResponse(stream_body(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +async def proxy_cloud_chat_completion( + request: Any, + *, + base_path: Any, + cloud_client: Any | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> Response: + """Proxy a selected virtual model through its configured provider.""" + + store = ModelManagerStore(base_path) + provider = store.resolve_cloud_model(request.model) + if provider is None and request.model.startswith("cloud/"): + managed_model = request.model[len("cloud/") :] + if managed_model.startswith(f"{AI2APPS_CLOUD_PROVIDER_ID}/"): + managed_model = managed_model[len(AI2APPS_CLOUD_PROVIDER_ID) + 1 :] + provider_id = managed_model.split("/", 1)[0] + local_provider = next( + (item for item in store.list_cloud() if item["id"] == provider_id), None + ) + if local_provider is None or not local_provider["configured"]: + original_model = request.model + request.model = f"{AI2APPS_CLOUD_MODEL_PREFIX}{managed_model}" + try: + return await _proxy_ai2apps_chat_completion(request, cloud_client) + finally: + request.model = original_model + if provider is None: + raise HTTPException(status_code=404, detail="Cloud model is not enabled") + + serialized = request.model_dump(mode="json", exclude_none=True, by_alias=True) + body = {key: value for key, value in serialized.items() if key in _OPENAI_CHAT_FIELDS} + body["model"] = provider["model_id"] + modern_parameter_key = (provider["base_url"], provider["model_id"]) + if modern_parameter_key in _MODERN_TOKEN_PARAMETER_MODELS: + body = _modern_openai_chat_body(body) + headers = { + "Authorization": f"Bearer {provider['api_key']}", + "Accept": "text/event-stream" if request.stream else "application/json", + "Content-Type": "application/json", + } + timeout = httpx.Timeout(connect=15.0, read=3600.0, write=120.0, pool=30.0) + client = httpx.AsyncClient(timeout=timeout, transport=transport) + try: + upstream_request = client.build_request( + "POST", _chat_url(provider), headers=headers, json=body + ) + upstream = await client.send(upstream_request, stream=bool(request.stream)) + except httpx.TimeoutException as exc: + await client.aclose() + raise HTTPException(status_code=504, detail="Cloud provider timed out") from exc + except httpx.HTTPError as exc: + await client.aclose() + raise HTTPException(status_code=502, detail="Cloud provider is unavailable") from exc + + if upstream.status_code >= 400: + await upstream.aread() + detail = _redacted_error(upstream, provider["api_key"]) + status = upstream.status_code + if _needs_max_completion_tokens(status, detail, body): + await upstream.aclose() + modern_body = _modern_openai_chat_body(body) + if len(_MODERN_TOKEN_PARAMETER_MODELS) >= 512: + _MODERN_TOKEN_PARAMETER_MODELS.clear() + _MODERN_TOKEN_PARAMETER_MODELS.add(modern_parameter_key) + try: + upstream = await client.send( + client.build_request( + "POST", + _chat_url(provider), + headers=headers, + json=modern_body, + ), + stream=bool(request.stream), + ) + except httpx.TimeoutException as exc: + await client.aclose() + raise HTTPException(status_code=504, detail="Cloud provider timed out") from exc + except httpx.HTTPError as exc: + await client.aclose() + raise HTTPException(status_code=502, detail="Cloud provider is unavailable") from exc + if upstream.status_code >= 400: + await upstream.aread() + detail = _redacted_error(upstream, provider["api_key"]) + status = upstream.status_code + await upstream.aclose() + await client.aclose() + raise HTTPException(status_code=status, detail=detail) + else: + await upstream.aclose() + await client.aclose() + raise HTTPException(status_code=status, detail=detail) + + content_type = upstream.headers.get( + "content-type", "text/event-stream" if request.stream else "application/json" + ) + if not request.stream: + content = await upstream.aread() + await upstream.aclose() + await client.aclose() + return Response( + content=content, + status_code=upstream.status_code, + headers={"content-type": content_type}, + ) + + async def stream_body(): + try: + async for chunk in upstream.aiter_bytes(): + yield chunk + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + stream_body(), + status_code=upstream.status_code, + headers={"content-type": content_type}, + ) diff --git a/ai2apps/remote/__init__.py b/ai2apps/remote/__init__.py new file mode 100644 index 00000000..c31861d5 --- /dev/null +++ b/ai2apps/remote/__init__.py @@ -0,0 +1,14 @@ +"""AI2Apps Remote Access v1 client integration.""" + +from .manager import RemoteAccessError, RemoteAccessManager +from .frpc import PINNED_FRP_VERSION, RemoteFrpcConfig, RemoteFrpcSupervisor +from .models import RemoteDeviceRecord, RemoteMobileSession +from .repository import RemoteDeviceRepository +from .security import RemoteTokenError, verify_remote_token + +__all__ = [ + "RemoteAccessError", "RemoteAccessManager", "RemoteDeviceRecord", + "RemoteDeviceRepository", "RemoteMobileSession", "RemoteTokenError", + "verify_remote_token", + "PINNED_FRP_VERSION", "RemoteFrpcConfig", "RemoteFrpcSupervisor", +] diff --git a/ai2apps/remote/frp-ca-2026.pem b/ai2apps/remote/frp-ca-2026.pem new file mode 100644 index 00000000..78e1d40d --- /dev/null +++ b/ai2apps/remote/frp-ca-2026.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIERzCCAq+gAwIBAgIJAM+Dlwbk8ih+MA0GCSqGSIb3DQEBCwUAMDoxJjAkBgNV +BAMMHUFJMkFwcHMtUmVtb3RlLUFjY2Vzcy1Sb290LUNBMRAwDgYDVQQKDAdBSTJB +cHBzMB4XDTI2MDgxNTAxMzQyNVoXDTM2MDgxMjAxMzQyNVowOjEmMCQGA1UEAwwd +QUkyQXBwcy1SZW1vdGUtQWNjZXNzLVJvb3QtQ0ExEDAOBgNVBAoMB0FJMkFwcHMw +ggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC5WBBN+tZhobbF5khxRr8i +0ygbpPPCZZhkkw7tFmpDlS8UOACatSPbq+cYKNpX0T1k00lj6PNvsm0ySQATEHEV +GljQONTYfHt1KpOxqfC4094ejPbvbwLe0K+xoF4djR42seGe2MSYqrcaUbUVCiJ6 +R8LWOVD3x9U00v2VKCvy+Ln2aKriqOdpPcJp+VLj9aVTJTfGEivy9hlpkcGtK8sp +D1u45mPVb7+3VCzeb2zBmjO3ZSg0ZEE6b/2Y0Dftg5tFYhNBuMCXs+oCPDRG1ShG +bBX8KOc/L271bKYjnM6zHdP1nklmvNCTJhUwfjsSXirtwvXASBDb9CIPdyNMpGoI +Yb0rjAW1lk3EN5twE1QlVYpcB/mh7po6qrNnIcqbI19xqX1aMtbqAX4ZwzSJXTcF +h8xyi9CO+BLBurw4o52NOZ5t+pF4HZ6Uls7LjNwyT1AZqBVtDfr3/M+rzkLmopyf +VsaeQKfLcfL4GT4rQGViX9PA5l4pVHOHmtuQIFMDS1cCAwEAAaNQME4wHQYDVR0O +BBYEFBYssfqQjHu7HnhZGUimoLv3iWDrMB8GA1UdIwQYMBaAFBYssfqQjHu7HnhZ +GUimoLv3iWDrMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAKHPXRYM +N15/JbcZtlWcm0q8Vax1U+lvTXlys5FxmiZctKIuYaw3MAvUUTxySJEPu9VUmzfi +/PURuhFApAn3PRtZ+aZnTPniXWWEelWvzULNqcjCfBvchx+iYTSCBTU2WChhviHZ +oGIy4xa9ti/HSGLDxjIvrBqpz4mYxATa8jxhBsEAbeLLRFDyzwY4MqogOcrkLMQy +l0oYTfoESD5SX1xNLI9OyCenll5tqN6Xa4whl9FjUQO+5ZnlE+r5f7y9WqAyFOs4 +Nx9ia0m+Ns9YGjZ/rIOjPJaQVLm5wf9qj05moduz6HpUvikXURc6Yo5yo6Dc/0vM ++TjOSRRfa6s7sjpLoXRKpYzyzpO2WQXkRrPHmNq6qHXoCfhuhbpiaCt2mijK+6Vf +8uAoRz0tFX2jHd4GmDOuCl5oLPFLN3VCZNOLA7tyjehXpS1/Dq3lVXAP4wEP4gwB +qGEry0AFX4gKwbg64E0E4lhvYConMIM+UtgRciAjueZvT5ytJx556lUdYA== +-----END CERTIFICATE----- diff --git a/ai2apps/remote/frpc-device.toml b/ai2apps/remote/frpc-device.toml new file mode 100644 index 00000000..2510a30c --- /dev/null +++ b/ai2apps/remote/frpc-device.toml @@ -0,0 +1,31 @@ +# Trusted AI2Apps Remote Access v1 template. Values come only from the main process. +serverAddr = "{{ .Envs.AI2APPS_FRP_SERVER_ADDR }}" +serverPort = {{ .Envs.AI2APPS_FRP_SERVER_PORT }} +loginFailExit = false + +transport.protocol = "tcp" +transport.heartbeatInterval = 30 +transport.heartbeatTimeout = 60 +transport.tls.enable = true +transport.tls.trustedCaFile = "{{ .Envs.AI2APPS_FRP_CA_FILE }}" +transport.tls.serverName = "frpc.ai2apps.com" +transport.tls.disableCustomTLSFirstByte = true + +auth.method = "token" +auth.additionalScopes = ["HeartBeats", "NewWorkConns"] +auth.token = "{{ .Envs.AI2APPS_FRP_BOOTSTRAP_TOKEN }}" + +metadatas.deviceId = "{{ .Envs.AI2APPS_REMOTE_DEVICE_ID }}" +metadatas.credentialVersion = "{{ .Envs.AI2APPS_REMOTE_CREDENTIAL_VERSION }}" +metadatas.connectorSecret = "{{ .Envs.AI2APPS_REMOTE_CONNECTOR_SECRET }}" + +[[proxies]] +name = "device-{{ .Envs.AI2APPS_REMOTE_DEVICE_ID }}" +type = "http" +localIP = "127.0.0.1" +localPort = {{ .Envs.AI2APPS_MOBILE_GATEWAY_PORT }} +subdomain = "device-{{ .Envs.AI2APPS_REMOTE_PUBLIC_SLUG }}" +locations = [] +customDomains = [] +transport.useEncryption = false +transport.useCompression = false diff --git a/ai2apps/remote/frpc.py b/ai2apps/remote/frpc.py new file mode 100644 index 00000000..6910fcd3 --- /dev/null +++ b/ai2apps/remote/frpc.py @@ -0,0 +1,214 @@ +"""Pinned, fail-closed frpc subprocess supervision.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import platform +import random +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path + +from ai2apps.secrets import SecretBackend + +from .models import RemoteDeviceRecord + +PINNED_FRP_VERSION = "0.62.1" +PINNED_FRP_CA_SHA256 = "2c460459daae289916e999a03baa3b4658fdfc0fb6a92243a002a601ad5017c0" + + +def _bundled_binary() -> Path | None: + system = platform.system().lower() + machine = platform.machine().lower() + candidate = Path(__file__).with_name("bin") / f"{system}-{machine}" / "frpc" + return candidate if candidate.is_file() else None + + +def _read_bootstrap_token(runtime_directory: Path) -> str: + value = os.environ.get("AI2APPS_FRP_BOOTSTRAP_TOKEN", "").strip() + if value: + return value + configured_file = os.environ.get("AI2APPS_FRP_BOOTSTRAP_TOKEN_FILE", "").strip() + path = ( + Path(configured_file).expanduser().resolve() + if configured_file + else (runtime_directory / "bootstrap-token").resolve() + ) + if not path.exists() and not configured_file: + return "" + if not path.is_file(): + raise ValueError("AI2APPS_FRP_BOOTSTRAP_TOKEN_FILE is not a file") + if path.stat().st_mode & 0o077: + raise ValueError("AI2Apps FRP bootstrap token file must not be group/world accessible") + return path.read_text(encoding="utf-8").strip() + + +@dataclass(frozen=True, slots=True) +class RemoteFrpcConfig: + binary: Path + ca_file: Path + bootstrap_token: str = field(repr=False) + mobile_gateway_port: int + runtime_directory: Path + + @classmethod + def unavailable_reason(cls, runtime_directory: Path) -> str: + runtime_directory = runtime_directory.resolve() + configured_binary = os.environ.get("AI2APPS_FRP_BINARY", "").strip() + runtime_binary = runtime_directory / "bin" / "frpc" + binary = ( + Path(configured_binary).expanduser().resolve() + if configured_binary + else runtime_binary if runtime_binary.is_file() else _bundled_binary() + ) + if binary is None or not binary.is_file(): + return f"FRP client {PINNED_FRP_VERSION} is not installed" + try: + if not _read_bootstrap_token(runtime_directory): + return "FRP bootstrap credential is not installed" + except ValueError as error: + return str(error) + return "FRP runtime configuration is not installed" + + @classmethod + def from_environment(cls, runtime_directory: Path) -> "RemoteFrpcConfig | None": + runtime_directory = runtime_directory.resolve() + configured_binary = os.environ.get("AI2APPS_FRP_BINARY", "").strip() + runtime_binary = runtime_directory / "bin" / "frpc" + binary_path = ( + Path(configured_binary).expanduser().resolve() + if configured_binary + else runtime_binary if runtime_binary.is_file() else _bundled_binary() + ) + configured_ca = os.environ.get("AI2APPS_FRP_CA_FILE", "").strip() + ca_path = ( + Path(configured_ca).expanduser().resolve() + if configured_ca + else Path(__file__).with_name("frp-ca-2026.pem").resolve() + ) + bootstrap = _read_bootstrap_token(runtime_directory) + if binary_path is None or not bootstrap: + return None + port = int(os.environ.get("AI2APPS_MOBILE_GATEWAY_PORT", "8000")) + if not 1 <= port <= 65535: + raise ValueError("AI2APPS_MOBILE_GATEWAY_PORT is invalid") + if not binary_path.is_file() or not os.access(binary_path, os.X_OK): + raise ValueError("AI2APPS_FRP_BINARY is not an executable file") + if not ca_path.is_file(): + raise ValueError("AI2APPS_FRP_CA_FILE is not a file") + if hashlib.sha256(ca_path.read_bytes()).hexdigest() != PINNED_FRP_CA_SHA256: + raise ValueError("AI2Apps Remote Access CA fingerprint does not match the pinned release") + return cls(binary_path, ca_path, bootstrap, port, runtime_directory) + + +class RemoteFrpcSupervisor: + def __init__( + self, + config: RemoteFrpcConfig | None, + secret_backend: SecretBackend, + *, + unavailable_reason: str | None = None, + ) -> None: + self.config = config + self.secret_backend = secret_backend + self._task: asyncio.Task[None] | None = None + self._process: asyncio.subprocess.Process | None = None + self._device: RemoteDeviceRecord | None = None + self._stop = asyncio.Event() + self.last_error = "" if config else ( + unavailable_reason or "FRP runtime configuration is not installed" + ) + + @property + def available(self) -> bool: + return self.config is not None + + @property + def running(self) -> bool: + return self._process is not None and self._process.returncode is None + + def status(self) -> dict: + return {"available": self.available, "running": self.running, + "deviceId": None if self._device is None else self._device.device_id, + "lastError": self.last_error} + + async def start(self, device: RemoteDeviceRecord) -> None: + if self.config is None: + raise RuntimeError(self.last_error) + if device.status != "active": + raise RuntimeError("Remote device is not active") + await self.stop() + await self._verify_version() + self._device = device + self._stop = asyncio.Event() + self._task = asyncio.create_task(self._supervise(), name="ai2apps-frpc") + + async def _verify_version(self) -> None: + assert self.config is not None + process = await asyncio.create_subprocess_exec( + str(self.config.binary), "--version", stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(process.communicate(), timeout=5) + if process.returncode != 0 or PINNED_FRP_VERSION not in output.decode(errors="replace"): + raise RuntimeError(f"Remote Access requires frpc {PINNED_FRP_VERSION}") + + async def _supervise(self) -> None: + assert self.config is not None and self._device is not None + template = Path(__file__).with_name("frpc-device.toml") + self.config.runtime_directory.mkdir(parents=True, exist_ok=True, mode=0o700) + runtime_template = self.config.runtime_directory / "frpc-device.toml" + if not runtime_template.exists() or runtime_template.read_bytes() != template.read_bytes(): + runtime_template.write_bytes(template.read_bytes()) + runtime_template.chmod(0o600) + delay = 1.0 + while not self._stop.is_set(): + try: + secret = self.secret_backend.load(self._device.secret_backend_key) + slug = self._device.subdomain.removeprefix("device-") + environment = { + **os.environ, + "AI2APPS_FRP_SERVER_ADDR": self._device.server_addr, + "AI2APPS_FRP_SERVER_PORT": str(self._device.server_port), + "AI2APPS_FRP_CA_FILE": str(self.config.ca_file), + "AI2APPS_FRP_BOOTSTRAP_TOKEN": self.config.bootstrap_token, + "AI2APPS_REMOTE_DEVICE_ID": self._device.device_id, + "AI2APPS_REMOTE_CREDENTIAL_VERSION": str(self._device.credential_version), + "AI2APPS_REMOTE_CONNECTOR_SECRET": secret, + "AI2APPS_REMOTE_PUBLIC_SLUG": slug, + "AI2APPS_MOBILE_GATEWAY_PORT": str(self.config.mobile_gateway_port), + } + self._process = await asyncio.create_subprocess_exec( + str(self.config.binary), "-c", str(runtime_template), env=environment, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + code = await self._process.wait() + self._process = None + if self._stop.is_set(): + break + self.last_error = f"frpc exited with status {code}" + except Exception as error: + self._process = None + self.last_error = str(error) + with suppress(TimeoutError): + await asyncio.wait_for(self._stop.wait(), timeout=delay * random.uniform(0.8, 1.2)) + delay = min(60.0, delay * 2) + + async def stop(self) -> None: + self._stop.set() + process = self._process + if process is not None and process.returncode is None: + process.terminate() + with suppress(TimeoutError): + await asyncio.wait_for(process.wait(), timeout=5) + if process.returncode is None: + process.kill() + await process.wait() + if self._task is not None: + with suppress(asyncio.CancelledError): + await self._task + self._task = None + self._process = None diff --git a/ai2apps/remote/manager.py b/ai2apps/remote/manager.py new file mode 100644 index 00000000..88d19519 --- /dev/null +++ b/ai2apps/remote/manager.py @@ -0,0 +1,360 @@ +"""Remote Access v1 control plane used by the local runtime and Mobile Gateway.""" + +from __future__ import annotations + +import platform +import re +from datetime import timedelta +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ai2apps.cloud_client import AI2AppsCloudClient +from ai2apps.core import utc_now +from ai2apps.secrets import SecretBackend + +from .frpc import RemoteFrpcSupervisor +from .models import RemoteDeviceRecord, RemoteMobileSession +from .repository import RemoteDeviceRepository +from .security import ACCESS_CHECK_WINDOW, RemoteSessionStore, verify_remote_token + +PAIRING_HOST = "coder.ai2apps.com" +LEGACY_PAIRING_HOST = "ai2apps.com" +PAIRING_PATH = "/mobile/pair" + + +class RemoteAccessError(RuntimeError): + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + + +class RemoteAccessManager: + def __init__( + self, + *, + cloud: AI2AppsCloudClient, + repository: RemoteDeviceRepository, + secret_backend: SecretBackend, + client_version: str, + frpc: RemoteFrpcSupervisor | None = None, + ) -> None: + self.cloud = cloud + self.repository = repository + self.secret_backend = secret_backend + self.client_version = client_version + self.sessions = RemoteSessionStore() + self.frpc = frpc or RemoteFrpcSupervisor(None, secret_backend) + + @staticmethod + async def _payload(response: httpx.Response) -> dict[str, Any]: + try: + value = response.json() + except ValueError: + value = {} + if response.status_code >= 400: + error = value.get("error", {}) if isinstance(value, dict) else {} + raise RemoteAccessError( + response.status_code, + str(error.get("code") or "REMOTE_REQUEST_FAILED"), + str(error.get("message") or f"Remote request failed ({response.status_code})"), + ) + if not isinstance(value, dict): + raise RemoteAccessError(502, "REMOTE_RESPONSE_INVALID", "Cloud returned an invalid remote response") + return value + + async def _request(self, method: str, path: str, *, json: Any | None = None, device: RemoteDeviceRecord | None = None) -> dict[str, Any]: + headers = None + if device is not None: + try: + secret = self.secret_backend.load(device.secret_backend_key) + except KeyError as error: + raise RemoteAccessError(409, "REMOTE_CREDENTIAL_MISSING", "Remote device credential is missing") from error + headers = {"Authorization": f"Device {device.device_id}.{secret}"} + response = await self.cloud.request(method, path, json=json, headers=headers) + try: + return await self._payload(response) + finally: + await response.aclose() + + @staticmethod + def _secret_key(device_id: str) -> str: + return f"ai2apps-remote-connector-{device_id}" + + @staticmethod + def _platform_name() -> str: + return "macos-arm64" if platform.system() == "Darwin" else platform.system().lower() + + @staticmethod + def _validate_connector(device: dict[str, Any], connector: dict[str, Any]) -> None: + device_id = str(device.get("id") or "") + subdomain = str(connector.get("subdomain") or "") + origin = urlparse(str(device.get("publicOrigin") or "")) + expected_host = f"{subdomain}.ai2apps.com" + if ( + connector.get("deviceId") != device_id + or connector.get("serverAddr") != "frpc.ai2apps.com" + or int(connector.get("serverPort", 0)) != 7000 + or connector.get("proxyType") != "http" + or connector.get("proxyName") != f"device-{device_id}" + or re.fullmatch(r"device-[0-9a-f]{32}", subdomain) is None + or origin.scheme != "https" + or origin.hostname != expected_host + or origin.port is not None + or origin.path not in {"", "/"} + or origin.query + or origin.fragment + ): + raise RemoteAccessError( + 502, "REMOTE_CONNECTOR_INVALID", + "Cloud returned connector settings outside the Remote Access v1 policy", + ) + + async def _recover_registration( + self, *, display_name: str, platform_name: str + ) -> RemoteDeviceRecord: + listed = await self._request("GET", "/v1/remote/devices") + local_ids = {item.device_id for item in self.repository.list()} + candidates = [ + item for item in listed.get("items", []) + if item.get("id") not in local_ids + and item.get("displayName") == display_name + and item.get("platform") == platform_name + and item.get("clientVersion") == self.client_version + and item.get("status") == "active" + ] + if len(candidates) != 1: + raise RemoteAccessError( + 409, "REMOTE_REGISTRATION_RECOVERY_REQUIRED", + "Remote device creation was ambiguous; review the account device list", + ) + device = candidates[0] + credential = await self._request( + "POST", f"/v1/remote/devices/{device['id']}/credentials/rotate" + ) + public_host = urlparse(device["publicOrigin"]).hostname or "" + connector = { + **credential, + "deviceId": device["id"], + "serverAddr": "frpc.ai2apps.com", + "serverPort": 7000, + "proxyType": "http", + "proxyName": f"device-{device['id']}", + "subdomain": public_host.removesuffix(".ai2apps.com"), + } + return self._persist_registration(device, connector) + + def _persist_registration( + self, device: dict[str, Any], connector_value: dict[str, Any] + ) -> RemoteDeviceRecord: + connector = dict(connector_value) + self._validate_connector(device, connector) + secret = connector.pop("secret", None) + if not isinstance(secret, str) or not secret: + raise RemoteAccessError(502, "REMOTE_CREDENTIAL_INVALID", "Cloud omitted the connector credential") + key = self._secret_key(device["id"]) + self.secret_backend.store(key, secret) + try: + return self.repository.upsert(device, connector, secret_backend_key=key) + except Exception: + self.secret_backend.delete(key) + raise + + async def register(self, *, display_name: str) -> RemoteDeviceRecord: + platform_name = self._platform_name() + try: + payload = await self._request("POST", "/v1/remote/devices", json={ + "displayName": display_name, + "platform": platform_name, + "clientVersion": self.client_version, + }) + except (httpx.TimeoutException, httpx.TransportError): + return await self._recover_registration( + display_name=display_name, platform_name=platform_name + ) + device, connector = payload["device"], payload["connector"] + return self._persist_registration(device, connector) + + async def reconcile(self) -> tuple[RemoteDeviceRecord, ...]: + payload = await self._request("GET", "/v1/remote/devices") + local = {item.device_id: item for item in self.repository.list()} + for device in payload.get("items", []): + if device.get("id") in local: + self.repository.update_cloud_state(device) + return self.repository.list() + + async def rotate(self, device_id: str) -> RemoteDeviceRecord: + device = self.require_device(device_id) + restart_after_rotation = device.enabled + await self.stop(device_id) + try: + payload = await self._request("POST", f"/v1/remote/devices/{device_id}/credentials/rotate") + except (httpx.TimeoutException, httpx.TransportError): + payload = await self._request("POST", f"/v1/remote/devices/{device_id}/credentials/rotate") + self.secret_backend.store(device.secret_backend_key, payload["secret"]) + record = self.repository.update_credential(device_id, payload) + assert record is not None + if restart_after_rotation: + await self.frpc.start(record) + restarted = self.repository.set_enabled(device_id, True) + assert restarted is not None + return restarted + return record + + async def pairing_challenge(self, device_id: str) -> dict[str, Any]: + device = self.require_device(device_id) + connector = self.frpc.status() + if ( + not device.enabled + or not connector.get("running") + or connector.get("deviceId") != device_id + ): + raise RemoteAccessError( + 409, + "REMOTE_CONNECTOR_NOT_RUNNING", + "Start Remote Access and wait for the connector to be online before pairing", + ) + cloud_device = await self._request("GET", f"/v1/remote/devices/{device_id}") + refreshed = self.repository.update_cloud_state(cloud_device) + if refreshed is None or not refreshed.proxy_connected: + raise RemoteAccessError( + 409, + "REMOTE_CONNECTOR_NOT_ONLINE", + "Wait for the Remote Access indicator to turn green before pairing", + ) + payload = await self._request("POST", f"/v1/remote/devices/{device_id}/pairing-challenges") + pairing_url = str(payload.get("pairingUrl") or "") + payload["pairingUrl"] = self._canonical_pairing_url(pairing_url) + return payload + + @staticmethod + def _canonical_pairing_url(pairing_url: str) -> str: + parsed = urlparse(pairing_url) + try: + port = parsed.port + except ValueError: + port = -1 + if ( + parsed.scheme != "https" + or parsed.hostname not in {PAIRING_HOST, LEGACY_PAIRING_HOST} + or parsed.username is not None + or parsed.password is not None + or port is not None + or parsed.path != PAIRING_PATH + or parsed.params + or parsed.query + or re.fullmatch(r"challenge=[A-Za-z0-9._~-]+", parsed.fragment) is None + ): + raise RemoteAccessError( + 502, "REMOTE_PAIRING_URL_INVALID", + "Cloud returned a pairing URL outside the Remote Access v1 policy", + ) + return parsed._replace(netloc=PAIRING_HOST).geturl() + + async def usage(self) -> dict[str, Any]: + return await self._request("GET", "/v1/remote/usage") + + async def revoke(self, device_id: str) -> dict[str, Any]: + self.require_device(device_id) + await self.stop(device_id) + payload = await self._request("POST", f"/v1/remote/devices/{device_id}/revoke") + self.repository.set_enabled(device_id, False) + self.sessions.revoke_device(device_id) + await self.reconcile() + return payload + + async def start(self, device_id: str) -> RemoteDeviceRecord: + device = self.require_device(device_id) + if device.status == "revoked": + raise RemoteAccessError( + 409, + "REMOTE_DEVICE_REVOKED", + "Revoked device identities cannot be started; register this Mac again", + ) + cloud_device = await self._request("GET", f"/v1/remote/devices/{device_id}") + refreshed = self.repository.update_cloud_state(cloud_device) + if refreshed is not None: + device = refreshed + if device.credential_expires_at <= utc_now() + timedelta(days=7): + raise RemoteAccessError( + 409, "REMOTE_CREDENTIAL_ROTATION_REQUIRED", + "Remote connector credential expires within seven days; rotate it before starting", + ) + await self.frpc.start(device) + record = self.repository.set_enabled(device_id, True) + assert record is not None + return record + + async def stop(self, device_id: str | None = None) -> RemoteDeviceRecord | None: + await self.frpc.stop() + if device_id is None: + return None + self.sessions.revoke_device(device_id) + return self.repository.set_enabled(device_id, False) + + async def startup(self) -> None: + enabled = next((item for item in self.repository.list() if item.enabled and item.status == "active"), None) + if enabled is not None and self.frpc.available: + await self.frpc.start(enabled) + + async def shutdown(self) -> None: + await self.frpc.stop() + self.sessions.clear() + + async def redact(self, device_id: str) -> None: + device = self.require_device(device_id) + response = await self.cloud.request("DELETE", f"/v1/remote/devices/{device_id}") + try: + if response.status_code >= 400: + await self._payload(response) + finally: + await response.aclose() + self.secret_backend.delete(device.secret_backend_key) + self.repository.delete(device_id) + + def require_device(self, device_id: str) -> RemoteDeviceRecord: + device = self.repository.get(device_id) + if device is None: + raise RemoteAccessError(404, "REMOTE_DEVICE_NOT_FOUND", "Remote device is not registered locally") + return device + + async def exchange_handoff(self, *, device_id: str, handoff: str) -> tuple[str, RemoteMobileSession]: + device = self.require_device(device_id) + token_payload = await self._request( + "POST", "/v1/internal/remote/mobile/exchange", + json={"handoff": handoff}, device=device, + ) + jwks = await self._request("GET", "/v1/remote/jwks.json") + claims = verify_remote_token( + token_payload["accessToken"], jwks, device_id=device_id, + access_epoch=int(token_payload["accessEpoch"]), + ) + return self.sessions.create( + device_id=device_id, owner_user_id=claims["sub"], + access_epoch=int(claims["access_epoch"]), + ) + + async def authorize_session(self, token: str | None) -> RemoteMobileSession | None: + session = self.sessions.get(token) + if session is None: + return None + if utc_now() - session.last_access_check_at < ACCESS_CHECK_WINDOW: + return session + device = self.repository.get(session.device_id) + if device is None: + self.sessions.revoke_device(session.device_id) + return None + try: + access = await self._request( + "GET", f"/v1/internal/remote/devices/{session.device_id}/access", + device=device, + ) + except (RemoteAccessError, httpx.HTTPError): + self.sessions.revoke_device(session.device_id) + return None + if access.get("status") != "active" or int(access.get("accessEpoch", 0)) != session.access_epoch: + self.sessions.revoke_device(session.device_id) + return None + return self.sessions.checked(session) diff --git a/ai2apps/remote/models.py b/ai2apps/remote/models.py new file mode 100644 index 00000000..37fa4a68 --- /dev/null +++ b/ai2apps/remote/models.py @@ -0,0 +1,42 @@ +"""Local-only records for AI2Apps Remote Access v1.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class RemoteDeviceRecord: + device_id: str + display_name: str + platform: str + client_version: str + status: str + suspension_reason: str | None + access_epoch: int + public_origin: str + credential_version: int + credential_expires_at: datetime + server_addr: str + server_port: int + proxy_name: str + subdomain: str + secret_backend_key: str + enabled: bool + online: bool + proxy_connected: bool + last_seen_at: datetime | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RemoteMobileSession: + token_digest: str + device_id: str + owner_user_id: str + access_epoch: int + created_at: datetime + expires_at: datetime + last_access_check_at: datetime diff --git a/ai2apps/remote/repository.py b/ai2apps/remote/repository.py new file mode 100644 index 00000000..5d5df6f1 --- /dev/null +++ b/ai2apps/remote/repository.py @@ -0,0 +1,119 @@ +"""Durable non-secret Remote Access client state.""" + +from __future__ import annotations + +from typing import Any + +from ai2apps.core import parse_utc, utc_now_text +from ai2apps.storage import PlatformDatabase + +from .models import RemoteDeviceRecord + + +class RemoteDeviceRepository: + def __init__(self, database: PlatformDatabase) -> None: + self.database = database + + @staticmethod + def _record(row) -> RemoteDeviceRecord: + return RemoteDeviceRecord( + device_id=row["device_id"], display_name=row["display_name"], + platform=row["platform"], client_version=row["client_version"], + status=row["status"], suspension_reason=row["suspension_reason"], + access_epoch=int(row["access_epoch"]), public_origin=row["public_origin"], + credential_version=int(row["credential_version"]), + credential_expires_at=parse_utc(row["credential_expires_at"]), + server_addr=row["server_addr"], server_port=int(row["server_port"]), + proxy_name=row["proxy_name"], subdomain=row["subdomain"], + secret_backend_key=row["secret_backend_key"], enabled=bool(row["enabled"]), + online=bool(row["online"]), proxy_connected=bool(row["proxy_connected"]), + last_seen_at=None if row["last_seen_at"] is None else parse_utc(row["last_seen_at"]), + created_at=parse_utc(row["created_at"]), updated_at=parse_utc(row["updated_at"]), + ) + + def list(self) -> tuple[RemoteDeviceRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM remote_client_devices ORDER BY updated_at DESC" + ).fetchall() + return tuple(self._record(row) for row in rows) + + def get(self, device_id: str) -> RemoteDeviceRecord | None: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM remote_client_devices WHERE device_id = ?", (device_id,) + ).fetchone() + return None if row is None else self._record(row) + + def upsert(self, device: dict[str, Any], connector: dict[str, Any], *, secret_backend_key: str) -> RemoteDeviceRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """INSERT INTO remote_client_devices( + device_id,display_name,platform,client_version,status,suspension_reason, + access_epoch,public_origin,credential_version,credential_expires_at, + server_addr,server_port,proxy_name,subdomain,secret_backend_key, + online,proxy_connected,last_seen_at,created_at,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(device_id) DO UPDATE SET + display_name=excluded.display_name,platform=excluded.platform, + client_version=excluded.client_version,status=excluded.status, + suspension_reason=excluded.suspension_reason,access_epoch=excluded.access_epoch, + public_origin=excluded.public_origin,credential_version=excluded.credential_version, + credential_expires_at=excluded.credential_expires_at, + server_addr=excluded.server_addr,server_port=excluded.server_port, + proxy_name=excluded.proxy_name,subdomain=excluded.subdomain, + secret_backend_key=excluded.secret_backend_key,online=excluded.online, + proxy_connected=excluded.proxy_connected,last_seen_at=excluded.last_seen_at, + updated_at=excluded.updated_at""", + ( + device["id"], device["displayName"], device["platform"], + device["clientVersion"], device["status"], device.get("suspensionReason"), + int(device["accessEpoch"]), device["publicOrigin"], + int(connector["credentialVersion"]), connector["credentialExpiresAt"], + connector["serverAddr"], int(connector["serverPort"]), + connector["proxyName"], connector["subdomain"], secret_backend_key, + int(bool(device.get("online"))), int(bool(device.get("proxyConnected"))), + device.get("lastSeenAt"), device.get("createdAt") or now, now, + ), + ) + record = self.get(device["id"]) + assert record is not None + return record + + def update_cloud_state(self, device: dict[str, Any]) -> RemoteDeviceRecord | None: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE remote_client_devices SET display_name=?,status=?,suspension_reason=?, + access_epoch=?,public_origin=?,credential_expires_at=?,online=?, + proxy_connected=?,last_seen_at=?,updated_at=? WHERE device_id=?""", + (device["displayName"], device["status"], device.get("suspensionReason"), + int(device["accessEpoch"]), device["publicOrigin"], + device["credentialExpiresAt"], int(bool(device.get("online"))), + int(bool(device.get("proxyConnected"))), device.get("lastSeenAt"), now, + device["id"]), + ) + return self.get(device["id"]) + + def update_credential(self, device_id: str, credential: dict[str, Any]) -> RemoteDeviceRecord | None: + with self.database.transaction(write=True) as connection: + connection.execute( + """UPDATE remote_client_devices SET credential_version=?, + credential_expires_at=?,updated_at=? WHERE device_id=?""", + (int(credential["credentialVersion"]), credential["credentialExpiresAt"], + utc_now_text(), device_id), + ) + return self.get(device_id) + + def set_enabled(self, device_id: str, enabled: bool) -> RemoteDeviceRecord | None: + with self.database.transaction(write=True) as connection: + connection.execute( + "UPDATE remote_client_devices SET enabled=?,updated_at=? WHERE device_id=?", + (int(enabled), utc_now_text(), device_id), + ) + return self.get(device_id) + + def delete(self, device_id: str) -> None: + with self.database.transaction(write=True) as connection: + connection.execute("DELETE FROM remote_client_devices WHERE device_id=?", (device_id,)) diff --git a/ai2apps/remote/security.py b/ai2apps/remote/security.py new file mode 100644 index 00000000..1e96b129 --- /dev/null +++ b/ai2apps/remote/security.py @@ -0,0 +1,125 @@ +"""Ed25519 Cloud token verification and bounded local mobile sessions.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +from datetime import timedelta +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from ai2apps.core import utc_now + +from .models import RemoteMobileSession + +REMOTE_TOKEN_ISSUER = "ai2apps-cloud" +REMOTE_TOKEN_AUDIENCE = "ai2apps-remote-mobile-v1" +MOBILE_SESSION_LIFETIME = timedelta(minutes=15) +ACCESS_CHECK_WINDOW = timedelta(seconds=60) + + +class RemoteTokenError(ValueError): + pass + + +def _decode(value: str) -> bytes: + try: + return base64.urlsafe_b64decode(value.encode("ascii") + b"=" * (-len(value) % 4)) + except (ValueError, UnicodeError) as error: + raise RemoteTokenError("Remote token contains invalid base64url") from error + + +def verify_remote_token( + token: str, + jwks: dict[str, Any], + *, + device_id: str, + access_epoch: int, +) -> dict[str, Any]: + parts = token.split(".") + if len(parts) != 3: + raise RemoteTokenError("Remote token must be a compact JWT") + try: + header = json.loads(_decode(parts[0])) + claims = json.loads(_decode(parts[1])) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise RemoteTokenError("Remote token JSON is invalid") from error + if header.get("alg") != "EdDSA" or not isinstance(header.get("kid"), str): + raise RemoteTokenError("Remote token algorithm is not allowed") + key = next((item for item in jwks.get("keys", []) if item.get("kid") == header["kid"]), None) + if not key or any(key.get(name) != expected for name, expected in { + "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", + }.items()): + raise RemoteTokenError("Remote token signing key is unavailable") + try: + public_key = Ed25519PublicKey.from_public_bytes(_decode(key["x"])) + public_key.verify(_decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii")) + except (KeyError, ValueError, InvalidSignature) as error: + raise RemoteTokenError("Remote token signature is invalid") from error + now = int(utc_now().timestamp()) + required = ("iss", "aud", "sub", "device_id", "access_epoch", "iat", "exp", "jti") + if any(name not in claims for name in required): + raise RemoteTokenError("Remote token is missing required claims") + if claims["iss"] != REMOTE_TOKEN_ISSUER or claims["aud"] != REMOTE_TOKEN_AUDIENCE: + raise RemoteTokenError("Remote token issuer or audience is invalid") + if claims["device_id"] != device_id or int(claims["access_epoch"]) != access_epoch: + raise RemoteTokenError("Remote token is bound to a different device epoch") + if not isinstance(claims["sub"], str) or not claims["sub"] or not isinstance(claims["jti"], str): + raise RemoteTokenError("Remote token identity claims are invalid") + if int(claims["exp"]) <= now or int(claims["iat"]) > now + 30: + raise RemoteTokenError("Remote token is expired or not yet valid") + return claims + + +class RemoteSessionStore: + """In-memory, restart-invalidated sessions keyed by a SHA-256 cookie digest.""" + + def __init__(self) -> None: + self._sessions: dict[str, RemoteMobileSession] = {} + + @staticmethod + def _digest(token: str) -> str: + return hashlib.sha256(token.encode("ascii")).hexdigest() + + def create(self, *, device_id: str, owner_user_id: str, access_epoch: int) -> tuple[str, RemoteMobileSession]: + now = utc_now() + token = secrets.token_urlsafe(32) + digest = self._digest(token) + session = RemoteMobileSession( + token_digest=digest, device_id=device_id, owner_user_id=owner_user_id, + access_epoch=access_epoch, created_at=now, + expires_at=now + MOBILE_SESSION_LIFETIME, last_access_check_at=now, + ) + self._sessions[digest] = session + return token, session + + def get(self, token: str | None) -> RemoteMobileSession | None: + if not token: + return None + session = self._sessions.get(self._digest(token)) + if session is not None and session.expires_at <= utc_now(): + self._sessions.pop(session.token_digest, None) + return None + return session + + def checked(self, session: RemoteMobileSession) -> RemoteMobileSession: + updated = RemoteMobileSession( + token_digest=session.token_digest, device_id=session.device_id, + owner_user_id=session.owner_user_id, access_epoch=session.access_epoch, + created_at=session.created_at, expires_at=session.expires_at, + last_access_check_at=utc_now(), + ) + self._sessions[session.token_digest] = updated + return updated + + def revoke_device(self, device_id: str) -> None: + for digest, session in tuple(self._sessions.items()): + if session.device_id == device_id: + self._sessions.pop(digest, None) + + def clear(self) -> None: + self._sessions.clear() diff --git a/docs/ai2apps-cloud-sse-bridge.md b/docs/ai2apps-cloud-sse-bridge.md new file mode 100644 index 00000000..e000ba40 --- /dev/null +++ b/docs/ai2apps-cloud-sse-bridge.md @@ -0,0 +1,605 @@ +# AI2Apps Cloud SSE 与本地 OpenAI 兼容网关对接方案 + +Status: Cloud contract confirmed; local bridge pending + +Last updated: 2026-08-14 + +Cloud verification: `ai-gateway-api-v1.md`, `client-integration-v1.md`, +`openapi-v1.yaml` and the implementation under `src/ai/` were checked on +2026-08-14. `npm run check` and all 30 Cloud tests passed. The Cloud repository +worktree was clean at verification time. + +## 1. 目标 + +AI2Apps Cloud 继续向 AI2Apps 官方客户端提供统一、与厂商无关的 +`/v1/ai/responses` 协议。AI2Apps local 继续向 Chat、Agent、第三方 SDK 提供现有的 +OpenAI-compatible `/v1/chat/completions` 协议。 + +服务器端增加一层有状态协议桥,使同一个逻辑模型 ID: + +```text +cloud/{provider}/{model} +``` + +按照以下优先级执行: + +1. 对应模型存在已启用的本地 API Key 时,沿用当前 BYOK Provider 路径; +2. 没有本地 Key,但存在有效 AI2Apps Session 且 Cloud 模型目录包含该模型时,调用 + AI2Apps Cloud `/v1/ai/responses`; +3. 两条路径都不可用时返回模型不可用,不影响任何本地模型和本地功能。 + +本地 Key 调用失败后不得静默切换到 AI2Apps 点数路径。凭证来源只在请求开始前解析 +一次,请求过程中保持冻结,避免意外扣点和难以解释的重试行为。 + +## 2. 非目标 + +- 不把 AI2Apps Cloud 的 Session 变成本地账户或本地授权前提; +- 不把 Provider Key、Cloud Cookie 或 prototype token 暴露给浏览器和 Swift UI; +- 不要求现有 OpenAI-compatible 客户端理解 AI2Apps Cloud 原生 SSE; +- 不把厂商原始 SSE 直接透传给客户端; +- 不在 Cloud 数据库或应用日志中保存 Prompt、回答正文或完整工具参数; +- 不因 Cloud 离线、Session 过期或点数不足影响本地模型加载和推理。 + +## 3. 当前实现与缺口 + +### 3.1 AI2Apps Cloud + +当前 `ai2apps-cloud/src/ai/routes.ts` 已实现: + +- `GET /v1/ai/models`; +- `POST /v1/ai/responses`; +- `GET /v1/ai/requests/:requestId`; +- `POST /v1/ai/requests/:requestId/cancel`; +- `response.created`、`output_text.delta`、`tool_call.delta`、 + `response.completed`、`response.failed` 五类 SSE 事件; +- Idempotency-Key、点数预留、结算、失败释放和显式取消; +- OpenAI、Anthropic、Google 和 OpenRouter Adapter。 + +最新实现和 OpenAPI 均已声明 `tools`,并且可以无损表达下一轮所需的: + +- assistant 历史消息中的 `tool_calls`; +- `role=tool`; +- `tool_call_id`; +- 工具执行结果。 + +Cloud 路由会校验调用 ID、工具名、JSON object 参数以及每个历史调用都有后续结果; +四个 Provider Adapter 均已有结构化历史转换测试。local 仍须完成第 7 节的 OpenAI +tool delta 转换和本地 capability approval,之后才能开放完整 Agent 工具循环。 + +### 3.2 AI2Apps local + +当前 local 的外部接口是 OpenAI Chat Completions: + +```text +POST /v1/chat/completions +``` + +流式响应格式为: + +```text +data: {"object":"chat.completion.chunk",...} + +data: [DONE] + +``` + +当前 `ai2apps/cloud_gateway.py` 只会把请求直接转发到用户配置的 Provider,并假设上游 +也是 OpenAI Chat Completions。新路径不能直接转发 AI2Apps Cloud SSE,因为 Cloud +使用带 `event:` 的规范化事件,现有 Chat 和 SDK 不认识该格式。 + +## 4. 总体架构 + +```mermaid +flowchart LR + CLIENT["Chat / Agent / OpenAI SDK"] --> LOCALAPI["local /v1/chat/completions"] + LOCALAPI --> RESOLVER["CloudModelRouteResolver"] + RESOLVER -->|"本地 Key 已配置并启用"| BYOK["现有 BYOK Gateway"] + RESOLVER -->|"无本地 Key + AI2Apps 已登录"| BRIDGE["AI2AppsCloudChatBridge"] + RESOLVER -->|"均不可用"| UNAVAILABLE["404 model_not_available"] + BRIDGE -->|"Cookie + Idempotency-Key"| CLOUD["Cloud /v1/ai/responses"] + CLOUD -->|"AI2Apps normalized SSE"| BRIDGE + BRIDGE -->|"OpenAI-compatible SSE"| CLIENT +``` + +协议桥必须位于 local 后端,不放在 Web UI 中。这样 Cookie jar、幂等、取消、错误 +清洗和协议转换都由受信任进程统一管理。 + +## 5. Cloud 端规范化 SSE 契约 + +Cloud 保留当前事件名,不改成厂商事件,也不新增一个重复的 Chat Completions API。 +每个 SSE frame 使用: + +```text +event: +data: + +``` + +所有事件的 `data` 必须是 JSON object。字段新增保持向后兼容;删除字段、改变类型或 +改变结算语义需要新协议版本。 + +### 5.1 `response.created` + +点数预留和请求记录成功后立即发送一次: + +```text +event: response.created +data: {"requestId":"uuid","model":"openai/gpt-x","pointsReserved":"12"} + +``` + +约束: + +- 必须是第一条业务事件; +- `requestId` 是后续查询、取消和审计的唯一标识; +- 所有点数字段保持十进制字符串; +- 发送该事件表示请求已产生 Cloud 状态,不能再把断开连接视为“从未请求”。 + +### 5.2 `output_text.delta` + +```text +event: output_text.delta +data: {"type":"output_text.delta","delta":"Hello"} + +``` + +`delta` 是追加文本,不是完整快照。Cloud 和 local 都不得重复、重排或自行 trim。 + +### 5.3 `tool_call.delta` + +```text +event: tool_call.delta +data: {"type":"tool_call.delta","callId":"call_1","name":"search","argumentsDelta":"{\"q\":"} + +``` + +约束: + +- `callId` 在一次响应内稳定; +- `name` 可以只在第一次出现; +- `argumentsDelta` 是原样追加的 JSON 字符串片段; +- 不允许 local 在收到完整参数前执行工具; +- 工具执行仍受 local capability policy 和用户审批控制。 + +### 5.4 `response.completed` + +成功结算后发送一次并结束响应: + +```json +{ + "requestId": "uuid", + "model": "openai/gpt-x", + "status": "completed", + "usage": { + "inputTokens": 100, + "cachedInputTokens": 20, + "outputTokens": 30, + "reasoningTokens": 5 + }, + "points": { + "reserved": "12", + "charged": "4" + }, + "pointsReleased": "8", + "balance": "796", + "pricingVersion": "usd-x100-2026-08-13" +} +``` + +Cloud 应保证结算事务提交后才发送该事件。收到它后 local 不再调用取消接口。 + +### 5.5 `response.failed` + +HTTP headers 已发送后发生的错误只能通过终止事件表达: + +```text +event: response.failed +data: {"requestId":"uuid","error":{"code":"AI_PROVIDER_ERROR","message":"provider request failed"}} + +``` + +Cloud 必须先释放点数预留或把失败释放纳入同一可靠事务,再发送该事件。错误信息不能 +包含 Provider Key、Cookie、完整 Prompt、完整回答或厂商原始响应。 + +### 5.6 Keepalive 与代理缓冲 + +Cloud 当前应补充 15 秒一次的 SSE comment heartbeat: + +```text +: keepalive + +``` + +同时保持: + +```http +Content-Type: text/event-stream; charset=utf-8 +Cache-Control: no-cache, no-transform +Connection: keep-alive +X-Accel-Buffering: no +``` + +Node 写入端必须处理 `raw.write()` 返回 `false` 的情况并等待 `drain`,避免慢客户端令 +进程无限积压内存。Heartbeat 不参与业务状态机,local parser 应直接忽略或转发为 +comment。 + +## 6. local 请求转换 + +local 收到 `ChatCompletionRequest` 后,只有路由解析结果为 `ai2apps-managed` 才执行 +以下转换。 + +### 6.1 模型 ID + +```text +local: cloud/openai/gpt-x +cloud: openai/gpt-x +``` + +转换前必须确认 Cloud 模型目录中存在精确 ID,不能只按显示名或后缀匹配。 + +### 6.2 消息 + +初始文本/图片阶段采用: + +| OpenAI Chat 字段 | Cloud 字段 | +| --- | --- | +| `role=system` | 合并到顶层 `system` | +| `role=user/assistant` + string content | `input[].content[{type:"input_text"}]` | +| content part `text` | `input_text` | +| content part `image_url.url` | `input_image.imageUrl` | + +多个 system message 按原顺序用换行连接。不能把 system message 降级成 user +message。音频、文件、`reasoning_content` 及尚未定义的 part 必须返回明确的 +`400 AI2APPS_CLOUD_INPUT_UNSUPPORTED`,不能静默丢弃。 + +### 6.3 生成参数 + +| OpenAI Chat 字段 | Cloud 字段 | v1 行为 | +| --- | --- | --- | +| `max_tokens` | `maxOutputTokens` | 直接映射并受目录上限约束 | +| `temperature` | `temperature` | 直接映射 | +| `stream` | `stream` | 直接映射 | +| `tools[].function` | `tools[]` | 去掉外层 `type/function` 包装 | +| `top_p` | 无 | 明确拒绝或 Cloud 增加字段后再启用 | +| `stop` | 无 | 明确拒绝或 Cloud 增加字段后再启用 | +| `response_format` | 无 | 明确拒绝 | +| `tool_choice` | 无 | v1 只允许缺省/`auto`,其他值明确拒绝 | + +不支持的参数不能被无声忽略,否则相同请求在 BYOK 和 AI2Apps 路径会产生不同且不可 +解释的行为。 + +### 6.4 Idempotency-Key + +AI2Apps 自有 Chat UI 每次“发送”生成一个 UUID,并通过 local 请求头传入: + +```http +Idempotency-Key: +``` + +local 使用同一个值调用 Cloud。网络结果不确定时重试原业务发送必须复用该值;“重新 +生成”必须创建新值。 + +第三方 OpenAI SDK 通常不发送该头。local 可以为该次 HTTP 请求生成 UUID,但必须 +承认这种客户端无法在跨 HTTP 重试时获得完整幂等保证。不得用 Prompt hash 代替业务 +幂等键,因为完全相同的 Prompt 也可能是用户有意再次生成。 + +## 7. local 响应转换 + +协议桥为一次请求生成一个稳定的 OpenAI completion ID: + +```text +chatcmpl-ai2apps- +``` + +同一流中的所有 chunk 必须使用相同 ID、原始 local 模型 ID 和创建时间。 + +### 7.1 事件映射 + +| Cloud 事件 | OpenAI-compatible 输出 | +| --- | --- | +| `response.created` | `delta.role="assistant"` 的首 chunk,并记录 Cloud requestId | +| `output_text.delta` | `delta.content=` | +| `tool_call.delta` | `delta.tool_calls[]`,按 callId 分配稳定 index | +| `response.completed` | finish chunk;可按 `stream_options.include_usage` 附带 usage | +| `response.failed` | OpenAI error frame,然后结束流 | +| Cloud EOF after completed/failed | `data: [DONE]` | + +文本示例: + +```text +data: {"id":"chatcmpl-ai2apps-...","object":"chat.completion.chunk","model":"cloud/openai/gpt-x","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ai2apps-...","object":"chat.completion.chunk","model":"cloud/openai/gpt-x","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ai2apps-...","object":"chat.completion.chunk","model":"cloud/openai/gpt-x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] + +``` + +如果响应产生过 tool call,最终 `finish_reason` 使用 `tool_calls`,否则使用 `stop`。 +Cloud 的结算详情可放进 AI2Apps 专用扩展字段 `delta.ai2apps.cloud`,供自有 UI 更新 +点数;普通 OpenAI 客户端会忽略未知扩展。点数仍以字符串呈现。 + +### 7.2 Usage 映射 + +```text +usage.inputTokens -> usage.prompt_tokens +usage.outputTokens -> usage.completion_tokens +两者相加 -> usage.total_tokens +usage.cachedInputTokens -> usage.prompt_tokens_details.cached_tokens +``` + +`reasoningTokens` 和点数结算不是标准 Chat Completions usage 字段,只能进入明确命名的 +AI2Apps 扩展,不能塞入另一个标准字段。 + +### 7.3 非流式响应 + +Cloud `stream:false` 返回的 `output[{type:"output_text",text}]` 转换为现有 +`ChatCompletionResponse`: + +```json +{ + "id": "chatcmpl-ai2apps-...", + "object": "chat.completion", + "model": "cloud/openai/gpt-x", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 30, + "total_tokens": 130 + } +} +``` + +## 8. Cloud 已实现的完整工具历史协议 + +Cloud request contract 已扩展 `AiInputMessage` 来支持历史工具消息,没有把工具记录 +拼成普通文本: + +```ts +type AiInputMessage = + | { + role: "user" | "assistant"; + content: readonly AiInputPart[]; + toolCalls?: readonly { + callId: string; + name: string; + arguments: string; + }[]; + } + | { + role: "tool"; + toolCallId: string; + content: readonly AiTextPart[]; + }; +``` + +Cloud 各 Provider Adapter 负责转换为厂商语义: + +- OpenAI Responses:`function_call` / `function_call_output`; +- Anthropic:`tool_use` / `tool_result`; +- Gemini:`functionCall` / `functionResponse`; +- OpenRouter Chat:assistant `tool_calls` / tool message。 + +Cloud 必须校验: + +- `toolCallId` 引用前面已出现的 tool call; +- tool name 在本次请求的 definitions 中存在; +- arguments 是 JSON object 字符串; +- message 数量、单项长度和总字节上限; +- Cloud 只生成 tool call,绝不代替 local 自动执行工具。 + +此扩展已同步写入 `openapi-v1.yaml`、`ai-gateway-api-v1.md` 和 Adapter contract +tests。local 对该结构的双向转换仍是接入任务。 + +## 9. 取消、断线与重试 + +local 在收到 `response.created` 后保存本地请求与 Cloud requestId 的临时映射。 + +以下情况调用: + +```text +POST /v1/ai/requests/{requestId}/cancel +``` + +- 用户点击停止; +- 下游 Chat SSE 连接断开; +- local 关闭且仍能完成有界清理; +- local 主动超时。 + +取消调用是幂等清理意图。`409 AI_REQUEST_NOT_RUNNING` 表示请求已经进入终态,可通过 +GET request 状态确认,不应覆盖原始完成结果。 + +Cloud 不保存 Prompt 和回答正文,因此不能在断线后重放已经发送的文本。恢复策略是: + +1. local 保留客户端已经收到的部分文本; +2. 查询 Cloud request 状态确认 completed/failed/cancelled; +3. 不自动重新调用模型; +4. 用户选择重新生成时使用新的 Idempotency-Key。 + +同一个 Idempotency-Key 收到 `AI_REQUEST_IN_PROGRESS` 时,local 只能等待/查询原请求; +不能换 Key 绕过冲突并产生第二次扣点。 + +## 10. 错误映射 + +在 Cloud 尚未发送 SSE headers 前,local 保留 HTTP 状态并转换成稳定的 OpenAI error +外壳,同时保留机器错误码: + +| Cloud 状态/错误 | local 行为 | +| --- | --- | +| `401 AUTHENTICATION_REQUIRED` | 将 Cloud Session 标为失效;本地功能保持可用 | +| `402 INSUFFICIENT_POINTS` | 返回余额不足,不尝试厂商直连 | +| `404 AI_MODEL_NOT_FOUND` | 刷新 Cloud 模型目录;当前请求失败 | +| `409 AI_REQUEST_IN_PROGRESS` | 查询原 requestId,不创建新扣费请求 | +| `409 AI_IDEMPOTENCY_CONFLICT` | 报告客户端幂等键复用错误 | +| `429` | 透传 `Retry-After`,不自动无限重试 | +| `502/503` | Cloud 路径不可用;不静默切 BYOK 或本地模型 | + +SSE 已经开始后的 `response.failed` 转成流内错误 frame。无论哪种错误,日志均不得包含 +Cookie、Provider Key、完整请求体、完整输出或完整工具参数。 + +## 11. Cloud 服务器完成情况 + +### 11.1 已确认完成 + +1. `src/ai/routes.ts` + - 已使用异步 `AiSseWriter` 处理串行写入和 backpressure; + - 已增加 15 秒 comment heartbeat; + - 已保证 completed/failed 是唯一终止业务事件; + - 已在结算事务完成后发送 completed; + - 已实现并校验完整历史工具消息。 +2. `src/ai/types.ts` + - 已固定公开 SSE payload 类型; + - 已增加完整历史工具消息 union; + - 已定义 stop reason 标准化集合。 +3. `src/ai/adapters/*.ts` + - 四个 Provider 已实现历史 tool call/result 转换; + - 已测试稳定 callId、name 和 arguments delta; + - 已统一 stop reason 和最终 usage。 +4. `openapi-v1.yaml` + - 已把 `tools` 加入 `AiResponseRequest`; + - 已增加完整 SSE event schemas; + - 已增加工具历史消息 schema; + - 已标明点数字段为十进制字符串。 +5. `docs/ai-gateway-api-v1.md` + - 已写明 heartbeat、终止事件、断线不可重放和工具安全边界。 + +### 11.2 不建议修改 + +- 不新增 `/v1/chat/completions` 到 Cloud; +- 不让 Cloud 同时维护 OpenAI、Anthropic 和 AI2Apps 三套客户端输出协议; +- 不在 Cloud 保存正文来实现 SSE replay; +- 不允许浏览器提供“应扣多少点”或厂商 usage; +- 不让 Cloud 执行 local 工具。 + +## 12. local 服务器修改清单 + +1. `ai2apps/model_manager.py` + - 将模型身份与 credential route 分离; + - 合并 BYOK inventory 和 AI2Apps model catalog; + - 对同一逻辑 ID 执行 BYOK-first 解析。 +2. `ai2apps/cloud_gateway.py` + - 保留现有 BYOK 路径; + - 增加 `AI2AppsCloudChatBridge`; + - 实现请求、SSE、非流式响应和错误转换; + - 请求开始后冻结 route,不做失败后付费 fallback。 +3. `ai2apps/cloud_client.py` + - 继续私有维护 Cookie jar; + - 提供模型目录缓存、request 查询和 cancel; + - 401 时使 Cloud Session 失效,但不影响 local runtime。 +4. `omlx/server.py` + - `/v1/models` 合并已登录用户可用的 AI2Apps 模型; + - `/v1/chat/completions` 使用统一 route resolver; + - 下游断开时触发有界 Cloud cancel。 +5. Model App + - 分开显示 Personal API Keys 与 AI2Apps Provider; + - 显示 `Personal Key`、`AI2Apps Points` 和 `Preferred` 路由状态; + - 登录、退出或目录刷新后更新可用性,不改变本地模型。 + +## 13. 测试矩阵 + +### 13.1 Cloud contract tests + +- 每次成功流严格为 created → zero-or-more delta → completed; +- 每次失败流严格为 created → zero-or-more delta → failed; +- completed/failed 只出现一次; +- heartbeat 不改变事件顺序; +- 慢消费者触发 backpressure,不产生无界缓冲; +- 四个 Provider 的文本 delta、tool delta、usage 和 stop reason 一致; +- 取消释放原点数桶; +- 同 Idempotency-Key 不重复调用或扣点; +- 工具历史消息在四个 Adapter 中语义等价。 + +### 13.2 local bridge tests + +- 本地 Key 与 AI2Apps 同时可用时只调用本地 Provider; +- 本地 Key 不存在且已登录时只调用 AI2Apps Cloud; +- 未登录时本地模型和 BYOK 模型照常工作; +- 本地 Key 调用失败时不消耗 AI2Apps 点数; +- 每个 Cloud delta 生成一个合法 OpenAI chunk; +- completion ID 和 tool index 在整条流中稳定; +- completed 生成 usage、finish chunk 和 `[DONE]`; +- failed 生成安全错误并结束; +- 客户端断开触发 cancel; +- 401、402、409、429、502/503 均不破坏本地状态; +- 不支持的请求字段明确失败而不是静默丢失。 + +### 13.3 端到端验收 + +使用完全相同的 Chat UI 和 OpenAI SDK,分别验证: + +1. local model; +2. BYOK OpenAI-compatible model; +3. AI2Apps-managed OpenAI model; +4. AI2Apps-managed Anthropic model; +5. AI2Apps-managed Gemini model; +6. AI2Apps-managed OpenRouter model; +7. 文本流、非流式、工具调用、取消、余额不足和 Session 过期。 + +## 14. 分阶段发布 + +### Phase A:文本闭环 + +- 合并模型目录; +- BYOK-first 路由; +- 文本/图片输入; +- 文本 SSE 和非流式响应转换; +- 幂等、取消、点数刷新和错误映射; +- 不支持的工具/结构化输出明确禁用。 + +### Phase B:完整工具循环 + +- Cloud 工具历史消息 schema(已完成); +- 四个 Adapter 的 tool call/result 转换(已完成); +- local tool delta 映射; +- capability policy 审批和多轮 Agent 测试。 + +### Phase C:GPT Image 2(客户端代理已接入) + +Image2 不经过文本 `/v1/ai/responses` 或 SSE 桥。local 使用 Cloud 的同步、 +Provider-neutral 图片协议: + +```text +POST /v1/platform/cloud/ai/images/generations +POST /v1/platform/cloud/ai/images/edits +``` + +local 原样转发 `Idempotency-Key` 和 JSON 请求,不记录 Prompt、输入 Data URL 或返回的 +图片 Data URL。模型目录中的 camelCase capability 会规范化成 snake_case;例如 +`imageGeneration`、`imageEdit`、`imageOutput` 分别成为 `image_generation`、 +`image_edit`、`image_output`,供 Model App 和默认能力路由使用。 + +首版客户端遵守 Cloud 限制:`openai/gpt-image-2`、`n=1`、三种固定尺寸、四种质量、 +PNG/JPEG/WebP;编辑输入使用 1 至 4 个受限的 `imageDataUrls` 和可选 PNG +`maskDataUrl`。成功响应中的 Data URL 必须由调用 App 立即保存,因为 Cloud 不保存 +图片且相同幂等键不能重放图片正文。 + +这两个平台端点完成协议接入,但不等同于已经提供用户可见的 Image App。图片生成与 +编辑 UI、结果文件落盘和历史管理应作为独立 App 层实现,不能塞进文本 Chat 的 SSE +通道。 + +### Phase D:生产稳态 + +- Cloud heartbeat、backpressure 和断线取消(已完成); +- local 超时、断线取消和终态确认; +- Cloud catalog 后台缓存和失效策略; +- 指标、审计、限流与故障注入; +- 协议兼容 fixtures 和发布门禁。 + +## 15. 完成定义 + +满足以下条件后,AI2Apps-managed 模型才可以作为 Model App 中的正式 Cloud 路径: + +1. 没有 AI2Apps 账户时所有 local 功能保持完整; +2. 同模型同时具备本地 Key 和 AI2Apps 权益时稳定选择本地 Key; +3. 本地 Key 的运行时错误不会触发隐式点数消费; +4. AI2Apps SSE 对现有 Chat 和 OpenAI SDK 完全兼容; +5. 停止、断线、失败和取消均能释放预留点数; +6. 工具能力只有在完整多轮工具协议通过后才对 Agent 开放; +7. Cookie、Key、正文和完整工具参数不进入 UI、数据库或日志; +8. Cloud 离线、401、402、429 和 Provider 故障均不影响本地模型。 diff --git a/docs/ai2apps-mobile-entry.md b/docs/ai2apps-mobile-entry.md new file mode 100644 index 00000000..4f98e4a3 --- /dev/null +++ b/docs/ai2apps-mobile-entry.md @@ -0,0 +1,484 @@ +# AI2Apps Mobile Entry Design + +Status: Local Mobile Shell implementation active; FRP integration pending +Last updated: 2026-08-14 +Scope: AI2Apps Mac/client runtime and WebUI; cloud/FRP server implementation is out of scope + +## 1. Purpose + +AI2Apps needs a dedicated mobile WebUI surface that can be reached through the +future authenticated FRP remote-access path. The mobile surface is not a +responsive copy of the desktop shell and it must not expose every installed +App automatically. + +An App explicitly declares whether it is **Mobile Ready**. For a Mobile Ready +App, the runtime selects the best available UI definition in this order: + +```text +Mobile-Entry -> Mini-Entry -> App-Entry +``` + +This keeps mobile eligibility separate from UI implementation. An App can ship +a purpose-built phone interface, reuse its compact Mini-Entry, or deliberately +reuse its normal Entry. + +## 2. Product decisions + +1. AI2Apps WebUI provides a dedicated `/mobile` shell. +2. Only Apps with `mobile.ready: true` appear in the Mobile App Catalog. +3. Mobile readiness is explicit and fail-closed. Existing Apps remain hidden + until their manifests are updated. +4. Entry selection uses the fixed fallback order Mobile-Entry, Mini-Entry, + App-Entry. +5. `mini_entry.placements` continues to describe conversational desktop + placement (`inline` and `sidebar`). Mobile eligibility is not encoded in + that list. +6. All selected entries reuse the same AppInstance, sessions, persistent + state, Runs, capability grants, Service bindings, and artifacts. +7. Remote-access subscription/entitlement is enforced by the cloud and local + remote-session boundary. It is separate from whether an App is Mobile + Ready. +8. System mobile pages such as pairing, connection status, account, and trust + recovery may be implemented directly by the Mobile Shell rather than as + third-party App entries. + +## 3. App manifest contract + +### 3.1 Minimal Mobile Ready App + +An App can opt in without adding a new UI resource: + +```yaml +schema: ai2apps.app/v1 +id: com.example.notes +name: Notes + +mobile: + ready: true + +entry: + kind: sandbox + resource: ui/entry.html +``` + +Because neither `mobile_entry` nor `mini_entry` exists, Mobile uses `entry`. +The App publisher is explicitly asserting that the normal Entry is usable on a +phone. + +### 3.2 App reusing Mini-Entry + +```yaml +schema: ai2apps.app/v1 +id: com.example.tasks +name: Tasks + +mobile: + ready: true + +entry: + kind: sandbox + resource: ui/entry.html + +mini_entry: + kind: schema + resource: ui/mini.json + placements: + - inline + - sidebar +``` + +Mobile uses `mini_entry` and renders it in a full-height mobile container. +The `placements` list still governs ConversationSession mounts; it does not +need a `mobile` value. + +### 3.3 App with a dedicated Mobile-Entry + +```yaml +schema: ai2apps.app/v1 +id: com.example.dashboard +name: Dashboard + +mobile: + ready: true + +entry: + kind: sandbox + resource: ui/entry.html + +mini_entry: + kind: schema + resource: ui/mini.json + placements: + - inline + - sidebar + +mobile_entry: + kind: sandbox + resource: ui/mobile.html +``` + +Mobile always selects `mobile_entry` when it is present and valid. + +### 3.4 Validation rules + +- `mobile` is optional. Missing `mobile` is equivalent to + `mobile.ready: false`. +- `mobile.ready` must be a Boolean. +- `mobile_entry` is allowed only on an App manifest. +- `mobile_entry` must use a supported renderer and reference an indexed package + resource when the renderer requires a resource. +- A manifest with `mobile.ready: true` must resolve to at least one valid entry. +- `mobile_entry` does not make an App Mobile Ready by itself. Requiring the + explicit flag prevents accidental publication. +- A broken higher-priority entry is a package validation error. Runtime launch + must not silently skip a declared but invalid `mobile_entry` and fall back to + a different UI. +- Package verification, signature, trust, dependency, and permission checks are + unchanged. + +### 3.5 Mobile Web resource contract + +Declaring `mobile.ready: true` also asserts that the selected entry works +through the public Mobile gateway's restrictive CSP and route allowlist. + +- A packaged `sandbox` entry must index every CSS, JavaScript, font, image, and + other resource in the package and reference it with a relative URL. +- Packaged Apps must not reference `/admin/static/*`, `/mobile/static/*`, + localhost URLs, arbitrary local ports, or external CDNs. `/mobile/static/*` + is reserved for explicitly allowlisted built-in Mobile Shell/system assets. +- Mobile entries must not depend on inline ` +{% endblock %} + +{% block content %} +
    + +
    +

    Mini-Entry

    +

    {{ app_name }}

    +

    This App does not provide a custom compact view.

    +
    +
    + + +
    +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/ai2apps/web/templates/app_views/safe_html.html b/ai2apps/web/templates/app_views/safe_html.html new file mode 100644 index 00000000..6fdc0bf1 --- /dev/null +++ b/ai2apps/web/templates/app_views/safe_html.html @@ -0,0 +1,16 @@ + + + + + + {{ app_name }} + + + + + +
    +
    Opening {{ app_name }}…
    +
    + + diff --git a/ai2apps/web/templates/app_views/schema.html b/ai2apps/web/templates/app_views/schema.html new file mode 100644 index 00000000..75c8d05c --- /dev/null +++ b/ai2apps/web/templates/app_views/schema.html @@ -0,0 +1,15 @@ + + + + + + {{ app_name }} + + + + +
    +
    Opening {{ app_name }}…
    +
    + + diff --git a/ai2apps/web/templates/base.html b/ai2apps/web/templates/base.html new file mode 100644 index 00000000..323ad01b --- /dev/null +++ b/ai2apps/web/templates/base.html @@ -0,0 +1,287 @@ + + + + + + {% block title %}AI2Apps Admin{% endblock %} + + + + + + + {% if current_lang == 'zh' %} + + {% elif current_lang == 'zh-TW' %} + + {% elif current_lang == 'ko' %} + + {% elif current_lang == 'ja' %} + + {% endif %} + + + + + + + + + + + + + + + + + {% block head %}{% endblock %} + + + {% block content %}{% endblock %} + + + + + + {% block scripts %}{% endblock %} + + diff --git a/omlx/admin/templates/chat.html b/ai2apps/web/templates/chat.html similarity index 61% rename from omlx/admin/templates/chat.html rename to ai2apps/web/templates/chat.html index a5a963a6..011e53f4 100644 --- a/omlx/admin/templates/chat.html +++ b/ai2apps/web/templates/chat.html @@ -84,6 +84,15 @@ height: 100dvh; } + .terminal-assistant-chat .terminal-assistant-hide, + .terminal-assistant-chat > .sidebar-overlay, + .terminal-assistant-chat > .sidebar-width, + .terminal-assistant-chat > .right-sidebar-overlay, + .terminal-assistant-chat > .right-sidebar-width, + body:has(.terminal-assistant-chat) > .ai2apps-shell-reveal { + display: none !important; + } + /* Scrollbar */ .custom-scrollbar::-webkit-scrollbar { width: 6px; @@ -779,6 +788,27 @@ display: none; } + /* Keep review feedback above the message stream and both settings rails. + Use a plain CSS rule instead of an arbitrary Tailwind z-index utility: + production CSS may be built before a newly introduced utility is seen. */ + .external-review-dialog { + z-index: 2147483000; + isolation: isolate; + } + + .external-review-dialog__panel { + width: min(35rem, calc(100vw - 2rem)); + max-height: min(80vh, 48rem); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .external-review-dialog__body { + min-height: 0; + overflow-y: auto; + } + /* Drag and drop state */ .input-container.drag-over { border-color: var(--text-tertiary) !important; @@ -978,6 +1008,206 @@ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } + + /* AgentRun status-v1. Rich HTML renderers intentionally fall back here. */ + .agent-run-card { + margin: 0.45rem 0 0.15rem; + border: 1px solid var(--border-faint); + border-radius: 0.8rem; + background: color-mix(in srgb, var(--bg-secondary) 72%, transparent); + overflow: hidden; + color: var(--text-secondary); + } + + .agent-status-line { + min-height: 2.35rem; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.5rem 0.7rem; + font-size: 0.8rem; + } + + .agent-status-icon { + width: 1.25rem; + height: 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + font-weight: 700; + } + + .agent-tone-info, + .agent-tone-accent { color: var(--timeline-accent); } + .agent-tone-success { color: #16a34a; } + .agent-tone-warning { color: #d97706; } + .agent-tone-danger { color: var(--text-danger); } + .agent-tone-neutral { color: var(--text-tertiary); } + + .agent-effect-pulse { animation: agentPulse 1.7s ease-in-out infinite; } + .agent-effect-blink { animation: agentBlink 1.2s steps(2, end) infinite; } + .agent-effect-spin { animation: agentSpin 1.1s linear infinite; } + .agent-effect-shimmer { + background: linear-gradient(90deg, currentColor 25%, var(--text-primary) 50%, currentColor 75%); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: agentShimmer 1.8s linear infinite; + } + + .agent-progress-track { + height: 2px; + background: var(--border-faint); + overflow: hidden; + } + + .agent-progress-value { + height: 100%; + background: currentColor; + transition: width 180ms ease; + } + + .agent-progress-indeterminate { + width: 35%; + animation: agentIndeterminate 1.4s ease-in-out infinite; + } + + .agent-run-detail, + .agent-interaction-card { + border-top: 1px solid var(--border-faint); + padding: 0.65rem 0.75rem; + font-size: 0.75rem; + } + + .agent-child-run { + margin: 0.45rem 0.65rem 0.55rem; + padding: 0.55rem 0.65rem; + border-left: 2px solid var(--border-normal); + border-radius: 0 0.5rem 0.5rem 0; + background: color-mix(in srgb, var(--surface-muted) 65%, transparent); + } + + .agent-child-run .agent-interaction-card { + margin: 0.5rem 0 0; + } + + .agent-interaction-card { color: var(--text-primary); } + .agent-interaction-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.55rem; } + .agent-interaction-button { + border: 1px solid var(--border-normal); + border-radius: 0.55rem; + padding: 0.35rem 0.65rem; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + } + .agent-interaction-button:hover { background: var(--bg-tertiary); } + .agent-interaction-button--primary { background: var(--btn-primary); color: var(--btn-primary-text); } + .agent-interaction-button--danger { color: var(--text-danger); } + .agent-interaction-input { + width: 100%; + margin-top: 0.5rem; + border: 1px solid var(--border-normal); + border-radius: 0.55rem; + padding: 0.45rem 0.6rem; + background: var(--bg-primary); + color: var(--text-primary); + } + + .chat-mode-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.45rem 0.65rem 0; + } + .chat-mode-switch { + display: inline-flex; + padding: 0.18rem; + border: 1px solid var(--border-faint); + border-radius: 0.65rem; + background: var(--bg-tertiary); + } + .chat-mode-option { + min-height: 1.65rem; + padding: 0.18rem 0.58rem; + border: 0; + border-radius: 0.48rem; + background: transparent; + color: var(--text-tertiary); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; + } + .chat-mode-option.is-active { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + } + .chat-mode-option:disabled { cursor: not-allowed; opacity: 0.55; } + .chat-mode-hint { min-width: 0; color: var(--text-tertiary); font-size: 0.68rem; } + .chat-agent-select { + min-width: 7rem; + max-width: 13rem; + height: 1.75rem; + border: 1px solid var(--border-faint); + border-radius: 0.55rem; + padding: 0 1.7rem 0 0.55rem; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 0.68rem; + } + .agent-parameter-panel { + margin: 0.45rem 0.65rem 0; + border: 1px solid var(--border-faint); + border-radius: 0.65rem; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 0.7rem; + } + .agent-parameter-panel summary { padding: 0.45rem 0.6rem; cursor: pointer; } + .agent-parameter-grid { display: grid; gap: 0.55rem; padding: 0 0.6rem 0.6rem; } + .agent-parameter-field { display: grid; gap: 0.25rem; } + .agent-parameter-field input:not([type="checkbox"]), + .agent-parameter-field select { + min-height: 2rem; + border: 1px solid var(--border-normal); + border-radius: 0.5rem; + padding: 0.3rem 0.5rem; + background: var(--bg-primary); + color: var(--text-primary); + } + .agent-input-error { margin: 0.35rem 0.65rem 0; color: var(--text-danger); font-size: 0.7rem; } + + .mini-entry-card { margin: .55rem 0 .2rem; overflow: hidden; border: 1px solid var(--border-normal); border-radius: .9rem; background: var(--bg-primary); } + .mini-entry-head { min-height: 2.25rem; display: flex; align-items: center; gap: .45rem; padding: .4rem .55rem .4rem .7rem; border-bottom: 1px solid var(--border-faint); color: var(--text-secondary); font-size: .7rem; } + .mini-entry-head strong { min-width: 0; flex: 1; overflow: hidden; color: var(--text-primary); text-overflow: ellipsis; white-space: nowrap; } + .mini-entry-action { width: 1.75rem; height: 1.75rem; display: grid; place-items: center; border: 0; border-radius: .45rem; color: var(--text-tertiary); background: transparent; cursor: pointer; } + .mini-entry-action:hover { color: var(--text-primary); background: var(--bg-tertiary); } + .mini-entry-action svg { width: .85rem; height: .85rem; } + .mini-entry-frame { width: 100%; height: 190px; display: block; border: 0; background: transparent; } + .mini-entry-sidebar-frame { width: 100%; height: 100%; display: block; border: 0; background: var(--bg-primary); } + .mini-app-menu { position: absolute; top: 2.8rem; right: 0; z-index: 110; width: min(19rem, calc(100vw - 2rem)); max-height: 22rem; overflow: auto; padding: .45rem; border: 1px solid var(--border-normal); border-radius: .85rem; background: var(--bg-primary); box-shadow: 0 12px 35px rgba(0,0,0,.14); } + .mini-app-menu button { width: 100%; display: flex; align-items: center; gap: .65rem; padding: .6rem; border: 0; border-radius: .65rem; color: var(--text-primary); background: transparent; text-align: left; cursor: pointer; } + .mini-app-menu button:hover { background: var(--bg-tertiary); } + .mini-app-launcher > summary { list-style: none; } + .mini-app-launcher > summary::-webkit-details-marker { display: none; } + + @keyframes agentPulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } + @keyframes agentBlink { 50% { opacity: .25; } } + @keyframes agentSpin { to { transform: rotate(360deg); } } + @keyframes agentShimmer { to { background-position: -200% 0; } } + @keyframes agentIndeterminate { from { transform: translateX(-110%); } to { transform: translateX(310%); } } + + @media (prefers-reduced-motion: reduce) { + .agent-effect-pulse, + .agent-effect-blink, + .agent-effect-spin, + .agent-effect-shimmer, + .agent-progress-indeterminate { animation: none !important; } + .agent-progress-value { transition: none; } + } {% endblock %} @@ -994,7 +1224,7 @@ document.documentElement.setAttribute('data-theme', theme); })(); -
    {{ t('login.login.label_api_key') }}
    + +
    +
    +
    +
    +
    + + + + +
    +
    +

    +

    +
    +
    + +
    + +
    +
    +

    + Cloud model is reviewing the answer. This may take a while; the answer will not be changed automatically. +

    +
    +
    +
    +
    +

    + Review completed. No issue requiring a change was found, so the original answer was kept. +

    +
    +

    + The reviewer found an issue and proposed a revised answer. Review it below, then choose whether to apply it. +

    +
    +

    Issues found

    +

    +
    +
    +
    +
    +

    External Review failed.

    +
    
    +                
    + +
    + +
    +
    +

    Prompt

    +
    
    +                        
    +
    +

    Response

    +
    
    +                        
    +
    +
    +
    + +
    + + +
    +
    +
    + @@ -1024,14 +1365,14 @@

    {{ t('login.login.label_api_key') }}

    - + AI2Apps AI2Apps
    - {{ t('chat.brand') }} + {{ t('chat.brand') }} v{{ version }} {{ t('login.login.label_api_key') }}
    - +
    + + + +
    +
    Apps in this conversation
    + +

    No Mini-Entry Apps installed.

    +
    +
    +
    @@ -1275,7 +1634,9 @@

    {{ t('chat.welcome_heading') }}

    @@ -1364,6 +1725,198 @@

    {{ t('chat.welcome_heading') }}

    + + + + + + + +
    @@ -1376,7 +1929,7 @@

    {{ t('chat.welcome_heading') }}

    style="color: var(--text-tertiary);" x-text="variantProfileLabel(msg)">
    -
    +
    +
    + +
    +
    External Review
    + +
    +
    + + +
    + :data-math-version="String(getTextContent(msg.content).length)" + x-html="renderMarkdown(getTextContent(msg.content))">
    -
    +
    @@ -1581,6 +2249,66 @@

    {{ t('chat.welcome_heading') }}

    +
    @@ -1591,7 +2319,7 @@

    {{ t('chat.welcome_heading') }}

    + x-text="currentStream()?.fusionTrace ? 'Generator draft' : (currentStream()?.finalContent ? window.t('chat.thinking_label') : window.t('chat.thinking_progress'))"> @@ -1658,6 +2386,71 @@

    {{ t('chat.welcome_heading') }}

    +
    +
    + + +
    + + +
    +
    + +
    + +
    +
    +
    +
    {{ t('chat.welcome_heading') }} @keydown.enter="onComposerEnter($event)" @input="autoResize($event.target)" @paste="handlePaste($event)" :placeholder="isMobile ? window.t('chat.input_placeholder_mobile') : window.t('chat.input_placeholder')" - :disabled="!apiKeySet || !currentModel || isCurrentChatStreaming()" rows="1" + :disabled="!apiKeySet || !currentModel || isCurrentChatStreaming() || (terminalAssistantMode && !terminalAssistantSessionId)" rows="1" class="flex-1 px-4 py-3 bg-transparent resize-none outline-none text-sm max-h-32" style="color: var(--text-primary);"> +
    +
    +
    + +

    {{ t('settings.huggingface.endpoint_hint') }}

    +

    {{ t('settings.huggingface.endpoint_clear_hint') }}

    + +
    +
    + + +
    +
    +
    +
    diff --git a/omlx/admin/templates/dashboard/_modal_model_settings.html b/ai2apps/web/templates/dashboard/_modal_model_settings.html similarity index 98% rename from omlx/admin/templates/dashboard/_modal_model_settings.html rename to ai2apps/web/templates/dashboard/_modal_model_settings.html index 9ccd6ba4..a96ab82e 100644 --- a/omlx/admin/templates/dashboard/_modal_model_settings.html +++ b/ai2apps/web/templates/dashboard/_modal_model_settings.html @@ -353,10 +353,10 @@

    {{ x-model="modelSettings.cache_moe_memory_tier" :disabled="modelSettings.cache_moe_memory_locked" :value="tier.id"> -
    +
    Recommended + class="basis-full text-[10px] font-bold uppercase leading-none tracking-wide text-emerald-600">Recommended
    @@ -379,6 +379,22 @@

    {{

    + + +