diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b506763e3..7599102bb 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "6ac34926b9c7f4fa975512833ca026ec43da9073e92b53068e2a987b730f2bb8", + "contractSha256": "16eff718a6607ab6ff3d03c2a279ee92c26d8895ac804e7259fc11173ac8e3c8", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 7cbd1eeea..1ed9b6b95 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -221,6 +221,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/server-info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get stable Server deployment identity and protocol contracts + * @description Returns deployment identity and compatibility metadata only. Runtime availability, enabled behavior, limits and inventory remain owned by health, capabilities and statistics endpoints. + */ + get: operations["get_server_info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/capabilities": { parameters: { query?: never; @@ -2103,6 +2123,32 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** @description A major/minor compatibility version. A major increment may remove or incompatibly change the governed contract. A minor increment only adds backward-compatible behavior or fields. */ + ContractVersion: { + major: number; + minor: number; + }; + /** @description Compatibility version for exactly the listed OpenAPI operation IDs. Adding operations or compatible semantics increments minor; removing, renaming or incompatibly changing a listed operation increments major. */ + FeatureContract: { + version: components["schemas"]["ContractVersion"]; + operations: string[]; + }; + /** @description Stable deployment identity and protocol compatibility metadata. Compatible clients must ignore unknown optional fields added by a future schema minor version. This contract intentionally excludes runtime capabilities, health, limits, inventory and authorization-principal identity. */ + ServerInfo: { + /** @description Compatibility version for this response shape and field semantics. */ + schema_version: components["schemas"]["ContractVersion"]; + /** @enum {string} */ + product: "powercontext"; + /** @description Opaque identity of the durable Server deployment. It is unrelated to Access deployment_id and remains stable across restarts, upgrades, backup restore and replicas sharing the same primary database. */ + server_id: string; + package_version: string; + /** @description Major/minor projection of the OpenAPI info.version served by this package. */ + api_contract_version: components["schemas"]["ContractVersion"]; + /** @description Stable feature groups keyed by contract name. */ + feature_contracts: { + [key: string]: components["schemas"]["FeatureContract"]; + }; + }; /** @enum {string} */ AtomicMemoryState: "active" | "forgotten" | "merged" | "retired"; AtomicMemoryWriteContent: { @@ -5283,6 +5329,30 @@ export interface operations { }; }; }; + get_server_info: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stable deployment identity and protocol compatibility metadata. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServerInfo"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 503: components["responses"]["Unavailable"]; + }; + }; get_capabilities: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 5a994dd44..38ab224bd 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "6ac34926b9c7f4fa975512833ca026ec43da9073e92b53068e2a987b730f2bb8"; +export const contractSha256 = "16eff718a6607ab6ff3d03c2a279ee92c26d8895ac804e7259fc11173ac8e3c8"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/development/remote-access-implementation.md b/docs/en/development/remote-access-implementation.md index 387ed1e0e..f0fe6c4b0 100644 --- a/docs/en/development/remote-access-implementation.md +++ b/docs/en/development/remote-access-implementation.md @@ -87,6 +87,7 @@ The source contract is `openapi/powercontext.yaml`. Generated Pydantic models an | Area | Operations | | --- | --- | | Health | liveness and readiness | +| Server discovery | stable deployment identity and protocol contracts | | Capabilities | source types, Artifact families, extraction, search modes | | Sources | capture durable content evidence | | Memory | flush pending Sources, remember explicit entries, search | @@ -100,6 +101,69 @@ Server errors use the OpenAPI error schema and include a Server-owned `X-PowerCo derived from the inbound request span. Validation errors, revision conflicts, missing entries, unavailable inference, and internal failures map to stable HTTP status codes. +### Server identity and compatibility discovery + +`GET /v1/server-info` is protected by `server.observe` and returns the stable deployment `server_id`, installed package +version, API contract version, response schema version, and initial feature contracts. It deliberately does not report +health, enabled runtime capabilities, limits, inventory, secrets, filesystem paths, or the authenticated principal. +Use the dedicated health, capabilities, statistics, and access endpoints for those concerns. + +All discovery versions use integers with `major >= 1` and `minor >= 0`. A major increment may remove or incompatibly +change the governed contract; a minor increment is backward compatible. The response `schema_version` governs its fields and +semantics, while `api_contract_version` is the major/minor projection of the OpenAPI `info.version`. Each feature +contract version applies only to its listed OpenAPI operation IDs: adding an operation or compatible semantics increments +minor; removing, renaming, or incompatibly changing a listed operation increments major. Compatible clients must ignore +unknown optional fields introduced by a schema minor version. + +OpenAPI's root `x-powercontext-feature-contracts` declares the explicit feature versions. Each governed operation lists +its membership in the same extension; `make api-generate` produces the discovery metadata from those declarations. +Generation rejects invalid versions (including values outside these bounds), unknown or duplicate memberships, and +features without operations. Version bumps remain an explicit contract edit, not an automatic consequence of changing membership. + +The Server stores one identity singleton using the Runtime-owned primary relational database. Startup creates the identity +table idempotently, then atomically creates or loads the singleton, so concurrent initializers converge on one ID and +restarts, package upgrades, backup restore, and replicas sharing that database retain it. If identity schema +initialization or loading fails, Server startup fails before readiness instead of publishing a temporary identity. +The identity repository retries only SQLite busy/locked errors, replaying the complete schema or singleton operation +after rollback. It admits retries for up to five seconds with 50 ms waits; each SQL attempt also retains the driver's +configured busy timeout. Exhaustion and all other errors propagate; offline rotation is not retried. +An in-memory or temporary SQLite deployment receives a new ID with each database lifetime because it has no durable store. Applications using the same shared-memory SQLite database share both data and identity while any +Runtime connection keeps that database alive; after the last connection closes, reopening creates new data and identity. +SQLite storage classification uses the dialect's effective connection arguments and the decoded SQLite URI, including +supported true spellings (`true`, `1`, `yes`, `on`), percent-encoded `:memory:` paths, and the built-in `vfs=memdb` +in-memory filesystem. Empty-path file URIs such as +`file:?uri=true` and `file:?cache=shared&uri=true` create connection-local temporary databases, not durable files. +Classification also honors SQLite's decoded NUL termination for filenames and query parameter names/values: +`file:%00tail?uri=true` is temporary, an encoded `:memory:` followed by `%00` remains memory storage, and +`mode=memory%2500tail` or `mode%2500tail=memory` in a SQLAlchemy URL still selects native memory mode. Only an exact lowercase `file:` prefix enables +SQLite URI interpretation; uppercase schemes, leading spaces, and literal filename controls are not normalized. +`SQLiteConfig.is_in_memory` distinguishes memory storage; `SQLiteConfig.is_persistent` excludes both memory and temporary +storage. Pooling keeps one connection for nonpersistent storage; offline maintenance, cursor-secret persistence, and +subprocess workers consume that same persistence classification. + +The unified migration implementation from [RFC #1771](../rfcs/1771-unified-database-migrations.md) currently +covers only a registered four-table Artifact bundle. It does not manage `pc_server_identity`, gate ordinary Server +startup, or establish complete Server readiness; its maintenance commands reject a complete business database with +unmanaged tables. Do not use that partial bundle to migrate a Server database. + +When unified migration takes ownership of the complete Server schema, identity table creation must move from startup +DDL into an immutable managed revision, with schema verification before Runtime composition. If discovery is already +released, the supported historical baseline must include the existing identity table and preserve its singleton value; +if still unmerged at framework enablement, discovery must ship that revision alongside the model. Deployment identity +remains distinct from `pc_schema_revision`, and schema adoption or upgrades must not rotate `server_id`. Clone rotation +remains an explicit offline operation. No independent schema-version or migration-readiness marker is added for identity. + +Treat a restored backup as the same deployment and keep its ID. When a backup is used to create an independent clone, +stop every Server process using the clone database and rotate only the clone: + +```bash +uv run powercontext server identity-reset --env-file /path/to/clone.env --maintenance-confirmed +``` + +The command refuses in-memory and temporary databases and requires the explicit maintenance confirmation. It cannot detect active +replicas, so stopping them is an operator precondition. Logical application-data imports do not copy the identity unless +the `pc_server_identity` table itself is included. + ## Python Client Install the Client role for the SDK: @@ -117,6 +181,7 @@ from powercontext.client import PowerContextClient async def search() -> None: async with PowerContextClient("http://127.0.0.1:8000") as client: + server_info = await client.get_server_info() capabilities = await client.get_capabilities() result = await client.search_memory( SearchMemoryRequest( @@ -126,6 +191,7 @@ async def search() -> None: mode="auto", ) ) + print(server_info.model_dump()) print(capabilities.model_dump()) print(result.model_dump()) ``` diff --git a/docs/zh/development/remote-access-implementation.md b/docs/zh/development/remote-access-implementation.md index e20cadd82..7c70469b8 100644 --- a/docs/zh/development/remote-access-implementation.md +++ b/docs/zh/development/remote-access-implementation.md @@ -83,6 +83,7 @@ inference 配置见[配置 Pydantic AI 推理](pydantic-ai-inference.md)。 | 领域 | Operation | | --- | --- | | Health | liveness 和 readiness | +| Server discovery | 稳定 deployment identity 和协议契约 | | Capabilities | source type、Artifact family、extraction、search mode | | Sources | capture 持久化 content evidence | | Memory | flush 待处理 Source、remember 显式 entry、search | @@ -96,6 +97,59 @@ Server error 使用 OpenAPI error schema,并在 response header 中包含由 i Server-owned `X-PowerContext-Request-ID`。validation error、revision conflict、entry 不存在、inference unavailable 和内部 failure 会映射为稳定的 HTTP status code。 +### Server identity 与兼容性发现 + +`GET /v1/server-info` 受 `server.observe` 保护,返回稳定的 deployment `server_id`、已安装 package version、API +contract version、response schema version 和首批 feature contract。它刻意不返回 health、已启用 runtime +capability、limit、inventory、secret、文件系统 path 或已认证 principal;这些信息分别由 health、capabilities、 +statistics 和 access endpoint 负责。 + +所有 discovery version 都使用 `major >= 1` 和 `minor >= 0` 的整数。major 增加表示受管契约可能被移除或发生不兼容变化;minor +增加只允许向后兼容的扩展。response 的 `schema_version` 管理字段与语义,`api_contract_version` 是 OpenAPI +`info.version` 的 major/minor 投影。每个 feature contract version 只约束它列出的 OpenAPI operation ID:增加 +operation 或兼容语义时增加 minor,移除、重命名或不兼容地改变已列 operation 时增加 major。兼容 client 必须忽略 +schema minor version 新增的未知可选字段。 + +OpenAPI 根级 `x-powercontext-feature-contracts` 显式声明 feature version,各受管 operation 使用同名扩展声明归属。 +`make api-generate` 从这些声明生成 discovery metadata,并拒绝超出上述范围的版本、未知或重复归属及没有 operation 的 feature。 +版本号仍须显式修改,不会因归属变化而自动增加。 + +Server 使用 Runtime 持有的主关系数据库保存一条 identity singleton。启动时先幂等创建 identity table,再原子创建或读取 +singleton,因此并发 initializer 会收敛到同一 ID,进程重启、package 升级、备份恢复以及共享同一数据库的 replica +也会保持该 ID。identity schema 初始化或读取失败时,Server 会在进入 readiness 之前直接启动失败,而不会发布 +临时 identity。identity repository 只重试 SQLite busy/locked 错误,在事务回滚后重新执行完整的 schema 或 singleton +操作。重试窗口为五秒,每次等待 50 ms;每条 SQL 仍使用 driver 配置的 busy timeout。窗口耗尽或其他错误会直接 +传播,离线轮换不重试。内存或临时 SQLite 没有持久存储,所以每个数据库生命周期都会获得新 ID。 +使用同一共享内存 SQLite 数据库的 application 会共享数据和 identity;只要仍有 Runtime 连接,数据库就保持存活。 +最后一个连接关闭后,再次打开会重新创建数据和 identity。 +SQLite 存储分类使用 dialect 的实际连接参数和解码后的 URI,包括支持的 true 拼写(`true`、`1`、`yes`、`on`)以及 +百分号编码的 `:memory:` path 以及内置的 `vfs=memdb` 内存文件系统。`file:?uri=true` 和 `file:?cache=shared&uri=true` 这类空路径 URI 创建的是连接独占的 +临时数据库,而非持久文件。分类也遵循 SQLite 在解码后的 NUL 处终止文件名和查询参数名/值的行为:`file:%00tail?uri=true` 属于临时存储, +编码后的 `:memory:` 即使后接 `%00` 也仍是内存存储,SQLAlchemy URL 中的 `mode=memory%2500tail` 或 +`mode%2500tail=memory` 也仍会选择原生 memory mode。只有精确的小写 `file:` 前缀才启用 SQLite URI 解释, +不会归一化大写 scheme、前导空格或文件名中的控制字符。`SQLiteConfig.is_in_memory` 区分内存存储;`SQLiteConfig.is_persistent` 同时排除内存与临时 +存储。非持久存储的连接池保留一个连接;离线维护、cursor secret 持久化及子进程 worker 统一使用该持久性分类。 + +[RFC #1771](../rfcs/1771-unified-database-migrations.md) 对应的统一迁移实现目前只覆盖已注册的四张 Artifact 表。 +它尚未管理 `pc_server_identity`、接管普通 Server 启动检查或证明完整 Server readiness;维护命令会拒绝包含未管理表的 +完整业务数据库。不能使用这个局部 bundle 迁移 Server 数据库。 + +统一迁移接管完整 Server schema 时,identity table 创建必须从启动 DDL 移到不可变的受管 revision,并在 Runtime +组装前验证 schema。若 discovery 已发布,支持的历史 baseline 必须包含现有 identity table,并保留 singleton 值; +若在框架启用时仍未合并,discovery 必须随 model 一起提交该 revision。deployment identity 与 `pc_schema_revision` +保持独立,schema 纳管或升级不能轮换 `server_id`。clone 轮换仍是显式离线操作,identity 不另设 schema version 或 +migration readiness marker。 + +把备份恢复为原 deployment 时应保留原 ID。若用备份创建独立 clone,请停止所有使用 clone 数据库的 Server +进程,然后只在 clone 上轮换: + +```bash +uv run powercontext server identity-reset --env-file /path/to/clone.env --maintenance-confirmed +``` + +该命令拒绝内存和临时数据库,并要求显式 maintenance confirmation。它无法检测仍在运行的 replica,因此停服是 operator +前置条件。逻辑 application-data import 不会复制 identity,除非显式包含 `pc_server_identity` 表。 + ## Python Client 安装 Client role 以使用 SDK: @@ -113,6 +167,7 @@ from powercontext.client import PowerContextClient async def search() -> None: async with PowerContextClient("http://127.0.0.1:8000") as client: + server_info = await client.get_server_info() capabilities = await client.get_capabilities() result = await client.search_memory( SearchMemoryRequest( @@ -122,6 +177,7 @@ async def search() -> None: mode="auto", ) ) + print(server_info.model_dump()) print(capabilities.model_dump()) print(result.model_dump()) ``` diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 0fdc8288e..5c527a7a2 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -302,6 +302,17 @@ const OPERATIONS$1 = { successStatuses: [200], emptyStatuses: [] }, + get_server_info: { + method: "GET", + path: "/v1/server-info", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_capabilities: { method: "GET", path: "/v1/capabilities", diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 7b970a0ce..34c0b7dd5 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -30,6 +30,7 @@ export const OPERATIONS = { flush_profile: { method: 'POST', path: '/v1/profile/flush', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 7b970a0ce..34c0b7dd5 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -30,6 +30,7 @@ export const OPERATIONS = { flush_profile: { method: 'POST', path: '/v1/profile/flush', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 7b970a0ce..34c0b7dd5 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -30,6 +30,7 @@ export const OPERATIONS = { flush_profile: { method: 'POST', path: '/v1/profile/flush', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 65c418539..05df0473b 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -17,6 +17,10 @@ info: title: PowerContext API description: Remote PowerContext transport. Runtime behavior is reported by /v1/capabilities. version: 1.2.0 +x-powercontext-feature-contracts: + access.principal: {major: 1, minor: 0} + scope.selection: {major: 1, minor: 0} + memory.explicit: {major: 1, minor: 0} security: - BearerAuth: [] - {} @@ -498,6 +502,31 @@ paths: application/json: schema: $ref: "#/components/schemas/ReadinessResponse" + /v1/server-info: + get: + tags: [server] + summary: Get stable Server deployment identity and protocol contracts + description: >- + Returns deployment identity and compatibility metadata only. Runtime availability, enabled behavior, + limits and inventory remain owned by health, capabilities and statistics endpoints. + operationId: get_server_info + x-powercontext-access: {action: server.observe, resource: {type: server}} + responses: + "200": + description: Stable deployment identity and protocol compatibility metadata. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ServerInfo" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/Unavailable" /v1/capabilities: get: tags: [capabilities] @@ -534,6 +563,7 @@ paths: matching follow the underlying database collation; results need not be identical across backends. Requests without query parameters preserve the existing complete-list behavior. operationId: list_scopes + x-powercontext-feature-contracts: [scope.selection] x-powercontext-access: {action: server.observe, resource: {type: server}} parameters: - name: query @@ -701,6 +731,7 @@ paths: Inspect a selected Scope by its exact identifier for navigation or configuration. This does not select or bind the current session, retrieve its Memory, or authorize cross-Scope access. operationId: get_scope + x-powercontext-feature-contracts: [scope.selection] x-powercontext-access: {resolver: path_scope_read_access} parameters: - name: scope_id @@ -774,6 +805,7 @@ paths: returns 409 scope_binding_target_missing with details.scope_id; repair the binding or restore its target explicitly instead of automatically provisioning a replacement. operationId: get_default_scope + x-powercontext-feature-contracts: [scope.selection] x-powercontext-access: {action: server.observe, resource: {type: server}} responses: "200": @@ -1838,6 +1870,7 @@ paths: description: >- Create a standalone Atomic Memory and formal Owner. Omit expected_revision or pass null. Non-null legacy collection revision preconditions are unsupported before any write. Response records use true Atomic ArtifactRefs. operationId: remember_memory + x-powercontext-feature-contracts: [memory.explicit] x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} x-powercontext-scope-mode: current requestBody: @@ -1875,6 +1908,7 @@ paths: description: >- Require Scope read and search current active Atomic Memory with content filters before limits. Returns true Atomic ArtifactRefs and state versions, never synthetic old collection citations. Empty results are valid. operationId: search_memory + x-powercontext-feature-contracts: [memory.explicit] x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} x-powercontext-scope-mode: current requestBody: @@ -1986,6 +2020,7 @@ paths: description: >- A legacy logical target maps deterministically to current Atomic Memory with current state. Merged targets are returned without automatically following their result. Exact historical citations are unsupported; read exact Atomic revisions through the Artifact revision endpoint. operationId: get_memory_entry + x-powercontext-feature-contracts: [memory.explicit] x-powercontext-access: {resolver: exact_memory_access} x-powercontext-scope-mode: current requestBody: @@ -4449,6 +4484,7 @@ paths: tags: [access] summary: Get the authenticated Principal and Access capabilities operationId: get_access_principal + x-powercontext-feature-contracts: [access.principal] x-powercontext-access: {action: access.self, resource: {type: server}} responses: "200": @@ -4826,6 +4862,69 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" schemas: + ContractVersion: + type: object + description: >- + A major/minor compatibility version. A major increment may remove or incompatibly change the governed + contract. A minor increment only adds backward-compatible behavior or fields. + required: [major, minor] + properties: + major: + type: integer + minimum: 1 + minor: + type: integer + minimum: 0 + FeatureContract: + type: object + description: >- + Compatibility version for exactly the listed OpenAPI operation IDs. Adding operations or compatible + semantics increments minor; removing, renaming or incompatibly changing a listed operation increments major. + required: [version, operations] + properties: + version: + $ref: "#/components/schemas/ContractVersion" + operations: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + ServerInfo: + type: object + description: >- + Stable deployment identity and protocol compatibility metadata. Compatible clients must ignore unknown + optional fields added by a future schema minor version. This contract intentionally excludes runtime + capabilities, health, limits, inventory and authorization-principal identity. + required: + [schema_version, product, server_id, package_version, api_contract_version, feature_contracts] + properties: + schema_version: + allOf: + - $ref: "#/components/schemas/ContractVersion" + description: Compatibility version for this response shape and field semantics. + product: + type: string + enum: [powercontext] + server_id: + type: string + minLength: 1 + maxLength: 128 + description: >- + Opaque identity of the durable Server deployment. It is unrelated to Access deployment_id and remains + stable across restarts, upgrades, backup restore and replicas sharing the same primary database. + package_version: + type: string + minLength: 1 + api_contract_version: + allOf: + - $ref: "#/components/schemas/ContractVersion" + description: Major/minor projection of the OpenAPI info.version served by this package. + feature_contracts: + type: object + description: Stable feature groups keyed by contract name. + additionalProperties: + $ref: "#/components/schemas/FeatureContract" AtomicMemoryState: type: string enum: diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 26506bd61..306c0a7ef 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -76,6 +76,11 @@ class _AccessRequirement(TypedDict): resolver: str +class _FeatureContract(TypedDict): + version: dict[str, int] + operations: list[str] + + def generate_sources() -> dict[Path, str]: """Build every generated source without modifying the worktree.""" @@ -320,6 +325,7 @@ def _generate_operations( ) -> str: imports: set[tuple[str, str]] = set() operations: list[str] = [] + feature_operations: list[tuple[str, OpenAPIOperation]] = [] for path, path_item in (contract.paths or {}).items(): if isinstance(path_item, dict): path_item = PathItem.model_validate(path_item) @@ -331,6 +337,7 @@ def _generate_operations( if operation.operationId is None or operation.summary is None: raise ContractGenerationError("operation metadata", path) # noqa: TRY003 operation_id = operation.operationId + feature_operations.append((operation_id, operation)) access = _access_requirement(operation, operation_id) parameters = _operation_parameters(path_item, operation) request_model = _request_model(operation, parameters, schemas) @@ -369,6 +376,7 @@ def _generate_operations( ) ) + feature_contracts = _feature_contracts(contract, feature_operations) import_lines = "\n".join(f"from {module} import {name}" for module, name in sorted(imports)) rendered_operations = "\n\n".join(operations) source = f"""# generated from openapi/powercontext.yaml; do not edit. @@ -385,6 +393,7 @@ def _generate_operations( API_TITLE = {contract.info.title!r} API_DESCRIPTION = {contract.info.description!r} API_VERSION = {contract.info.version!r} +FEATURE_CONTRACTS: dict[str, dict[str, JsonValue]] = {pformat(feature_contracts, sort_dicts=False)} RequestT = TypeVar("RequestT") ResponseT = TypeVar("ResponseT") @@ -424,6 +433,42 @@ class AccessRequirement(BaseModel): return f"{formatter.format_code(source).rstrip()}\n" +def _feature_contracts( + contract: OpenAPI, operations: list[tuple[str, OpenAPIOperation]] +) -> dict[str, _FeatureContract]: + """Validate and collect feature versions and membership from the wire contract.""" + + versions = (contract.model_extra or {}).get("x-powercontext-feature-contracts", {}) + if not isinstance(versions, dict): + raise ContractGenerationError("x-powercontext-feature-contracts", versions) + features: dict[str, _FeatureContract] = {} + for name, version in versions.items(): + if ( + not isinstance(name, str) + or not name.strip() + or not isinstance(version, dict) + or set(version) != {"major", "minor"} + or any(type(value) is not int or value < 0 for value in version.values()) + or version["major"] < 1 + ): + raise ContractGenerationError("feature version", {name: version}) # noqa: TRY003 + features[name] = {"version": version, "operations": []} + for operation_id, operation in operations: + memberships = (operation.model_extra or {}).get("x-powercontext-feature-contracts", []) + if ( + not isinstance(memberships, list) + or any(not isinstance(name, str) or name not in features for name in memberships) + or len(memberships) != len(set(memberships)) + ): + raise ContractGenerationError(f"feature membership for {operation_id}", memberships) # noqa: TRY003 + for name in memberships: + features[name]["operations"].append(operation_id) + for name, feature in features.items(): + if not feature["operations"]: + raise ContractGenerationError("feature without operations", name) # noqa: TRY003 + return features + + def _generate_schema(contract: dict[str, JsonValue]) -> str: source = f"""# generated from openapi/powercontext.yaml; do not edit. diff --git a/src/powercontext/builtin/persistence/migrations/deployment.py b/src/powercontext/builtin/persistence/migrations/deployment.py index 45a12a1ab..34bbd9156 100644 --- a/src/powercontext/builtin/persistence/migrations/deployment.py +++ b/src/powercontext/builtin/persistence/migrations/deployment.py @@ -83,7 +83,7 @@ def deployment_runner( if not isinstance(database, SQLiteConfig): raise MigrationError("unsupported_backend", "The database has no registered migration adapter.") url = make_url(database.url) - if database.is_in_memory or url.query or not url.database or url.username or url.password or url.host or url.port: + if url.query or not url.database or url.username or url.password or url.host or url.port or database.is_in_memory: raise MigrationError( "unsupported_target", "Use a regular SQLite file URL without credentials, host, port or URI options." ) diff --git a/src/powercontext/builtin/persistence/sqlite/__init__.py b/src/powercontext/builtin/persistence/sqlite/__init__.py index 7e0f4591c..d04093813 100644 --- a/src/powercontext/builtin/persistence/sqlite/__init__.py +++ b/src/powercontext/builtin/persistence/sqlite/__init__.py @@ -14,7 +14,7 @@ """SQLite async relational profile.""" -from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile, is_sqlite_lock_error from powercontext.builtin.persistence.sqlite.topic_memory_index import ( SQLiteTopicMemoryFTSIndex, SQLiteTopicMemoryVectorIndex, @@ -25,4 +25,5 @@ "SQLiteProfile", "SQLiteTopicMemoryFTSIndex", "SQLiteTopicMemoryVectorIndex", + "is_sqlite_lock_error", ) diff --git a/src/powercontext/builtin/persistence/sqlite/profile.py b/src/powercontext/builtin/persistence/sqlite/profile.py index 89c0c9de0..6415d3e35 100644 --- a/src/powercontext/builtin/persistence/sqlite/profile.py +++ b/src/powercontext/builtin/persistence/sqlite/profile.py @@ -17,11 +17,12 @@ from __future__ import annotations import asyncio +import sqlite3 from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from pathlib import Path from typing import Any, Literal, cast -from urllib.parse import unquote, urlsplit +from urllib.parse import parse_qsl, unquote, urlsplit from weakref import WeakKeyDictionary, WeakSet import aiosqlite @@ -67,9 +68,15 @@ def require_async_sqlite(cls, value: str) -> str: @property def is_in_memory(self) -> bool: - """Return whether this profile has no persistent database file.""" + """Return whether this profile stores its database only in process memory.""" - return _is_memory_url(self.url) + return _storage_kind(self.url) == "memory" + + @property + def is_persistent(self) -> bool: + """Return whether data survives closing the last connection.""" + + return _storage_kind(self.url) == "persistent" class SQLiteProfile: @@ -92,11 +99,11 @@ async def open( _create_database_directory(config.url) engine_options: dict[str, object] = {"echo": config.echo, "hide_parameters": True} - if config.is_in_memory: + if not config.is_persistent: engine_options["poolclass"] = StaticPool engine = create_async_engine(config.url, **engine_options) _configure_sqlite(engine, config, load_vector_extension=load_vector_extension) - database = AsyncDatabase.own(engine, shared_connection=config.is_in_memory) + database = AsyncDatabase.own(engine, shared_connection=not config.is_persistent) profile = cls(database=database, tables=tables) try: await _warm_sqlite(engine, config) @@ -120,7 +127,7 @@ async def open_readonly( visible. Its shared-memory coordination may use the WAL/SHM sidecars. """ - if config.is_in_memory: + if not config.is_persistent: raise ValueError("read-only SQLite inspection requires a persistent database") # noqa: TRY003 engine = create_async_engine(_readonly_sqlite_url(config.url), echo=config.echo, hide_parameters=True) _configure_sqlite(engine, config, load_vector_extension=load_vector_extension, read_only=True) @@ -132,17 +139,26 @@ async def open_readonly( await database.close() -def _is_memory_url(value: str) -> bool: +def _storage_kind(value: str) -> Literal["memory", "temporary", "persistent"]: url = make_url(value) - database = url.database or "" - if database in {"", ":memory:"}: - return True - uri = str(url.query.get("uri", "false")).lower() in {"1", "true", "yes", "on", "t", "y"} - return ( - uri - and database.startswith("file:") - and (unquote(urlsplit(database).path) in {"", ":memory:"} or url.query.get("mode") == "memory") - ) + # Match the driver's effective URI flag and filename, including boolean aliases. + args, options = url.get_dialect()().create_connect_args(url) + filename = str(args[0]) + if filename == ":memory:": + return "memory" + if not filename: + return "temporary" + if options.get("uri") and filename.startswith("file:"): + # SQLite recognizes only lowercase file:, and does not strip literal URI controls. + uri = urlsplit(filename.replace("\t", "%09").replace("\n", "%0A").replace("\r", "%0D")) + # SQLite terminates decoded filenames and parameter strings at the first NUL. + path = unquote(uri.path).partition("\0")[0] + parameters = {name.partition("\0")[0]: value.partition("\0")[0] for name, value in parse_qsl(uri.query)} + if path == ":memory:" or parameters.get("mode") == "memory" or parameters.get("vfs") == "memdb": + return "memory" + if not path: + return "temporary" + return "persistent" def _readonly_sqlite_url(value: str) -> URL: @@ -155,7 +171,7 @@ def _readonly_sqlite_url(value: str) -> URL: def _create_database_directory(value: str) -> None: - if _is_memory_url(value): + if _storage_kind(value) != "persistent": return database = make_url(value).database if not database or database == ":memory:": @@ -263,7 +279,7 @@ async def _warm_sqlite(engine: AsyncEngine, config: SQLiteConfig) -> None: await connection.commit() except OperationalError as error: remaining = deadline - loop.time() - if not _database_is_locked(error) or remaining <= 0: + if not is_sqlite_lock_error(error) or remaining <= 0: raise await asyncio.sleep(min(_WARMUP_RETRY_SECONDS, remaining)) else: @@ -271,7 +287,7 @@ async def _warm_sqlite(engine: AsyncEngine, config: SQLiteConfig) -> None: def _warmup_lock(value: str) -> asyncio.Lock: - if _is_memory_url(value): + if _storage_kind(value) != "persistent": return asyncio.Lock() loop = asyncio.get_running_loop() locks = _WARMUP_LOCKS.setdefault(loop, {}) @@ -279,5 +295,11 @@ def _warmup_lock(value: str) -> asyncio.Lock: return locks.setdefault(key, asyncio.Lock()) -def _database_is_locked(error: OperationalError) -> bool: - return "database is locked" in str(error.orig).lower() +def is_sqlite_lock_error(error: OperationalError) -> bool: + """Recognize SQLite contention, including shared-cache table/schema locks.""" + + original = error.orig + return isinstance(original, sqlite3.OperationalError) and (getattr(original, "sqlite_errorcode", 0) & 0xFF) in ( + sqlite3.SQLITE_BUSY, + sqlite3.SQLITE_LOCKED, + ) diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 9bfca705c..8dbe0cfa0 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -138,6 +138,7 @@ ArtifactGovernance, ArtifactLifecycleState, ) +from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.records import ( @@ -437,6 +438,7 @@ class _RuntimeStateError(RuntimeError): def __init__(self, code: str) -> None: messages = { "closed": "Built-in Runtime is closed", + "database": "Primary database is not configured", "empty-write": "explicit Memory write did not produce a Memory", "experience-incubation": "Experience incubation is not configured", "external-skill-registry": "External Skill Registry is not configured", @@ -2936,6 +2938,7 @@ def __init__( *, provider: PowerContextProvider[BuiltinSources, BuiltinArtifacts, BuiltinTriggers], capabilities: RuntimeCapabilities, + primary_database: AsyncDatabase | None = None, extraction_diagnostics: ExtractionDiagnostics | None = None, code_service: CodeService | None = None, source_window_limit: int = 100, @@ -2999,6 +3002,7 @@ def __init__( if scope_cache_size < 1: raise _RuntimeConfigurationError("scope_cache_size") self._provider = provider + self._primary_database = primary_database self._capabilities = capabilities self._extraction_diagnostics = extraction_diagnostics self._review_service = review_service @@ -3111,6 +3115,14 @@ def __init__( async def __aenter__(self) -> BuiltinRuntime: return self + @property + def primary_database(self) -> AsyncDatabase: + """Borrow the primary database while the composed Runtime remains open.""" + + if self._primary_database is None: + raise _RuntimeStateError("database") + return self._primary_database + async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: await self.close() diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index a5da80e56..d9aa54733 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -682,6 +682,7 @@ async def run_profile(scope_id, high): BuiltinRuntime( code_service=await resources.enter_async_context(open_code_service(config.code, config.database)), provider=contexts, + primary_database=contexts.database, capabilities=RuntimeCapabilities( memory_extraction=contexts.memory_extraction, experience_generation=contexts.experience_generation, @@ -863,7 +864,7 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg or (family == "memory" and injected_memory_write_gate is not None) ): raise BuiltinConfigurationError("artifact-processing-child-resources") - if isinstance(config.database, SQLiteConfig) and config.database.is_in_memory: + if isinstance(config.database, SQLiteConfig) and not config.database.is_persistent: raise BuiltinConfigurationError("topic-memory-database") prefix = family.replace("-", "_") if family == "topic-memory": diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 50e1e169d..0f2fa8b5a 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -181,6 +181,7 @@ SearchMemoryResponse, SearchTopicMemoryRequest, SearchTopicMemoryResponse, + ServerInfo, SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, @@ -253,6 +254,7 @@ GET_PROMPT_CONFIGURATION, GET_READINESS, GET_SCOPE, + GET_SERVER_INFO, GET_SKILL, GET_SKILL_PACKAGE_MANIFEST, GET_SOURCE, @@ -400,6 +402,11 @@ async def get_capabilities(self) -> Capabilities: return await self._request(GET_CAPABILITIES) + async def get_server_info(self) -> ServerInfo: + """Read stable deployment identity and protocol compatibility metadata.""" + + return await self._request(GET_SERVER_INFO) + async def list_scopes( self, query: str | None = None, diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index dae3e44a6..11a64d879 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -114,6 +114,7 @@ ContextAssemblySection, ContextReference, ContinueHandoffRequest, + ContractVersion, CreateAccessBindingRequest, CreateArtifactRequest, CreateAtomicMemoryArtifactRequest, @@ -172,6 +173,7 @@ FailureSignature, FailureVerification, FamilyCount, + FeatureContract, FinalizeHandoffRequest, FlushMemoryRequest, FlushMemoryResponse, @@ -381,6 +383,7 @@ SearchTopicMemoryRequest, SearchTopicMemoryResponse, ServerAccessResource, + ServerInfo, SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, @@ -540,6 +543,7 @@ "ContextAssemblySection", "ContextReference", "ContinueHandoffRequest", + "ContractVersion", "CreateAccessBindingRequest", "CreateArtifactRequest", "CreateAtomicMemoryArtifactRequest", @@ -598,6 +602,7 @@ "FailureSignature", "FailureVerification", "FamilyCount", + "FeatureContract", "FinalizeHandoffRequest", "FlushMemoryRequest", "FlushMemoryResponse", @@ -807,6 +812,7 @@ "SearchTopicMemoryRequest", "SearchTopicMemoryResponse", "ServerAccessResource", + "ServerInfo", "SetDefaultScopeRequest", "SetScopeBindingRequest", "SkillArtifact", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 8e865a2aa..503de9b18 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -22,6 +22,42 @@ ) +class ContractVersion(BaseModel): + major: Annotated[StrictInt, Field(ge=1)] + minor: Annotated[StrictInt, Field(ge=0)] + + +class FeatureContract(BaseModel): + version: ContractVersion + operations: Annotated[list[StrictStr], Field(min_length=1)] + + +class Product(StrEnum): + POWERCONTEXT = "powercontext" + + +class ServerInfo(BaseModel): + schema_version: Annotated[ + ContractVersion, Field(description="Compatibility version for this response shape and field semantics.") + ] + product: Product + server_id: Annotated[ + StrictStr, + Field( + description="Opaque identity of the durable Server deployment. It is unrelated to Access deployment_id and remains stable across restarts, upgrades, backup restore and replicas sharing the same primary database.", + max_length=128, + min_length=1, + ), + ] + package_version: Annotated[StrictStr, Field(min_length=1)] + api_contract_version: Annotated[ + ContractVersion, Field(description="Major/minor projection of the OpenAPI info.version served by this package.") + ] + feature_contracts: Annotated[ + dict[str, FeatureContract], Field(description="Stable feature groups keyed by contract name.") + ] + + class AtomicMemoryState(StrEnum): ACTIVE = "active" FORGOTTEN = "forgotten" diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 5cbe61dc1..bfea1bf8f 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -173,6 +173,7 @@ SearchMemoryResponse, SearchTopicMemoryRequest, SearchTopicMemoryResponse, + ServerInfo, SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, @@ -195,6 +196,17 @@ API_TITLE = "PowerContext API" API_DESCRIPTION = "Remote PowerContext transport. Runtime behavior is reported by /v1/capabilities." API_VERSION = "1.2.0" +FEATURE_CONTRACTS: dict[str, dict[str, JsonValue]] = { + "access.principal": {"version": {"major": 1, "minor": 0}, "operations": ["get_access_principal"]}, + "scope.selection": { + "version": {"major": 1, "minor": 0}, + "operations": ["list_scopes", "get_scope", "get_default_scope"], + }, + "memory.explicit": { + "version": {"major": 1, "minor": 0}, + "operations": ["remember_memory", "search_memory", "get_memory_entry"], + }, +} RequestT = TypeVar("RequestT") ResponseT = TypeVar("ResponseT") @@ -572,6 +584,30 @@ class AccessRequirement(BaseModel): access=None, ) +GET_SERVER_INFO = Operation[None, ServerInfo]( + method="GET", + path="/v1/server-info", + operation_id="get_server_info", + request_type=None, + request_location=None, + path_parameters=(), + response_type=ServerInfo, + success_status=200, + summary="Get stable Server deployment identity and protocol contracts", + tags=("server",), + scope_mode="none", + responses={ + 200: { + "description": "Stable deployment identity and protocol compatibility metadata.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), +) + GET_CAPABILITIES = Operation[None, Capabilities]( method="GET", path="/v1/capabilities", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 125e7078e..2763e5fa4 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -436,6 +436,29 @@ "security": [], } }, + "/v1/server-info": { + "get": { + "tags": ["server"], + "summary": "Get stable Server deployment identity and protocol contracts", + "description": "Returns deployment identity and " + "compatibility metadata only. Runtime " + "availability, enabled behavior, limits and " + "inventory remain owned by health, " + "capabilities and statistics endpoints.", + "operationId": "get_server_info", + "responses": { + "200": { + "description": "Stable deployment identity and protocol compatibility metadata.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerInfo"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, + } + }, "/v1/capabilities": { "get": { "tags": ["capabilities"], @@ -476,6 +499,7 @@ "query parameters preserve the existing " "complete-list behavior.", "operationId": "list_scopes", + "x-powercontext-feature-contracts": ["scope.selection"], "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, "parameters": [ {"name": "query", "in": "query", "required": False, "schema": {"type": "string", "maxLength": 256}}, @@ -646,6 +670,7 @@ "its Memory, or authorize cross-Scope " "access.", "operationId": "get_scope", + "x-powercontext-feature-contracts": ["scope.selection"], "x-powercontext-access": {"resolver": "path_scope_read_access"}, "parameters": [ { @@ -721,6 +746,7 @@ "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, + "x-powercontext-feature-contracts": ["scope.selection"], "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, }, "put": { @@ -1928,6 +1954,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-feature-contracts": ["memory.explicit"], "x-powercontext-access": { "action": "scope.contribute", "resource": {"type": "scope", "scope-id-from": "scope_id"}, @@ -1965,6 +1992,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-feature-contracts": ["memory.explicit"], "x-powercontext-access": { "action": "scope.read", "resource": {"type": "scope", "scope-id-from": "scope_id"}, @@ -2075,6 +2103,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-feature-contracts": ["memory.explicit"], "x-powercontext-access": {"resolver": "exact_memory_access"}, "x-powercontext-scope-mode": "current", } @@ -4717,6 +4746,7 @@ "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, + "x-powercontext-feature-contracts": ["access.principal"], "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, @@ -4933,6 +4963,101 @@ }, "components": { "schemas": { + "ContractVersion": { + "properties": { + "major": {"type": "integer", "minimum": 1.0}, + "minor": {"type": "integer", "minimum": 0.0}, + }, + "type": "object", + "required": ["major", "minor"], + "description": "A major/minor compatibility " + "version. A major increment may " + "remove or incompatibly change the " + "governed contract. A minor " + "increment only adds " + "backward-compatible behavior or " + "fields.", + }, + "FeatureContract": { + "properties": { + "version": {"$ref": "#/components/schemas/ContractVersion"}, + "operations": {"items": {"type": "string"}, "type": "array", "minItems": 1, "uniqueItems": True}, + }, + "type": "object", + "required": ["version", "operations"], + "description": "Compatibility version for exactly " + "the listed OpenAPI operation IDs. " + "Adding operations or compatible " + "semantics increments minor; " + "removing, renaming or incompatibly " + "changing a listed operation " + "increments major.", + }, + "ServerInfo": { + "properties": { + "schema_version": { + "allOf": [{"$ref": "#/components/schemas/ContractVersion"}], + "description": "Compatibility version for this response shape and field semantics.", + }, + "product": {"type": "string", "enum": ["powercontext"]}, + "server_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "Opaque " + "identity " + "of the " + "durable " + "Server " + "deployment. " + "It is " + "unrelated " + "to Access " + "deployment_id " + "and " + "remains " + "stable " + "across " + "restarts, " + "upgrades, " + "backup " + "restore " + "and " + "replicas " + "sharing " + "the same " + "primary " + "database.", + }, + "package_version": {"type": "string", "minLength": 1}, + "api_contract_version": { + "allOf": [{"$ref": "#/components/schemas/ContractVersion"}], + "description": "Major/minor projection of the OpenAPI info.version served by this package.", + }, + "feature_contracts": { + "additionalProperties": {"$ref": "#/components/schemas/FeatureContract"}, + "type": "object", + "description": "Stable feature groups keyed by contract name.", + }, + }, + "type": "object", + "required": [ + "schema_version", + "product", + "server_id", + "package_version", + "api_contract_version", + "feature_contracts", + ], + "description": "Stable deployment identity and protocol " + "compatibility metadata. Compatible " + "clients must ignore unknown optional " + "fields added by a future schema minor " + "version. This contract intentionally " + "excludes runtime capabilities, health, " + "limits, inventory and " + "authorization-principal identity.", + }, "AtomicMemoryState": {"type": "string", "enum": ["active", "forgotten", "merged", "retired"]}, "AtomicMemoryWriteContent": { "properties": { @@ -11082,4 +11207,9 @@ }, }, "security": [{"BearerAuth": []}, {}], + "x-powercontext-feature-contracts": { + "access.principal": {"major": 1, "minor": 0}, + "scope.selection": {"major": 1, "minor": 0}, + "memory.explicit": {"major": 1, "minor": 0}, + }, } diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 3394b3cb3..7bb45d97b 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -543,6 +543,7 @@ SearchTopicMemoryRequest, SearchTopicMemoryResponse, ServerAccessResource, + ServerInfo, SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, @@ -689,6 +690,7 @@ GET_PROMPT_CONFIGURATION, GET_READINESS, GET_SCOPE, + GET_SERVER_INFO, GET_SKILL, GET_SKILL_PACKAGE_MANIFEST, GET_SOURCE, @@ -1288,6 +1290,7 @@ def create_app( search_modes=[], context_versions=[], ) + app.state.server_info = None @app.middleware("http") async def attach_request_id(request: Request, call_next: RequestResponseEndpoint) -> Response: @@ -1353,6 +1356,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, LIST_DREAM_RUNS, list_dream_runs) _add_route(app, GET_LIVENESS, get_liveness) _add_route(app, GET_READINESS, get_readiness) + _add_route(app, GET_SERVER_INFO, get_server_info) _add_route(app, GET_CAPABILITIES, get_capabilities) _add_route(app, LIST_SCOPES, list_scopes) _add_route(app, CREATE_SCOPE, create_scope) @@ -1559,6 +1563,13 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities +async def get_server_info(request: Request) -> ServerInfo: + info: ServerInfo | None = request.app.state.server_info + if info is None: + raise _RuntimeNotReadyError + return info + + async def get_access_principal(request: Request) -> AccessMeResponse: access = _require_access_control(request) provider = access.provider_capabilities diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 6be51e753..45f7e9592 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -53,6 +53,7 @@ ) from powercontext.server.database_migration import app as database_migration_app from powercontext.server.factory import create_server_app +from powercontext.server.identity import open_server_identity_repository from powercontext.server.logging import configure_server_logging from powercontext.server.processing_security import build_worker_security from powercontext.server.settings import ( @@ -140,7 +141,7 @@ async def _processing_maintenance( manifest = canonical_processing_manifest(config) database = settings.database if isinstance(database, SQLiteConfig): - if database.is_in_memory: + if not database.is_persistent: raise typer.BadParameter("offline migration requires a persistent database") # noqa: TRY003 opened = SQLiteProfile.open(database, tables=()) elif isinstance(database, OceanBaseConfig): @@ -172,6 +173,32 @@ async def _processing_maintenance( return verification.ready +@app.command("identity-reset") +def identity_reset( + env_file: Annotated[Path | None, typer.Option(help="Load deployment settings from this environment file.")] = None, + maintenance_confirmed: Annotated[ + bool, + typer.Option(help="Confirm every Server process using the primary database is stopped."), + ] = False, +) -> None: + """Rotate the durable Server identity after cloning a stopped deployment.""" + + if not maintenance_confirmed: + raise typer.BadParameter( # noqa: TRY003 + "identity reset requires --maintenance-confirmed after stopping every Server process" + ) + with server_settings_context(env_file=env_file) as settings: + if isinstance(settings.database, SQLiteConfig) and not settings.database.is_persistent: + raise typer.BadParameter("identity reset requires a persistent database") # noqa: TRY003 + server_id = asyncio.run(_reset_server_identity(settings)) + typer.echo(server_id) + + +async def _reset_server_identity(settings: ServerSettings) -> str: + async with open_server_identity_repository(settings.database) as repository: + return await repository.rotate() + + @app.command("atomic-memory-migrate") def atomic_memory_migrate( action: Annotated[ @@ -264,7 +291,7 @@ async def _atomic_memory_maintenance( database = settings.database read_only = action in {"plan", "verify"} if isinstance(database, SQLiteConfig): - if database.is_in_memory: + if not database.is_persistent: raise typer.BadParameter("offline migration requires a persistent database") # noqa: TRY003 opened = ( SQLiteProfile.open_readonly(database, load_vector_extension=embedding_profile is not None) diff --git a/src/powercontext/server/cursor_secret.py b/src/powercontext/server/cursor_secret.py index be8815fff..cdb668dc7 100644 --- a/src/powercontext/server/cursor_secret.py +++ b/src/powercontext/server/cursor_secret.py @@ -35,7 +35,7 @@ def resolve_cursor_secret(database: DatabaseConfig, configured_secret: str | Non if configured_secret is not None: return configured_secret.encode() if isinstance(database, SQLiteConfig): - if database.is_in_memory: + if not database.is_persistent: return None database_name = make_url(database.url).database if database_name: diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 767ec0c8f..e881db7a9 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -78,6 +78,8 @@ from powercontext.server.cursor_secret import resolve_cursor_secret from powercontext.server.dashboard import mount_dashboard from powercontext.server.dream_access import DreamAccess +from powercontext.server.identity import ServerIdentityRepository +from powercontext.server.info import server_info from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import AuthenticationMiddleware @@ -244,6 +246,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ), ) ) + identity_repository = ServerIdentityRepository(runtime.primary_database) + await identity_repository.initialize() + server_id = await identity_repository.load_or_create() _bind_evidence_access(runtime, active_access_control, resolved.access.mode) if active_access_control is not None: migrated, unresolved = await runtime._records().migrate_handoff_receipts( @@ -272,6 +277,7 @@ def current_capabilities() -> Capabilities: app.state.capabilities = capabilities app.state.capability_provider = current_capabilities + app.state.server_info = server_info(server_id) await readiness_probe() try: yield @@ -282,6 +288,7 @@ def current_capabilities() -> Capabilities: app.state.capability_provider = None app.state.access_control = configured_access_control app.state.authentication_provider = configured_authentication + app.state.server_info = None app.state.capabilities = Capabilities( source_types=[], artifact_families=[], diff --git a/src/powercontext/server/identity.py b/src/powercontext/server/identity.py new file mode 100644 index 000000000..f98bcc50f --- /dev/null +++ b/src/powercontext/server/identity.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Durable identity for one configured Server deployment.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import TypeVar +from uuid import uuid4 + +from sqlalchemy import CheckConstraint, Column, Integer, MetaData, String, Table, insert, select, update +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.schema import CreateTable + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile, is_sqlite_lock_error +from powercontext.builtin.runtime.config import DatabaseConfig + +_IDENTITY_METADATA = MetaData() +_SINGLETON_KEY = 1 +_INITIALIZATION_TIMEOUT_SECONDS = 5.0 +_LOCK_RETRY_SECONDS = 0.05 +_T = TypeVar("_T") + +SERVER_IDENTITY_TABLE = Table( + "pc_server_identity", + _IDENTITY_METADATA, + Column("singleton_key", Integer, primary_key=True, autoincrement=False), + Column("server_id", String(36), nullable=False, unique=True), + CheckConstraint("singleton_key = 1", name="ck_pc_server_identity_singleton"), +) + + +class ServerIdentityRepository: + """Own the singleton deployment identity in the primary relational backend.""" + + def __init__(self, database: AsyncDatabase) -> None: + self._database = database + + async def initialize(self) -> None: + """Create the identity schema safely across concurrent initializers.""" + + await _retry_initialization(self._initialize) + + async def _initialize(self) -> None: + async with self._database.transaction() as connection: + await connection.execute(CreateTable(SERVER_IDENTITY_TABLE, if_not_exists=True)) + + async def load_or_create(self) -> str: + """Return the durable identity, creating it once across concurrent replicas.""" + + return await _retry_initialization(self._load_or_create) + + async def _load_or_create(self) -> str: + server_id = await self._load() + if server_id is not None: + return server_id + + candidate = str(uuid4()) + try: + async with self._database.transaction() as connection: + await connection.execute( + insert(SERVER_IDENTITY_TABLE).values(singleton_key=_SINGLETON_KEY, server_id=candidate) + ) + except IntegrityError: + # A concurrent initializer may have committed the singleton first. + server_id = await self._load() + if server_id is None: + raise + return server_id + return candidate + + async def rotate(self) -> str: + """Replace the identity during an operator-confirmed offline clone procedure.""" + + candidate = str(uuid4()) + async with self._database.transaction() as connection: + result = await connection.execute( + update(SERVER_IDENTITY_TABLE) + .where(SERVER_IDENTITY_TABLE.c.singleton_key == _SINGLETON_KEY) + .values(server_id=candidate) + ) + if result.rowcount == 0: + await connection.execute( + insert(SERVER_IDENTITY_TABLE).values(singleton_key=_SINGLETON_KEY, server_id=candidate) + ) + return candidate + + async def _load(self) -> str | None: + async with self._database.transaction() as connection: + result = await connection.execute( + select(SERVER_IDENTITY_TABLE.c.server_id).where(SERVER_IDENTITY_TABLE.c.singleton_key == _SINGLETON_KEY) + ) + value = result.scalar_one_or_none() + return None if value is None else str(value) + + +async def _retry_initialization(operation: Callable[[], Awaitable[_T]]) -> _T: + # SQLite's busy timeout does not wait for shared-cache table/schema locks. + # Replay only these rolled-back, idempotent initialization operations. + loop = asyncio.get_running_loop() + deadline = loop.time() + _INITIALIZATION_TIMEOUT_SECONDS + while True: + try: + return await operation() + except OperationalError as error: + remaining = deadline - loop.time() + if not is_sqlite_lock_error(error) or remaining <= 0: + raise + await asyncio.sleep(min(_LOCK_RETRY_SECONDS, remaining)) + if loop.time() >= deadline: + raise + + +@asynccontextmanager +async def open_server_identity_repository(config: DatabaseConfig) -> AsyncIterator[ServerIdentityRepository]: + """Open the configured primary database with only the Server identity schema.""" + + tables: tuple[Table, ...] = () + if isinstance(config, SQLiteConfig): + opened = SQLiteProfile.open(config, tables=tables) + elif isinstance(config, OceanBaseConfig): + opened = OceanBaseProfile.open(config, tables=tables) + elif isinstance(config, SeekDBConfig): + opened = SeekDBProfile.open(config, tables=tables) + else: + raise TypeError(f"unsupported Server identity database: {type(config).__name__}") # noqa: TRY003 + async with opened as profile: + repository = ServerIdentityRepository(profile.database) + await repository.initialize() + yield repository + + +__all__ = ("SERVER_IDENTITY_TABLE", "ServerIdentityRepository", "open_server_identity_repository") diff --git a/src/powercontext/server/info.py b/src/powercontext/server/info.py new file mode 100644 index 000000000..fcb1ddcc0 --- /dev/null +++ b/src/powercontext/server/info.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Project the generated discovery contract for the PowerContext Server.""" + +from __future__ import annotations + +from importlib.metadata import version + +from packaging.version import Version + +from powercontext.http import ServerInfo +from powercontext.http._generated.operations import API_VERSION, FEATURE_CONTRACTS + +_SCHEMA_VERSION = {"major": 1, "minor": 0} + + +def server_info(server_id: str) -> ServerInfo: + """Build discovery metadata from the generated API contract and installed package.""" + + api_version = Version(API_VERSION) + return ServerInfo.model_validate({ + "schema_version": _SCHEMA_VERSION, + "product": "powercontext", + "server_id": server_id, + "package_version": version("powercontext"), + "api_contract_version": {"major": api_version.major, "minor": api_version.minor}, + "feature_contracts": FEATURE_CONTRACTS, + }) + + +__all__ = ("server_info",) diff --git a/tests/builtin/persistence/test_sqlite_profile.py b/tests/builtin/persistence/test_sqlite_profile.py index 97a7bbcbc..7dfa29ea9 100644 --- a/tests/builtin/persistence/test_sqlite_profile.py +++ b/tests/builtin/persistence/test_sqlite_profile.py @@ -389,6 +389,73 @@ def test_sqlite_config_requires_the_async_dialect() -> None: SQLiteConfig(url="sqlite:///:memory:") +@pytest.mark.parametrize( + "url", + [ + "sqlite+aiosqlite:///:memory:", + "sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared&uri=true", + "sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared&uri=1", + "sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared&uri=yes", + "sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared&uri=on", + "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true", + "sqlite+aiosqlite:///file:%3Amemory%3A?cache=shared&uri=true", + "sqlite+aiosqlite:///file:%3Amemory%3A%00tail?uri=true", + "sqlite+aiosqlite:///file:deployment?mode=memory%2500tail&uri=true", + "sqlite+aiosqlite:///file:deployment?mode%2500tail=memory&uri=true", + "sqlite+aiosqlite:///file:deployment?mode=rwc&mode%2500tail=memory&uri=true", + "sqlite+aiosqlite:///file:deployment?vfs=memdb&uri=true", + ], +) +def test_sqlite_config_recognizes_memory_urls(url: str) -> None: + config = SQLiteConfig(url=url) + assert config.is_in_memory + assert not config.is_persistent + + +@pytest.mark.parametrize("uri", ["", "&uri=false", "&uri=0", "&uri=no", "&uri=off"]) +def test_sqlite_config_does_not_treat_uri_like_filename_as_memory_without_uri_mode(uri: str) -> None: + assert not SQLiteConfig(url=f"sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared{uri}").is_in_memory + + +@pytest.mark.parametrize( + "filename", + [ + "FILE:deployment?mode=memory", + " file:deployment?mode=memory", + "file::mem\nory:", + "file:deployment?mode=memory&mode%2500tail=rwc", + ], +) +def test_sqlite_config_preserves_persistent_targets(filename: str) -> None: + separator = "&" if "?" in filename else "?" + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{filename}{separator}uri=true") + assert config.is_persistent + assert not config.is_in_memory + + +@pytest.mark.parametrize("uri", ["file:?uri=true", "file:?cache=shared&uri=true", "file:%00tail?uri=true"]) +def test_temporary_sqlite_database_is_connection_local_and_not_persistent(uri: str) -> None: + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{uri}") + assert not config.is_in_memory + assert not config.is_persistent + async with SQLiteProfile.open(config, tables=()) as profile: + async with profile.database.transaction() as connection: + await connection.exec_driver_sql("CREATE TABLE probe (value INTEGER)") + await connection.exec_driver_sql("INSERT INTO probe VALUES (1)") + async with profile.database.transaction() as connection: + assert (await connection.exec_driver_sql("SELECT value FROM probe")).scalar_one() == 1 + async with ( + SQLiteProfile.open(config, tables=()) as reopened, + reopened.database.transaction() as connection, + ): + assert ( + await connection.exec_driver_sql("SELECT count(*) FROM sqlite_master WHERE name = 'probe'") + ).scalar_one() == 0 + + asyncio.run(scenario()) + + def test_sqlite_profile_creates_a_missing_database_directory(tmp_path) -> None: async def scenario() -> None: database = tmp_path / "nested" / "powercontext.db" diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index d68e274f1..301d2ced7 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -156,6 +156,7 @@ ) from powercontext.server.app import create_app from powercontext.server.factory import create_server_app +from powercontext.server.info import server_info from powercontext.server.settings import HandoffReportConfig, ServerSettings @@ -184,6 +185,47 @@ def test_contract_uses_the_namespaced_request_id_header() -> None: assert "X-Request-ID" not in contract +def test_server_info_contract_is_observable_and_forward_compatible() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + + assert contract["info"]["version"] == "1.2.0" + operation = contract["paths"]["/v1/server-info"]["get"] + assert operation["operationId"] == "get_server_info" + assert operation["x-powercontext-access"] == { + "action": "server.observe", + "resource": {"type": "server"}, + } + schema = contract["components"]["schemas"]["ServerInfo"] + assert schema["required"] == [ + "schema_version", + "product", + "server_id", + "package_version", + "api_contract_version", + "feature_contracts", + ] + assert "additionalProperties" not in schema + + +def test_server_info_feature_contracts_match_openapi_versions_and_membership() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + expected = { + name: { + "version": version, + "operations": [ + operation["operationId"] + for path_item in contract["paths"].values() + for operation in path_item.values() + if name in operation.get("x-powercontext-feature-contracts", []) + ], + } + for name, version in contract["x-powercontext-feature-contracts"].items() + } + assert { + name: feature.model_dump() for name, feature in server_info("server-a").feature_contracts.items() + } == expected + + def test_contract_declares_server_and_remote_target_bearer_boundaries() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) diff --git a/tests/test_api_generation.py b/tests/test_api_generation.py index ab16bb7e6..461d0ae26 100644 --- a/tests/test_api_generation.py +++ b/tests/test_api_generation.py @@ -14,9 +14,12 @@ from __future__ import annotations +import ast import importlib.util from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -68,3 +71,78 @@ def test_path_and_header_parameters_do_not_create_a_query_model_for_no_content_s assert "request_location=None" in source assert "response_type=None" in source assert "success_status=204" in source + + +@pytest.fixture +def feature_contract(): + return { + "openapi": "3.0.3", + "info": {"title": "Test API", "version": "1.0.0"}, + "x-powercontext-feature-contracts": { + "scope.selection": {"major": 1, "minor": 0}, + "memory.explicit": {"major": 1, "minor": 0}, + }, + "paths": { + path: { + "get": { + "summary": operation_id, + "operationId": operation_id, + "responses": {"204": {"description": "Done."}}, + "x-powercontext-feature-contracts": [feature], + } + } + for path, operation_id, feature in ( + ("/scopes", "list_scopes", "scope.selection"), + ("/memory", "search_memory", "memory.explicit"), + ) + }, + } + + +def test_generation_projects_explicit_feature_versions_and_operation_membership(feature_contract) -> None: + generator = _load_generator() + feature_contract["x-powercontext-feature-contracts"]["scope.selection"]["minor"] = 2 + feature_contract["paths"]["/memory"]["get"]["x-powercontext-feature-contracts"] = [ + "scope.selection", + "memory.explicit", + ] + source = generator._generate_operations(generator.OpenAPI.model_validate(feature_contract), {}) + assignment = next( + node + for node in ast.parse(source).body + if isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == "FEATURE_CONTRACTS" + ) + assert assignment.value is not None + assert ast.literal_eval(assignment.value) == { + "scope.selection": {"version": {"major": 1, "minor": 2}, "operations": ["list_scopes", "search_memory"]}, + "memory.explicit": {"version": {"major": 1, "minor": 0}, "operations": ["search_memory"]}, + } + + +@pytest.mark.parametrize( + ("invalid", "value"), + [ + ("major", 0), + ("major", -1), + ("major", True), + ("minor", -1), + ("minor", True), + ("membership", ["undefined.feature"]), + ("membership", "scope.selection"), + ("membership", ["scope.selection", "scope.selection"]), + ("unused", {"major": 1, "minor": 0}), + ], +) +def test_generation_rejects_invalid_feature_contract_declarations(feature_contract, invalid, value) -> None: + generator = _load_generator() + if invalid in {"major", "minor"}: + feature_contract["x-powercontext-feature-contracts"]["scope.selection"][invalid] = value + elif invalid == "membership": + feature_contract["paths"]["/scopes"]["get"]["x-powercontext-feature-contracts"] = value + else: + feature_contract["x-powercontext-feature-contracts"]["unused"] = value + + with pytest.raises(generator.ContractGenerationError, match="feature"): + generator._generate_operations(generator.OpenAPI.model_validate(feature_contract), {}) diff --git a/tests/test_cli.py b/tests/test_cli.py index dc0ec5e7b..222ee251f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import json import os import subprocess @@ -28,6 +29,7 @@ from typer.testing import CliRunner import powercontext.client.cli as client_cli +from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.cli.app import create_cli from powercontext.client import ServerResponseError from powercontext.client.receiver_service import ReceiverServiceInstallation @@ -68,6 +70,7 @@ UnpublishRemoteSkillRequest, ) from powercontext.server.cli import app as server_app +from powercontext.server.identity import open_server_identity_repository def _empty_inventory() -> dict[str, object]: @@ -629,6 +632,60 @@ def test_cli_exposes_installed_role_commands() -> None: assert "client" not in result.output +def test_server_identity_reset_requires_offline_confirmation_and_rotates(tmp_path) -> None: + database = tmp_path / "runtime.db" + environment = tmp_path / "server.env" + environment.write_text(f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{database}\n") + cli = create_cli([server_app]) + + refused = CliRunner().invoke(cli, ["server", "identity-reset", "--env-file", str(environment)]) + assert refused.exit_code == 2 + assert not database.exists() + + first = CliRunner().invoke( + cli, + ["server", "identity-reset", "--env-file", str(environment), "--maintenance-confirmed"], + ) + second = CliRunner().invoke( + cli, + ["server", "identity-reset", "--env-file", str(environment), "--maintenance-confirmed"], + ) + + assert first.exit_code == second.exit_code == 0 + assert first.output.strip() != second.output.strip() + + async def readback() -> str: + async with open_server_identity_repository(SQLiteConfig(url=f"sqlite+aiosqlite:///{database}")) as repository: + return await repository.load_or_create() + + assert asyncio.run(readback()) == second.output.strip() + + +@pytest.mark.parametrize( + "url", + [ + "sqlite+aiosqlite:///:memory:", + "sqlite+aiosqlite:///file:deployment?mode=memory&cache=shared&uri=1", + "sqlite+aiosqlite:///file:?uri=true", + "sqlite+aiosqlite:///file:?cache=shared&uri=true", + "sqlite+aiosqlite:///file:%00tail?uri=true", + "sqlite+aiosqlite:///file:deployment?mode=memory%2500tail&uri=true", + "sqlite+aiosqlite:///file:deployment?vfs=memdb&uri=true", + ], +) +def test_server_identity_reset_rejects_nonpersistent_sqlite(tmp_path, url: str) -> None: + environment = tmp_path / "server.env" + environment.write_text(f"POWERCONTEXT_SERVER_DATABASE_URL={url}\n") + + result = CliRunner().invoke( + create_cli([server_app]), + ["server", "identity-reset", "--env-file", str(environment), "--maintenance-confirmed"], + ) + + assert result.exit_code == 2 + assert "identity reset requires a persistent database" in result.output + + def test_service_command_provider_requires_the_complete_server_role() -> None: script = """ import builtins diff --git a/tests/test_client.py b/tests/test_client.py index 6963e9108..cf2e158e5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -65,6 +65,38 @@ ) +def test_client_reads_server_info_and_ignores_future_optional_fields() -> None: + async def scenario() -> None: + def respond(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/server-info" + return httpx.Response( + 200, + json={ + "schema_version": {"major": 1, "minor": 0}, + "product": "powercontext", + "server_id": "server-a", + "package_version": "1.2.3", + "api_contract_version": {"major": 1, "minor": 2}, + "feature_contracts": { + "access.principal": { + "version": {"major": 1, "minor": 0}, + "operations": ["get_access_principal"], + } + }, + "future_optional_field": True, + }, + request=request, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + info = await PowerContextClient("https://memory.example", http_client=http_client).get_server_info() + + assert info.server_id == "server-a" + assert info.api_contract_version.minor == 2 + + asyncio.run(scenario()) + + @pytest.mark.parametrize( "selection", [ diff --git a/tests/test_server.py b/tests/test_server.py index 320fa38c5..cc69ba046 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -274,6 +274,68 @@ def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path, monke assert len(second_page.json()["items"]) == 1 +def test_server_info_uses_one_durable_identity_across_restarts(tmp_path) -> None: + settings = ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + + with TestClient(create_server_app(settings=settings)) as client: + first = client.get("/v1/server-info") + with TestClient(create_server_app(settings=settings)) as client: + second = client.get("/v1/server-info") + + assert first.status_code == second.status_code == 200 + assert first.json() == second.json() + assert first.json()["schema_version"] == {"major": 1, "minor": 0} + assert first.json()["api_contract_version"] == {"major": 1, "minor": 2} + + +def test_server_startup_fails_when_identity_initialization_fails(tmp_path, monkeypatch) -> None: + async def unavailable_identity(_repository): + raise OSError("identity backend unavailable") # noqa: TRY003 + + monkeypatch.setattr("powercontext.server.identity.ServerIdentityRepository.initialize", unavailable_identity) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + ) + + with pytest.raises(OSError, match="identity backend unavailable"), TestClient(app): + pass + + +def test_servers_sharing_memory_scopes_share_identity_for_the_database_lifetime(tmp_path) -> None: + settings = ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///file:{tmp_path / 'shared'}?mode=memory&cache=shared&uri=true"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + + with TestClient(create_server_app(settings=settings)) as first: + original_id = first.get("/v1/server-info").json()["server_id"] + created = first.post( + "/v1/scopes", + json={"title": "Shared Scope", "summary": "Shared deployment data", "idempotency_key": "shared-scope"}, + ) + assert created.status_code == 201 + scope_id = created.json()["scope_id"] + with TestClient(create_server_app(settings=settings)) as second: + assert second.get(f"/v1/scopes/{scope_id}").json() == created.json() + assert second.get("/v1/server-info").json()["server_id"] == original_id + + assert first.get("/v1/server-info").json()["server_id"] == original_id + assert first.get(f"/v1/scopes/{scope_id}").status_code == 200 + + with TestClient(create_server_app(settings=settings)) as reopened: + assert reopened.get(f"/v1/scopes/{scope_id}").status_code == 404 + assert reopened.get("/v1/server-info").json()["server_id"] != original_id + + def test_server_settings_use_configured_workspace_for_default_skill_targets(tmp_path, monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_WORKSPACE", str(tmp_path)) monkeypatch.delenv("POWERCONTEXT_SERVER_EXTERNAL_SKILLS", raising=False) @@ -582,6 +644,8 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: missing = client.get("/v1/capabilities") invalid = client.get("/v1/capabilities", headers={"Authorization": "Bearer wrong"}) accepted = client.get("/v1/capabilities", headers={"Authorization": "Bearer server-secret"}) + protected_server_info = client.get("/v1/server-info") + accepted_server_info = client.get("/v1/server-info", headers={"Authorization": "Bearer server-secret"}) protected_metrics = client.get("/metrics") accepted_metrics = client.get("/metrics", headers={"Authorization": "Bearer server-secret"}) liveness = client.get("/health/live") @@ -599,6 +663,8 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: } assert invalid.status_code == 401 assert accepted.status_code == 200 + assert protected_server_info.status_code == 401 + assert accepted_server_info.status_code == 200 assert protected_metrics.status_code == 401 assert accepted_metrics.status_code == 200 assert liveness.status_code == 200 diff --git a/tests/test_server_identity.py b/tests/test_server_identity.py new file mode 100644 index 000000000..845b27a3b --- /dev/null +++ b/tests/test_server_identity.py @@ -0,0 +1,141 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import shutil +from contextlib import AsyncExitStack + +import pytest +from sqlalchemy import event, insert +from sqlalchemy.dialects import mysql +from sqlalchemy.exc import OperationalError +from sqlalchemy.schema import CreateTable + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.server.identity import ( + SERVER_IDENTITY_TABLE, + ServerIdentityRepository, + open_server_identity_repository, +) + + +def test_server_identity_singleton_key_is_not_auto_incremented_by_mysql_profiles() -> None: + ddl = str(CreateTable(SERVER_IDENTITY_TABLE).compile(dialect=mysql.dialect())) + + assert "AUTO_INCREMENT" not in ddl + assert "CHECK (singleton_key = 1)" in ddl + + +def test_server_identity_survives_reopen_restore_and_explicit_clone_rotation(tmp_path) -> None: + async def scenario() -> None: + primary_path = tmp_path / "primary.db" + restored_path = tmp_path / "restored.db" + primary = SQLiteConfig(url=f"sqlite+aiosqlite:///{primary_path}") + + async with open_server_identity_repository(primary) as repository: + original = await repository.load_or_create() + assert await repository.load_or_create() == original + + shutil.copy2(primary_path, restored_path) + restored = SQLiteConfig(url=f"sqlite+aiosqlite:///{restored_path}") + async with open_server_identity_repository(restored) as repository: + assert await repository.load_or_create() == original + rotated = await repository.rotate() + assert rotated != original + assert await repository.load_or_create() == rotated + + async with open_server_identity_repository(primary) as repository: + assert await repository.load_or_create() == original + + asyncio.run(scenario()) + + +def test_concurrent_server_initializers_converge_on_one_identity(tmp_path) -> None: + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'deployment.db'}") + + async def initialize() -> str: + async with open_server_identity_repository(config) as repository: + return await repository.load_or_create() + + identities = await asyncio.gather(*(initialize() for _ in range(8))) + assert len(set(identities)) == 1 + + asyncio.run(scenario()) + + +def test_shared_memory_initializers_converge_on_one_identity(tmp_path) -> None: + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///file:{tmp_path / 'identity'}?mode=memory&cache=shared&uri=true") + async with AsyncExitStack() as resources: + repositories = [ + await resources.enter_async_context(open_server_identity_repository(config)) for _ in range(8) + ] + identities = await asyncio.gather(*(repository.load_or_create() for repository in repositories)) + assert len(set(identities)) == 1 + assert await repositories[0].load_or_create() == identities[0] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ["schema", "read"]) +def test_identity_initialization_recovers_from_locks_but_exhaustion_still_fails(tmp_path, monkeypatch, phase) -> None: + async def scenario() -> None: + config = SQLiteConfig( + url=f"sqlite+aiosqlite:///file:{tmp_path / 'locked'}?mode=memory&cache=shared&uri=true", + busy_timeout_ms=0, + ) + async with ( + SQLiteProfile.open(config, tables=()) as owner, + SQLiteProfile.open(config, tables=()) as contender, + ): + repository = ServerIdentityRepository(contender.database) + if phase == "read": + await repository.initialize() + operation = repository.initialize if phase == "schema" else repository.load_or_create + contended = asyncio.Event() + + @event.listens_for(contender.database.engine.sync_engine, "handle_error") + def observed_lock(context) -> None: + contended.set() + + async with owner.database.transaction() as connection: + if phase == "schema": + await connection.exec_driver_sql("BEGIN IMMEDIATE") + await connection.exec_driver_sql("CREATE TABLE lock_probe (value INTEGER)") + else: + await connection.execute( + insert(SERVER_IDENTITY_TABLE).values(singleton_key=1, server_id="committed") + ) + with monkeypatch.context() as budget: + budget.setattr("powercontext.server.identity._INITIALIZATION_TIMEOUT_SECONDS", 0) + with pytest.raises(OperationalError, match="locked"): + await operation() + contended.clear() + pending = asyncio.create_task(operation()) + try: + await asyncio.wait_for(contended.wait(), 2) + except BaseException: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + raise + result = await pending + if phase == "read": + assert result == "committed" + else: + assert await repository.load_or_create() + + asyncio.run(scenario())