Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/business/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
- 一个 canonical `extensions` row 表示 deployment 安装的 exact Release;`installed`、
`enabled[]`、`running` 不可混用。
- `ExtensionBase` 保持配置校验、`on_start`、`on_close` 与可逆 route/source/resolver publication。
- `ExtensionBase.get_config()`、`get_state()` 读取当前数据库真值;config/state mutation 通过 Host
提供的语义接口持久化。Extension 不接触 SQLModel relation,且 Host 不缓存 state 真值。
- `config` 是用户声明的部署配置;`state` 是 Extension 管理的 deployment-wide 运行状态。
并发与事务由 Core/PostgreSQL adapter 负责,不要求 Extension 自己实现 compare-and-set。
- Extension wheel 直接 import Core 模块;Host 只接受标准 `inkcre.core.extensions` entry point。
- Registry Simple URL 必须与配置的 Registry 同源且路径精确匹配 Project。
- runtime 只能把普通 wheel 安装到当前 Core interpreter/site-packages;禁止 `pip --target`、
Expand All @@ -21,6 +25,8 @@
- enable 先启动 runtime,再调用 atomic enabled RPC;返回 version 不一致时移除 peer 并停止旧 runtime。
- disable 先停止 runtime,再调用 RPC;RPC 失败时重启 exact prior runtime,durable intent 不变。
- cold restore 失败不得删除 `enabled[]`,并使 bootstrap/readiness 失败。
- 公开 HTTP 入口必须由 Extension 显式声明 exact method/path;Host 仅在 runtime publication
存活期间授权该 route 绕过 Peer JWT,撤销 runtime 时同步撤销授权。

## 权限和持久化

Expand Down
16 changes: 13 additions & 3 deletions app/business/extension/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from .main import EXTENSION_HOST, ExtensionBase, ExtensionHost
from .state import ExtensionState
from .main import (
EXTENSION_HOST,
EmptyConfig,
EmptyState,
ExtensionBase,
ExtensionHost,
PublicHTTPRoute,
)
from .state import InstalledExtension

__all__ = [
"EXTENSION_HOST",
"EmptyConfig",
"EmptyState",
"ExtensionBase",
"ExtensionHost",
"ExtensionState",
"InstalledExtension",
"PublicHTTPRoute",
]
140 changes: 122 additions & 18 deletions app/business/extension/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@
from .runtime import (
ExtensionPublication,
ExtensionPublicationSnapshot,
PublicHTTPRoute,
PublicHTTPRouteClaim,
ExtensionRuntimeClaim,
ExtensionRuntimeClaimConflictError,
ExtensionRuntimeRecord,
)
from .state import ExtensionState, ExtensionStateStore, SQLExtensionStateStore
from .state import ExtensionStore, InstalledExtension, SQLExtensionStore


LOGGER = get_logger().getChild(__name__)
Expand All @@ -52,6 +54,9 @@
class EmptyConfig(sqlmodel.SQLModel): ...


class EmptyState(sqlmodel.SQLModel): ...


ConfigTV = typing.TypeVar("ConfigTV", bound=sqlmodel.SQLModel)


Expand All @@ -64,11 +69,13 @@ def __init_subclass__(
cls,
ext_id: str,
config_cls: type[ConfigTV],
state_cls: type[sqlmodel.SQLModel] = EmptyState,
**kwargs: typing.Any,
) -> None:
cls.__extid__ = ext_id
cls.__configcls__ = config_cls # pyrefly: ignore[no-access]
cls.__configschema__ = config_cls.model_json_schema()
cls.__statecls__ = state_cls
super().__init_subclass__(**kwargs)

@classmethod
Expand Down Expand Up @@ -98,10 +105,16 @@ def on_start(
setattr(cls, "config", validated_config)
router = fastapi.APIRouter(prefix=f"/{cls.__extid__}")
cls._register_apis(router)
registered_routes = tuple(router.routes)
app.include_router(router, tags=["extension", cls.__extid__])
cls._init_sources()
cls._init_resolvers()
publication = snapshot.finish()
publication.public_http_claim = PublicHTTPRouteClaim.acquire(
cls.__extid__,
cls.public_http_routes(),
registered_routes,
)
publication.activate_source_types()
extension.persist_config_schema(dict(cls.__configschema__))
except Exception:
Expand All @@ -128,8 +141,6 @@ async def on_close(cls) -> None:
)
if record is None:
raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state")
config = typing.cast(sqlmodel.SQLModel, getattr(cls, "config"))
record.persist_config(config.model_dump())
LOGGER.info("Extension %s closed", cls.__extid__)

@classmethod
Expand Down Expand Up @@ -161,12 +172,81 @@ def _register_apis(cls, router: fastapi.APIRouter) -> None:
"""Register Extension-owned API endpoints."""

@classmethod
def update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> None:
def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]:
return ()

@classmethod
def _runtime_record(cls) -> ExtensionRuntimeRecord:
record = typing.cast(
ExtensionRuntimeRecord | None,
cls.__dict__.get("__runtime_record__"),
)
if record is None:
raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state")
return record

@classmethod
def get_config(cls) -> ConfigTV:
validated = cls.__configcls__( # pyrefly: ignore[missing-attribute]
**cls._runtime_record().read_config()
)
setattr(cls, "config", validated)
return validated

@classmethod
def update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> ConfigTV:
if isinstance(new_config, dict):
validated = cls.__configcls__(**new_config) # pyrefly: ignore[missing-attribute]
else:
validated = new_config
cls._runtime_record().persist_config(validated.model_dump(mode="json"))
setattr(cls, "config", validated)
return validated

@classmethod
def get_state(cls) -> sqlmodel.SQLModel:
return cls.__statecls__(**cls._runtime_record().read_state()) # pyrefly: ignore[missing-attribute]

@classmethod
def mutate_state(
cls,
transform: typing.Callable[[sqlmodel.SQLModel], sqlmodel.SQLModel],
) -> sqlmodel.SQLModel:
state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute]

def mutate(raw: dict[str, typing.Any]) -> dict[str, typing.Any]:
current = state_cls(**raw)
updated = transform(current)
if not isinstance(updated, state_cls):
raise TypeError("Extension state transform returned the wrong model")
return updated.model_dump(mode="json")

return state_cls(**cls._runtime_record().mutate_state(mutate))

@classmethod
def mutate_config_and_state(
cls,
transform: typing.Callable[
[ConfigTV, sqlmodel.SQLModel], tuple[ConfigTV, sqlmodel.SQLModel]
],
) -> tuple[ConfigTV, sqlmodel.SQLModel]:
config_cls = cls.__configcls__ # pyrefly: ignore[missing-attribute]
state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute]

def mutate(
raw_config: dict[str, typing.Any],
raw_state: dict[str, typing.Any],
) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]:
config, state = transform(config_cls(**raw_config), state_cls(**raw_state))
if not isinstance(config, config_cls) or not isinstance(state, state_cls):
raise TypeError("Extension config/state transform returned the wrong models")
return config.model_dump(mode="json"), state.model_dump(mode="json")

raw_config, raw_state = cls._runtime_record().mutate_config_and_state(mutate)
config = config_cls(**raw_config)
state = state_cls(**raw_state)
setattr(cls, "config", config)
return config, state


@dataclass
Expand All @@ -186,11 +266,11 @@ class ExtensionHost:
def __init__(
self,
*,
store: ExtensionStateStore | None = None,
store: ExtensionStore | None = None,
release_client: ReleaseResolver | None = None,
distribution_consumer: DistributionConsumer | None = None,
) -> None:
self.store = store or SQLExtensionStateStore()
self.store = store or SQLExtensionStore()
self.release_client = release_client or RegistryReleaseClient(
settings.extension_registry_url,
settings.extension_registry_timeout_seconds,
Expand All @@ -203,10 +283,10 @@ def __init__(
self._loaded_versions: dict[str, str] = {}
self._runtime_lock = asyncio.Lock()

def list(self) -> tuple[ExtensionState, ...]:
def list(self) -> tuple[InstalledExtension, ...]:
return self.store.list()

def get(self, name: str) -> ExtensionState:
def get(self, name: str) -> InstalledExtension:
validate_coordinate(name)
state = self.store.get(name)
if state is None:
Expand All @@ -230,7 +310,7 @@ def _resolve(
association = require_python_association(release)
return release, association

def install(self, name: str, version: str) -> ExtensionState:
def install(self, name: str, version: str) -> InstalledExtension:
validate_coordinate(name, version)
existing = self.store.get(name)
if existing is not None and existing.version == version:
Expand All @@ -253,7 +333,7 @@ def update_config(
self,
name: str,
config: dict[str, typing.Any],
) -> ExtensionState:
) -> InstalledExtension:
state = self.get(name)
running = self.running.get(name)
if running is None:
Expand All @@ -263,11 +343,10 @@ def update_config(
getattr(running.extension_class, "__configcls__"),
)
validated = config_class(**config)
updated = self.store.update_config(name, validated.model_dump())
running.extension_class.update_config(validated)
return updated
return self.get(name)

def _acquire(self, state: ExtensionState):
def _acquire(self, state: InstalledExtension):
release, association = self._resolve(
state.name,
state.version,
Expand All @@ -279,7 +358,7 @@ def _acquire(self, state: ExtensionState):
async def _start(
self,
app: fastapi.FastAPI,
state: ExtensionState,
state: InstalledExtension,
) -> RunningExtension:
existing = self.running.get(state.name)
if existing is not None:
Expand All @@ -295,7 +374,7 @@ async def _start(
async def _start_acquired(
self,
app: fastapi.FastAPI,
state: ExtensionState,
state: InstalledExtension,
association: PythonReleaseDescriptor,
acquired: AcquiredDistribution,
*,
Expand All @@ -317,6 +396,25 @@ async def _start_acquired(
def persist_config(config: dict[str, typing.Any]) -> None:
self.store.update_config(state.name, config)

def read_config() -> dict[str, typing.Any]:
return self.store.read_config(state.name)

def read_state() -> dict[str, typing.Any]:
return self.store.read_state(state.name)

def mutate_state(
transform: typing.Callable[[dict[str, typing.Any]], dict[str, typing.Any]],
) -> dict[str, typing.Any]:
return self.store.mutate_state(state.name, transform)

def mutate_config_and_state(
transform: typing.Callable[
[dict[str, typing.Any], dict[str, typing.Any]],
tuple[dict[str, typing.Any], dict[str, typing.Any]],
],
) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]:
return self.store.mutate_config_and_state(state.name, transform)

def stage_schema(schema: dict[str, typing.Any]) -> None:
schema_box["value"] = schema

Expand All @@ -329,7 +427,11 @@ def stage_schema(schema: dict[str, typing.Any]) -> None:
runtime_record = ExtensionRuntimeRecord(
extension_id=association.entry_point.name,
config=dict(state.config),
read_config=read_config,
persist_config=persist_config,
read_state=read_state,
mutate_state=mutate_state,
mutate_config_and_state=mutate_config_and_state,
persist_config_schema=stage_schema,
)
extension_class.on_start(
Expand Down Expand Up @@ -404,7 +506,7 @@ async def enable(
name: str,
*,
app: fastapi.FastAPI | None = None,
) -> ExtensionState:
) -> InstalledExtension:
validate_coordinate(name)
runtime_app = app or self.fastapi_app
if runtime_app is None:
Expand Down Expand Up @@ -445,7 +547,7 @@ async def enable(
) from conflict
raise conflict

async def disable(self, name: str) -> ExtensionState:
async def disable(self, name: str) -> InstalledExtension:
validate_coordinate(name)
peer_id = ClientManager.get_current_client_id()
async with self._runtime_lock:
Expand Down Expand Up @@ -519,8 +621,10 @@ async def close_running(self) -> None:
__all__ = [
"EXTENSION_HOST",
"EmptyConfig",
"EmptyState",
"ExtensionBase",
"ExtensionHost",
"ExtensionHostError",
"ExtensionState",
"InstalledExtension",
"PublicHTTPRoute",
]
Loading
Loading