diff --git a/.changes/extension-install-boundary.changed.md b/.changes/extension-install-boundary.changed.md new file mode 100644 index 00000000..589e755a --- /dev/null +++ b/.changes/extension-install-boundary.changed.md @@ -0,0 +1 @@ +Extension 安装改为只确认 published Release;Python 兼容性和已加载版本保护在启用前执行。 diff --git a/.changes/peer-runtime-identity.changed.md b/.changes/peer-runtime-identity.changed.md new file mode 100644 index 00000000..807b8643 --- /dev/null +++ b/.changes/peer-runtime-identity.changed.md @@ -0,0 +1 @@ +Publish each running Peer's application version with its runtime identity. diff --git a/app/business/extension/main.py b/app/business/extension/main.py index db307d7b..17a7ab43 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -251,21 +251,15 @@ def _resolve( raise ExtensionCompatibilityError( f"{name}@{version} is not available for this operation" ) - association = require_python_association(release) - return release, association + return release async def install(self, name: str, version: str) -> InstalledExtension: validate_coordinate(name, version) existing = await self.store.get(name) if existing is not None and existing.version == version: return existing - loaded_version = self._loaded_versions.get(name) - if loaded_version is not None and loaded_version != version: - raise ExtensionRestartRequiredError( - f"{name} {loaded_version} was already imported; restart before installing {version}" - ) release_client, _ = await self._operation_consumers() - release, _ = await asyncio.to_thread( + release = await asyncio.to_thread( self._resolve, name, version, @@ -323,13 +317,20 @@ async def _operation_consumers( async def _acquire(self, state: InstalledExtension): release_client, distribution_consumer = await self._operation_consumers() - release, association = await asyncio.to_thread( + release = await asyncio.to_thread( self._resolve, state.name, state.version, allow_yanked=True, release_client=release_client, ) + association = require_python_association(release) + loaded_version = self._loaded_versions.get(state.name) + if loaded_version is not None and loaded_version != state.version: + raise ExtensionRestartRequiredError( + f"{state.name} {loaded_version} was already imported; " + f"restart before enabling {state.version}" + ) acquired = await asyncio.to_thread(distribution_consumer.acquire, release, association) return association, acquired diff --git a/app/business/peer/AGENTS.md b/app/business/peer/AGENTS.md index 2cf9afa2..c8b12543 100644 --- a/app/business/peer/AGENTS.md +++ b/app/business/peer/AGENTS.md @@ -15,6 +15,6 @@ - `route_to_peer` is a caller-local constraint and never enters payload/advertisement。 - A normal response or outcome-unknown dispatch stops generic failover。 - HTTP absolute URLs come only from owner config + fixed inbound paths;do not infer them from bind host or requests。 -- Browser runtimes register with an ordinary `peers` upsert whose payload contains only - runtime-owned `id`、`name`、`config_schema` and `capabilities`; omitted owner-authored - `config` and `labels` must remain unchanged. +- Peer runtimes register with an ordinary `peers` upsert. A default `name` is inserted only for a + new identity; registration refreshes runtime-owned `application_version` and `config_schema` + without replacing Human-owned `name`、`config` or `labels`. diff --git a/app/business/peer/main.py b/app/business/peer/main.py index 54c334a1..7de8f379 100644 --- a/app/business/peer/main.py +++ b/app/business/peer/main.py @@ -22,6 +22,7 @@ normalize_capability_snapshot, ) from app.settings import settings +from app.version import APPLICATION_VERSION from .contracts import ( CapabilityDelegationUnavailable, @@ -58,7 +59,10 @@ async def register_self(cls) -> PeerModel: """Upsert runtime-owned identity/schema without changing owner configuration.""" async with peer_uow() as peers: peer = await peers.register( - settings.peer_id, settings.peer_name, cls._config_contract.json_schema() + settings.peer_id, + settings.peer_name, + APPLICATION_VERSION, + cls._config_contract.json_schema(), ) logger.info("Peer registered", extra={"peer": str(peer.id), "name": peer.name}) return peer diff --git a/app/database_contract/catalog.py b/app/database_contract/catalog.py index 91836f99..19009ea0 100644 --- a/app/database_contract/catalog.py +++ b/app/database_contract/catalog.py @@ -255,7 +255,8 @@ def development_baseline_fingerprint( ( "development_peer", sql.SQL( - "SELECT id::text, name, labels, config, config_schema, capabilities, " + "SELECT id::text, name, application_version, labels, config, config_schema, " + "capabilities, " "EXTRACT(EPOCH FROM lease_expires_at)::bigint, " "EXTRACT(EPOCH FROM created_at)::bigint " "FROM {}.peers WHERE id = %s" diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index fbf7db7b..d5c78755 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -4,7 +4,7 @@ CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "peer-extension-setup-v1" +CONTRACT_REVISION = "peer-runtime-identity-v1" PROTOCOL_SCHEMA = "inkcre" INTERNAL_SCHEMA = "inkcre_internal" diff --git a/app/database_contract/readiness.py b/app/database_contract/readiness.py index d2a7d218..328912e7 100644 --- a/app/database_contract/readiness.py +++ b/app/database_contract/readiness.py @@ -510,7 +510,7 @@ def _seed_component(cursor, profile: str) -> dict[str, Any]: return {"status": "not_required"} cursor.execute( sql.SQL( - "SELECT name, labels, config, config_schema, capabilities, " + "SELECT name, application_version, labels, config, config_schema, capabilities, " "lease_expires_at, created_at, updated_at " "FROM {}.peers WHERE id = %s" ).format(sql.Identifier(PROTOCOL_SCHEMA)), @@ -519,6 +519,7 @@ def _seed_component(cursor, profile: str) -> dict[str, Any]: row = cursor.fetchone() expected = ( DEVELOPMENT_PEER_NAME, + None, ["development", "canonical-seed"], {}, {}, diff --git a/app/persistence/peer/repository.py b/app/persistence/peer/repository.py index 9eea4bac..886d3e7c 100644 --- a/app/persistence/peer/repository.py +++ b/app/persistence/peer/repository.py @@ -16,14 +16,26 @@ class PeerRepository: def __init__(self, session: AsyncSession) -> None: self._session = session - async def register(self, peer_id: PeerRef, name: str, schema: dict) -> PeerModel: + async def register( + self, + peer_id: PeerRef, + name: str, + application_version: str, + schema: dict, + ) -> PeerModel: statement = insert(PeerModel).values( - id=peer_id, name=name, labels=[], config={}, config_schema=schema, capabilities=[] + id=peer_id, + name=name, + application_version=application_version, + labels=[], + config={}, + config_schema=schema, + capabilities=[], ) statement = statement.on_conflict_do_update( index_elements=["id"], set_={ - "name": statement.excluded.name, + "application_version": statement.excluded.application_version, "config_schema": statement.excluded.config_schema, }, ).returning(PeerModel) diff --git a/app/schemas/peer/main.py b/app/schemas/peer/main.py index 382e165f..250c4abf 100644 --- a/app/schemas/peer/main.py +++ b/app/schemas/peer/main.py @@ -111,6 +111,10 @@ class PeerModel(sqlmodel.SQLModel, table=True): ), ) name: str = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=False)) + application_version: str | None = sqlmodel.Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Text, nullable=True), + ) labels: list[str] = sqlmodel.Field( default_factory=list, sa_column=sqlalchemy.Column( diff --git a/app/version.py b/app/version.py index 1b7d6e70..1ca57f6a 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,9 @@ -"""Core Extension Host SDK version, independent of the service package version.""" +"""Core application and Extension Host SDK versions.""" + +from pathlib import Path +import tomllib CORE_VERSION = "0.3.0" +APPLICATION_VERSION = tomllib.loads( + (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8") +)["project"]["version"] diff --git a/docs/40-deployment/database-contract.md b/docs/40-deployment/database-contract.md index 58ab5217..e9e874e4 100644 --- a/docs/40-deployment/database-contract.md +++ b/docs/40-deployment/database-contract.md @@ -76,10 +76,12 @@ Deleting a referenced `storages` catalog row is `RESTRICT`。Changing/deleting a ## Peer Discovery Protocol -`peers` 持有 deployment Peer identity、owner config/schema、完整 capability/inbound snapshot 与 -`lease_expires_at`。Capability snapshot 是 runtime-owned derived projection;`labels` 不参与 routing,readiness -不进入 advertisement。HTTP inbound 的 absolute URL 只由当前 Peer 的 `config.http_public_base_url` 与固定业务 path -组合,Peer row 不再持有通用 `rest_api_url`。 +`peers` 持有 deployment Peer identity、应用版本、owner config/schema、完整 capability/inbound snapshot 与 +`lease_expires_at`。Peer runtime 在注册时写入自己的 `application_version`,供管理界面识别实际运行的应用;它不替代 +Extension Host SDK 的兼容性版本。旧行可以暂时为 `NULL`,Peer 再次注册后自然补齐。注册只在首次创建 identity 时写入 +默认名称,后续刷新应用版本和 schema 时保留 Human 修改的名称、配置与标签。Capability snapshot 是 runtime-owned +derived projection;`labels` 不参与 routing,readiness 不进入 advertisement。HTTP inbound 的 absolute URL 只由当前 +Peer 的 `config.http_public_base_url` 与固定业务 path 组合,Peer row 不再持有通用 `rest_api_url`。 `renew_peer_lease(peer uuid, ttl_seconds integer) -> timestamptz` 是 `SECURITY INVOKER`、database-time helper。 调用者提供正 TTL;不存在的 Peer 或非正 TTL 明确失败。普通 Peer row update 不续租,graceful shutdown 清空 expiry, diff --git a/docs/_shared b/docs/_shared index 5aa97e62..d87049c4 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 5aa97e6243dce02dd27c5f58eda345be2ba47297 +Subproject commit d87049c49ba10f0a710022dce23abcd316127b3c diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index ffd12e48..aff994ef 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -8,6 +8,7 @@ "3f7a9c2d5e1b_merge_extension_registry_feature_retrieval.py": "a12ebb2f37a00dcc13b6dea6b7f9be0f6870d57530e8d234eb934c080d027c07", "50b2c08dd267_add_relation_endpoint_indexes.py": "56a0dbfd5e032fa57b125e478940ff89254c167b38fe0a7a98046498577d13e5", "77cd53ad8080_add_global_jobs_crons_and_source_runtime.py": "8eda54520a7099c957ee6a1b5e6e48d5ee2b3dbe18b0dea4b6a5246dbef04bd2", + "a0465e3b028f_add_peer_application_version.py": "6ba72a21e6361cd92c5977856602105498be0f4ea6e6454330dfdb89e3b50253", "a1b2c3d4e5f6_add_clients_modify_extensions_enabled.py": "15292a9fcf834474c8453e3d1d9db962962fb5d729bc0b80053615cf71315b96", "a8b9c0d1e2f3_add_ai_registry_and_profiles.py": "4c9346dac54e797ba8cb6fa7dfa8604482a114905bea88d3ff71b32fb9da5246", "b8c1d2e3f4a5_native_extension_distribution_cutover.py": "c0527edff210a9646c69de2da01c2877bce4acbc05af969c5bfb8d1a3fd0d3c8", diff --git a/migrations/versions/a0465e3b028f_add_peer_application_version.py b/migrations/versions/a0465e3b028f_add_peer_application_version.py new file mode 100644 index 00000000..6f2848d7 --- /dev/null +++ b/migrations/versions/a0465e3b028f_add_peer_application_version.py @@ -0,0 +1,52 @@ +"""add peer application version + +Revision ID: a0465e3b028f +Revises: d41cc84db0c5 +Create Date: 2026-09-22 20:24:20.666591 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +from app.database_contract import INTERNAL_SCHEMA, PROTOCOL_SCHEMA + + +revision: str = "a0465e3b028f" +down_revision: str | Sequence[str] | None = "d41cc84db0c5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_CONTRACT_REVISION = "peer-runtime-identity-v1" +_PREVIOUS_CONTRACT_REVISION = "peer-extension-setup-v1" + + +def _set_contract_revision(value: str) -> None: + op.execute( + sa.text( + f""" + UPDATE "{INTERNAL_SCHEMA}".contract_state + SET contract_revision = :revision, + updated_at = statement_timestamp() + WHERE singleton + """ + ).bindparams(revision=value) + ) + + +def upgrade() -> None: + op.add_column( + "peers", + sa.Column("application_version", sa.Text(), nullable=True), + schema=PROTOCOL_SCHEMA, + ) + _set_contract_revision(_CONTRACT_REVISION) + op.execute("NOTIFY pgrst, 'reload schema'") + + +def downgrade() -> None: + _set_contract_revision(_PREVIOUS_CONTRACT_REVISION) + op.drop_column("peers", "application_version", schema=PROTOCOL_SCHEMA) + op.execute("NOTIFY pgrst, 'reload schema'") diff --git a/tests/peer/integration/test_peer_runtime.py b/tests/peer/integration/test_peer_runtime.py index dcf0f07d..f324f73a 100644 --- a/tests/peer/integration/test_peer_runtime.py +++ b/tests/peer/integration/test_peer_runtime.py @@ -10,6 +10,7 @@ from app.business.peer import PeerHTTPInbound, PeerManager from tests.database import TestSession from app.schemas.peer import PEER_HTTP_PROTOCOL, PeerModel +from app.version import APPLICATION_VERSION pytestmark = pytest.mark.skipif( @@ -41,6 +42,9 @@ def test_real_snapshot_database_lease_and_candidate_filtering(monkeypatch, async with TestSession() as db: local_row = db.get(PeerModel, local) assert local_row is not None + assert local_row.application_version == APPLICATION_VERSION + local_row.name = "Human name" + local_row.application_version = "0.0.0" local_row.config = {"http_public_base_url": "https://local.example/root/"} db.add(local_row) now = ( @@ -84,6 +88,10 @@ def test_real_snapshot_database_lease_and_candidate_filtering(monkeypatch, async ) db.commit() + registered_again = async_runner.run(PeerManager.register_self()) + assert registered_again.name == "Human name" + assert registered_again.application_version == APPLICATION_VERSION + published = async_runner.run(PeerManager.publish_self()) assert published.capabilities == [ {