diff --git a/desktop/README.md b/desktop/README.md index b811d37e2e..9c88623920 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -172,7 +172,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For qualified current Server sources, explicitly choose `sqlite-atomic-dd62723d-v1` or `sqlite-atomic-4ffdf4e9-v1` according to the recorded Server sources and wheel, then check the connection before selecting a Scope. Both records match this bundled contract and retain their independently verified operation sets. Historical profiles, including `sqlite-atomic-c535d816-v1`, retain their original Server sources, contracts and operation sets and do not match this bundled contract. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For the qualified current Server sources, explicitly choose `sqlite-atomic-68491568-v1` and check the connection before selecting a Scope; that record's contract digest matches this build's bundled contract, while `sqlite-atomic-dd62723d-v1`, `sqlite-atomic-98275dbb-v1`, `sqlite-atomic-1da99747-v1`, `sqlite-atomic-8f10c560-v1`, `sqlite-atomic-d9a78567-v1`, `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` qualify their corresponding Server source deployments and do not match it. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index c6bf0d0fcb..e04dff40d5 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -155,7 +155,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已资格化的当前 Server 源码,请按记录的 Server 源码和 wheel 明确选择 `sqlite-atomic-dd62723d-v1` 或 `sqlite-atomic-4ffdf4e9-v1`,检查连接后再选择范围。两项记录均匹配本构建的契约,并保留各自独立验证的操作集合。包括 `sqlite-atomic-c535d816-v1` 在内的历史配置保留原有 Server 源码、契约和操作集合,不匹配本构建的打包契约。已记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。历史资格证据和剩余要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已资格化的当前 Server 源码,请按记录的 Server 源码和 wheel 明确选择 `sqlite-atomic-68491568-v1`,检查连接后再选择范围。该记录匹配本构建的契约,并保留独立验证的操作集合。包括 `sqlite-atomic-c535d816-v1` 在内的历史配置保留原有 Server 源码、契约和操作集合,不匹配本构建的打包契约。已记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。历史资格证据和剩余要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 4d90fa8c13..c684257b2d 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract digest matches `sqlite-atomic-cc5980f0-v1`, which records the ten protocol operations qualified below. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract matches `sqlite-atomic-0a49db9b-v1`, which qualifies the eleven protocol operations recorded below. Existing records retain their exact source and contract identities. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -61,6 +61,44 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server 0a49db9b protocol qualification + +`sqlite-atomic-0a49db9b-v1` qualifies Server sources at `0a49db9bf640703e1067ecda67650f20612d3427` +for OpenAPI digest `bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd`. +The wheel `powercontext-1.4.1.dev0+g0a49db9b-py3-none-any.whl` has SHA-256 +`8e57881de55502f99f44a6076bcffee2935f3d3666a00330664f4bcb0fb92150`. It was built from `git archive` of that commit with +`SETUPTOOLS_SCM_PRETEND_VERSION=1.4.1.dev0+g0a49db9b uv build --wheel`. +All 649 packaged runtime files match the archived sources byte for byte; +the sorted runtime path/content-digest manifest has SHA-256 +`da9bf44eb0b21cfa72cef3cc863e87322a4c1d98af3dd7409d94d0e9ba57d9a6`. + +On 2026-10-30, Linux x86_64, Python 3.12.12 and Rust 1.95.0 ran +`desktop/tests/real_server.py` against that wheel in independent SQLite Server processes. +Anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and +injected-provider/enforced access all passed. The eleven recorded operations include +Server discovery, health/readiness, principal/capabilities, Scope listing/detail/default, +Atomic Memory save/search and exact Artifact revision reads. The native context owner +also passed identity changes, same-identity revocation, committed writes with lost +responses without replay, Scope pagination and independent Server survival after client exit. + +The native probe compiled the unchanged product `connections`, `credentials`, `error`, +`session_observations` and `transport` modules and `server_probe.rs` in a standalone library. +Resolved dependency versions and checksums match the product Cargo lock. The standalone +library excludes the Tauri GUI shell; it links the host's installed D-Bus library. +This qualifies the stated SQLite protocol behavior, not Windows/macOS execution, +native credential persistence, installed GUI workflows, signing or release readiness. +Historical records retain their original identities and evidence. + +Reproduce using the archived wheel, native `server_probe` build, +`fixture_qualification.py --server-commit 0a49db9bf640703e1067ecda67650f20612d3427`, and +`real_server.py` as described below. Ignored evidence is retained in +`desktop/.artifacts/real-server.json` and `desktop/.artifacts/qualification-wheel-identity.json`. + +本记录绑定上述准确源码、契约和 wheel;649 个运行时文件逐一核对一致。 +四种真实 SQLite/HTTP 连接模式均已通过,覆盖包括 Server discovery 在内的十一项协议操作。 +原生探针使用未修改的产品协议模块及产品锁文件中的依赖版本。 +本次验收不覆盖 Windows/macOS 执行、原生凭据持久化、安装后 GUI 或发布验收。 + #### Server cc5980f0 protocol qualification `sqlite-atomic-cc5980f0-v1` qualifies Server sources at @@ -151,6 +189,40 @@ original source, contract and wheel identities. 原生探针通过匿名回环、Bearer、显式 CA/路径前缀 HTTPS 及强制访问控制四种真实 SQLite 协议验收。该记录仅覆盖所列十项操作,不包含 manifest 摘要、Windows、安装包或原生凭据库验收。 +#### Server 68491568 protocol qualification + +`sqlite-atomic-68491568-v1` qualifies Server sources at `68491568359fd9f5723208bbeeec2f808fd2bcc9` +for OpenAPI digest `b89e721f9fba1c4ef4267b4420af417e5a88a7f7fbe893f639df913b77d24dc0`. +The wheel is `powercontext-1.4.1.dev188+g684915683-py3-none-any.whl`, +SHA-256 `91c54cfd8551cc1590abcedb8bbd7bf9865ffc41667fee7b3ac015c249f8bbfb`. All 634 packaged runtime files +match the clean source commit byte for byte; the sorted runtime manifest digest is +`13a9fac57efe09f5f336f2bb5dcb82b07d2e161dcb31c267f3ef60247a6e805b` +(one `wheel/path sha256` line per file, with a final newline). + +On 2026-10-26, Linux x86_64, Python 3.12.12 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against this wheel and independent SQLite Servers. +All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, +and an injected provider with enforced access. The ten recorded operations cover Atomic Memory +save/search/exact reads, Scope selection and pagination, identity changes and revocation, +committed writes with lost responses without replay, and Server survival after client exit. + +The native client compiled the unchanged product `connections`, `credentials`, `error` and +`transport` modules and existing `server_probe.rs` in a standalone library, excluding the Tauri GUI +shell. Every resolved dependency version and checksum matches the product Cargo lock. +This qualifies the stated SQLite protocol behavior, not Windows/macOS execution, native credential +persistence, installed GUI workflows, signing or release readiness. Historical records retain their +original identities and evidence. + +Reproduce from the recorded source using the wheel build, native `server_probe` build, +`fixture_qualification.py --server-commit 68491568359fd9f5723208bbeeec2f808fd2bcc9`, and +`real_server.py` commands described below for Linux protocol-only qualification. +Ignored evidence is retained in `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. + +本记录绑定上述精确源码、契约和 wheel;四种真实 SQLite/HTTP 连接模式均已通过。 +原生客户端使用未修改的产品协议模块,依赖版本及校验和与产品 Cargo 锁文件一致。 +本次验收不覆盖 Windows/macOS 执行、原生凭据库、安装后 GUI 或发布验收。 + #### Server 4ffdf4e9 protocol qualification `sqlite-atomic-4ffdf4e9-v1` qualifies the v1.4.1 preparation sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 149d952b16..5c0f8605d9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -304,5 +304,44 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-c535d816-protocol-qualification" + }, + { + "id": "sqlite-atomic-68491568-v1", + "serverCommit": "68491568359fd9f5723208bbeeec2f808fd2bcc9", + "contractSha256": "b89e721f9fba1c4ef4267b4420af417e5a88a7f7fbe893f639df913b77d24dc0", + "artifactSha256": "91c54cfd8551cc1590abcedb8bbd7bf9865ffc41667fee7b3ac015c249f8bbfb", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-68491568-protocol-qualification" + }, + { + "id": "sqlite-atomic-0a49db9b-v1", + "serverCommit": "0a49db9bf640703e1067ecda67650f20612d3427", + "contractSha256": "bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd", + "artifactSha256": "8e57881de55502f99f44a6076bcffee2935f3d3666a00330664f4bcb0fb92150", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-0a49db9b-protocol-qualification" } ] diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 97d7c66898..9427a5963d 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d", + "contractSha256": "bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 3787e06727..48789d6742 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1888,7 +1888,7 @@ export interface paths { }; /** * List public Sources in one Scope - * @description List a snapshot-bounded page of public Content Sources in ascending journal position. + * @description List public Content Sources and registered Source Observations in one Scope in ascending journal position. Subsequent pages retain the Source type selection and journal upper bound recorded in the cursor. */ get: operations["list_sources"]; put?: never; @@ -2303,6 +2303,265 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/source-management/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** pc source capabilities */ + post: operations["pc_source_capabilities"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/add": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * pc source add + * @description Use the discovered write or collect capability and version. Collect capabilities require an explicit instance_id. Write runs in the Server using loaded Definition and Projection implementations, without an instance or management service. Write waits for Source references or an error in the original call; no durable request or status query is created. Collect establishes or reuses a persistent target and never resumes a stopped target. No mode or idempotency_key is accepted; write retries use the underlying Source identity rules. + */ + post: operations["pc_source_add"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * pc source status + * @description Query a collect target by scope_id and target_id. Result view requires scope.read and returns received Source references and submission time, not per-round completion. Control view requires scope.contribute and returns enabled and tracking without input or results. + */ + post: operations["pc_source_status"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/target-set-enabled": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stop or resume a collect target + * @description Set collect target enabled state using scope.contribute. Repeating the current state is safe. Resume requires stopped confirmation; it does not restart an ended apply or enable a disabled instance. No revision or retry key is required. + */ + post: operations["pc_source_target_set_enabled"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/instances": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * pc source instances + * @description Discover instances with scope_id from the Server capability catalog. Control view requires instance_id and server.admin, and returns enabled, actual and availability without business configuration. + */ + post: operations["pc_source_instances"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/instance-set-enabled": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * pc source instance set enabled + * @description Set instance business enabled state using server.admin. Repeating the same state does not restart apply. Restarting an ended run requires disable, confirmed stopped, then enable. The Runtime process stays online. + */ + post: operations["pc_source_instance_set_enabled"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/configure": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** source configure */ + post: operations["source_configure"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/write/load": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Load a trusted Server-local Source write plugin + * @description Requires server.admin. The Server imports the local directory or module, validates and registers its Source Definitions, and persists its entry for restart. An identical deployment is reusable; conflicting configurations fail. Existing code is replaced only through controlled Server restart. + */ + post: operations["load_source_write_plugin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/channel/open": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Source manager reverse channel open */ + post: operations["source_channel_open"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/channel/catalog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Source manager reverse channel catalog */ + post: operations["source_channel_catalog"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/channel/poll": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Source manager reverse channel poll */ + post: operations["source_channel_poll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/channel/reply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Source manager reverse channel reply */ + post: operations["source_channel_reply"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/channel/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Source manager reverse channel close */ + post: operations["source_channel_close"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-management/instances/{instance_id}/{operation}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Authenticated instance control, permission checks and durable result exchange + * @description Served by the optional standalone Source manager. Instances initiate every exchange; this is not a Server or public MCP endpoint. The operation path chooses owner/connect/poll/reply/sync/execution/results/stopped-targets/close and the applicable fields. Source observations continue to go directly to the PC Server. + */ + post: operations["source_instance_exchange"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -4896,8 +5155,8 @@ export interface components { /** @description Server-owned identity attestation when this Source contains an enforced-mode Handoff Receipt. */ receipt_identity?: components["schemas"]["HandoffReceiptIdentity"]; scope_id: string; - /** @enum {string} */ - source_type: "content"; + /** @description Built-in content or a registered Source Observation type. */ + source_type: string; source_id: string; /** @description Persisted canonical JSON content. */ content: unknown; @@ -5264,46 +5523,223 @@ export interface components { /** @default [] */ versions: components["schemas"]["Candidate"][]; }; - }; - responses: { - /** @description This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible candidate shapes remain available without the header. */ - ClientUpgradeRequired: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; + SourceCapabilityQuery: { + scope_id: string; + capability?: string; + instance_id?: string; + input?: { + [key: string]: unknown; }; }; - /** @description The request query or pagination cursor is invalid. */ - BadRequest: { - headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; + SourceWritePluginLoadRequest: { + id: string; + entry: string; }; - /** @description The pagination cursor has expired. */ - CursorExpired: { - headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; + SourceWritePluginState: { + id: string; + entry: string; + capabilities: { + [key: string]: unknown; + }[]; }; - /** @description A valid bearer token is required by this Server deployment. */ - Unauthorized: { - headers: { - "WWW-Authenticate": components["headers"]["BearerChallenge"]; - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; + SourceAddRequest: { + scope_id: string; + capability: string; + capability_version: string; + instance_id?: string; + input: { + [key: string]: unknown; }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; + }; + SourceStatusRequest: { + scope_id: string; + target_id: string; + /** + * @default result + * @enum {string} + */ + view: "result" | "control"; + }; + SourceInstanceSetEnabledRequest: { + instance_id: string; + enabled: boolean; + }; + SourceConfigureRequest: { + /** @default false */ + update: boolean; + registration: { + [key: string]: unknown; + }; + }; + SourceCapabilityResponse: { + capabilities: { + [key: string]: unknown; + }[]; + /** @enum {string} */ + management: "not-configured" | "online" | "unavailable"; + }; + SourceInstancesResponse: { + instances: { + [key: string]: unknown; + }[]; + capabilities?: { + [key: string]: unknown; + }[]; + /** @enum {string} */ + management?: "not-configured" | "online" | "unavailable"; + }; + SourceCollectionResult: { + /** @enum {string} */ + outcome: "saved" | "unchanged"; + source_refs: components["schemas"]["SourceTypeReference"][]; + }; + SourceManagementReceipt: { + target_id?: string; + /** @enum {string} */ + tracking?: "enabled" | "stopping" | "stopped"; + result?: components["schemas"]["SourceCollectionResult"]; + error?: string; + instance_id?: string; + capability?: string; + capability_version?: string; + /** @enum {string} */ + availability?: "online" | "offline" | "unavailable"; + updated_at?: string; + /** @enum {string} */ + kind?: "collect" | "write"; + enabled?: boolean; + /** @description Default collect status only. The single Source reference from the submission report most recently received by the manager, or empty before any report. Omitted from collect add responses; not a batch result or submission history. */ + source_refs?: components["schemas"]["SourceTypeReference"][]; + /** @description Default collect status only. Runtime-reported submission time paired with source_refs, or null before any report. Selection follows manager receipt order, not timestamp order. Omitted from collect add responses. */ + last_submitted_at?: string | null; + }; + SourceInstanceStatus: { + targets_initialized?: boolean; + target_id?: string; + instance_id: string; + enabled?: boolean; + actual?: string; + run_id?: string | null; + availability?: string; + reused?: boolean; + }; + SourceChannelOpen: { + protocol: string; + deployment_id: string; + manager_id: string; + }; + SourceChannelRequest: { + generation: string; + message_id?: string; + response?: { + [key: string]: unknown; + }; + catalog?: { + [key: string]: unknown; + }; + }; + SourceChannelResponse: { + generation?: string; + protocol?: string; + confirmed?: boolean; + messages?: { + [key: string]: unknown; + }[]; + }; + SourceInstanceExchangeRequest: { + generation?: string; + protocol?: string; + message_id?: string; + execution_location?: string; + configuration_digest?: string; + run_id?: string; + execution?: string; + target_id?: string; + capabilities?: { + [key: string]: unknown; + }[]; + response?: { + [key: string]: unknown; + }; + owner?: { + [key: string]: unknown; + }; + /** @enum {string} */ + status?: "running" | "complete" | "completed" | "pending" | "failed" | "stopped" | "cleanup-failed"; + /** @description One confirmed Source submission reported by the Runtime; not a batch or historical reference list. */ + source_refs?: components["schemas"]["SourceTypeReference"][]; + error?: string; + targets?: { + [key: string]: unknown; + }[]; + cursor?: string; + owner_exited?: boolean; + previous_owner?: { + [key: string]: unknown; + }; + submitted_at?: string; + }; + SourceInstanceExchangeResponse: { + [key: string]: unknown; + }; + SourceTargetSetEnabledRequest: { + scope_id: string; + target_id: string; + enabled: boolean; + }; + SourceInstancesQuery: { + scope_id?: string; + capability?: string; + instance_id?: string; + input?: { + [key: string]: unknown; + }; + /** + * @default discovery + * @enum {string} + */ + view: "discovery" | "control"; + }; + }; + responses: { + /** @description This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible candidate shapes remain available without the header. */ + ClientUpgradeRequired: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The request query or pagination cursor is invalid. */ + BadRequest: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The pagination cursor has expired. */ + CursorExpired: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A valid bearer token is required by this Server deployment. */ + Unauthorized: { + headers: { + "WWW-Authenticate": components["headers"]["BearerChallenge"]; + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; }; }; /** @description The command conflicts with current immutable state. */ @@ -8602,7 +9038,7 @@ export interface operations { header?: never; path: { scope_id: string; - source_type: "content"; + source_type: string; source_id: string; }; cookie?: never; @@ -9428,4 +9864,434 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + pc_source_capabilities: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceCapabilityQuery"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceCapabilityResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + pc_source_add: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceAddRequest"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceManagementReceipt"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + pc_source_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceStatusRequest"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceManagementReceipt"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + pc_source_target_set_enabled: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceTargetSetEnabledRequest"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceManagementReceipt"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + pc_source_instances: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceInstancesQuery"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceInstancesResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + pc_source_instance_set_enabled: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceInstanceSetEnabledRequest"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceInstanceStatus"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_configure: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceConfigureRequest"]; + }; + }; + responses: { + /** @description Confirmed Source management result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceInstanceStatus"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + load_source_write_plugin: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceWritePluginLoadRequest"]; + }; + }; + responses: { + /** @description The Server has loaded and persisted the executable write capabilities. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceWritePluginState"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_channel_open: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceChannelOpen"]; + }; + }; + responses: { + /** @description Channel exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceChannelResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_channel_catalog: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceChannelRequest"]; + }; + }; + responses: { + /** @description Channel exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceChannelResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_channel_poll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceChannelRequest"]; + }; + }; + responses: { + /** @description Channel exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceChannelResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_channel_reply: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceChannelRequest"]; + }; + }; + responses: { + /** @description Channel exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceChannelResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_channel_close: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceChannelRequest"]; + }; + }; + responses: { + /** @description Channel exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceChannelResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + source_instance_exchange: { + parameters: { + query?: never; + header?: never; + path: { + instance_id: string; + operation: "owner" | "connect" | "poll" | "reply" | "sync" | "execution" | "results" | "stopped-targets" | "close"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceInstanceExchangeRequest"]; + }; + }; + responses: { + /** @description Instance control exchange. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceInstanceExchangeResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; } diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index c6ef2eca0f..8447802e08 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 = "fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d"; +export const contractSha256 = "bce4e0d3deec56590a29c3c06af4d853fc8cad2917d8832b23c92dd160472ccd"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 01e5927773..95e76d08de 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -16,8 +16,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -101,12 +101,13 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_4_1_003` | 1.4.1 | 3 | `s1402` | Portable restore receipts and timestamp adaptation | | `v1_4_1_004` | 1.4.1 | 4 | `v1_4_1_004` | Durable owner recovery intents | | `v1_4_1_005` | 1.4.1 | 5 | `v1_4_1_005` | Nullable Atomic Memory description projection | +| `v1_4_1_006` | 1.4.1 | 6 | `v1_4_1_006` | Source write plugin registrations | -Both registered source paths reach `v1_4_1_005`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_4_1_006`; only 1.3.0 is selected for CI upgrade acceptance: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -114,7 +115,7 @@ before the existing machine fields. `apply` and `verify` show `migration`. Each contains `name`, `software_version`, `sequence` and the compatible `revision` ID. For example: ```json -{"revision":"v1_4_1_005","name":"v1_4_1_005","software_version":"1.4.1","sequence":5} +{"revision":"v1_4_1_006","name":"v1_4_1_006","software_version":"1.4.1","sequence":6} ``` The software version describes the registered database state, not the currently running binary @@ -128,7 +129,7 @@ existing plans and original backups usable. Partial prototype bundles keep their For a new Server migration, use the release-based name directly as its Alembic `revision` and register its schema, domain steps and recovery checkpoints as usual. `down_revision` must reference -the actual preceding ID (`v1_4_1_005` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_4_1_006` when appending directly to the current head), even if that ID has a display alias. The framework follows this registered chain, not lexical name ordering. Already registered names and release assignments stay immutable; a version bump must not rename history. diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 06d9b97b9f..53ffa29817 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -269,7 +269,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_write_plugins,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ -f ``` diff --git a/docs/en/docs/workflows/meta.json b/docs/en/docs/workflows/meta.json index eba60402db..76a12e3d57 100644 --- a/docs/en/docs/workflows/meta.json +++ b/docs/en/docs/workflows/meta.json @@ -7,6 +7,7 @@ "scopes-and-access", "---Sources and evidence---", "sources", + "source-plugins", "ingest-text-files-with-opendal", "---Artifacts and knowledge---", "artifacts", diff --git a/docs/en/docs/workflows/source-plugins.md b/docs/en/docs/workflows/source-plugins.md new file mode 100644 index 0000000000..87eca81725 --- /dev/null +++ b/docs/en/docs/workflows/source-plugins.md @@ -0,0 +1,175 @@ +--- +title: Source plugins +description: Load trusted local plugins for synchronous writes or ongoing Source collection through the public MCP. +--- + +# Source plugins + +Hosts connect to the PowerContext Server's public `/mcp`. A `write` capability saves supplied content synchronously inside Server. A `collect` capability runs in a separate Runtime; the shared management service tracks targets and lifecycle state. + +Loading code is a deployment operation requiring `server.admin`. Install trusted plugin code and dependencies in the Python environment that executes them: Server for write, the Runtime machine for collect. Credential files must be regular files, not symlinks; on Linux, set permissions to `600` (`chmod 600 /path/to/token`). + +Before starting, prepare a Server with authentication and PC Access enabled, an existing Scope, and separate deployment administrator and ordinary host credentials. Replace paths, addresses, and `SCOPE_ID` with actual values. MCP examples use `tool_name(parameters)` shorthand for the `name` and `arguments` of a `/mcp` `tools/call`. Use discovery results for capability names, versions, and input fields. + +| Operation | PC permission | +| --- | --- | +| Discover capabilities or instances; query targets | `scope.read` on the relevant Scope | +| Write or add a collect target | `scope.contribute` and `scope.read` on the relevant Scope | +| Enable/disable a target; read its control view | `scope.contribute` on the relevant Scope | +| Load plugins; configure/control instances; read instance control views | `server.admin` | + +Plugins manage credentials for external systems; these do not replace PC permissions. Hosts only connect to Server and do not need a collect Runtime installed. + +## Load a write plugin + +Prepare a Python package exporting `definitions` and write `capabilities`, and a configuration file: + +```yaml +id: local-tickets +entry: . +``` + +Save this as `write.yml` in the plugin directory; entry `.` selects its `__init__.py`. On the Server machine, run from that directory: + +```sh +powercontext source load --kind write --config ./write.yml \ + --server-url http://127.0.0.1:8000 --credential-file /run/secrets/pc-admin +``` + +Server imports executable code, validates inputs and Definition/Projection contracts, and publishes capabilities only after successful registration. It persists the entry and imports it again on restart. Identical loads reuse the registration. Currently, code updates through a controlled Server restart must preserve the entry, capabilities, and Definition/Projection declarations; Python hot replacement is not supported. Changing declared versions or input/output schemas makes restoration fail because the persisted contract differs. Restarting does not perform a contract upgrade. + +A host with `scope.read` and `scope.contribute` on the selected Scope calls: + +```javascript +pc_source_capabilities({"scope_id":"SCOPE_ID"}) +pc_source_add({ + "scope_id":"SCOPE_ID","capability":"ticket.write","capability_version":"1", + "input":{"namespace":"support","project":"demo","ticket_id":"A","title":"Recovery","body":"Service restored"} +}) +``` + +Write does not take `instance_id`. Returned `result.source_refs` identify saved Sources and Projections. Write creates no persistent request and uses neither collect targets nor checkpoints. Identical content under the same Source identity is reused; different content under that identity conflicts. The ticket example derives identity from the complete snapshot. Timeouts mean the result is unconfirmed, not that nothing was saved. Complete add arguments are limited to 1 MiB of JSON and materialized observations to 4 MiB, including Base64 expansion. Oversized input fails rather than being truncated. + +## Load collect on a running Server + +Enable authentication and PC Access, and configure a trusted instance directory in the Server environment file: + +```dotenv +POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR=/srv/powercontext/source-plugins +POWERCONTEXT_SERVER_ACCESS='{"mode":"enforced"}' +``` + +Restart Server after initial configuration. Automatic deployment creates separate management and execution credentials without editing user identity files. Startup scans direct child directories for `powercontext.yml`; subsequent additions require an explicit load. This scan deploys collect instances; load write plugins explicitly with `--kind write` as above. Local collect deployment requires Linux: `source_management.process_mode="background"` (the default) uses background processes; `"local-service"` additionally requires an available systemd user service manager (`systemctl --user`). Neither mode supports local deployment on macOS or Windows. This restriction concerns collect process management, not Server-local synchronous write plugins. + +An instance directory keeps `__init__.py`, `definition.py`, data files, and `powercontext.yml` together: + +```yaml +id: my-tickets +plugin: . +scope_ids: [SCOPE_ID] +config: + file: tickets.json + poll_interval: 10 +``` + +Scopes must exist, and configured scope selection does not grant PC permissions. The plugin's `Config` validates business parameters; the plugin controls collection timing. + +On the Server machine, using the same environment file: + +```sh +powercontext source load --config /srv/powercontext/source-plugins/my-tickets/powercontext.yml \ + --env-file ./pc.env +``` + +Load registers Definitions, grants execution permissions, registers the instance, and starts its Runtime. Repeating an identical load preserves the process and enabled state; changing the code entry or business configuration requires an explicit update. The manager owns the deployed instance's Scope selection and targets; repeating load does not overwrite them from the file. + +Hosts establish ongoing collection through the public MCP: + +```javascript +pc_source_instances({"scope_id":"SCOPE_ID"}) +pc_source_add({ + "scope_id":"SCOPE_ID","instance_id":"my-tickets", + "capability":"tickets.item","capability_version":"1","input":{"id":"A"} +}) +pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID"}) +``` + +Add and repeated add return the target identity and control state, without Source references or submission time. Status includes only the manager's most recently received successful submission report: `source_refs` contains one reference, paired with `last_submitted_at`. Before any report, the reference array is empty and no submission time is available. Reports replace this state in arrival order; it is neither a completed batch nor a history of collected Sources. The manager does not accumulate historical references, and a lost report does not undo a saved Source. Plugins retain the real SourceRef in each submission receipt and use checkpoints for collection progress. Repeated add reuses the same target without restarting collection or re-enabling a disabled one. Optional manifest `targets` only initialize the first deployment; subsequent target state belongs to the manager. + +## Remote collect Runtimes + +Prepare the code on its execution machine and add this deployment block: + +```yaml +deployment: + server: + url: https://pc.example.com + credential_file: /run/secrets/pc-admin + management_url: https://source-manager.example.com + execution_credential_file: /run/secrets/pc-worker + state_dir: ./instance-state +``` + +Run `powercontext source load --config ./powercontext.yml` there. Server and the manager must already be reachable; non-local connections require HTTPS. Deployment and Runtime identities must differ, and the Runtime identity must not have `server.admin`. The command starts a local Runtime; it does not distribute code or execute remote shells. Explicitly choose `deployment.start: local-service` for systemd user services. + +Ordinary background processes do not restart automatically after a machine reboot. Restart an already deployed instance using the Runtime configuration returned by load, without registering it again: + +```sh +powercontext source runtime start --config /path/to/runtime-config.json +powercontext source runtime status --config /path/to/runtime-config.json +``` + +For systemd supervision, add `--system-service` to the start/status commands above. A live process only confirms that Runtime is running; also check instance availability and actual Source submissions through MCP. + +## Extend an instance's Scopes + +When only adding Scopes, first grant the execution identity the corresponding permissions through the Access API. Then submit the complete instance registration with `update=true` to `POST /v1/source-management/configure`, extending `scope_ids` without changing other configuration. The instance can keep running with its existing Context and checkpoints. Hosts with permission on the new Scope can then add targets. Editing the manifest and repeating load does not perform this expansion. + +## Disable and update + +An administrator disables the instance and checks its control state: + +```javascript +pc_source_instance_set_enabled({"instance_id":"my-tickets","enabled":false}) +pc_source_instances({"instance_id":"my-tickets","view":"control"}) +``` + +After `actual=stopped`, stop the outer Runtime on its execution machine using the configuration path returned at deployment or saved in its private state directory: + +```sh +powercontext source runtime stop --config /path/to/runtime-config.json +powercontext source load --update --config /path/to/powercontext.yml +``` + +For local automatic deployment, keep the same `--env-file`. Stopping a system service requires `--system-service`. When reducing Scopes, changing business configuration, or upgrading code, preserve the instance ID, state directory, and compatible Definition, capability, target identity, and checkpoint contracts. `source load --update` requires the old Runtime to have exited. The replacement stays disabled until an administrator explicitly sets `enabled=true`. Ordinary load does not implicitly upgrade. + +To stop collecting one item without affecting the whole instance, control its target: + +```javascript +pc_source_target_set_enabled({"scope_id":"SCOPE_ID","target_id":"TARGET_ID","enabled":false}) +pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID","view":"control"}) +``` + +`stopping` means the stop intent is saved; `stopped` means Runtime has confirmed the stop. Set `enabled` to `true` to resume; this does not enable a disabled instance. A disconnected manager does not prove that collection stopped. Disabling preserves Sources, targets, and checkpoints. Source saving, checkpoint updates, and submission reports are separate operations. + +The current implementation has no complete unload command that deletes an instance registration and its targets. `runtime stop` stops execution while preserving management records and saved data. Stopping Server or deleting a plugin directory does not uninstall the independent services. + +## Troubleshooting + +| Symptom | Check or action | +| --- | --- | +| Startup logs report `background-unsupported` or `service-unavailable` | Local collect deployment requires Linux; `local-service` also requires a working `systemctl --user`. Check the execution platform and selected process mode. | +| Manager disconnected | Collect can continue with synchronized targets; new management calls may fail. Built-in and loaded write capabilities do not depend on the manager. | +| Runtime restarts while the manager is unreachable | Wait for target synchronization; do not fall back to initial targets in the file. | +| A target produces no new Sources | Check that collection logic is still running, its schedule, external credentials, and Scope permissions. A live process does not prove successful collection. | +| State remains stopping | Check the original Runtime and management connection; disconnection does not confirm a stop. | +| A saved Source is absent from target status | Submission reports may be lost. Target status is not a complete inventory of Server data; do not rewind checkpoints on that basis. | +| An agent does not use saved content | Check that collection and host Scopes match, then check downstream Projection, Artifact, and indexing processing. | + +An ongoing collector maintains its own loop in `apply`. After `apply` exits, new targets or reconnection do not restart it. To run it again, explicitly disable the instance, confirm it has stopped, then enable it. + +## Runnable examples + +The repository's `examples/source_plugins` groups examples by source: tickets, ticket/chat and images. Each directory contains its code and loading configuration. The tickets example shares one Definition and Projection implementation across write and collect: use `write.yml` for Server-local synchronous writes or `powercontext.yml` for managed collection. Loading and MCP usage are documented in `examples/source_plugins/README.zh.md`. + +Keep generated credentials, databases, logs, and process state on the deployment machine. Commit plugin code, configuration templates without sensitive values, and synthetic example data. diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index 204305cca7..bf6b9ead5f 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -86,12 +86,13 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_4_1_003` | 1.4.1 | 第三步 | `s1402` | 可移植恢复回执与时间戳适配 | | `v1_4_1_004` | 1.4.1 | 第四步 | `v1_4_1_004` | 持久化所有权恢复意图 | | `v1_4_1_005` | 1.4.1 | 第五步 | `v1_4_1_005` | Atomic Memory 可空摘要投影列 | +| `v1_4_1_006` | 1.4.1 | 第六步 | `v1_4_1_006` | Source write 插件注册 | -两条已登记来源路径都升级到 `v1_4_1_005`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_4_1_006`;CI 升级验收只选择 1.3.0: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -99,7 +100,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 `software_version`、`sequence` 和兼容保留的 `revision` ID。例如: ```json -{"revision":"v1_4_1_005","name":"v1_4_1_005","software_version":"1.4.1","sequence":5} +{"revision":"v1_4_1_006","name":"v1_4_1_006","software_version":"1.4.1","sequence":6} ``` 这里的软件版本描述已登记的数据库状态,不代表当前运行的软件包版本,也不代表中断维护窗口最初的来源。 @@ -110,7 +111,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_005`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_006`,即使它已有展示别名。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index a619c3b45a..21ce571dd0 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -260,7 +260,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_write_plugins,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ -f ``` diff --git a/docs/zh/docs/workflows/meta.json b/docs/zh/docs/workflows/meta.json index 0898b41934..fcac653a1c 100644 --- a/docs/zh/docs/workflows/meta.json +++ b/docs/zh/docs/workflows/meta.json @@ -7,6 +7,7 @@ "scopes-and-access", "---Source 与证据---", "sources", + "source-plugins", "ingest-text-files-with-opendal", "---Artifact 与知识---", "artifacts", diff --git a/docs/zh/docs/workflows/source-plugins.md b/docs/zh/docs/workflows/source-plugins.md new file mode 100644 index 0000000000..b3633e1037 --- /dev/null +++ b/docs/zh/docs/workflows/source-plugins.md @@ -0,0 +1,173 @@ +--- +title: Source 插件 +description: 加载可信本地插件,通过公共 MCP 同步写入或持续采集 Source。 +--- + +# Source 插件 + +宿主始终连接 PowerContext Server 的公共 `/mcp`。`write` 在 Server 内同步保存宿主提供的内容;`collect` 由独立 Runtime 采集外部资料,共享管理服务负责目标与启停状态。 + +加载代码是拥有 `server.admin` 权限的部署操作。插件代码和依赖须在执行它的 Python 环境可用:write 在 Server 机器,collect 在 Runtime 机器。只加载受信任的代码。凭据文件须为普通文件,不能是符号链接;Linux 下将权限设为 `600`(`chmod 600 /path/to/token`)。 + +开始前,准备已启用鉴权和 PC Access 的 Server、已有 Scope,以及分开的部署管理员和普通宿主凭据。本文示例中的路径、地址和 `SCOPE_ID` 需替换为实际值;MCP 示例采用 `工具名(参数)` 简写,对应 `/mcp` 的 `tools/call` 中的 `name` 和 `arguments`。能力名称、版本和输入字段以发现结果为准。 + +| 操作 | PC 权限 | +| --- | --- | +| 发现能力、实例和查询目标 | 对应 Scope 的 `scope.read` | +| write、添加 collect 目标 | 对应 Scope 的 `scope.contribute` 和 `scope.read` | +| 启停目标、读取目标 control 视图 | 对应 Scope 的 `scope.contribute` | +| 加载插件、配置或启停实例、读取实例 control 视图 | `server.admin` | + +插件访问外部系统的凭据由插件管理,不替代 PC 权限。宿主只连接 Server,不需要安装 collect Runtime。 + +## 加载 write + +准备包含 `definitions` 和 write `capabilities` 的本地插件,以及部署配置: + +```yaml +id: local-tickets +entry: . +``` + +将配置保存为插件目录中的 `write.yml`,入口 `.` 指向同目录的 `__init__.py`。管理员在该目录执行: + +```sh +powercontext source load --kind write --config ./write.yml \ + --server-url http://127.0.0.1:8000 --credential-file /run/secrets/pc-admin +``` + +Server 实际导入代码、校验输入模型和 Definition/Projection 契约,注册成功后公开能力。它持久保存入口,重启重新导入。一致配置重复加载可复用。当前仅支持保持入口、能力和 Definition/Projection 声明不变的代码更新,通过受控重启 Server 生效;不做进程内 Python 热替换。修改声明中的版本、输入或输出 schema 后,重启会因持久契约不一致而恢复失败,不能用重启完成契约升级。 + +宿主使用具有目标 Scope 的 `scope.read` 和 `scope.contribute` 权限的身份调用: + +```javascript +pc_source_capabilities({"scope_id":"SCOPE_ID"}) +pc_source_add({ + "scope_id":"SCOPE_ID","capability":"ticket.write","capability_version":"1", + "input":{"namespace":"support","project":"demo","ticket_id":"A","title":"故障恢复","body":"服务已恢复"} +}) +``` + +write 不传 `instance_id`。返回 `result.source_refs` 表示真实 Source/Projection 已保存。write 不创建持久请求,也不使用 collect 目标或 checkpoint。相同身份和相同内容复用,同身份不同内容冲突;工单示例按完整快照计算身份。超时表示结果未确认,不能认定未保存。完整 add 参数 JSON 上限为 1 MiB,物化 observation 上限为 4 MiB,包含 Base64 膨胀;超限报错,不截断。 + +## 在运行中的 Server 上加载 collect + +Server 的环境文件中启用鉴权、PC Access,并指定可信实例目录: + +```dotenv +POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR=/srv/powercontext/source-plugins +POWERCONTEXT_SERVER_ACCESS='{"mode":"enforced"}' +``` + +首次配置后重启 Server。自动部署创建独立管理和执行凭据,不修改用户身份文件。Server 启动扫描直接子目录的 `powercontext.yml`;之后新增实例显式 load,不持续扫描。此自动扫描用于 collect;write 使用前述 `--kind write` 显式加载。本地 collect 部署要求 Linux:`source_management.process_mode="background"`(默认)使用后台进程;`"local-service"` 还要求可用的 systemd 用户服务管理器(`systemctl --user`)。两种模式均不支持在 macOS 或 Windows 上本地部署。此限制针对 collect 进程管理,不针对 Server 内同步执行的 write 插件。 + +实例目录就近放置 `__init__.py`、`definition.py`、资料文件和 `powercontext.yml`,例如: + +```yaml +id: my-tickets +plugin: . +scope_ids: [SCOPE_ID] +config: + file: tickets.json + poll_interval: 10 +``` + +Scope 须已存在,配置里的范围不替代 PC 权限。业务参数由插件的 `Config` 校验,采集节奏由插件决定。 + +```sh +powercontext source load --config /srv/powercontext/source-plugins/my-tickets/powercontext.yml \ + --env-file ./pc.env +``` + +命令在 Server 所在机器使用相同环境配置执行,完成 Definition 注册、Scope 授权、实例登记和 Runtime 启动。重复加载一致配置保留启停状态和进程;修改代码入口或业务配置须显式更新。已部署实例的 Scope 范围与目标以管理服务记录为准,重复 load 不用文件覆盖它们。 + +宿主通过公共 MCP 建立持续采集目标: + +```javascript +pc_source_instances({"scope_id":"SCOPE_ID"}) +pc_source_add({ + "scope_id":"SCOPE_ID","instance_id":"my-tickets", + "capability":"tickets.item","capability_version":"1","input":{"id":"A"} +}) +pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID"}) +``` + +add 和重复 add 返回目标身份及控制状态,不携带 Source 引用和提交时间。状态查询只携带管理服务最近收到的一次成功提交报告:`source_refs` 包含一个引用,`last_submitted_at` 是同一报告的提交时间;尚未收到报告时,引用为空数组,也没有提交时间。报告按接收顺序覆盖,这既不表示一批已完成,也不是已采集 Source 的历史清单。管理服务不累计历史引用,报告丢失不撤销已保存的 Source。插件仍从每次真实提交回执取得 SourceRef,并用 checkpoint 管理采集进度。相同目标重复 add 复用,不重启采集,也不恢复已停用目标。可选 `targets` 清单只用于首次初始化,后续以管理服务记录为准。 + +## 远端 collect Runtime + +在执行机器准备插件代码,并在其清单中增加: + +```yaml +deployment: + server: + url: https://pc.example.com + credential_file: /run/secrets/pc-admin + management_url: https://source-manager.example.com + execution_credential_file: /run/secrets/pc-worker + state_dir: ./instance-state +``` + +在该机器执行 `powercontext source load --config ./powercontext.yml`。Server 和管理服务须已可达;非本机连接要求 HTTPS。管理员和 Runtime 使用不同身份,Runtime 身份不能具有 `server.admin`。命令启动本机 Runtime,不分发代码或执行远程 shell。显式设置 `deployment.start: local-service` 可使用 systemd 用户服务。 + +普通后台进程不会在机器重启后自动拉起。已部署实例可使用 load 输出的 Runtime 配置重新启动,无需重新登记: + +```sh +powercontext source runtime start --config /path/to/runtime-config.json +powercontext source runtime status --config /path/to/runtime-config.json +``` + +使用 systemd 托管时,上述 start/status 命令增加 `--system-service`。进程在线只表示 Runtime 存活,还需通过 MCP 查看实例可用性和实际 Source 提交。 + +## 扩展实例的 Scope + +仅增加 Scope 且其他配置不变时,管理员先通过 Access 接口授予执行身份相应权限,再向 `POST /v1/source-management/configure` 提交完整实例登记和 `update=true`,扩展 `scope_ids`。实例可以保持运行,原 Context 与 checkpoint 保留;宿主取得新 Scope 权限后即可添加目标。修改清单后重复 load 不会完成这项扩展。 + +## 停用和更新 + +管理员通过 MCP 停用整个实例: + +```javascript +pc_source_instance_set_enabled({"instance_id":"my-tickets","enabled":false}) +pc_source_instances({"instance_id":"my-tickets","view":"control"}) +``` + +等到 `actual=stopped` 后,在执行机器停止外层 Runtime,使用部署输出或私有状态目录中的配置路径: + +```sh +powercontext source runtime stop --config /path/to/runtime-config.json +powercontext source load --update --config /path/to/powercontext.yml +``` + +本机自动部署的 load 仍须带相同 `--env-file`;系统服务 stop 带 `--system-service`。缩减 Scope、修改业务配置或升级代码时,保留实例 ID、状态目录和兼容的 Definition、能力、目标身份及 checkpoint。`source load --update` 必须确认旧 Runtime 已退出;成功后仍停用,管理员显式 `enabled=true` 才恢复采集。普通 load 不隐式升级。 + +只停止某份资料时,控制对应目标,不影响整个实例: + +```javascript +pc_source_target_set_enabled({"scope_id":"SCOPE_ID","target_id":"TARGET_ID","enabled":false}) +pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID","view":"control"}) +``` + +`stopping` 表示停止意图已保存,`stopped` 表示 Runtime 已确认停止。恢复时将 `enabled` 设为 `true`;这不会自动启用已停用的实例。管理失联不等于业务停止。Source、目标和 checkpoint 不随停用删除;Source 保存和 checkpoint/报告提交是独立动作。 + +当前实现尚无删除实例登记及所属目标的完整卸载命令。`runtime stop` 只停止执行进程,保留管理记录和已保存数据;停止 Server 或删除插件目录也不等于卸载独立服务。 + +## 常见问题 + +| 现象 | 检查和处理 | +| --- | --- | +| 启动日志出现 `background-unsupported` 或 `service-unavailable` | 本地 collect 部署要求 Linux;`local-service` 还需要可用的 `systemctl --user`。检查执行平台和进程模式。 | +| 管理服务断线 | 已同步的 collect 可继续;新管理调用可能失败。内置及已加载的 write 不依赖管理服务。 | +| Runtime 重启后管理服务不可达 | 等待重新同步目标,不从文件初始 targets 回退采集。 | +| 目标长期没有新 Source | 检查插件采集逻辑是否仍运行、采集节奏、外部凭据和 Scope 权限;进程在线不保证采集成功。 | +| 一直处于 stopping | 检查原 Runtime 与管理连接,不能把断线当成停止完成。 | +| Source 已保存但目标状态没有引用 | 提交报告可能丢失;目标状态不是 Server 全部数据的清单,不应因此回退 checkpoint。 | +| Agent 没使用已保存的资料 | 核对采集 Scope 与宿主当前 Scope,以及后续 Projection、Artifact 和索引处理。 | + +持续采集插件应在 `apply` 中保持自己的循环。`apply` 结束后,新目标或通信重连不会自动重跑它;需要重新运行时,显式停用并确认停止,再启用。 + +## 可运行示例 + +仓库 `examples/source_plugins` 按工单、工单/聊天、图片组织示例,每个目录就近放置代码和加载配置。工单示例共用一份 Definition 和 Projection:`write.yml` 用于 Server 内同步写入,`powercontext.yml` 用于托管采集。加载和 MCP 调用步骤见 `examples/source_plugins/README.zh.md`。 + +凭据、数据库、日志和生成的进程状态留在部署机器;只提交插件代码、无敏感信息的配置模板和合成示例资料。 diff --git a/e2e/bub/tests/test_access_audit.py b/e2e/bub/tests/test_access_audit.py index d438f94268..5df0ec58d8 100644 --- a/e2e/bub/tests/test_access_audit.py +++ b/e2e/bub/tests/test_access_audit.py @@ -260,6 +260,9 @@ def test_a_handoff_report_over_every_scope_reaches_them_although_it_is_authorize "set_scope_binding", "clear_scope_binding", "register_source_definition", + "load_source_write_plugin", + "source_configure", + "pc_source_instance_set_enabled", "scan_external_skills", "list_external_skills", "resolve_external_skill", diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 0d22a7c92d..f66999d056 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1643,6 +1643,7 @@ wheels = [ name = "powercontext" source = { editable = "../../" } dependencies = [ + { name = "jsonschema" }, { name = "pydantic" }, { name = "rfc8785" }, { name = "typing-extensions" }, @@ -1699,11 +1700,7 @@ requires-dist = [ { name = "jieba", marker = "extra == 'server'", specifier = "==0.42.1" }, { name = "jinja2", marker = "extra == 'ops'", specifier = ">=3.1.6,<4" }, { name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.6,<4" }, - { name = "jsonschema", marker = "extra == 'builtin'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'cli'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'ops'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'seekdb'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23,<5" }, + { name = "jsonschema", specifier = ">=4.23,<5" }, { name = "markdown-it-py", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "markdown-it-py", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "opentelemetry-api", marker = "extra == 'cli'", specifier = ">=1.30,<2" }, @@ -1719,6 +1716,7 @@ requires-dist = [ { name = "packaging", marker = "extra == 'ops'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'seekdb'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'server'", specifier = ">=24,<27" }, + { name = "pillow", marker = "extra == 'source-images'", specifier = ">=11,<13" }, { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, @@ -1787,7 +1785,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "tracing-otlp"] +provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "source-images", "tracing-otlp"] [package.metadata.requires-dev] dev = [ @@ -1797,6 +1795,7 @@ dev = [ { name = "langgraph", specifier = ">=1.2,<2" }, { name = "powercontext", extras = ["cli", "server"] }, { name = "powercontext", extras = ["code"] }, + { name = "powercontext", extras = ["source-images"] }, { name = "powercontext-langchain", editable = "../../integrations/langchain" }, { name = "powercontext-langgraph", editable = "../../integrations/langgraph" }, { name = "prek", specifier = ">=0.2.0" }, diff --git a/evaluation/skills/skill-up/README.md b/evaluation/skills/skill-up/README.md index e8b4014a26..295d9256af 100644 --- a/evaluation/skills/skill-up/README.md +++ b/evaluation/skills/skill-up/README.md @@ -101,7 +101,7 @@ All relative config paths start at this project root, not at `evals/`. Both arms receive the same `evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md`: required/optional top-level argument names, types and defaults derived from OpenAPI and its MCP specialization, including content CAS headers. -The mock catalog contains the Server's complete declared MCP tool subset (55 operations), verified against +The mock catalog contains the Server's complete declared MCP tool subset, verified against `src/powercontext/server/mcp.py` and the generated operation IDs. The successful save reply contains Atomic `records`; empty search returns the current `text` mode and an empty `hits` list. This fills the generic mock schemas' parameter information gap diff --git a/evaluation/skills/skill-up/evals/cases/empty-search.yaml b/evaluation/skills/skill-up/evals/cases/empty-search.yaml index 6ed70794e9..88ba26dcc0 100644 --- a/evaluation/skills/skill-up/evals/cases/empty-search.yaml +++ b/evaluation/skills/skill-up/evals/cases/empty-search.yaml @@ -156,5 +156,17 @@ judge: - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_target_set_enabled} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__pc_source_target_set_enabled} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml index 459ccb95ff..22139667c7 100644 --- a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml @@ -93,4 +93,10 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_target_set_enabled} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/failed-save.yaml b/evaluation/skills/skill-up/evals/cases/failed-save.yaml index 4c3b9286ed..dbf4ddf3fe 100644 --- a/evaluation/skills/skill-up/evals/cases/failed-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/failed-save.yaml @@ -109,4 +109,10 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_target_set_enabled} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml index 8cac74cf78..f2586c1178 100644 --- a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml +++ b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml @@ -96,4 +96,10 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_target_set_enabled} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml index fb19ba13cd..b680bfe47b 100644 --- a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml +++ b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml @@ -87,4 +87,10 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_add} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_capabilities} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instance_set_enabled} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_instances} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_status} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__pc_source_target_set_enabled} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json index c67e315af5..ec198bbd0a 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json @@ -248,6 +248,24 @@ }, "set_scope_binding": { "error": "unexpected" + }, + "pc_source_add": { + "error": "unexpected" + }, + "pc_source_capabilities": { + "error": "unexpected" + }, + "pc_source_instance_set_enabled": { + "error": "unexpected" + }, + "pc_source_instances": { + "error": "unexpected" + }, + "pc_source_status": { + "error": "unexpected" + }, + "pc_source_target_set_enabled": { + "error": "unexpected" } } } diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json index f143e8e26d..982e727f7c 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json @@ -238,6 +238,24 @@ }, "set_scope_binding": { "error": "unexpected" + }, + "pc_source_add": { + "error": "unexpected" + }, + "pc_source_capabilities": { + "error": "unexpected" + }, + "pc_source_instance_set_enabled": { + "error": "unexpected" + }, + "pc_source_instances": { + "error": "unexpected" + }, + "pc_source_status": { + "error": "unexpected" + }, + "pc_source_target_set_enabled": { + "error": "unexpected" } } } diff --git a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md index 23c13fd992..6c717801bd 100644 --- a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md +++ b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md @@ -200,6 +200,36 @@ not replace those schemas or enforce their constraints. It contains no scenario - Required: `content: AtomicMemoryWriteContent`, `inputs: array`, `scope_id: string`. - Optional: `artifact_refs: array`, `source_refs: array`. +## mcp__powercontext__pc_source_add + +- Required: `capability: string`, `capability_version: string`, `input: object`, `scope_id: string`. +- Optional: `instance_id: string`. + +## mcp__powercontext__pc_source_capabilities + +- Required: `scope_id: string`. +- Optional: `capability: string`, `input: object`, `instance_id: string`. + +## mcp__powercontext__pc_source_instance_set_enabled + +- Required: `enabled: boolean`, `instance_id: string`. +- Optional: (none). + +## mcp__powercontext__pc_source_instances + +- Required: (none). +- Optional: `capability: string`, `input: object`, `instance_id: string`, `scope_id: string`, `view: "discovery" | "control" = "discovery"`. + +## mcp__powercontext__pc_source_status + +- Required: `scope_id: string`, `target_id: string`. +- Optional: `view: "result" | "control" = "result"`. + +## mcp__powercontext__pc_source_target_set_enabled + +- Required: `enabled: boolean`, `scope_id: string`, `target_id: string`. +- Optional: (none). + ## mcp__powercontext__prepare_handoff_hint - Required: `scope_id: string`, `selection: "prepared" | "exact" | "latest"`. diff --git a/evaluation/uv.lock b/evaluation/uv.lock index 33357bdd95..a4be7cd0b4 100644 --- a/evaluation/uv.lock +++ b/evaluation/uv.lock @@ -1659,6 +1659,7 @@ wheels = [ name = "powercontext" source = { editable = "../" } dependencies = [ + { name = "jsonschema" }, { name = "pydantic" }, { name = "rfc8785" }, { name = "typing-extensions" }, @@ -1675,7 +1676,6 @@ server = [ { name = "httpx" }, { name = "jieba" }, { name = "jinja2" }, - { name = "jsonschema" }, { name = "markdown-it-py" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -1737,11 +1737,7 @@ requires-dist = [ { name = "jieba", marker = "extra == 'server'", specifier = "==0.42.1" }, { name = "jinja2", marker = "extra == 'ops'", specifier = ">=3.1.6,<4" }, { name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.6,<4" }, - { name = "jsonschema", marker = "extra == 'builtin'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'cli'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'ops'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'seekdb'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23,<5" }, + { name = "jsonschema", specifier = ">=4.23,<5" }, { name = "markdown-it-py", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "markdown-it-py", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "opentelemetry-api", marker = "extra == 'cli'", specifier = ">=1.30,<2" }, @@ -1757,6 +1753,7 @@ requires-dist = [ { name = "packaging", marker = "extra == 'ops'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'seekdb'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'server'", specifier = ">=24,<27" }, + { name = "pillow", marker = "extra == 'source-images'", specifier = ">=11,<13" }, { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, @@ -1825,7 +1822,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "tracing-otlp"] +provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "source-images", "tracing-otlp"] [package.metadata.requires-dev] dev = [ @@ -1835,6 +1832,7 @@ dev = [ { name = "langgraph", specifier = ">=1.2,<2" }, { name = "powercontext", extras = ["cli", "server"] }, { name = "powercontext", extras = ["code"] }, + { name = "powercontext", extras = ["source-images"] }, { name = "powercontext-langchain", editable = "../integrations/langchain" }, { name = "powercontext-langgraph", editable = "../integrations/langgraph" }, { name = "prek", specifier = ">=0.2.0" }, diff --git a/examples/source_plugins/README.zh.md b/examples/source_plugins/README.zh.md new file mode 100644 index 0000000000..21f0e18cce --- /dev/null +++ b/examples/source_plugins/README.zh.md @@ -0,0 +1,67 @@ +# Source 插件示例 + +本目录按业务场景组织可复制的插件,每个目录就近放置代码、加载配置和样例数据。Server 部署、权限、启停与升级步骤见 [Source 插件指南](../../docs/zh/docs/workflows/source-plugins.md)。 + +| 目录 | 能力 | 配置及准备 | +| --- | --- | --- | +| [tickets](tickets/) | `ticket.write@1` 同步写入;`tickets.item@1` 持续采集 | write 使用 `write.yml`;collect 使用 `powercontext.yml` 和 `tickets.json` | +| [ticket_chat](ticket_chat/) | `ticket-chat.dataset@1` 采集工单和聊天 | `powercontext.yml`、`events.json`;修改 Scope 和初始目标 | +| [images](images/) | `images.write@1` 写入 PNG/JPEG | `write.yml`;Server 安装 `powercontext[source-images]` | + +write 代码部署在 Server 机器,collect 代码部署在 Runtime 机器。清单中的 `entry: .` 或 `plugin: .` 都指向同目录的代码。以下命令使用已配置鉴权、MCP 和 PC Access 的 Server;Scope 须已存在,宿主须有对应读取和贡献权限。 + +## 工单同步写入 + +复制整个 `tickets` 目录到 Server 上的普通部署目录,使用管理员凭据加载: + +```sh +powercontext source load --kind write \ + --config /path/to/tickets/write.yml \ + --server-url http://127.0.0.1:8000 \ + --credential-file /path/to/admin-token +``` + +宿主通过公共 MCP 发现能力并提交完整工单: + +```javascript +pc_source_capabilities({"scope_id":"SCOPE_ID"}) +pc_source_add({ + "scope_id": "SCOPE_ID", + "capability": "ticket.write", + "capability_version": "1", + "input": { + "namespace": "support", + "project": "powercontext", + "ticket_id": "PC-123", + "title": "数据库恢复", + "body": "重试连接后服务恢复。", + "labels": ["数据库"], + "metadata": {"owner": null, "attempts": 2} + } +}) +``` + +返回 `result.source_refs` 后可读取保存结果。同一完整快照复用 Source,内容变化产生新身份。[definition.py](tickets/definition.py) 同时提供结构化 `example.ticket-details@1` 和标准文本 `powercontext.text-evidence@1`。 + +## 工单持续采集 + +使用同一个 `tickets` 包,将 `powercontext.yml` 的 `scope_ids` 改为实际 Scope。按需修改同目录的 `tickets.json`,然后使用与 Server 一致的环境文件加载: + +```sh +powercontext source load \ + --config /path/to/tickets/powercontext.yml \ + --env-file ./pc.env +``` + +宿主添加文件中 ID 为 A 的工单作为持续采集目标: + +```javascript +pc_source_add({"scope_id":"SCOPE_ID","instance_id":"my-tickets","capability":"tickets.item","capability_version":"1","input":{"id":"A"}}) +``` + +插件按 `config.poll_interval` 读取文件,跳过未变化的快照。write 与 collect 共用同一份 Definition 和 Projection,分别加载,不会互相启动。 + +## 其他示例的输入与输出 + +- **ticket_chat:** 按相同 collect 流程加载。清单中的初始目标输入为 `{}`;`events.json` 生成工单对象 `example.ticket-details@1` 和聊天数组 `example.chat-messages@2`。`receipts.jsonl` 记录已确认的 Scope 和 SourceRef。 +- **images:** 按相同 write 流程加载 `images/write.yml`。MCP 输入为 `media_type`、`data_base64` 和可选 `title`,校验 PNG/JPEG 内容;大小和并发限制沿用 Server write 规则。 diff --git a/examples/source_plugins/images/__init__.py b/examples/source_plugins/images/__init__.py new file mode 100644 index 0000000000..2c9f2c9636 --- /dev/null +++ b/examples/source_plugins/images/__init__.py @@ -0,0 +1,33 @@ +# 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. + +"""Server-local PNG/JPEG write capabilities; install powercontext[source-images].""" + +from powercontext.source_management import Capability + +from .definition import ImageCapture, ImageDefinition + +image_definition = ImageDefinition() +definitions = (image_definition,) +capabilities = ( + Capability( + name="images.write", + version="1", + kind="write", + description="Save a supplied PNG or JPEG image", + input_schema=ImageCapture.model_json_schema(), + definition_name=image_definition.name, + definition_version=image_definition.version, + ), +) diff --git a/examples/source_plugins/images/definition.py b/examples/source_plugins/images/definition.py new file mode 100644 index 0000000000..eacf1f4452 --- /dev/null +++ b/examples/source_plugins/images/definition.py @@ -0,0 +1,78 @@ +# 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. + +"""Captured PNG/JPEG content with validated bytes and immutable identity.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import io + +from PIL import Image, UnidentifiedImageError +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from powercontext.sources import Source, SourceMaterialization + + +class ImageCapture(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + media_type: str + data_base64: str = Field(min_length=1, max_length=1024 * 1024) + title: str = Field(default="", max_length=256) + + @model_validator(mode="after") + def validate_image(self) -> ImageCapture: + try: + raw = base64.b64decode(self.data_base64, validate=True) + if base64.b64encode(raw).decode() != self.data_base64: + raise ValueError("Use canonical Base64") # noqa: TRY003 + with Image.open(io.BytesIO(raw)) as image: + if image.format not in {"PNG", "JPEG"} or self.media_type != Image.MIME[image.format]: + raise ValueError("Supply a PNG or JPEG matching media_type") # noqa: TRY003 + if image.width * image.height > 16_000_000: + raise ValueError("Image exceeds 16 million pixels") # noqa: TRY003 + image.verify() + with Image.open(io.BytesIO(raw)) as image: + image.load() + except (binascii.Error, OSError, UnidentifiedImageError, Image.DecompressionBombError) as error: + raise ValueError("Invalid image encoding or content") from error # noqa: TRY003 + return self + + def snapshot_id(self) -> str: + return "image-" + hashlib.sha256(self.model_dump_json().encode()).hexdigest() + + +class ImageSource(Source): + capture: ImageCapture + + +class ImageDefinition: + name = "example-image" + version = "1" + input_class = ImageCapture + source_class = ImageSource + projections = () + + async def resolve(self, value: ImageCapture, /) -> ImageSource: + return ImageSource( + name=value.snapshot_id(), + materialization=SourceMaterialization.CAPTURED, + description=value.title or "Captured image", + capture=value, + ) + + async def read(self, source: ImageSource, /) -> ImageCapture: + return source.capture diff --git a/examples/source_plugins/images/write.yml b/examples/source_plugins/images/write.yml new file mode 100644 index 0000000000..fc3bb9e77b --- /dev/null +++ b/examples/source_plugins/images/write.yml @@ -0,0 +1,16 @@ +# 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. + +id: local-images +entry: . diff --git a/examples/source_plugins/ticket_chat/__init__.py b/examples/source_plugins/ticket_chat/__init__.py new file mode 100644 index 0000000000..70e634408b --- /dev/null +++ b/examples/source_plugins/ticket_chat/__init__.py @@ -0,0 +1,148 @@ +# 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. + +"""Collect ticket and chat snapshots through the managed Runtime.""" + +import asyncio +import hashlib +import json +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter + +from powercontext.errors import InvalidConnectorRunError +from powercontext.source_management import Capability, TargetIdentity +from powercontext.source_management.context import PluginContext +from powercontext.source_management.errors import PluginError +from powercontext.source_management.models import ManagementError +from powercontext.sources import ConnectorRunCompletion, ConnectorRunSession, ConnectorRunStatus + +from .definition import ChatCapture, ChatDefinition, TicketDefinition, TicketDetails + +plugin_api_version = "2" +connector_name = "example-ticket-chat" +connector_version = "1" +definitions = (TicketDefinition(), ChatDefinition()) + + +class Config(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + namespace: str = Field(min_length=1) + file: str = "events.json" + batch_size: int = Field(default=100, ge=1, le=1000) + interval_seconds: float = Field(default=5, gt=0) + + +capabilities = ( + Capability( + name="ticket-chat.dataset", + version="1", + description="Collect ticket and chat snapshots from the configured JSON dataset", + input_schema={"type": "object", "properties": {}, "additionalProperties": False}, + ), +) + + +async def identify_target(request, config): + return TargetIdentity(target_key=config.namespace, normalized_input={}) + + +class Checkpoint(BaseModel): + model_config = ConfigDict(extra="forbid") + + version: Literal["1"] = "1" + namespace: str + seen: dict[str, str] = Field(default_factory=dict) + + +class TicketChatConnector: + name = connector_name + version = connector_version + source_definitions = frozenset({"example-ticket", "example-chat"}) + + def __init__(self, ctx: PluginContext, config: Config) -> None: + self.ctx, self.config = ctx, config + self.path = ctx.plugin_dir / config.file + self.target = None + + async def run(self, session: ConnectorRunSession, /) -> ConnectorRunCompletion: + checkpoint = ( + Checkpoint.model_validate(session.checkpoint) + if session.checkpoint is not None + else Checkpoint(namespace=self.config.namespace) + ) + if checkpoint.namespace != self.config.namespace: + raise InvalidConnectorRunError("checkpoint", "namespace changed; use a new Binding ID") + rows = TypeAdapter(dict[str, list[dict[str, JsonValue]]]).validate_python( + json.loads(self.path.read_text(encoding="utf-8")) + ) + items = [ + ("example-ticket", TicketDetails.model_validate({**row, "namespace": self.config.namespace})) + for row in rows.get("tickets", []) + ] + [ + ("example-chat", ChatCapture.model_validate({**row, "namespace": self.config.namespace})) + for row in rows.get("chats", []) + ] + identities = [ + (kind, value.ticket_id if isinstance(value, TicketDetails) else value.chat_id) for kind, value in items + ] + if len(set(identities)) != len(identities): + raise InvalidConnectorRunError("duplicate-item", "fixture contains duplicate identities") + accepted = 0 + for (kind, value), (_, identity) in zip(items, identities, strict=True): + item_key = f"{kind}:{identity}" + fingerprint = hashlib.sha256( + json.dumps(value.model_dump(mode="json"), sort_keys=True, ensure_ascii=False).encode() + ).hexdigest() + if checkpoint.seen.get(item_key) != fingerprint and accepted < self.config.batch_size: + await self.ctx.targets.submit( + self.target, session=session, item_id=item_key, definition_name=kind, value=value + ) + checkpoint.seen[item_key] = fingerprint + accepted += 1 + return ConnectorRunCompletion(status=ConnectorRunStatus.COMPLETE, checkpoint=checkpoint.model_dump(mode="json")) + + +async def apply(ctx: PluginContext, config: Config) -> None: + connector = TicketChatConnector(ctx, config) + ctx.connect(connector) + while True: + for target in await ctx.targets.list(): + connector.target = target + scope_id = target.scope_id + try: + result = await ctx.run_batch(scope_id=scope_id, targets=(target,)) + except ManagementError as error: + if error.code != "stale-target": + raise + # A target can be stopped while another Scope's batch is running. + break + with (ctx.plugin_dir / "receipts.jsonl").open("a", encoding="utf-8") as receipts: + for item in result.items: + if item.source_ref is not None: + receipts.write( + json.dumps( + { + "scope_id": scope_id, + "item_id": item.item_id, + "source": item.source_ref.model_dump(mode="json"), + }, + ensure_ascii=False, + ) + + "\n" + ) + if result.status is not ConnectorRunStatus.COMPLETE: + raise PluginError("batch-incomplete", "ticket/chat batch did not complete") + await asyncio.sleep(config.interval_seconds) diff --git a/examples/source_plugins/ticket_chat/definition.py b/examples/source_plugins/ticket_chat/definition.py new file mode 100644 index 0000000000..91f071be7a --- /dev/null +++ b/examples/source_plugins/ticket_chat/definition.py @@ -0,0 +1,122 @@ +# 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. + +"""Two captured Source types with independent structured projection contracts.""" + +import hashlib +from typing import cast + +import rfc8785 +from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel + +from powercontext.sources import Source, SourceMaterialization, SourceProjection + + +class TicketDetails(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + namespace: str = Field(min_length=1) + ticket_id: str = Field(min_length=1) + title: str + body: str + labels: list[str] = Field(default_factory=list) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + +class ChatMessage(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + author: str + text: str + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + +class ChatCapture(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + namespace: str = Field(min_length=1) + chat_id: str = Field(min_length=1) + room: str + messages: list[ChatMessage] + + +class ChatMessages(RootModel[list[ChatMessage]]): + pass + + +class TicketSource(Source): + capture: TicketDetails + + +class ChatSource(Source): + capture: ChatCapture + + +class TicketDetailsProjection: + name = "example.ticket-details" + version = "1" + source_class = TicketSource + output_class = TicketDetails + + def project(self, source: TicketSource, /) -> TicketDetails: + return source.capture + + +class ChatMessagesProjection: + name = "example.chat-messages" + version = "2" + source_class = ChatSource + output_class = ChatMessages + + def project(self, source: ChatSource, /) -> ChatMessages: + return ChatMessages(source.capture.messages) + + +class TicketDefinition: + input_class = TicketDetails + source_class = TicketSource + name = "example-ticket" + version = "2" + projections = (cast(SourceProjection[TicketSource], TicketDetailsProjection()),) + + async def resolve(self, value: TicketDetails, /) -> TicketSource: + return TicketSource( + name="ticket-" + hashlib.sha256(rfc8785.dumps(value.model_dump(mode="json"))).hexdigest(), + definition_version=self.version, + materialization=SourceMaterialization.CAPTURED, + description=value.title, + capture=value, + ) + + async def read(self, source: TicketSource, /) -> TicketDetails: + return source.capture + + +class ChatDefinition: + input_class = ChatCapture + source_class = ChatSource + name = "example-chat" + version = "1" + projections = (cast(SourceProjection[ChatSource], ChatMessagesProjection()),) + + async def resolve(self, value: ChatCapture, /) -> ChatSource: + return ChatSource( + name="chat-" + hashlib.sha256(rfc8785.dumps(value.model_dump(mode="json"))).hexdigest(), + materialization=SourceMaterialization.CAPTURED, + description=value.room, + capture=value, + ) + + async def read(self, source: ChatSource, /) -> ChatCapture: + return source.capture diff --git a/examples/source_plugins/ticket_chat/events.json b/examples/source_plugins/ticket_chat/events.json new file mode 100644 index 0000000000..71e5af7e5e --- /dev/null +++ b/examples/source_plugins/ticket_chat/events.json @@ -0,0 +1,47 @@ +{ + "tickets": [ + { + "ticket_id": "42", + "title": "部署失败", + "body": "检查发布任务的构建日志。", + "labels": [ + "发布", + "数据库" + ], + "metadata": { + "priority": 3, + "active": true, + "owner": null, + "details": { + "attempts": 2 + } + } + } + ], + "chats": [ + { + "chat_id": "release-42", + "room": "发布支持", + "messages": [ + { + "author": "张三", + "text": "正在检查日志。", + "metadata": { + "edited": false, + "reply_to": null + } + }, + { + "author": "李四", + "text": "重试后已恢复。", + "metadata": { + "duration": 1.5, + "tags": [ + "恢复" + ] + } + } + ] + } + ] +} diff --git a/examples/source_plugins/ticket_chat/powercontext.yml b/examples/source_plugins/ticket_chat/powercontext.yml new file mode 100644 index 0000000000..a5c3ff0c3c --- /dev/null +++ b/examples/source_plugins/ticket_chat/powercontext.yml @@ -0,0 +1,27 @@ +# 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. + +id: ticket-chat-local +plugin: . +scope_ids: [SCOPE_ID] +config: + namespace: example-company + file: events.json + batch_size: 100 + interval_seconds: 5 +targets: + - scope_id: SCOPE_ID + capability: ticket-chat.dataset + capability_version: "1" + input: {} diff --git a/examples/source_plugins/tickets/__init__.py b/examples/source_plugins/tickets/__init__.py new file mode 100644 index 0000000000..a5f32bfc23 --- /dev/null +++ b/examples/source_plugins/tickets/__init__.py @@ -0,0 +1,137 @@ +# 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. + +"""A runnable local-file collector for dynamic ticket targets.""" + +from __future__ import annotations + +import asyncio +import json + +from pydantic import BaseModel, Field + +from powercontext.source_management import Capability, TargetIdentity +from powercontext.source_management.models import ManagementError +from powercontext.sources import ConnectorRunCompletion, ConnectorRunStatus, ConnectorSubmissionStatus + +from .definition import TicketCapture, TicketDefinition + +plugin_api_version = "2" +connector_name = "tickets" +connector_version = "1" +ticket_definition = TicketDefinition() +definitions = (ticket_definition,) +capabilities = ( + Capability( + name="tickets.item", + version="1", + description="Collect a ticket from the local JSON file", + input_schema={ + "type": "object", + "properties": {"id": {"type": "string", "minLength": 1}}, + "required": ["id"], + "additionalProperties": False, + }, + ), + Capability( + name="ticket.write", + version="1", + kind="write", + description="Save complete ticket content supplied by the host", + input_schema=TicketCapture.model_json_schema(), + definition_name=ticket_definition.name, + definition_version=ticket_definition.version, + ), +) + + +class Config(BaseModel): + file: str = "tickets.json" + namespace: str = "tickets" + project: str = "default" + poll_interval: float = Field(default=1, gt=0) + + +async def identify_target(request, config): + identifier = request.input["id"].strip() + if not identifier: + raise ValueError("Ticket ID must not be blank") # noqa: TRY003 + return TargetIdentity( + target_key=f"{config.namespace}:{config.project}:{identifier}", normalized_input={"id": identifier} + ) + + +class TicketConnector: + name = connector_name + version = connector_version + source_definitions = frozenset({ticket_definition.name}) + + def __init__(self, ctx, config): + self.ctx, self.config, self.targets = ctx, config, () + + async def run(self, session): + records = json.loads((self.ctx.plugin_dir / self.config.file).read_text(encoding="utf-8")) + values = {str(record["id"]): record for record in records} + seen = dict((session.checkpoint or {}).get("seen", {})) + complete = True + for target in self.targets: + ticket = values.get(target.input["id"]) + if ticket is None: + continue + content = ticket.get("body", "") + capture = TicketCapture( + namespace=self.config.namespace, + project=self.config.project, + ticket_id=target.input["id"], + title=ticket.get("title", ""), + body=content, + labels=ticket.get("labels", []), + metadata=ticket.get("metadata", {}), + ) + digest = capture.snapshot_id() + if seen.get(target.target_id) == digest: + continue + receipt = await self.ctx.targets.submit( + target, + session=session, + item_id=target.target_id, + definition_name=ticket_definition.name, + value=capture, + ) + accepted = receipt.status == ConnectorSubmissionStatus.ACCEPTED + + complete = complete and accepted + if accepted: + seen[target.target_id] = digest + return ConnectorRunCompletion( + status=ConnectorRunStatus.COMPLETE if complete else ConnectorRunStatus.INCOMPLETE, checkpoint={"seen": seen} + ) + + +async def apply(ctx, config): + connector = TicketConnector(ctx, config) + ctx.connect(connector) + while True: + targets = await ctx.targets.list() + for scope_id in sorted({target.scope_id for target in targets}): + selected = tuple(target for target in targets if target.scope_id == scope_id) + connector.targets = selected + try: + await ctx.run_batch(scope_id=scope_id, targets=selected) + except ManagementError as error: + if error.code != "stale-target": + raise + # Another Scope's batch may have outlived this target snapshot. + break + await asyncio.sleep(config.poll_interval) diff --git a/examples/source_plugins/tickets/definition.py b/examples/source_plugins/tickets/definition.py new file mode 100644 index 0000000000..1f98a0eaa6 --- /dev/null +++ b/examples/source_plugins/tickets/definition.py @@ -0,0 +1,98 @@ +# 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. + +"""Capture immutable ticket snapshots with text and structured projections.""" + +from __future__ import annotations + +import hashlib +from typing import cast + +import rfc8785 +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + Source, + SourceMaterialization, + SourceProjection, + TextEvidence, +) + + +class TicketCapture(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + namespace: str = Field(min_length=1) + project: str = Field(min_length=1) + ticket_id: str = Field(min_length=1) + title: str + body: str + labels: list[str] = Field(default_factory=list) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + def snapshot_id(self) -> str: + return "ticket-" + hashlib.sha256(rfc8785.dumps(self.model_dump(mode="json"))).hexdigest() + + +class TicketSource(Source): + capture: TicketCapture + + +class TicketDetails: + name = "example.ticket-details" + version = "1" + source_class = TicketSource + output_class = TicketCapture + + def project(self, source: TicketSource, /) -> TicketCapture: + return source.capture + + +class TicketText: + name = TEXT_EVIDENCE_PROJECTION_KEY.name + version = TEXT_EVIDENCE_PROJECTION_KEY.version + source_class = TicketSource + output_class = TextEvidence + + def project(self, source: TicketSource, /) -> TextEvidence: + ticket = source.capture + return TextEvidence( + source_type="example-ticket", + source_id=source.name, + content=f"{ticket.title}\n\n{ticket.body}", + metadata={"namespace": ticket.namespace, "project": ticket.project, "ticket_id": ticket.ticket_id}, + ) + + +class TicketDefinition: + name = "example-ticket" + version = "1" + input_class = TicketCapture + source_class = TicketSource + projections = ( + cast(SourceProjection[TicketSource], TicketDetails()), + cast(SourceProjection[TicketSource], TicketText()), + ) + + async def resolve(self, value: TicketCapture, /) -> TicketSource: + return TicketSource( + name=value.snapshot_id(), + materialization=SourceMaterialization.CAPTURED, + description=f"Ticket {value.ticket_id}", + capture=value, + ) + + async def read(self, source: TicketSource, /) -> TicketCapture: + return source.capture diff --git a/examples/source_plugins/tickets/powercontext.yml b/examples/source_plugins/tickets/powercontext.yml new file mode 100644 index 0000000000..05dd9e46a7 --- /dev/null +++ b/examples/source_plugins/tickets/powercontext.yml @@ -0,0 +1,20 @@ +# 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. + +id: my-tickets +plugin: . +scope_ids: [SCOPE_ID] +config: + file: tickets.json + poll_interval: 10 diff --git a/examples/source_plugins/tickets/tickets.json b/examples/source_plugins/tickets/tickets.json new file mode 100644 index 0000000000..d4c8076316 --- /dev/null +++ b/examples/source_plugins/tickets/tickets.json @@ -0,0 +1,13 @@ +[ + { + "id": "A", + "title": "Example ticket", + "body": "First document", + "labels": [ + "example" + ], + "metadata": { + "priority": 1 + } + } +] diff --git a/examples/source_plugins/tickets/write.yml b/examples/source_plugins/tickets/write.yml new file mode 100644 index 0000000000..c677a8557f --- /dev/null +++ b/examples/source_plugins/tickets/write.yml @@ -0,0 +1,16 @@ +# 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. + +id: local-tickets +entry: . diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index 16333c6eb6..d030e45beb 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -104,6 +104,24 @@ capabilities = ["memory_write"] id = "capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] +id = "pc_source_capabilities" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_add" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_status" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_target_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_instances" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_instance_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] id = "capture_working_note" capabilities = ["source_capture"] [[toolsets.tools]] @@ -319,6 +337,24 @@ capabilities = ["memory_write"] id = "mcp__powercontext__capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] +id = "mcp__powercontext__pc_source_capabilities" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "mcp__powercontext__pc_source_add" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "mcp__powercontext__pc_source_status" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "mcp__powercontext__pc_source_target_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "mcp__powercontext__pc_source_instances" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "mcp__powercontext__pc_source_instance_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] id = "mcp__powercontext__capture_working_note" capabilities = ["source_capture"] [[toolsets.tools]] @@ -1151,6 +1187,24 @@ capabilities = ["memory_write"] id = "capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] +id = "pc_source_capabilities" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_add" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_status" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_target_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_instances" +capabilities = ["source_capture"] +[[toolsets.tools]] +id = "pc_source_instance_set_enabled" +capabilities = ["source_capture"] +[[toolsets.tools]] id = "capture_working_note" capabilities = ["source_capture"] [[toolsets.tools]] diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index dd9f77e131..b663b52c71 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -1552,6 +1552,160 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + pc_source_capabilities: { + method: "POST", + path: "/v1/source-management/capabilities", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/source-management/add", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_status: { + method: "POST", + path: "/v1/source-management/status", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "POST", + path: "/v1/source-management/target-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instances: { + method: "POST", + path: "/v1/source-management/instances", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instance_set_enabled: { + method: "POST", + path: "/v1/source-management/instance-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_configure: { + method: "POST", + path: "/v1/source-management/configure", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + load_source_write_plugin: { + method: "POST", + path: "/v1/source-management/write/load", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_open: { + method: "POST", + path: "/v1/source-management/channel/open", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_catalog: { + method: "POST", + path: "/v1/source-management/channel/catalog", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_poll: { + method: "POST", + path: "/v1/source-management/channel/poll", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_reply: { + method: "POST", + path: "/v1/source-management/channel/reply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_close: { + method: "POST", + path: "/v1/source-management/channel/close", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_instance_exchange: { + method: "POST", + path: "/v1/source-management/instances/{instance_id}/{operation}", + location: "body", + scopeMode: "none", + pathParameters: ["instance_id", "operation"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 5c1eaf194a..355098a0b1 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1621,6 +1621,160 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + pc_source_capabilities: { + method: "POST", + path: "/v1/source-management/capabilities", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/source-management/add", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_status: { + method: "POST", + path: "/v1/source-management/status", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "POST", + path: "/v1/source-management/target-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instances: { + method: "POST", + path: "/v1/source-management/instances", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instance_set_enabled: { + method: "POST", + path: "/v1/source-management/instance-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_configure: { + method: "POST", + path: "/v1/source-management/configure", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + load_source_write_plugin: { + method: "POST", + path: "/v1/source-management/write/load", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_open: { + method: "POST", + path: "/v1/source-management/channel/open", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_catalog: { + method: "POST", + path: "/v1/source-management/channel/catalog", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_poll: { + method: "POST", + path: "/v1/source-management/channel/poll", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_reply: { + method: "POST", + path: "/v1/source-management/channel/reply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_close: { + method: "POST", + path: "/v1/source-management/channel/close", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_instance_exchange: { + method: "POST", + path: "/v1/source-management/instances/{instance_id}/{operation}", + location: "body", + scopeMode: "none", + pathParameters: ["instance_id", "operation"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); @@ -2679,6 +2833,7 @@ const PAYLOAD_WRITES = new Set([ "change_atomic_memory_lifecycle" ]); const CONTENT_FIELDS = new Map([ + ["pc_source_add", ["input"]], ["create_scope", [ "title", "summary", @@ -3419,6 +3574,12 @@ const MCP_OPERATIONS = { "list_memory_entries": { "readOnly": true }, "list_scopes": { "readOnly": true }, "merge_atomic_memories": { "readOnly": false }, + "pc_source_add": { "readOnly": false }, + "pc_source_capabilities": { "readOnly": true }, + "pc_source_instance_set_enabled": { "readOnly": false }, + "pc_source_instances": { "readOnly": true }, + "pc_source_status": { "readOnly": true }, + "pc_source_target_set_enabled": { "readOnly": false }, "prepare_handoff_hint": { "readOnly": true }, "preview_atomic_memory_restoration": { "readOnly": true }, "propose_experience": { "readOnly": false }, @@ -3584,7 +3745,14 @@ function rawOperation(name$1) { const operation = name$1.slice(MCP_TOOL_PREFIX.length); return Object.prototype.hasOwnProperty.call(MCP_OPERATIONS, operation) ? operation : void 0; } -function usesScope(operation) { +function usesScope(operation, args) { + if ([ + "pc_source_capabilities", + "pc_source_add", + "pc_source_status", + "pc_source_target_set_enabled" + ].includes(operation)) return "current"; + if (operation === "pc_source_instances") return isRecord(args) && args.view === "control" ? "none" : "current"; const metadata = OPERATIONS[operation]; if (metadata.pathParameters.some((parameter) => parameter === "scope_id")) return "current"; return metadata.scopeMode; @@ -3607,7 +3775,7 @@ function registerMcpPolicy(ctx, resolveScope) { kind: "deny", reason: "secret_rejected: PowerContext refuses to send likely secret content. No MCP request was sent." }; - const mode = usesScope(operation); + const mode = usesScope(operation, exec.arguments); if (mode !== "none") { const cwd = exec.agent?.session?.header?.cwd; let scopeId; diff --git a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts index e17c46b64b..69ce14fd33 100644 --- a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts @@ -131,6 +131,24 @@ export const MCP_OPERATIONS = { "merge_atomic_memories": { "readOnly": false }, + "pc_source_add": { + "readOnly": false + }, + "pc_source_capabilities": { + "readOnly": true + }, + "pc_source_instance_set_enabled": { + "readOnly": false + }, + "pc_source_instances": { + "readOnly": true + }, + "pc_source_status": { + "readOnly": true + }, + "pc_source_target_set_enabled": { + "readOnly": false + }, "prepare_handoff_hint": { "readOnly": true }, diff --git a/integrations/dsh/plugins/powercontext/src/mcp.ts b/integrations/dsh/plugins/powercontext/src/mcp.ts index ad82791b13..3cb36fbd00 100644 --- a/integrations/dsh/plugins/powercontext/src/mcp.ts +++ b/integrations/dsh/plugins/powercontext/src/mcp.ts @@ -71,7 +71,10 @@ function rawOperation(name: string): string | undefined { return Object.prototype.hasOwnProperty.call(MCP_OPERATIONS, operation) ? operation : undefined } -function usesScope(operation: string): 'current' | 'selection' | 'none' { +function usesScope(operation: string, args: unknown): 'current' | 'selection' | 'none' { + // Source business calls carry scope_id in their body; instance controls are Server-wide. + if (['pc_source_capabilities', 'pc_source_add', 'pc_source_status', 'pc_source_target_set_enabled'].includes(operation)) return 'current' + if (operation === 'pc_source_instances') return isRecord(args) && args.view === 'control' ? 'none' : 'current' const metadata = OPERATIONS[operation as keyof typeof OPERATIONS] // scopeMode describes implicit Scope resolution; path scope_id still binds an MCP call to the host Scope. if (metadata.pathParameters.some(parameter => parameter === 'scope_id')) return 'current' @@ -103,7 +106,7 @@ export function registerMcpPolicy( return { kind: 'deny', reason: 'secret_rejected: PowerContext refuses to send likely secret content. No MCP request was sent.' } } - const mode = usesScope(operation) + const mode = usesScope(operation, exec.arguments) if (mode !== 'none') { const cwd = exec.agent?.session?.header?.cwd let scopeId: string | undefined diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 0e843a3a33..d235ab781c 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -144,6 +144,20 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_capabilities: { method: 'POST', path: '/v1/source-management/capabilities', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/source-management/add', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_status: { method: 'POST', path: '/v1/source-management/status', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'POST', path: '/v1/source-management/target-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instances: { method: 'POST', path: '/v1/source-management/instances', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instance_set_enabled: { method: 'POST', path: '/v1/source-management/instance-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_configure: { method: 'POST', path: '/v1/source-management/configure', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + load_source_write_plugin: { method: 'POST', path: '/v1/source-management/write/load', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_open: { method: 'POST', path: '/v1/source-management/channel/open', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_catalog: { method: 'POST', path: '/v1/source-management/channel/catalog', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_poll: { method: 'POST', path: '/v1/source-management/channel/poll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/dsh/plugins/powercontext/src/secrets.ts b/integrations/dsh/plugins/powercontext/src/secrets.ts index 8a7937c10e..e3fc1975dc 100644 --- a/integrations/dsh/plugins/powercontext/src/secrets.ts +++ b/integrations/dsh/plugins/powercontext/src/secrets.ts @@ -20,6 +20,7 @@ const PAYLOAD_WRITES = new Set([ 'replace_artifact', 'change_atomic_memory_lifecycle', ]) const CONTENT_FIELDS = new Map([ + ['pc_source_add', ['input']], ['create_scope', ['title', 'summary', 'external_references']], ['create_work_contract', ['contract']], ['record_task_outcome', ['outcome']], diff --git a/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts b/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts index 456e252d37..76f5796e88 100644 --- a/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts @@ -124,6 +124,48 @@ describe('PowerContext MCP bridge', () => { }, ) + it.each([ + 'pc_source_capabilities', 'pc_source_status', 'pc_source_add', + 'pc_source_target_set_enabled', 'pc_source_instances', + ])('binds %s to the host Scope before allowing or asking', async operation => { + for (const requested of ['scope-b', undefined]) { + const next = vi.fn(async () => ({ kind: 'allow' as const })) + const result = await policyHook()({ + name: `mcp__powercontext__${operation}`, + arguments: { scope_id: requested }, + signal: new AbortController().signal, + }, next) + expect(result).toMatchObject({ kind: 'deny' }) + expect(next).not.toHaveBeenCalled() + } + const result = await policyHook()({ + name: `mcp__powercontext__${operation}`, + arguments: { scope_id: 'scope-a' }, + signal: new AbortController().signal, + }, async () => ({ kind: 'allow' })) + expect(result).toMatchObject({ kind: MCP_OPERATIONS[operation as keyof typeof MCP_OPERATIONS].readOnly ? 'allow' : 'ask' }) + const unresolved = await policyHook(async () => undefined)({ + name: `mcp__powercontext__${operation}`, + arguments: { scope_id: 'scope-a' }, + signal: new AbortController().signal, + }, async () => ({ kind: 'allow' })) + expect(unresolved).toMatchObject({ kind: 'deny' }) + }) + + it.each([ + ['pc_source_instances', { instance_id: 'tickets', view: 'control' }, 'allow'], + ['pc_source_instance_set_enabled', { instance_id: 'tickets', enabled: false }, 'ask'], + ])('keeps administrator operation %s independent of the host Scope', async (operation, args, kind) => { + const resolveScope = vi.fn(async () => undefined) + const result = await policyHook(resolveScope)({ + name: `mcp__powercontext__${operation}`, + arguments: args, + signal: new AbortController().signal, + }, async () => ({ kind: 'allow' })) + expect(result).toMatchObject({ kind }) + expect(resolveScope).not.toHaveBeenCalled() + }) + it('asks before candidate approval even when the MCP server only advertises annotations', async () => { const next = vi.fn(async () => ({ kind: 'allow' as const })) const result = await policyHook()({ @@ -137,6 +179,8 @@ describe('PowerContext MCP bridge', () => { }) it.each([ + ['pc_source_add', { capability: 'content.inline', input: { text: 'sk-review-fixture-only' } }], + ['pc_source_add', { capability: 'ticket.write', input: { metadata: { notes: ['api_key=FAKE_REVIEW_CANARY'] } } }], ['remember_memory', { text: 'api_key=FAKE_REVIEW_MARKER' }], ['replace_artifact', { content: { kind: 'decision', text: 'api_key=FAKE_REVIEW_MARKER' } }], ['merge_atomic_memories', { content: { kind: 'decision', text: 'api_key=FAKE_REVIEW_MARKER' } }], diff --git a/integrations/kimi-code/plugins/powercontext/profiles.json b/integrations/kimi-code/plugins/powercontext/profiles.json index 5e35aee5be..2fb8623e83 100644 --- a/integrations/kimi-code/plugins/powercontext/profiles.json +++ b/integrations/kimi-code/plugins/powercontext/profiles.json @@ -26,6 +26,12 @@ "list_atomic_memories", "list_memory_entries", "merge_atomic_memories", + "pc_source_add", + "pc_source_capabilities", + "pc_source_instance_set_enabled", + "pc_source_instances", + "pc_source_status", + "pc_source_target_set_enabled", "prepare_handoff_hint", "preview_atomic_memory_restoration", "record_task_outcome", @@ -67,6 +73,12 @@ "list_managed_skills", "list_memory_entries", "merge_atomic_memories", + "pc_source_add", + "pc_source_capabilities", + "pc_source_instance_set_enabled", + "pc_source_instances", + "pc_source_status", + "pc_source_target_set_enabled", "prepare_handoff_hint", "preview_atomic_memory_restoration", "propose_experience", diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 0e843a3a33..d235ab781c 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -144,6 +144,20 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_capabilities: { method: 'POST', path: '/v1/source-management/capabilities', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/source-management/add', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_status: { method: 'POST', path: '/v1/source-management/status', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'POST', path: '/v1/source-management/target-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instances: { method: 'POST', path: '/v1/source-management/instances', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instance_set_enabled: { method: 'POST', path: '/v1/source-management/instance-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_configure: { method: 'POST', path: '/v1/source-management/configure', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + load_source_write_plugin: { method: 'POST', path: '/v1/source-management/write/load', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_open: { method: 'POST', path: '/v1/source-management/channel/open', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_catalog: { method: 'POST', path: '/v1/source-management/channel/catalog', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_poll: { method: 'POST', path: '/v1/source-management/channel/poll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 0e843a3a33..d235ab781c 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -144,6 +144,20 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_capabilities: { method: 'POST', path: '/v1/source-management/capabilities', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/source-management/add', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_status: { method: 'POST', path: '/v1/source-management/status', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'POST', path: '/v1/source-management/target-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instances: { method: 'POST', path: '/v1/source-management/instances', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_instance_set_enabled: { method: 'POST', path: '/v1/source-management/instance-set-enabled', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_configure: { method: 'POST', path: '/v1/source-management/configure', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + load_source_write_plugin: { method: 'POST', path: '/v1/source-management/write/load', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_open: { method: 'POST', path: '/v1/source-management/channel/open', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_catalog: { method: 'POST', path: '/v1/source-management/channel/catalog', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_poll: { method: 'POST', path: '/v1/source-management/channel/poll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_reply: { method: 'POST', path: '/v1/source-management/channel/reply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_channel_close: { method: 'POST', path: '/v1/source-management/channel/close', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + source_instance_exchange: { method: 'POST', path: '/v1/source-management/instances/{instance_id}/{operation}', location: "body", scopeMode: 'none', pathParameters: ['instance_id', 'operation'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 8487b03ea4..3d637e34f5 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3903,7 +3903,9 @@ paths: get: tags: [sources] summary: List public Sources in one Scope - description: List a snapshot-bounded page of public Content Sources in ascending journal position. + description: >- + List public Content Sources and registered Source Observations in one Scope in ascending journal position. + Subsequent pages retain the Source type selection and journal upper bound recorded in the cursor. operationId: list_sources x-powercontext-access: {resolver: path_scope_read_access} parameters: @@ -4021,7 +4023,9 @@ paths: required: true schema: type: string - enum: [content] + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' - name: source_id in: path required: true @@ -5109,197 +5113,742 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. - TargetBearerAuth: - type: http - scheme: bearer - description: Per-target credential issued once during remote Receiver enrollment. - headers: - ArtifactETag: - description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. - schema: - type: string - minLength: 1 - BearerChallenge: - description: Authentication scheme required by the Server. - schema: - type: string - example: Bearer - RequestId: - description: Opaque identifier for correlating one request. - schema: - type: string - Location: - description: URI of the newly created Source or Artifact head. - schema: - type: string - responses: - ClientUpgradeRequired: - description: >- - This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, - CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible - candidate shapes remain available without the header. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - BadRequest: - description: The request query or pagination cursor is invalid. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - CursorExpired: - description: The pagination cursor has expired. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Unauthorized: - description: A valid bearer token is required by this Server deployment. - headers: - WWW-Authenticate: - $ref: "#/components/headers/BearerChallenge" - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Conflict: - description: The command conflicts with current immutable state. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - PreconditionFailed: - description: If-Match does not identify the current Artifact head. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - PreconditionRequired: - description: A current Artifact ETag is required in If-Match. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InvalidRequest: - description: The request violates the transport or application contract. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: The requested durable value was not found or is not observable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Unavailable: - description: A required Runtime binding or dependency is unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InternalError: - description: The Server failed without exposing internal details. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Forbidden: - description: The authenticated Principal is not authorized for the requested action and resource. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - 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 + /v1/source-management/capabilities: + post: + tags: + - source-management + summary: pc source capabilities + operationId: pc_source_capabilities + x-powercontext-scope-mode: none + x-powercontext-access: + action: scope.read + resource: + type: scope + scope-id-from: scope_id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceCapabilityQuery' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceCapabilityResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/add: + post: + tags: + - source-management + summary: pc source add + operationId: pc_source_add 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 + Use the discovered write or collect capability and version. Collect capabilities require an explicit instance_id. Write runs in the Server using loaded Definition and Projection implementations, without an instance or management service. Write waits for Source references or an error in the original call; no durable request or status query is created. Collect establishes or reuses a persistent target and never resumes a stopped target. No mode or idempotency_key is accepted; write retries use the underlying Source identity rules. + x-powercontext-scope-mode: none + x-powercontext-access: + action: scope.contribute + resource: + type: scope + scope-id-from: scope_id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceAddRequest' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceManagementReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/status: + post: + tags: + - source-management + summary: pc source status + operationId: pc_source_status 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. + Query a collect target by scope_id and target_id. Result view requires scope.read and returns received Source references and submission time, not per-round completion. Control view requires scope.contribute and returns enabled and tracking without input or results. + x-powercontext-scope-mode: none + x-powercontext-access: + resolver: source_status_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceStatusRequest' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceManagementReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/target-set-enabled: + post: + tags: + - source-management + summary: Stop or resume a collect target + operationId: pc_source_target_set_enabled + description: >- + Set collect target enabled state using scope.contribute. Repeating the current state is safe. Resume requires stopped confirmation; it does not restart an ended apply or enable a disabled instance. No revision or retry key is required. + x-powercontext-scope-mode: none + x-powercontext-access: + action: scope.contribute + resource: + type: scope + scope-id-from: scope_id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceTargetSetEnabledRequest' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceManagementReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/instances: + post: + tags: + - source-management + summary: pc source instances + operationId: pc_source_instances + description: >- + Discover instances with scope_id from the Server capability catalog. Control view requires instance_id and server.admin, and returns enabled, actual and availability without business configuration. + x-powercontext-scope-mode: none + x-powercontext-access: + resolver: source_instances_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstancesQuery' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstancesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/instance-set-enabled: + post: + tags: + - source-management + summary: pc source instance set enabled + operationId: pc_source_instance_set_enabled + description: >- + Set instance business enabled state using server.admin. Repeating the same state does not restart apply. Restarting an ended run requires disable, confirmed stopped, then enable. The Runtime process stays online. + x-powercontext-scope-mode: none + x-powercontext-access: + action: server.admin + resource: {type: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstanceSetEnabledRequest' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstanceStatus' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/configure: + post: + tags: + - source-management + summary: source configure + operationId: source_configure + x-powercontext-scope-mode: none + x-powercontext-access: + action: server.admin + resource: {type: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceConfigureRequest' + responses: + '200': + description: Confirmed Source management result. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstanceStatus' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/write/load: + post: + tags: [source-management] + summary: Load a trusted Server-local Source write plugin + operationId: load_source_write_plugin + description: >- + Requires server.admin. The Server imports the local directory or module, validates and registers its + Source Definitions, and persists its entry for restart. An identical deployment is reusable; + conflicting configurations fail. Existing code is replaced only through controlled Server restart. + x-powercontext-scope-mode: none + x-powercontext-access: + action: server.admin + resource: {type: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceWritePluginLoadRequest' + responses: + '200': + description: The Server has loaded and persisted the executable write capabilities. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceWritePluginState' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/channel/open: + post: + tags: + - source-management-channel + summary: Source manager reverse channel open + operationId: source_channel_open + x-powercontext-access: + resolver: source_management_channel_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelOpen' + responses: + '200': + description: Channel exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/channel/catalog: + post: + tags: + - source-management-channel + summary: Source manager reverse channel catalog + operationId: source_channel_catalog + x-powercontext-access: + resolver: source_management_channel_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelRequest' + responses: + '200': + description: Channel exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/channel/poll: + post: + tags: + - source-management-channel + summary: Source manager reverse channel poll + operationId: source_channel_poll + x-powercontext-access: + resolver: source_management_channel_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelRequest' + responses: + '200': + description: Channel exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/channel/reply: + post: + tags: + - source-management-channel + summary: Source manager reverse channel reply + operationId: source_channel_reply + x-powercontext-access: + resolver: source_management_channel_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelRequest' + responses: + '200': + description: Channel exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/channel/close: + post: + tags: + - source-management-channel + summary: Source manager reverse channel close + operationId: source_channel_close + x-powercontext-access: + resolver: source_management_channel_access + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelRequest' + responses: + '200': + description: Channel exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceChannelResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + /v1/source-management/instances/{instance_id}/{operation}: + post: + tags: + - source-management-instance + summary: Authenticated instance control, permission checks and durable result + exchange + description: Served by the optional standalone Source manager. Instances initiate + every exchange; this is not a Server or public MCP endpoint. The operation path + chooses owner/connect/poll/reply/sync/execution/results/stopped-targets/close and + the applicable fields. Source observations continue to go directly to the PC + Server. + operationId: source_instance_exchange + servers: + - url: http://127.0.0.1:8010 + description: Replace with the configured Source manager instance endpoint; use + HTTPS off loopback. + x-powercontext-access: + resolver: source_instance_channel_access + x-powercontext-role: source-manager + x-powercontext-scope-mode: none + parameters: + - name: instance_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: ^[A-Za-z0-9_.-]+$ + - name: operation + in: path + required: true + schema: + type: string + enum: + - owner + - connect + - poll + - reply + - sync + - execution + - results + - stopped-targets + - close + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstanceExchangeRequest' + responses: + '200': + description: Instance control exchange. + content: + application/json: + schema: + $ref: '#/components/schemas/SourceInstanceExchangeResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. + TargetBearerAuth: + type: http + scheme: bearer + description: Per-target credential issued once during remote Receiver enrollment. + headers: + ArtifactETag: + description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. + schema: + type: string + minLength: 1 + BearerChallenge: + description: Authentication scheme required by the Server. + schema: + type: string + example: Bearer + RequestId: + description: Opaque identifier for correlating one request. + schema: + type: string + Location: + description: URI of the newly created Source or Artifact head. + schema: + type: string + responses: + ClientUpgradeRequired: + description: >- + This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, + CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible + candidate shapes remain available without the header. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + BadRequest: + description: The request query or pagination cursor is invalid. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + CursorExpired: + description: The pagination cursor has expired. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unauthorized: + description: A valid bearer token is required by this Server deployment. + headers: + WWW-Authenticate: + $ref: "#/components/headers/BearerChallenge" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The command conflicts with current immutable state. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + PreconditionFailed: + description: If-Match does not identify the current Artifact head. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + PreconditionRequired: + description: A current Artifact ETag is required in If-Match. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InvalidRequest: + description: The request violates the transport or application contract. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ReportTooLarge: + description: The selected Handoff Report exceeds the deterministic output limit. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: The requested durable value was not found or is not observable. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unavailable: + description: A required Runtime binding or dependency is unavailable. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InternalError: + description: The Server failed without exposing internal details. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: The authenticated Principal is not authorized for the requested action and resource. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + 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] @@ -12098,7 +12647,9 @@ components: pattern: '.*\S.*' source_type: type: string - enum: [content] + minLength: 1 + maxLength: 128 + description: Built-in content or a registered Source Observation type. source_id: type: string minLength: 1 @@ -12707,154 +13258,550 @@ components: type: object additionalProperties: false required: - - cursor - - event_id - - occurred_at - - request_id - - transport - - operation - - principal - - actor - - action - - resource - - allowed - - reason_code - - policy_revision - - matched_subject - - binding_id - - target - - role - - expected_version - - result_version + - cursor + - event_id + - occurred_at + - request_id + - transport + - operation + - principal + - actor + - action + - resource + - allowed + - reason_code + - policy_revision + - matched_subject + - binding_id + - target + - role + - expected_version + - result_version + properties: + cursor: {type: integer, minimum: 1} + event_id: {type: string, minLength: 1, maxLength: 64} + occurred_at: {type: string, format: date-time} + request_id: {type: string, maxLength: 128, nullable: true} + transport: {type: string, minLength: 1, maxLength: 16} + operation: {type: string, minLength: 1, maxLength: 128} + principal: + $ref: "#/components/schemas/AccessPrincipal" + actor: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + matched_subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + binding_id: {type: string, maxLength: 64, nullable: true} + target: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + expected_version: {type: integer, minimum: 1, nullable: true} + result_version: {type: integer, minimum: 1, nullable: true} + AccessAuditPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessAuditEvent" + next_cursor: {type: string, maxLength: 2048, nullable: true} + TagDreamTarget: + type: object + additionalProperties: false + required: + - target + - expected_etag + - basis_ref + properties: + target: &id002 + $ref: '#/components/schemas/TagTarget' + expected_etag: &id001 + type: string + minLength: 1 + basis_ref: &id003 + $ref: '#/components/schemas/ArtifactReference' + CandidateAudit: + type: object + additionalProperties: false + required: + - origin + - operation + - dream_run_id + - spec_version + - proposal_digest + - evidence_manifest_ref + - validation_policy_digest + - proposal_fingerprint + properties: + origin: *id001 + operation: *id001 + dream_run_id: *id001 + spec_version: *id001 + proposal_digest: *id001 + evidence_manifest_ref: *id001 + validation_policy_digest: *id001 + proposal_fingerprint: + type: string + nullable: true + CatalogChangeProposal: + type: object + additionalProperties: false + required: + - target + - expected_etag + - basis_ref + - before_tags + - after_tags + properties: + target: *id002 + expected_etag: *id001 + basis_ref: *id003 + before_tags: &id005 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + maxItems: 32 + after_tags: *id005 + CatalogTagResult: + type: object + additionalProperties: false + required: + - scope_id + - target + - tags + - tag_digest + - etag + properties: + scope_id: *id001 + target: + $ref: '#/components/schemas/TagTarget' + tags: *id005 + tag_digest: *id001 + etag: *id001 + CandidateHistory: + type: object + additionalProperties: false + required: + - versions + properties: + versions: + type: array + items: + $ref: '#/components/schemas/Candidate' + default: [] + SourceCapabilityQuery: + type: object + additionalProperties: false + required: + - scope_id + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + capability: + type: string + minLength: 1 + maxLength: 256 + instance_id: + type: string + minLength: 1 + maxLength: 256 + input: &source_mgmt_001 + type: object + additionalProperties: true + SourceWritePluginLoadRequest: + type: object + additionalProperties: false + required: [id, entry] + properties: + id: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]*$'} + entry: {type: string, minLength: 1, maxLength: 4096} + SourceWritePluginState: + type: object + additionalProperties: false + required: [id, entry, capabilities] + properties: + id: {type: string} + entry: {type: string} + capabilities: + type: array + items: {type: object, additionalProperties: true} + SourceAddRequest: + type: object + additionalProperties: false + required: + - scope_id + - capability + - capability_version + - input + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + capability: + type: string + minLength: 1 + maxLength: 256 + capability_version: + type: string + minLength: 1 + maxLength: 256 + instance_id: + type: string + minLength: 1 + maxLength: 256 + input: + type: object + additionalProperties: true + SourceStatusRequest: + type: object + additionalProperties: false + required: + - scope_id + - target_id + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 256 + view: + type: string + enum: + - result + - control + default: result + SourceInstanceSetEnabledRequest: + type: object + additionalProperties: false + required: + - instance_id + - enabled + properties: + instance_id: + type: string + minLength: 1 + maxLength: 256 + enabled: + type: boolean + SourceConfigureRequest: + type: object + additionalProperties: false + required: + - registration + properties: + update: + type: boolean + default: false + registration: *source_mgmt_001 + SourceCapabilityResponse: + type: object + additionalProperties: false + required: + - capabilities + - management + properties: + capabilities: + type: array + items: *source_mgmt_001 + management: + type: string + enum: + - not-configured + - online + - unavailable + SourceInstancesResponse: + type: object + additionalProperties: false + required: + - instances + properties: + instances: + type: array + items: *source_mgmt_001 + capabilities: + type: array + items: *source_mgmt_001 + management: + type: string + enum: + - not-configured + - online + - unavailable + SourceCollectionResult: + type: object + additionalProperties: false + required: + - outcome + - source_refs properties: - cursor: {type: integer, minimum: 1} - event_id: {type: string, minLength: 1, maxLength: 64} - occurred_at: {type: string, format: date-time} - request_id: {type: string, maxLength: 128, nullable: true} - transport: {type: string, minLength: 1, maxLength: 16} - operation: {type: string, minLength: 1, maxLength: 128} - principal: - $ref: "#/components/schemas/AccessPrincipal" - actor: - allOf: - - $ref: "#/components/schemas/AccessPrincipal" - nullable: true - action: - $ref: "#/components/schemas/AccessAction" - resource: - $ref: "#/components/schemas/AccessResource" - allowed: {type: boolean} - reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} - matched_subject: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - binding_id: {type: string, maxLength: 64, nullable: true} - target: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - role: - allOf: - - $ref: "#/components/schemas/AccessRole" - nullable: true - expected_version: {type: integer, minimum: 1, nullable: true} - result_version: {type: integer, minimum: 1, nullable: true} - AccessAuditPage: + outcome: + type: string + enum: + - saved + - unchanged + source_refs: + type: array + items: + $ref: '#/components/schemas/SourceTypeReference' + SourceManagementReceipt: type: object additionalProperties: false - required: [items, next_cursor] properties: - items: + target_id: + type: string + minLength: 1 + maxLength: 256 + tracking: + type: string + enum: + - enabled + - stopping + - stopped + result: + $ref: '#/components/schemas/SourceCollectionResult' + error: + type: string + instance_id: + type: string + minLength: 1 + maxLength: 256 + capability: + type: string + minLength: 1 + maxLength: 256 + capability_version: + type: string + minLength: 1 + maxLength: 256 + availability: + type: string + enum: + - online + - offline + - unavailable + updated_at: + type: string + kind: + type: string + enum: + - collect + - write + enabled: + type: boolean + source_refs: + description: Default collect status only. The single Source reference from the submission report most recently received by the manager, or empty before any report. Omitted from collect add responses; not a batch result or submission history. type: array - maxItems: 500 + maxItems: 1 items: - $ref: "#/components/schemas/AccessAuditEvent" - next_cursor: {type: string, maxLength: 2048, nullable: true} - TagDreamTarget: + $ref: '#/components/schemas/SourceTypeReference' + last_submitted_at: + type: string + nullable: true + description: Default collect status only. Runtime-reported submission time paired with source_refs, or null before any report. Selection follows manager receipt order, not timestamp order. Omitted from collect add responses. + SourceInstanceStatus: type: object additionalProperties: false required: - - target - - expected_etag - - basis_ref + - instance_id properties: - target: &id002 - $ref: '#/components/schemas/TagTarget' - expected_etag: &id001 + targets_initialized: + type: boolean + target_id: + type: string + instance_id: type: string minLength: 1 - basis_ref: &id003 - $ref: '#/components/schemas/ArtifactReference' - CandidateAudit: + maxLength: 256 + enabled: + type: boolean + actual: + type: string + run_id: + type: string + nullable: true + availability: + type: string + reused: + type: boolean + SourceChannelOpen: type: object additionalProperties: false required: - - origin - - operation - - dream_run_id - - spec_version - - proposal_digest - - evidence_manifest_ref - - validation_policy_digest - - proposal_fingerprint + - protocol + - deployment_id + - manager_id properties: - origin: *id001 - operation: *id001 - dream_run_id: *id001 - spec_version: *id001 - proposal_digest: *id001 - evidence_manifest_ref: *id001 - validation_policy_digest: *id001 - proposal_fingerprint: + protocol: type: string - nullable: true - CatalogChangeProposal: + minLength: 1 + maxLength: 256 + deployment_id: + type: string + minLength: 1 + maxLength: 256 + manager_id: + type: string + minLength: 1 + maxLength: 256 + SourceChannelRequest: type: object additionalProperties: false required: - - target - - expected_etag - - basis_ref - - before_tags - - after_tags + - generation properties: - target: *id002 - expected_etag: *id001 - basis_ref: *id003 - before_tags: &id005 + generation: + type: string + minLength: 1 + maxLength: 256 + message_id: + type: string + minLength: 1 + maxLength: 256 + response: *source_mgmt_001 + catalog: *source_mgmt_001 + SourceChannelResponse: + type: object + additionalProperties: false + properties: + generation: + type: string + minLength: 1 + maxLength: 256 + protocol: + type: string + minLength: 1 + maxLength: 256 + confirmed: + type: boolean + messages: + type: array + items: *source_mgmt_001 + SourceInstanceExchangeRequest: + type: object + additionalProperties: false + properties: + generation: &source_mgmt_002 + type: string + minLength: 1 + maxLength: 256 + protocol: *source_mgmt_002 + message_id: *source_mgmt_002 + execution_location: *source_mgmt_002 + configuration_digest: *source_mgmt_002 + run_id: *source_mgmt_002 + execution: *source_mgmt_002 + target_id: *source_mgmt_002 + capabilities: + type: array + maxItems: 64 + items: &source_mgmt_003 + type: object + additionalProperties: true + response: *source_mgmt_003 + owner: *source_mgmt_003 + status: + type: string + enum: + - running + - complete + - completed + - pending + - failed + - stopped + - cleanup-failed + source_refs: type: array + description: One confirmed Source submission reported by the Runtime; not a batch or historical reference list. + minItems: 1 + maxItems: 1 items: - type: string - minLength: 1 - maxLength: 64 - maxItems: 32 - after_tags: *id005 - CatalogTagResult: + $ref: '#/components/schemas/SourceTypeReference' + error: + type: string + maxLength: 1024 + targets: + type: array + maxItems: 256 + items: *source_mgmt_003 + cursor: + type: string + maxLength: 256 + owner_exited: + type: boolean + previous_owner: + type: object + additionalProperties: true + submitted_at: + type: string + SourceInstanceExchangeResponse: + type: object + additionalProperties: true + SourceTargetSetEnabledRequest: type: object additionalProperties: false required: - scope_id - - target - - tags - - tag_digest - - etag + - target_id + - enabled properties: - scope_id: *id001 - target: - $ref: '#/components/schemas/TagTarget' - tags: *id005 - tag_digest: *id001 - etag: *id001 - CandidateHistory: + scope_id: + type: string + minLength: 1 + maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 256 + enabled: + type: boolean + SourceInstancesQuery: type: object additionalProperties: false - required: - - versions properties: - versions: - type: array - items: - $ref: '#/components/schemas/Candidate' - default: [] + scope_id: + type: string + minLength: 1 + maxLength: 256 + capability: + type: string + minLength: 1 + maxLength: 256 + instance_id: + type: string + minLength: 1 + maxLength: 256 + input: + type: object + additionalProperties: true + view: + type: string + enum: + - discovery + - control + default: discovery diff --git a/pyproject.toml b/pyproject.toml index 09381618bf..a837bfa0d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ license-files = ["LICENSE", "LICENSES/*"] keywords = ['python'] requires-python = ">=3.11,<4.0" dependencies = [ + "jsonschema>=4.23,<5", "pydantic>=2.10,<3", "rfc8785>=0.1.4,<1", "typing-extensions>=4.12,<5", @@ -52,7 +53,6 @@ builtin = [ "apscheduler>=3.11,<4", "filelock>=3.16,<4", "httpx>=0.28,<1", - "jsonschema>=4.23,<5", "jieba==0.42.1", "packaging>=24,<27", "pydantic-ai-slim[anthropic,openai]>=2.27.1,<3", @@ -109,6 +109,9 @@ cli = [ "pyyaml>=6,<7", "typer>=0.16,<1", ] +source-images = [ + "pillow>=11,<13", +] [project.scripts] powercontext = "powercontext.cli.app:main" @@ -169,6 +172,7 @@ notebooks = [ "playwright>=1.55,<2", ] dev = [ + "powercontext[source-images]", "powercontext[code]", "datamodel-code-generator==0.64.0", "powercontext[cli,server]", diff --git a/release/upgrade-support.json b/release/upgrade-support.json index ad02d6ff52..f29f0f4319 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "0ae4020d8fa82ad105a93fb014bd1afd19df5a9eb25bb73aa8c87521dbe71ea2", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance." + "persistence_sha256": "6479e5cdb3ffeba35a45b1e1d0632b7890cbc2990c593b437c2e33b66116149b", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 3ffc68c92c..deece2d1b8 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -28,6 +28,10 @@ "v1_4_1_005": [ "schemas/sqlite-v1_4_1_005.json", "schemas/mysql-v1_4_1_005.json" + ], + "v1_4_1_006": [ + "schemas/sqlite-v1_4_1_006.json", + "schemas/mysql-v1_4_1_006.json" ] }, "sqlite_baselines": { @@ -51,6 +55,9 @@ ], "v1_4_1_005": [ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" + ], + "v1_4_1_006": [ + "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" ] }, "affected_objects": [ @@ -145,7 +152,8 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_source_write_plugins" ], "readiness_scope": "server", "retained_objects": [ @@ -206,6 +214,10 @@ "v1_4_1_005": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_006": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -222,7 +234,8 @@ "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", "schemas/mysql-v1_4_1_004.json", - "schemas/mysql-v1_4_1_005.json" + "schemas/mysql-v1_4_1_005.json", + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py new file mode 100644 index 0000000000..e26391cc95 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py @@ -0,0 +1,24 @@ +# 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. + +"""Register Source write plugins through the shared DDL executor.""" + +from alembic import context + +revision = "v1_4_1_006" +down_revision = "v1_4_1_005" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json new file mode 100644 index 0000000000..c6960dbb0c --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json @@ -0,0 +1,52 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_4_1_006": { + "statements": [ + "CREATE TABLE pc_source_write_plugins (\n\tplugin_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tdefinition MEDIUMBLOB NOT NULL, \n\tPRIMARY KEY (plugin_id)\n)" + ], + "changes": [ + { + "pc_source_write_plugins": { + "columns": { + "plugin_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "definition": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "plugin_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": {} + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_006.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_006.json new file mode 100644 index 0000000000..3ea2c4c855 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_006.json @@ -0,0 +1,3 @@ +{ + "create": "CREATE TABLE pc_source_write_plugins (\n\tplugin_id VARCHAR(128) NOT NULL, \n\tdefinition BLOB NOT NULL, \n\tPRIMARY KEY (plugin_id)\n)" +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_source_write_plugins.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_source_write_plugins.py new file mode 100644 index 0000000000..fae1ef3eea --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_source_write_plugins.py @@ -0,0 +1,28 @@ +# 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. + +"""Register trusted Source write plugins in the managed Server schema.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_4_1_006" +down_revision = "v1_4_1_005" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_006.json" + op.get_bind().exec_driver_sql(json.loads(resource.read_text())["create"]) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index dea50c81f8..66050c9009 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -27,6 +27,10 @@ "v1_4_1_005": [ "schemas/sqlite-v1_4_1_005.json", "schemas/mysql-v1_4_1_005.json" + ], + "v1_4_1_006": [ + "schemas/sqlite-v1_4_1_006.json", + "schemas/mysql-v1_4_1_006.json" ] }, "sqlite_baselines": { @@ -50,6 +54,9 @@ ], "v1_4_1_005": [ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" + ], + "v1_4_1_006": [ + "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" ] }, "affected_objects": [ @@ -144,7 +151,8 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_source_write_plugins" ], "readiness_scope": "server", "retained_objects": [ @@ -210,6 +218,10 @@ "v1_4_1_005": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_006": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -226,7 +238,8 @@ "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", "schemas/mysql-v1_4_1_004.json", - "schemas/mysql-v1_4_1_005.json" + "schemas/mysql-v1_4_1_005.json", + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index f96ee16d8f..cd1d54dc87 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -101,6 +101,11 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: row[3] = connection.execute( "SELECT sql FROM sqlite_schema WHERE name='pc_atomic_memory_current'" ).fetchone()[0] + if "v1_4_1_006" in manifest["revision_resources"]: + plugin = json.loads((directory / "schemas/sqlite-v1_4_1_006.json").read_text(encoding="utf-8"))["create"] + layouts["v1_4_1_006"] = layouts["v1_4_1_005"] + [ + ["table", "pc_source_write_plugins", "pc_source_write_plugins", plugin] + ] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index dffa936c66..42f249acd2 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -61,6 +61,7 @@ from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE, + SOURCE_DEFINITION_MANIFESTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) @@ -96,7 +97,7 @@ ) from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget from powercontext.errors import RevisionConflictError -from powercontext.sources import SourceMaterialization, SourceRef +from powercontext.sources import SourceMaterialization, SourceObservation, SourceRef Clock = Callable[[], datetime] IdFactory = Callable[[str], str] @@ -270,7 +271,6 @@ async def _store_sources(self, scope_id: str, sources: Sequence[ContentSource]) return tuple(_source_record(scope_id, item) for item, _ in stored) async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: - self._require_content_source(source_type) ref = SourceRef(source_type=source_type, source_id=source_id) async with self._database.transaction() as connection: return _source_record(scope_id, await self._get_source(connection, scope_id, ref)) @@ -285,17 +285,24 @@ async def list_sources( caller: str = "runtime", ) -> SourceRecordPage: _require_limit(limit) - expected_cursor = { + expected_cursor: dict[str, JsonValue] = { "version": 1, "endpoint": "list_sources", "scope_id": scope_id, - "source_types": [CONTENT_SOURCE_NAME], + "source_types": "content-and-observations", "authorization": "scope_read", "caller": caller, "limit": limit, "order": "journal_position:asc", } - cursor_state = self._cursor_after_text(cursor, expected_cursor) + content_only = False + try: + cursor_state = self._cursor_after_text(cursor, expected_cursor) + except InvalidCursorError: + # Cursors issued before Observation reads retain their content-only range. + expected_cursor["source_types"] = [CONTENT_SOURCE_NAME] + cursor_state = self._cursor_after_text(cursor, expected_cursor) + content_only = True async with self._database.transaction() as connection: high_watermark = await self._sources.journal_position(connection, scope_id) if cursor_state: @@ -311,13 +318,18 @@ async def list_sources( selected: list[SourceRecord] = [] page_bytes = 0 has_more = False + observation_types = ( + (await connection.execute(select(SOURCE_DEFINITION_MANIFESTS_TABLE.c.definition_name).distinct())) + .scalars() + .all() + ) source_stream = self._sources.iter_list( connection, scope_id, after=after, through=through, limit=limit + 1, - source_type=CONTENT_SOURCE_NAME, + source_types=(CONTENT_SOURCE_NAME,) if content_only else (CONTENT_SOURCE_NAME, *observation_types), ) async with aclosing(source_stream): async for stored in source_stream: @@ -474,6 +486,12 @@ async def get_artifact( raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) from None return _artifact_record(scope_id, artifact) + @property + def database(self) -> AsyncDatabase: + """The runtime-owned store shared by Server management services.""" + + return self._database + async def get_artifact_revision( self, scope_id: str, @@ -870,16 +888,21 @@ def _source_record( scope_id: str, stored: StoredSource, ) -> SourceRecord: - if not isinstance(stored.value, ContentSource): + if not isinstance(stored.value, (ContentSource, SourceObservation)): raise BaseValueNotFoundError("source", (scope_id, stored.ref)) + content = ( + _source_content(stored.value) + if isinstance(stored.value, ContentSource) + else cast(JsonValue, stored.value.model_dump(mode="json")) + ) return SourceRecord( scope_id=scope_id, - source_type=cast(Any, stored.ref.source_type), + source_type=stored.ref.source_type, source_id=stored.ref.source_id, - content=_source_content(stored.value), + content=content, position=stored.journal_position, - content_digest=_content_digest(_source_content(stored.value)), - handoff_receipt=stored.value.handoff_receipt, + content_digest=_content_digest(content), + handoff_receipt=isinstance(stored.value, ContentSource) and stored.value.handoff_receipt, ) diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 9b57917df0..4505f2b742 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -338,6 +338,7 @@ async def iter_list( through: int | None = None, limit: int | None = None, source_type: str | None = None, + source_types: tuple[str, ...] | None = None, ) -> AsyncGenerator[StoredSource, None]: """Stream a stable journal-ordered page without decoding it eagerly.""" @@ -356,6 +357,8 @@ async def iter_list( predicates.append(SOURCES_TABLE.c.journal_position <= through) if source_type is not None: predicates.append(SOURCES_TABLE.c.source_type == source_type) + if source_types is not None: + predicates.append(SOURCES_TABLE.c.source_type.in_(source_types)) statement = select(SOURCES_TABLE).where(*predicates).order_by(SOURCES_TABLE.c.journal_position) if limit is not None: statement = statement.limit(limit) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index a548e5f4e6..1b223bc8da 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -830,6 +830,13 @@ def _entry_text_type(): UniqueConstraint("definition_name", "fingerprint", name="uq_pc_source_definition_manifest_fingerprint"), ) +SOURCE_WRITE_PLUGINS_TABLE = Table( + "pc_source_write_plugins", + SHARED_METADATA, + Column("plugin_id", identity_string(128), primary_key=True), + Column("definition", _canonical_payload_type(), nullable=False), +) + EXTERNAL_SKILL_REGISTRATIONS_TABLE = Table( "pc_external_skill_registrations", SHARED_METADATA, @@ -1085,6 +1092,7 @@ def _entry_text_type(): ARTIFACT_PROCESSING_MIGRATION_RECEIPTS_TABLE, CONNECTOR_CHECKPOINTS_TABLE, SOURCE_DEFINITION_MANIFESTS_TABLE, + SOURCE_WRITE_PLUGINS_TABLE, EXTERNAL_SKILL_REGISTRATIONS_TABLE, SKILL_PACKAGES_TABLE, AGENT_SKILL_TARGETS_TABLE, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index 8ae6a95646..4924442f08 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -42,7 +42,7 @@ class SourceRecord(_RecordModel): """One durable Source and its journal position.""" scope_id: str - source_type: Literal["content"] + source_type: str source_id: str content: JsonValue position: int diff --git a/src/powercontext/cli/app.py b/src/powercontext/cli/app.py index 2600e3ce5c..f4ded89c30 100644 --- a/src/powercontext/cli/app.py +++ b/src/powercontext/cli/app.py @@ -147,6 +147,9 @@ def main( ) registered_names = register_commands(cli) + from powercontext.source_management.cli import register_commands as register_source_commands + + registered_names.update(register_source_commands(cli)) if commands is None: commands = _load_command_providers() diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 95c047559d..7ff21b25d0 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -96,6 +96,12 @@ "list_memory_entries", "list_scopes", "merge_atomic_memories", + "pc_source_add", + "pc_source_capabilities", + "pc_source_instance_set_enabled", + "pc_source_instances", + "pc_source_status", + "pc_source_target_set_enabled", "preview_atomic_memory_restoration", "propose_experience", "propose_skill", diff --git a/src/powercontext/client/__init__.py b/src/powercontext/client/__init__.py index 0faaa4d411..26cb3dcced 100644 --- a/src/powercontext/client/__init__.py +++ b/src/powercontext/client/__init__.py @@ -24,7 +24,7 @@ UnauthorizedResponseError, UnavailableResponseError, ) -from powercontext.client.ingestion import RemoteConnectorWorker +from powercontext.client.ingestion import RemoteConnectorWorker, register_source_definitions from powercontext.client.skill_receiver import ( RECEIVER_VERSION, ReceiverSyncResult, @@ -55,5 +55,6 @@ "TransportError", "UnauthorizedResponseError", "UnavailableResponseError", + "register_source_definitions", "require_remote_skill_server_url", ] diff --git a/src/powercontext/client/ingestion.py b/src/powercontext/client/ingestion.py index 2cd943b4d4..2fc12cf727 100644 --- a/src/powercontext/client/ingestion.py +++ b/src/powercontext/client/ingestion.py @@ -16,6 +16,8 @@ from __future__ import annotations +from typing import Protocol + from pydantic import JsonValue from typing_extensions import override @@ -50,17 +52,35 @@ from powercontext.sources.connectors import ( ConnectorCheckpointStore, ConnectorLifecycle, + ConnectorRunObserver, ConnectorSourceSink, validate_connector, ) +class RemoteConnectorObserver(ConnectorRunObserver, Protocol): + """Observe materialization and confirmed submission without changing Source semantics.""" + + async def materialized( + self, binding: ConnectorBinding, item_id: str, observation: SourceObservation, / + ) -> None: ... + + async def accepted(self, item_id: str, source_ref: SourceRef, /) -> None: ... + + class _RemoteConnectorSourceSink(ConnectorSourceSink): """Resolve and project Definition-native values inside the worker.""" - def __init__(self, *, client: PowerContextClient, registry: SourceDefinitionRegistry) -> None: + def __init__( + self, + *, + client: PowerContextClient, + registry: SourceDefinitionRegistry, + observer: RemoteConnectorObserver | None = None, + ) -> None: self._client = client self._registry = registry + self._observer = observer @override async def submit( @@ -71,7 +91,6 @@ async def submit( value: object, /, ) -> SourceRef: - del item_id self._registry.definition_for_name(definition_name) source = await self._registry.resolve(value) observation: SourceObservation = project_source_for_transport(self._registry, source) @@ -84,19 +103,31 @@ async def submit( raise ConnectorSubmissionRejectedError( # noqa: TRY003 f"observation exceeds {MAX_SOURCE_OBSERVATION_BYTES} bytes" ) - receipt = await self._client.submit_source_observation( - SubmitSourceObservationRequest( - scope_id=binding.scope_id, - observation=HttpSourceObservation.model_validate(observation.model_dump(mode="json")), - ) - ) - source_ref = SourceRef(source_type=receipt.source.name, source_id=receipt.source.source_id) - expected_ref = SourceRef(source_type=observation.source_type, source_id=observation.name) - if source_ref != expected_ref: - raise InvalidConnectorRunError("identity-mismatch", "Server receipt changed the accepted Source identity") + if self._observer is not None: + await self._observer.materialized(binding, item_id, observation) + source_ref = await submit_materialized_observation(self._client, binding, observation) + if self._observer is not None: + await self._observer.accepted(item_id, source_ref) return source_ref +async def submit_materialized_observation( + client: PowerContextClient, binding: ConnectorBinding, observation: SourceObservation +) -> SourceRef: + """Submit exactly the captured value, including its stable Source identity.""" + receipt = await client.submit_source_observation( + SubmitSourceObservationRequest( + scope_id=binding.scope_id, + observation=HttpSourceObservation.model_validate(observation.model_dump(mode="json")), + ) + ) + source_ref = SourceRef(source_type=receipt.source.name, source_id=receipt.source.source_id) + expected_ref = SourceRef(source_type=observation.source_type, source_id=observation.name) + if source_ref != expected_ref: + raise InvalidConnectorRunError("identity-mismatch", "Server receipt changed the accepted Source identity") + return source_ref + + class _RemoteConnectorCheckpointStore(ConnectorCheckpointStore): """Load and compare-and-swap opaque checkpoints through the Server API.""" @@ -133,34 +164,49 @@ async def save( class RemoteConnectorWorker: - """Register worker-owned Definitions and execute one Connector binding.""" + """Register connector Definitions and execute one binding. - def __init__(self, *, client: PowerContextClient, registry: SourceDefinitionRegistry) -> None: + Managed runtimes disable registration after deployment registers their Definitions, + allowing collection with Scope credentials alone. + """ + + def __init__( + self, + *, + client: PowerContextClient, + registry: SourceDefinitionRegistry, + observer: RemoteConnectorObserver | None = None, + register_definitions: bool = True, + ) -> None: self._client = client self._registry = registry + self._observer = observer + self._register_definitions = register_definitions self._lifecycle = ConnectorLifecycle( - sink=_RemoteConnectorSourceSink(client=client, registry=registry), + sink=_RemoteConnectorSourceSink(client=client, registry=registry, observer=observer), checkpoints=_RemoteConnectorCheckpointStore(client), ) async def run(self, connector: Connector, binding: ConnectorBinding, /) -> ConnectorRunResult: - source_definitions = validate_connector(connector, binding) - for definition_name in sorted(source_definitions): - definition = self._registry.definition_for_name(definition_name) - manifest = manifest_for_definition(definition) - registered = await self._client.register_source_definition( - RegisterSourceDefinitionRequest( - manifest=HttpSourceDefinitionManifest.model_validate( - manifest.model_dump(mode="json", by_alias=True) - ) - ) + definitions = tuple( + self._registry.definition_for_name(name) for name in sorted(validate_connector(connector, binding)) + ) + if self._register_definitions: + await register_source_definitions(client=self._client, registry=SourceDefinitionRegistry(definitions)) + return await self._lifecycle.run(connector, binding, observer=self._observer) + + +async def register_source_definitions(*, client: PowerContextClient, registry: SourceDefinitionRegistry) -> None: + """Register trusted output contracts using a deployment credential, before collection.""" + for definition in registry.definitions: + manifest = manifest_for_definition(definition) + registered = await client.register_source_definition( + RegisterSourceDefinitionRequest( + manifest=HttpSourceDefinitionManifest.model_validate(manifest.model_dump(mode="json", by_alias=True)) ) - if registered.model_dump(mode="json", by_alias=True) != manifest.model_dump(mode="json", by_alias=True): - raise InvalidConnectorRunError( - "manifest-mismatch", - f"Server returned a different manifest for {definition.name!r}", - ) - return await self._lifecycle.run(connector, binding) + ) + if registered.model_dump(mode="json", by_alias=True) != manifest.model_dump(mode="json", by_alias=True): + raise InvalidConnectorRunError("manifest-mismatch", "Server returned a different Definition manifest") def _http_binding(binding: ConnectorBinding) -> HttpConnectorBinding: @@ -175,4 +221,4 @@ def _validate_checkpoint_binding( raise InvalidConnectorRunError("binding-mismatch", "Server returned a different Connector binding") -__all__ = ["RemoteConnectorWorker"] +__all__ = ["RemoteConnectorWorker", "register_source_definitions"] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 491358cba3..95b5ab0779 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3552,6 +3552,273 @@ class CatalogTagResult(BaseModel): etag: Annotated[StrictStr, Field(min_length=1)] +class SourceCapabilityQuery(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + capability: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + input: dict[str, Any] | None = None + + +class SourceWritePluginLoadRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")] + entry: Annotated[StrictStr, Field(max_length=4096, min_length=1)] + + +class SourceWritePluginState(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: StrictStr + entry: StrictStr + capabilities: list[dict[str, Any]] + + +class SourceAddRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + capability: Annotated[StrictStr, Field(max_length=256, min_length=1)] + capability_version: Annotated[StrictStr, Field(max_length=256, min_length=1)] + instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + input: dict[str, Any] + + +class View(StrEnum): + RESULT = "result" + CONTROL = "control" + + +class SourceStatusRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + target_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + view: View = View.RESULT + + +class SourceInstanceSetEnabledRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + enabled: StrictBool + + +class SourceConfigureRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + update: StrictBool = False + registration: dict[str, Any] + + +class Management(StrEnum): + NOT_CONFIGURED = "not-configured" + ONLINE = "online" + UNAVAILABLE = "unavailable" + + +class SourceCapabilityResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + capabilities: list[dict[str, Any]] + management: Management + + +class SourceInstancesResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + instances: list[dict[str, Any]] + capabilities: list[dict[str, Any]] | None = None + management: Management | None = None + + +class Outcome2(StrEnum): + SAVED = "saved" + UNCHANGED = "unchanged" + + +class SourceCollectionResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + outcome: Outcome2 + source_refs: list[SourceTypeReference] + + +class Tracking(StrEnum): + ENABLED = "enabled" + STOPPING = "stopping" + STOPPED = "stopped" + + +class Availability(StrEnum): + ONLINE = "online" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + + +class Kind10(StrEnum): + COLLECT = "collect" + WRITE = "write" + + +class SourceManagementReceipt(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + tracking: Tracking | None = None + result: SourceCollectionResult | None = None + error: StrictStr | None = None + instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + capability: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + capability_version: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + availability: Availability | None = None + updated_at: StrictStr | None = None + kind: Kind10 | None = None + enabled: StrictBool | None = None + source_refs: Annotated[ + list[SourceTypeReference] | None, + Field( + description="Default collect status only. The single Source reference from the submission report most recently received by the manager, or empty before any report. Omitted from collect add responses; not a batch result or submission history.", + max_length=1, + ), + ] = None + last_submitted_at: Annotated[ + StrictStr | None, + Field( + description="Default collect status only. Runtime-reported submission time paired with source_refs, or null before any report. Selection follows manager receipt order, not timestamp order. Omitted from collect add responses." + ), + ] = None + + +class SourceInstanceStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + targets_initialized: StrictBool | None = None + target_id: StrictStr | None = None + instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + enabled: StrictBool | None = None + actual: StrictStr | None = None + run_id: StrictStr | None = None + availability: StrictStr | None = None + reused: StrictBool | None = None + + +class SourceChannelOpen(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + protocol: Annotated[StrictStr, Field(max_length=256, min_length=1)] + deployment_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + manager_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + + +class SourceChannelRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + generation: Annotated[StrictStr, Field(max_length=256, min_length=1)] + message_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + response: dict[str, Any] | None = None + catalog: dict[str, Any] | None = None + + +class SourceChannelResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + generation: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + protocol: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + confirmed: StrictBool | None = None + messages: list[dict[str, Any]] | None = None + + +class Status6(StrEnum): + RUNNING = "running" + COMPLETE = "complete" + COMPLETED = "completed" + PENDING = "pending" + FAILED = "failed" + STOPPED = "stopped" + CLEANUP_FAILED = "cleanup-failed" + + +class SourceInstanceExchangeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + generation: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + protocol: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + message_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + execution_location: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + configuration_digest: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + run_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + execution: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + target_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + capabilities: Annotated[list[dict[str, Any]] | None, Field(max_length=64)] = None + response: dict[str, Any] | None = None + owner: dict[str, Any] | None = None + status: Status6 | None = None + source_refs: Annotated[ + list[SourceTypeReference] | None, + Field( + description="One confirmed Source submission reported by the Runtime; not a batch or historical reference list.", + max_length=1, + min_length=1, + ), + ] = None + error: Annotated[StrictStr | None, Field(max_length=1024)] = None + targets: Annotated[list[dict[str, Any]] | None, Field(max_length=256)] = None + cursor: Annotated[StrictStr | None, Field(max_length=256)] = None + owner_exited: StrictBool | None = None + previous_owner: dict[str, Any] | None = None + submitted_at: StrictStr | None = None + + +class SourceInstanceExchangeResponse(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + + +class SourceTargetSetEnabledRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + target_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + enabled: StrictBool + + +class View1(StrEnum): + DISCOVERY = "discovery" + CONTROL = "control" + + +class SourceInstancesQuery(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + capability: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + input: dict[str, Any] | None = None + view: View1 = View1.DISCOVERY + + class RecallManifestRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -4752,7 +5019,10 @@ class SourceRecord(BaseModel): ), ] = None scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: SourceType + source_type: Annotated[ + StrictStr, + Field(description="Built-in content or a registered Source Observation type.", max_length=128, min_length=1), + ] source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] content: Annotated[Any, Field(description="Persisted canonical JSON content.")] position: Annotated[StrictInt, Field(ge=1)] diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index aa0ed2e6c5..1b2b014542 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -193,10 +193,28 @@ SkillGovernance, SkillPackageDownload, SkillPackageManifest, + SourceAddRequest, + SourceCapabilityQuery, + SourceCapabilityResponse, + SourceChannelOpen, + SourceChannelRequest, + SourceChannelResponse, + SourceConfigureRequest, SourceDefinitionManifest, + SourceInstanceExchangeRequest, + SourceInstanceExchangeResponse, + SourceInstanceSetEnabledRequest, + SourceInstancesQuery, + SourceInstancesResponse, + SourceInstanceStatus, + SourceManagementReceipt, SourceObservationReceipt, SourcePage, SourceRecord, + SourceStatusRequest, + SourceTargetSetEnabledRequest, + SourceWritePluginLoadRequest, + SourceWritePluginState, SubmitSourceObservationRequest, TopicMemoryArtifact, TopicMemoryProcessingBlock, @@ -3649,3 +3667,362 @@ class AccessRequirement(BaseModel): }, access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) + +PC_SOURCE_CAPABILITIES = Operation[SourceCapabilityQuery, SourceCapabilityResponse]( + method="POST", + path="/v1/source-management/capabilities", + operation_id="pc_source_capabilities", + request_type=SourceCapabilityQuery, + request_location="body", + path_parameters=(), + response_type=SourceCapabilityResponse, + success_status=200, + summary="pc source capabilities", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +PC_SOURCE_ADD = Operation[SourceAddRequest, SourceManagementReceipt]( + method="POST", + path="/v1/source-management/add", + operation_id="pc_source_add", + request_type=SourceAddRequest, + request_location="body", + path_parameters=(), + response_type=SourceManagementReceipt, + success_status=200, + summary="pc source add", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + +PC_SOURCE_STATUS = Operation[SourceStatusRequest, SourceManagementReceipt]( + method="POST", + path="/v1/source-management/status", + operation_id="pc_source_status", + request_type=SourceStatusRequest, + request_location="body", + path_parameters=(), + response_type=SourceManagementReceipt, + success_status=200, + summary="pc source status", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="source_status_access"), +) + +PC_SOURCE_TARGET_SET_ENABLED = Operation[SourceTargetSetEnabledRequest, SourceManagementReceipt]( + method="POST", + path="/v1/source-management/target-set-enabled", + operation_id="pc_source_target_set_enabled", + request_type=SourceTargetSetEnabledRequest, + request_location="body", + path_parameters=(), + response_type=SourceManagementReceipt, + success_status=200, + summary="Stop or resume a collect target", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + +PC_SOURCE_INSTANCES = Operation[SourceInstancesQuery, SourceInstancesResponse]( + method="POST", + path="/v1/source-management/instances", + operation_id="pc_source_instances", + request_type=SourceInstancesQuery, + request_location="body", + path_parameters=(), + response_type=SourceInstancesResponse, + success_status=200, + summary="pc source instances", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="source_instances_access"), +) + +PC_SOURCE_INSTANCE_SET_ENABLED = Operation[SourceInstanceSetEnabledRequest, SourceInstanceStatus]( + method="POST", + path="/v1/source-management/instance-set-enabled", + operation_id="pc_source_instance_set_enabled", + request_type=SourceInstanceSetEnabledRequest, + request_location="body", + path_parameters=(), + response_type=SourceInstanceStatus, + success_status=200, + summary="pc source instance set enabled", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +SOURCE_CONFIGURE = Operation[SourceConfigureRequest, SourceInstanceStatus]( + method="POST", + path="/v1/source-management/configure", + operation_id="source_configure", + request_type=SourceConfigureRequest, + request_location="body", + path_parameters=(), + response_type=SourceInstanceStatus, + success_status=200, + summary="source configure", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "Confirmed Source management result."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +LOAD_SOURCE_WRITE_PLUGIN = Operation[SourceWritePluginLoadRequest, SourceWritePluginState]( + method="POST", + path="/v1/source-management/write/load", + operation_id="load_source_write_plugin", + request_type=SourceWritePluginLoadRequest, + request_location="body", + path_parameters=(), + response_type=SourceWritePluginState, + success_status=200, + summary="Load a trusted Server-local Source write plugin", + tags=("source-management",), + scope_mode="none", + responses={ + 200: {"description": "The Server has loaded and persisted the executable write capabilities."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +SOURCE_CHANNEL_OPEN = Operation[SourceChannelOpen, SourceChannelResponse]( + method="POST", + path="/v1/source-management/channel/open", + operation_id="source_channel_open", + request_type=SourceChannelOpen, + request_location="body", + path_parameters=(), + response_type=SourceChannelResponse, + success_status=200, + summary="Source manager reverse channel open", + tags=("source-management-channel",), + scope_mode="none", + responses={ + 200: {"description": "Channel exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_management_channel_access" + ), +) + +SOURCE_CHANNEL_CATALOG = Operation[SourceChannelRequest, SourceChannelResponse]( + method="POST", + path="/v1/source-management/channel/catalog", + operation_id="source_channel_catalog", + request_type=SourceChannelRequest, + request_location="body", + path_parameters=(), + response_type=SourceChannelResponse, + success_status=200, + summary="Source manager reverse channel catalog", + tags=("source-management-channel",), + scope_mode="none", + responses={ + 200: {"description": "Channel exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_management_channel_access" + ), +) + +SOURCE_CHANNEL_POLL = Operation[SourceChannelRequest, SourceChannelResponse]( + method="POST", + path="/v1/source-management/channel/poll", + operation_id="source_channel_poll", + request_type=SourceChannelRequest, + request_location="body", + path_parameters=(), + response_type=SourceChannelResponse, + success_status=200, + summary="Source manager reverse channel poll", + tags=("source-management-channel",), + scope_mode="none", + responses={ + 200: {"description": "Channel exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_management_channel_access" + ), +) + +SOURCE_CHANNEL_REPLY = Operation[SourceChannelRequest, SourceChannelResponse]( + method="POST", + path="/v1/source-management/channel/reply", + operation_id="source_channel_reply", + request_type=SourceChannelRequest, + request_location="body", + path_parameters=(), + response_type=SourceChannelResponse, + success_status=200, + summary="Source manager reverse channel reply", + tags=("source-management-channel",), + scope_mode="none", + responses={ + 200: {"description": "Channel exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_management_channel_access" + ), +) + +SOURCE_CHANNEL_CLOSE = Operation[SourceChannelRequest, SourceChannelResponse]( + method="POST", + path="/v1/source-management/channel/close", + operation_id="source_channel_close", + request_type=SourceChannelRequest, + request_location="body", + path_parameters=(), + response_type=SourceChannelResponse, + success_status=200, + summary="Source manager reverse channel close", + tags=("source-management-channel",), + scope_mode="none", + responses={ + 200: {"description": "Channel exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_management_channel_access" + ), +) + +SOURCE_INSTANCE_EXCHANGE = Operation[SourceInstanceExchangeRequest, SourceInstanceExchangeResponse]( + method="POST", + path="/v1/source-management/instances/{instance_id}/{operation}", + operation_id="source_instance_exchange", + request_type=SourceInstanceExchangeRequest, + request_location="body", + path_parameters=("instance_id", "operation"), + response_type=SourceInstanceExchangeResponse, + success_status=200, + summary="Authenticated instance control, permission checks and durable result exchange", + tags=("source-management-instance",), + scope_mode="none", + responses={ + 200: {"description": "Instance control exchange."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="source_instance_channel_access" + ), +) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 9a56cdffd9..1950852184 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3993,7 +3993,14 @@ "get": { "tags": ["sources"], "summary": "List public Sources in one Scope", - "description": "List a snapshot-bounded page of public Content Sources in ascending journal position.", + "description": "List public Content Sources " + "and registered Source " + "Observations in one Scope in " + "ascending journal position. " + "Subsequent pages retain the " + "Source type selection and " + "journal upper bound recorded " + "in the cursor.", "operationId": "list_sources", "x-powercontext-access": {"resolver": "path_scope_read_access"}, "parameters": [ @@ -4091,7 +4098,7 @@ "name": "source_type", "in": "path", "required": True, - "schema": {"type": "string", "enum": ["content"]}, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*"}, }, { "name": "source_id", @@ -5534,6 +5541,576 @@ "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, + "/v1/source-management/capabilities": { + "post": { + "tags": ["source-management"], + "summary": "pc source capabilities", + "operationId": "pc_source_capabilities", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceCapabilityQuery"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceCapabilityResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + } + }, + "/v1/source-management/add": { + "post": { + "tags": ["source-management"], + "summary": "pc source add", + "description": "Use the discovered write or " + "collect capability and version. " + "Collect capabilities require an " + "explicit instance_id. Write runs " + "in the Server using loaded " + "Definition and Projection " + "implementations, without an " + "instance or management service. " + "Write waits for Source " + "references or an error in the " + "original call; no durable " + "request or status query is " + "created. Collect establishes or " + "reuses a persistent target and " + "never resumes a stopped target. " + "No mode or idempotency_key is " + "accepted; write retries use the " + "underlying Source identity " + "rules.", + "operationId": "pc_source_add", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceAddRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceManagementReceipt"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + } + }, + "/v1/source-management/status": { + "post": { + "tags": ["source-management"], + "summary": "pc source status", + "description": "Query a collect target by " + "scope_id and target_id. " + "Result view requires " + "scope.read and returns " + "received Source references " + "and submission time, not " + "per-round completion. Control " + "view requires " + "scope.contribute and returns " + "enabled and tracking without " + "input or results.", + "operationId": "pc_source_status", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceStatusRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceManagementReceipt"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": {"resolver": "source_status_access"}, + } + }, + "/v1/source-management/target-set-enabled": { + "post": { + "tags": ["source-management"], + "summary": "Stop or resume a collect target", + "description": "Set collect " + "target enabled " + "state using " + "scope.contribute. " + "Repeating the " + "current state is " + "safe. Resume " + "requires stopped " + "confirmation; it " + "does not restart " + "an ended apply or " + "enable a disabled " + "instance. No " + "revision or retry " + "key is required.", + "operationId": "pc_source_target_set_enabled", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceTargetSetEnabledRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceManagementReceipt"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + } + }, + "/v1/source-management/instances": { + "post": { + "tags": ["source-management"], + "summary": "pc source instances", + "description": "Discover instances with " + "scope_id from the Server " + "capability catalog. " + "Control view requires " + "instance_id and " + "server.admin, and returns " + "enabled, actual and " + "availability without " + "business configuration.", + "operationId": "pc_source_instances", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceInstancesQuery"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceInstancesResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": {"resolver": "source_instances_access"}, + } + }, + "/v1/source-management/instance-set-enabled": { + "post": { + "tags": ["source-management"], + "summary": "pc source instance set enabled", + "description": "Set instance " + "business " + "enabled state " + "using " + "server.admin. " + "Repeating the " + "same state does " + "not restart " + "apply. " + "Restarting an " + "ended run " + "requires " + "disable, " + "confirmed " + "stopped, then " + "enable. The " + "Runtime process " + "stays online.", + "operationId": "pc_source_instance_set_enabled", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceInstanceSetEnabledRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceInstanceStatus"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + } + }, + "/v1/source-management/configure": { + "post": { + "tags": ["source-management"], + "summary": "source configure", + "operationId": "source_configure", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceConfigureRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Confirmed Source management result.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceInstanceStatus"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + } + }, + "/v1/source-management/write/load": { + "post": { + "tags": ["source-management"], + "summary": "Load a trusted Server-local Source write plugin", + "description": "Requires server.admin. " + "The Server imports the " + "local directory or " + "module, validates and " + "registers its Source " + "Definitions, and persists " + "its entry for restart. An " + "identical deployment is " + "reusable; conflicting " + "configurations fail. " + "Existing code is replaced " + "only through controlled " + "Server restart.", + "operationId": "load_source_write_plugin", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceWritePluginLoadRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The Server has loaded and persisted the executable write capabilities.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceWritePluginState"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "none", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + } + }, + "/v1/source-management/channel/open": { + "post": { + "tags": ["source-management-channel"], + "summary": "Source manager reverse channel open", + "operationId": "source_channel_open", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelOpen"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Channel exchange.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"resolver": "source_management_channel_access"}, + } + }, + "/v1/source-management/channel/catalog": { + "post": { + "tags": ["source-management-channel"], + "summary": "Source manager reverse channel catalog", + "operationId": "source_channel_catalog", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Channel exchange.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"resolver": "source_management_channel_access"}, + } + }, + "/v1/source-management/channel/poll": { + "post": { + "tags": ["source-management-channel"], + "summary": "Source manager reverse channel poll", + "operationId": "source_channel_poll", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Channel exchange.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"resolver": "source_management_channel_access"}, + } + }, + "/v1/source-management/channel/reply": { + "post": { + "tags": ["source-management-channel"], + "summary": "Source manager reverse channel reply", + "operationId": "source_channel_reply", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Channel exchange.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"resolver": "source_management_channel_access"}, + } + }, + "/v1/source-management/channel/close": { + "post": { + "tags": ["source-management-channel"], + "summary": "Source manager reverse channel close", + "operationId": "source_channel_close", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Channel exchange.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceChannelResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"resolver": "source_management_channel_access"}, + } + }, + "/v1/source-management/instances/{instance_id}/{operation}": { + "post": { + "tags": ["source-management-instance"], + "summary": "Authenticated instance control, permission checks and durable result exchange", + "description": "Served " + "by " + "the " + "optional " + "standalone " + "Source " + "manager. " + "Instances " + "initiate " + "every " + "exchange; " + "this " + "is " + "not " + "a " + "Server " + "or " + "public " + "MCP " + "endpoint. " + "The " + "operation " + "path " + "chooses " + "owner/connect/poll/reply/sync/execution/results/stopped-targets/close " + "and " + "the " + "applicable " + "fields. " + "Source " + "observations " + "continue " + "to " + "go " + "directly " + "to " + "the " + "PC " + "Server.", + "operationId": "source_instance_exchange", + "servers": [ + { + "url": "http://127.0.0.1:8010", + "description": "Replace " + "with " + "the " + "configured " + "Source " + "manager " + "instance " + "endpoint; " + "use " + "HTTPS " + "off " + "loopback.", + } + ], + "x-powercontext-access": {"resolver": "source_instance_channel_access"}, + "x-powercontext-role": "source-manager", + "x-powercontext-scope-mode": "none", + "parameters": [ + { + "name": "instance_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9_.-]+$"}, + }, + { + "name": "operation", + "in": "path", + "required": True, + "schema": { + "type": "string", + "enum": [ + "owner", + "connect", + "poll", + "reply", + "sync", + "execution", + "results", + "stopped-targets", + "close", + ], + }, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceInstanceExchangeRequest"}} + }, + }, + "responses": { + "200": { + "description": "Instance control exchange.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/SourceInstanceExchangeResponse"} + } + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, }, "components": { "schemas": { @@ -12086,7 +12663,12 @@ "nullable": True, }, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "enum": ["content"]}, + "source_type": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "Built-in content or a registered Source Observation type.", + }, "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "content": {"description": "Persisted canonical JSON content."}, "position": {"type": "integer", "minimum": 1.0}, @@ -12824,6 +13406,322 @@ "type": "object", "required": ["versions"], }, + "SourceCapabilityQuery": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability": {"type": "string", "maxLength": 256, "minLength": 1}, + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "input": {"additionalProperties": True, "type": "object"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "SourceWritePluginLoadRequest": { + "properties": { + "id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_.-]*$", + }, + "entry": {"type": "string", "maxLength": 4096, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["id", "entry"], + }, + "SourceWritePluginState": { + "properties": { + "id": {"type": "string"}, + "entry": {"type": "string"}, + "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["id", "entry", "capabilities"], + }, + "SourceAddRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability_version": {"type": "string", "maxLength": 256, "minLength": 1}, + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "input": {"additionalProperties": True, "type": "object"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "capability", "capability_version", "input"], + }, + "SourceStatusRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "view": {"type": "string", "enum": ["result", "control"], "default": "result"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target_id"], + }, + "SourceInstanceSetEnabledRequest": { + "properties": { + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["instance_id", "enabled"], + }, + "SourceConfigureRequest": { + "properties": { + "update": {"type": "boolean", "default": False}, + "registration": {"additionalProperties": True, "type": "object"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["registration"], + }, + "SourceCapabilityResponse": { + "properties": { + "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, + "management": {"type": "string", "enum": ["not-configured", "online", "unavailable"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["capabilities", "management"], + }, + "SourceInstancesResponse": { + "properties": { + "instances": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, + "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, + "management": {"type": "string", "enum": ["not-configured", "online", "unavailable"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["instances"], + }, + "SourceCollectionResult": { + "properties": { + "outcome": {"type": "string", "enum": ["saved", "unchanged"]}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["outcome", "source_refs"], + }, + "SourceManagementReceipt": { + "properties": { + "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "tracking": {"type": "string", "enum": ["enabled", "stopping", "stopped"]}, + "result": {"$ref": "#/components/schemas/SourceCollectionResult"}, + "error": {"type": "string"}, + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability_version": {"type": "string", "maxLength": 256, "minLength": 1}, + "availability": {"type": "string", "enum": ["online", "offline", "unavailable"]}, + "updated_at": {"type": "string"}, + "kind": {"type": "string", "enum": ["collect", "write"]}, + "enabled": {"type": "boolean"}, + "source_refs": { + "items": {"$ref": "#/components/schemas/SourceTypeReference"}, + "type": "array", + "maxItems": 1, + "description": "Default " + "collect " + "status " + "only. " + "The " + "single " + "Source " + "reference " + "from " + "the " + "submission " + "report " + "most " + "recently " + "received " + "by " + "the " + "manager, " + "or " + "empty " + "before " + "any " + "report. " + "Omitted " + "from " + "collect " + "add " + "responses; " + "not " + "a " + "batch " + "result " + "or " + "submission " + "history.", + }, + "last_submitted_at": { + "type": "string", + "description": "Default " + "collect " + "status " + "only. " + "Runtime-reported " + "submission " + "time " + "paired " + "with " + "source_refs, " + "or " + "null " + "before " + "any " + "report. " + "Selection " + "follows " + "manager " + "receipt " + "order, " + "not " + "timestamp " + "order. " + "Omitted " + "from " + "collect " + "add " + "responses.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + }, + "SourceInstanceStatus": { + "properties": { + "targets_initialized": {"type": "boolean"}, + "target_id": {"type": "string"}, + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "enabled": {"type": "boolean"}, + "actual": {"type": "string"}, + "run_id": {"type": "string", "nullable": True}, + "availability": {"type": "string"}, + "reused": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["instance_id"], + }, + "SourceChannelOpen": { + "properties": { + "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, + "deployment_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "manager_id": {"type": "string", "maxLength": 256, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["protocol", "deployment_id", "manager_id"], + }, + "SourceChannelRequest": { + "properties": { + "generation": {"type": "string", "maxLength": 256, "minLength": 1}, + "message_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "response": {"additionalProperties": True, "type": "object"}, + "catalog": {"additionalProperties": True, "type": "object"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["generation"], + }, + "SourceChannelResponse": { + "properties": { + "generation": {"type": "string", "maxLength": 256, "minLength": 1}, + "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, + "confirmed": {"type": "boolean"}, + "messages": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + }, + "SourceInstanceExchangeRequest": { + "properties": { + "generation": {"type": "string", "maxLength": 256, "minLength": 1}, + "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, + "message_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "execution_location": {"type": "string", "maxLength": 256, "minLength": 1}, + "configuration_digest": {"type": "string", "maxLength": 256, "minLength": 1}, + "run_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "execution": {"type": "string", "maxLength": 256, "minLength": 1}, + "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "capabilities": { + "items": {"additionalProperties": True, "type": "object"}, + "type": "array", + "maxItems": 64, + }, + "response": {"additionalProperties": True, "type": "object"}, + "owner": {"additionalProperties": True, "type": "object"}, + "status": { + "type": "string", + "enum": ["running", "complete", "completed", "pending", "failed", "stopped", "cleanup-failed"], + }, + "source_refs": { + "items": {"$ref": "#/components/schemas/SourceTypeReference"}, + "type": "array", + "maxItems": 1, + "minItems": 1, + "description": "One " + "confirmed " + "Source " + "submission " + "reported " + "by " + "the " + "Runtime; " + "not " + "a " + "batch " + "or " + "historical " + "reference " + "list.", + }, + "error": {"type": "string", "maxLength": 1024}, + "targets": { + "items": {"additionalProperties": True, "type": "object"}, + "type": "array", + "maxItems": 256, + }, + "cursor": {"type": "string", "maxLength": 256}, + "owner_exited": {"type": "boolean"}, + "previous_owner": {"additionalProperties": True, "type": "object"}, + "submitted_at": {"type": "string"}, + }, + "additionalProperties": False, + "type": "object", + }, + "SourceInstanceExchangeResponse": {"additionalProperties": True, "type": "object"}, + "SourceTargetSetEnabledRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target_id", "enabled"], + }, + "SourceInstancesQuery": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "capability": {"type": "string", "maxLength": 256, "minLength": 1}, + "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "input": {"additionalProperties": True, "type": "object"}, + "view": {"type": "string", "enum": ["discovery", "control"], "default": "discovery"}, + }, + "additionalProperties": False, + "type": "object", + }, }, "responses": { "ClientUpgradeRequired": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index ae90e11d54..4db64faf85 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -583,7 +583,6 @@ SourceObservationReceipt, SourcePage, SourceRecord, - SourceType, SubmitSourceObservationRequest, TopicMemoryArtifact, UnpublishRemoteSkillRequest, @@ -1464,6 +1463,10 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_SERVER_INFO, get_server_info) _add_route(app, GET_CAPABILITIES, get_capabilities) + from powercontext.source_management.gateway import mount_source_routes + + mount_source_routes(app) + _add_route(app, LIST_SCOPES, list_scopes) _add_route(app, CREATE_SCOPE, create_scope) _add_route(app, GET_DEFAULT_SCOPE, get_default_scope) @@ -2525,7 +2528,7 @@ async def on_commit(connection, artifact, candidate): async def get_source( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - source_type: Annotated[Literal["content"], Path()], + source_type: Annotated[str, Path(min_length=1, max_length=128, pattern=r".*\S.*")], source_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], http_request: Request, application: Annotated[ServerApplication, Depends(_require_application)], @@ -3004,7 +3007,7 @@ async def get_artifact_revision( def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: return SourceRecord( scope_id=value.scope_id, - source_type=SourceType(value.source_type), + source_type=value.source_type, source_id=value.source_id, content=value.content, position=value.position, diff --git a/src/powercontext/server/authentication.py b/src/powercontext/server/authentication.py index b8724c13b7..40c0b06043 100644 --- a/src/powercontext/server/authentication.py +++ b/src/powercontext/server/authentication.py @@ -19,7 +19,7 @@ from collections.abc import Mapping from dataclasses import dataclass from secrets import compare_digest -from typing import Protocol +from typing import Any, Protocol from powercontext.server.authz import GroupRef, PrincipalRef @@ -127,3 +127,72 @@ async def readiness(self) -> ProviderReadiness: "ProviderReadiness", "StaticBearerAuthenticationProvider", ) + + +class MultiBearerAuthenticationProvider: + """Reload a protected static identity map; no identity receives an implicit role. + + The JSON file contains ``identities`` with ``credential_id``, ``token_digest`` + (SHA-256), and ``subject`` (a Principal object). Rotation takes effect on the + next request, including every management-channel heartbeat. + """ + + def __init__(self, path) -> None: + from pathlib import Path + + self.path = Path(path) + self._identities() + + def _identities(self) -> list[dict[str, Any]]: + import json + import os + + if self.path.is_symlink() or not self.path.is_file(): + raise ValueError("Identity configuration must be a regular protected file") # noqa: TRY003 + if os.name == "posix" and self.path.stat().st_mode & 0o077: + raise ValueError("Identity configuration must have mode 600") # noqa: TRY003 + identities = json.loads(self.path.read_text(encoding="utf-8"))["identities"] + if not isinstance(identities, list) or not identities or len(identities) > 1000: + raise ValueError("Invalid identity configuration") # noqa: TRY003 + keys = set() + digests = set() + for value in identities: + PrincipalRef(**value["subject"]) + digest = value["token_digest"] + if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise ValueError("Identity token digests must be SHA-256") # noqa: TRY003 + if value["credential_id"] in keys or digest in digests: + raise ValueError("Identity credentials must be distinct") # noqa: TRY003 + keys.add(value["credential_id"]) + digests.add(digest) + return identities + + def principals(self) -> tuple[PrincipalRef, ...]: + return tuple(PrincipalRef(**value["subject"]) for value in self._identities()) + + async def authenticate(self, request: AuthenticationRequest, /) -> AuthenticationResult: + from hashlib import sha256 + + authorization = request.headers.get("authorization", "") + scheme, separator, credential = authorization.partition(" ") + if not separator or scheme.casefold() != "bearer" or not credential: + raise AuthenticationRejectedError + try: + identities = self._identities() + except (OSError, ValueError, KeyError, TypeError) as error: + raise AuthenticationUnavailableError from error + digest = sha256(credential.encode()).hexdigest() + matched = None + for value in identities: + if compare_digest(digest, value["token_digest"]): + matched = value + if matched is None: + raise AuthenticationRejectedError + return AuthenticationResult(subject=PrincipalRef(**matched["subject"]), credential_id=matched["credential_id"]) + + async def readiness(self) -> ProviderReadiness: + try: + self._identities() + except (OSError, ValueError, KeyError, TypeError): + return ProviderReadiness(ready=False, reason="identity_configuration_unavailable") + return ProviderReadiness(ready=True) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 570c47fef9..8f37d3739c 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -48,12 +48,15 @@ async def open_builtin_access_control( database: DatabaseConfig, *, bootstrap_administrators: Sequence[PrincipalRef] = (), + server_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, ) -> AsyncIterator[AccessControlService]: - """Open a Server-owned Access schema without coupling it to Runtime domains.""" + """Open Access; only legacy bootstrap administrators receive the static Scope preset.""" async with _open_access_repository(database) as repository: - await _bootstrap_server_roles(repository, bootstrap_administrators, deployment_id=deployment_id) + await _bootstrap_server_roles( + repository, (*bootstrap_administrators, *server_administrators), deployment_id=deployment_id + ) provider = BuiltinAuthorizationProvider( repository, deployment_id=deployment_id, diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index d2cfa9a554..91feecae88 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -36,6 +36,7 @@ from powercontext.builtin.artifacts.skill import ExternalSkillProvider, SkillGenerator from powercontext.builtin.dream.generation import DreamGenerator from powercontext.builtin.inference import EmbeddingModel +from powercontext.builtin.persistence.records import RelationalRecordService from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.records import BaseOperationNotSupportedError from powercontext.builtin.runtime import ( @@ -63,6 +64,7 @@ from powercontext.server.app import _bind_evidence_access, access_readiness_checks, create_app from powercontext.server.authentication import ( AuthenticationProvider, + MultiBearerAuthenticationProvider, StaticBearerAuthenticationProvider, ) from powercontext.server.authz import ( @@ -166,6 +168,9 @@ def create_server_app( # noqa: C901 authentication_provider=authentication_provider, ) ) + from powercontext.source_management.deployment import prepare as prepare_source_deployment + + configured_authentication, _ = prepare_source_deployment(resolved, configured_authentication) config = BuiltinConfig( code=resolved.code, runtime=resolved.runtime, @@ -210,6 +215,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: open_builtin_access_control( resolved.database, bootstrap_administrators=(static_principal,) if legacy_static_admin else (), + server_administrators=( + tuple( + principal + for principal in configured_authentication.principals() + if f"{principal.type}:{principal.id}" in resolved.auth.administrators + ) + if isinstance(configured_authentication, MultiBearerAuthenticationProvider) + else () + ), deployment_id=resolved.access.deployment_id, ) ) @@ -298,6 +312,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) await _remove_legacy_topic_owners(active_access_control) readiness_probe.bind(runtime) + gateway = await _source_gateway(runtime, resolved) + await resources.enter_async_context(gateway) + app.state.source_gateway = gateway app.state.application = runtime app.state.dashboard_runtime = runtime app.state.access_control = active_access_control @@ -319,14 +336,23 @@ def current_capabilities() -> Capabilities: app.state.capability_provider = current_capabilities app.state.server_info = server_info(server_id) await readiness_probe() + deployment_task = None + if resolved.source_plugins_dir is not None or resolved.source_management.autostart: + from powercontext.source_management.deployment import DeploymentCoordinator + + deployment_task = asyncio.create_task(DeploymentCoordinator(resolved).startup()) if metrics is not None: await resources.enter_async_context(readiness_probe.monitor()) try: yield finally: + if deployment_task is not None: + deployment_task.cancel() + await asyncio.gather(deployment_task, return_exceptions=True) _log_lifecycle("server.stopping", "PowerContext Server is stopping") readiness_probe.unbind() app.state.application = None + app.state.source_gateway = None app.state.dashboard_runtime = None app.state.capability_provider = None app.state.access_control = configured_access_control @@ -414,6 +440,18 @@ def current_capabilities() -> Capabilities: return app +async def _source_gateway(runtime: BuiltinRuntime, settings: ServerSettings): + from powercontext.source_management.gateway import SourceGateway + from powercontext.source_management.write import SourceWritePlugins + + records = runtime._records() + write_plugins = None + if isinstance(records, RelationalRecordService): + write_plugins = SourceWritePlugins(records.database, settings.workspace) + await write_plugins.restore() + return SourceGateway(settings, write_plugins) + + def _state_access_checks(app: FastAPI) -> Callable[[], Awaitable[Mapping[str, str]]]: async def checks() -> dict[str, str]: return await access_readiness_checks(app.state) @@ -438,6 +476,9 @@ def _resolve_security_providers( return static_principal, None, None, False if authentication_provider is not None: return static_principal, authentication_provider, access_control, False + if settings.auth.identities_file is not None: + authentication = MultiBearerAuthenticationProvider(settings.auth.identities_file) + return static_principal, authentication, access_control, False if settings.auth.token is None: raise MissingAuthenticationProviderError authentication = StaticBearerAuthenticationProvider( diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 2491471b97..3a108f350e 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -158,6 +158,12 @@ claim saved/restored context, or repeatedly retry. Continue ordinary work when the requested operation is unavailable. """ _MCP_OPERATION_IDS = frozenset({ + "pc_source_capabilities", + "pc_source_add", + "pc_source_status", + "pc_source_target_set_enabled", + "pc_source_instances", + "pc_source_instance_set_enabled", "get_atomic_memory_state", "list_atomic_memories", "get_recall_manifest", @@ -217,6 +223,9 @@ PUBLISH_ARTIFACT.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ + "pc_source_capabilities", + "pc_source_status", + "pc_source_instances", "get_atomic_memory_state", "list_atomic_memories", "get_recall_manifest", diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 78fdfffb6e..e5ef05518c 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -140,14 +140,32 @@ class BearerAuthConfig(BaseModel): enabled: bool = False token: SecretStr | None = Field(default=None, repr=False) + identities_file: Path | None = None + administrators: tuple[str, ...] = () @model_validator(mode="after") def require_token_when_enabled(self) -> BearerAuthConfig: - if self.enabled and (self.token is None or not self.token.get_secret_value()): + if self.enabled and self.identities_file is None and (self.token is None or not self.token.get_secret_value()): raise MissingBearerTokenError("Bearer token is required when authentication is enabled") # noqa: TRY003 return self +class SourceManagementConfig(BaseModel): + """One trusted manager and optional local background deployment.""" + + process_mode: Literal["background", "local-service"] = Field( + default="background", + description="Local collect deployment requires Linux; local-service also requires systemd --user.", + ) + manager_subject: str | None = None + manager_id: str = "sources" + autostart: bool = False + state_dir: Path | None = None + request_seconds: float = Field(default=10, gt=0, le=60) + lease_seconds: float = Field(default=30, ge=3, le=300) + write_wait_timeout_seconds: float = Field(default=300, ge=1, le=300) + + class DashboardConfig(BaseModel): """Optional personal and demonstration UI sharing the Server access mode.""" @@ -218,6 +236,7 @@ class ServerSettings(BaseSettings): allow_insecure_http: bool = False mcp: McpConfig = Field(default_factory=McpConfig) auth: BearerAuthConfig = Field(default_factory=BearerAuthConfig) + source_management: SourceManagementConfig = Field(default_factory=SourceManagementConfig) dashboard: DashboardConfig = Field(default_factory=DashboardConfig) access: AccessControlConfig = Field(default_factory=AccessControlConfig) allow_unauthenticated_non_loopback: bool = False @@ -228,6 +247,7 @@ class ServerSettings(BaseSettings): handoff_generation_verification_secrets: tuple[SecretStr, ...] = Field(default=(), max_length=8, repr=False) code: CodeConfig = Field(default_factory=CodeConfig) runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + source_plugins_dir: Path | None = None database: DatabaseConfig = Field(default_factory=_default_database, discriminator="kind") handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) inference: InferenceConfig = Field(default_factory=InferenceConfig) @@ -340,11 +360,18 @@ def default_database_to_sqlite(cls, value: object) -> object: return {"kind": "sqlite", "url": _default_database().url, **value} @model_validator(mode="after") - def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: + def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: # noqa: C901 - coupled deployment constraints if self.access.background_principal_description is not None and self.access.background_principal_id is None: raise ValueError("ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION requires BACKGROUND_PRINCIPAL_ID") # noqa: TRY003 if self.auth.enabled: self.access.mode = "enforced" + if self.auth.identities_file is not None: + if self.auth.token is not None: + raise ValueError("Configure either the identity map or the legacy token") # noqa: TRY003 + if self.access.mode != "enforced": + raise ValueError("An identity map requires enforced Access Mode") # noqa: TRY003 + if self.source_management.manager_subject is not None and self.access.mode != "enforced": + raise ValueError("Source management requires authenticated enforced Access Mode") # noqa: TRY003 if ( self.dashboard.enabled and self.access.mode == "enforced" diff --git a/src/powercontext/source_management/__init__.py b/src/powercontext/source_management/__init__.py new file mode 100644 index 0000000000..6db950956c --- /dev/null +++ b/src/powercontext/source_management/__init__.py @@ -0,0 +1,19 @@ +# 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. + +"""Source management contracts; plugins own their collection schedule.""" + +from .models import Capability, ManagementError, SourceTarget, TargetIdentity, TargetIdentityRequest + +__all__ = ["Capability", "ManagementError", "SourceTarget", "TargetIdentity", "TargetIdentityRequest"] diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py new file mode 100644 index 0000000000..93581b8bb4 --- /dev/null +++ b/src/powercontext/source_management/background.py @@ -0,0 +1,228 @@ +# 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. + +"""Detached Source processes with explicit startup, identity checks and graceful stop.""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import select +import signal +import socket +import struct +import subprocess +import sys +import threading +import time +from contextlib import suppress +from pathlib import Path + +from typing_extensions import override + +from powercontext.source_management.config import ManagerConfig, RuntimeConfig, read_config, secret +from powercontext.source_management.durable import RuntimeOwner +from powercontext.source_management.models import ManagementError +from powercontext.source_management.service import protected_write + + +def _alive(state): + if not state: + return False + owner = state["owner"] + if owner["boot_id"] != Path("/proc/sys/kernel/random/boot_id").read_text().strip(): + return False + if owner["machine_id"] != Path("/etc/machine-id").read_text().strip(): + raise ManagementError("foreign-process", "Process state belongs to another machine") + try: + stat = Path(f"/proc/{int(owner['pid'])}/stat").read_text().rsplit(")", 1)[1].split() + except (FileNotFoundError, ProcessLookupError): + return False + return stat[0] != "Z" and stat[19] == owner["start_time"] + + +def process_action(role, config, action): + """Start or stop only a confirmed local process; repeated starts preserve its state.""" + if sys.platform != "linux": + raise ManagementError("background-unsupported", "Source background processes currently require Linux") + + import fcntl + + if action == "start": + secret(config.server.credential_file) + if isinstance(config, RuntimeConfig): + secret(config.management.credential_file) + directory = config.state_dir / (role + "-process") + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor = os.open(directory / "command.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + return _operate(role, config, action, directory) + finally: + os.close(descriptor) + + +def _operate(role, config, action, directory): # noqa: C901 - serialized process control + state_path, snapshot = directory / "process.json", directory / "config.json" + state = json.loads(state_path.read_text()) if state_path.exists() else {} + alive = _alive(state) + if action == "start": + serialized = config.model_dump_json(indent=2) + if alive: + if not state.get("ready") or state.get("stopping"): + raise ManagementError("process-starting", "Process startup or cleanup has not completed") + if not snapshot.exists() or snapshot.read_text() != serialized: + raise ManagementError("configuration-conflict", "Stop the process before changing its configuration") + else: + protected_write(snapshot, serialized) + _spawn(role, snapshot, directory) + state = json.loads(state_path.read_text()) + return {"state": "running", "pid": state["owner"]["pid"], "configuration": str(snapshot)} + if action == "stop" and alive: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(5) + try: + connection.connect(_address(directory)) + connection.sendall(json.dumps(state["owner"]).encode()) + if connection.recv(16) != b"stopping": + raise ManagementError("process-changed", "The running process did not accept the stop request") + except (ConnectionRefusedError, FileNotFoundError): + if _alive(state): + raise ManagementError("process-unavailable", "Cannot confirm process cleanup") from None + deadline = time.monotonic() + 45 + while _alive(state): + if time.monotonic() >= deadline: + raise ManagementError("stop-timeout", "Process cleanup is unfinished; replacement is blocked") + time.sleep(0.05) + alive = False + elif action not in {"stop", "status"}: + raise ManagementError("invalid-action", "Choose start, status or stop") + return {"state": "running" if alive else "stopped", "pid": state["owner"]["pid"] if state else None} + + +def _spawn(role, snapshot, directory): + read_fd, write_fd = os.pipe() + try: + log_fd = os.open(directory / "process.log", os.O_CREAT | os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW, 0o600) + with os.fdopen(log_fd, "a") as log: + process = subprocess.Popen( # noqa: S603 - fixed interpreter and module, no shell + [sys.executable, "-m", __name__, role, str(snapshot), str(write_fd)], + stdin=subprocess.DEVNULL, + stdout=log, + stderr=log, + start_new_session=True, + pass_fds=(write_fd,), + ) + os.close(write_fd) + write_fd = -1 + try: + if not select.select([read_fd], [], [], 60)[0] or os.read(read_fd, 16) != b"ready": + raise ManagementError("startup-failed", "Source process did not become ready; inspect process.log") # noqa: TRY301 + except BaseException: + if process.poll() is None: + process.terminate() + # Keep identity and locks if cleanup has not completed. + with suppress(subprocess.TimeoutExpired): + process.wait(timeout=45) + raise + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + + +def _address(directory): + return "\0pc-source-managed-" + hashlib.sha256(os.fsencode(directory.resolve())).hexdigest() + + +def _control(listener, identity, state_path): + stopping = False + while True: + try: + connection, _ = listener.accept() + with connection: + _, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if uid != os.getuid(): + continue + connection.settimeout(2) + request = json.loads(connection.recv(4096)) + if request == identity: + connection.sendall(b"stopping") + if not stopping: + stopping = True + protected_write(state_path, json.dumps({"owner": identity, "ready": True, "stopping": True})) + os.kill(os.getpid(), signal.SIGTERM) + except (OSError, ValueError): + if listener.fileno() < 0: + return + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("role", choices=("manager", "runtime")) + parser.add_argument("config", type=Path) + parser.add_argument("ready_fd", type=int) + args = parser.parse_args() + config = read_config(args.config, ManagerConfig if args.role == "manager" else RuntimeConfig) + owner = RuntimeOwner(args.config.parent) + identity = owner.identity() + state_path = args.config.parent / "process.json" + protected_write(state_path, json.dumps({"owner": identity, "ready": False})) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + notified = False + + def ready(): + nonlocal notified + if not notified: + listener.bind(_address(args.config.parent)) + listener.listen(4) + threading.Thread(target=_control, args=(listener, identity, state_path), daemon=True).start() + protected_write(state_path, json.dumps({"owner": identity, "ready": True})) + os.write(args.ready_fd, b"ready") + notified = True + + try: + if args.role == "runtime": + from powercontext.source_management.runtime import run_runtime + + asyncio.run(run_runtime(config, ready=ready)) + else: + import uvicorn + + from powercontext.source_management.manager import create_manager_app + + class Server(uvicorn.Server): + @override + async def startup(self, sockets=None): + await super().startup(sockets=sockets) + if self.started: + ready() + + Server( + uvicorn.Config(create_manager_app(config), host=config.host, port=config.port, access_log=False) + ).run() + except Exception as error: + print(f"Source process failed ({type(error).__name__}); check deployment configuration", file=sys.stderr) + raise SystemExit(1) from None + finally: + listener.close() + os.close(args.ready_fd) + owner.close() + + +if __name__ == "__main__": + main() diff --git a/src/powercontext/source_management/bootstrap.py b/src/powercontext/source_management/bootstrap.py new file mode 100644 index 0000000000..7b97cf7749 --- /dev/null +++ b/src/powercontext/source_management/bootstrap.py @@ -0,0 +1,81 @@ +# 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. + +"""HTTP helpers for authenticated Source instance deployment.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +import httpx + +from powercontext.source_management.models import ManagementError +from powercontext.source_management.storage import canonical + + +def request(client: httpx.Client, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + try: + response = client.request(method, path, json=body) if body is not None else client.request(method, path) + except httpx.HTTPError as error: + raise ManagementError("server-unreachable", "Deployment cannot contact the configured Server", 503) from error + if response.is_error: + # Error bodies may contain plugin data; expose the stable code only. + try: + code = response.json().get("error", {}).get("code", "request-failed") + except (ValueError, AttributeError): + code = "request-failed" + raise ManagementError( + str(code), f"Deployment request {path} failed (HTTP {response.status_code})", response.status_code + ) + return response.json() + + +def _principal(client: httpx.Client) -> dict[str, Any]: + result = request(client, "GET", "/v1/access/me") + if result["mode"] != "enforced": + raise ManagementError( + "access-required", "Shared Source deployment requires enforced Access and distinct identities" + ) + return result["principal"] + + +def _allowed(client: httpx.Client, action: str, resource: dict[str, Any]) -> bool: + result = request( + client, + "POST", + "/v1/access/check", + { + "match": "all", + "requirements": [{"action": action, "resource": resource}], + }, + ) + return result["allowed"] + + +def _grant(admin: httpx.Client, principal: dict[str, Any], scope: str) -> None: + subject = {key: principal[key] for key in ("type", "id")} + identity = hashlib.sha256(canonical([subject, scope, "scope.contributor"]).encode()).hexdigest() + request( + admin, + "POST", + "/v1/access/bindings/create", + { + "subject": subject, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": f"source-up-{identity}", + "reason": "Source workspace deployment", + }, + ) diff --git a/src/powercontext/source_management/channel.py b/src/powercontext/source_management/channel.py new file mode 100644 index 0000000000..17a0c6c8fa --- /dev/null +++ b/src/powercontext/source_management/channel.py @@ -0,0 +1,143 @@ +# 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. + +"""Authenticated reverse request/reply channel with a single expiring lease.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from dataclasses import dataclass, field +from time import monotonic +from typing import Any +from uuid import uuid4 + +from powercontext.source_management.models import MAX_MESSAGE_BYTES, ManagementError +from powercontext.source_management.storage import canonical + + +@dataclass +class Connection: + generation: str + owner: str + seen: float + queue: asyncio.Queue[dict[str, Any]] = field(default_factory=lambda: asyncio.Queue(maxsize=32)) + pending: dict[str, asyncio.Future[dict[str, Any]]] = field(default_factory=dict) + polling: bool = False + + +class ReverseChannel: + def __init__(self, *, lease_seconds: float = 30, request_seconds: float = 10) -> None: + self.lease_seconds = lease_seconds + self.request_seconds = request_seconds + self.connection: Connection | None = None + self.catalog: dict[str, Any] = {"revision": 0, "capabilities": [], "instances": []} + + @property + def online(self) -> bool: + connection = self.connection + return connection is not None and monotonic() - connection.seen <= self.lease_seconds + + def open(self, owner: str) -> str: + if self.online: + raise ManagementError("connection-active", "A live connection already owns this identity", 409) + self.close() + generation = uuid4().hex + self.connection = Connection(generation, owner, monotonic()) + return generation + + def check(self, owner: str, generation: str) -> Connection: + connection = self.connection + if not self.online or connection is None or connection.owner != owner or connection.generation != generation: + raise ManagementError("connection-expired", "Reconnect and synchronize the current state", 409) + connection.seen = monotonic() + return connection + + async def poll(self, owner: str, generation: str) -> list[dict[str, Any]]: + connection = self.check(owner, generation) + if connection.polling: + raise ManagementError("poll-active", "Only one poll may be active on a connection", 409) + connection.polling = True + try: + async with asyncio.timeout(min(self.lease_seconds / 3, 5)): + while True: + message = await connection.queue.get() + future = connection.pending.get(message["message_id"]) + if future is not None and not future.done(): + break + except TimeoutError: + return [] + finally: + connection.polling = False + return [message] + + async def request( + self, operation: str, arguments: dict[str, Any], *, wait_seconds: float | None = None + ) -> dict[str, Any]: + if len(canonical(arguments).encode()) > MAX_MESSAGE_BYTES - 1024: + raise ManagementError("request-too-large", "The management message exceeds its size limit") + connection = self.connection + if not self.online or connection is None: + raise ManagementError("management-unavailable", "Source management is unavailable", 503) + if len(connection.pending) >= 32: + raise ManagementError("management-busy", "The management connection has reached its request limit", 503) + identifier = uuid4().hex + future = asyncio.get_running_loop().create_future() + connection.pending[identifier] = future + try: + try: + connection.queue.put_nowait({ + "generation": connection.generation, + "message_id": identifier, + "operation": operation, + "arguments": arguments, + }) + except asyncio.QueueFull as error: + raise ManagementError("management-busy", "The management request was not sent", 503) from error + async with asyncio.timeout(self.request_seconds if wait_seconds is None else wait_seconds): + result = await future + if "error" in result: + error = result["error"] + raise ManagementError(error["code"], error["message"], error.get("status", 400)) + return result["result"] + except (TimeoutError, ConnectionError) as error: + raise ManagementError( + "result-unconfirmed", "The result is unconfirmed; query or retry the original request", 503 + ) from error + finally: + connection.pending.pop(identifier, None) + if not future.done(): + future.cancel() + + def reply(self, owner: str, generation: str, message_id: str, result: dict[str, Any]) -> None: + if len(canonical(result).encode()) > MAX_MESSAGE_BYTES - 1024: + raise ManagementError("response-too-large", "The management reply exceeds its size limit") + connection = self.check(owner, generation) + future = connection.pending.get(message_id) + if future is not None and not future.done(): + future.set_result(result) + + def close(self, owner: str | None = None, generation: str | None = None) -> None: + connection = self.connection + if connection is None: + return + if owner is not None and (connection.owner != owner or connection.generation != generation): + raise ManagementError("connection-expired", "Connection ownership changed", 409) + self.connection = None + for future in connection.pending.values(): + if not future.done(): + future.set_exception(ConnectionError("channel closed")) + with suppress(asyncio.QueueEmpty): + while True: + connection.queue.get_nowait() diff --git a/src/powercontext/source_management/cli.py b/src/powercontext/source_management/cli.py new file mode 100644 index 0000000000..71299b16d6 --- /dev/null +++ b/src/powercontext/source_management/cli.py @@ -0,0 +1,233 @@ +# 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. + +"""Deployment commands and native service entry points for Source management.""" + +# Credential input is validated and errors are redacted at the command boundary. +# ruff: noqa: TRY301 + +from __future__ import annotations + +import asyncio +import json +import re +from pathlib import Path +from typing import Annotated + +import typer + +from powercontext.paths import powercontext_data_dir +from powercontext.source_management.config import ManagerConfig, RuntimeConfig, read_config, secret +from powercontext.source_management.models import ManagementError +from powercontext.source_management.service import Role, protected_write, service_action + +source_app = typer.Typer(name="source", help="Manage Source services and instances.", no_args_is_help=True) +manager_app = typer.Typer(name="manager", no_args_is_help=True) +runtime_app = typer.Typer(name="runtime", no_args_is_help=True) +credentials_app = typer.Typer(name="credentials", no_args_is_help=True) +source_app.add_typer(manager_app) +source_app.add_typer(runtime_app) +ConfigFile = Annotated[Path, typer.Option("--config", help="Path to the deployment configuration.")] + + +def output(value: object) -> None: + typer.echo(json.dumps(value, ensure_ascii=False, indent=2)) + + +def fail(error: Exception) -> None: + if isinstance(error, ManagementError): + typer.echo(f"{error.code}: {error.message}", err=True) + else: + # Validation inputs can include tokens. Do not print exception payloads. + typer.echo( + f"deployment-failed: {type(error).__name__}; check configuration and private credential files", err=True + ) + raise typer.Exit(1) from error + + +def _operate(role: Role, action: str, path: Path, system_service: bool = False) -> None: + try: + config = read_config(path, ManagerConfig if role == "manager" else RuntimeConfig) + if action == "install": + secret(config.server.credential_file) + if isinstance(config, RuntimeConfig): + secret(config.management.credential_file) + if action in {"install", "uninstall"} or system_service: + output(service_action(role, config, "install" if action == "start" else action)) + else: + from powercontext.source_management.background import process_action + + output(process_action(role, config, action)) + except (OSError, ValueError, RuntimeError, ManagementError) as error: + fail(error) + + +def _commands(app: typer.Typer, role: Role) -> None: + for action in ("start", "install", "status", "stop", "uninstall"): + + def register(action: str) -> None: + def command( + config: ConfigFile, + system_service: Annotated[ + bool, typer.Option(help="Operate an explicitly installed system service.") + ] = False, + ) -> None: + _operate(role, action, config, system_service) + + app.command(action)(command) + + register(action) + + +_commands(manager_app, "manager") +_commands(runtime_app, "runtime") + + +@manager_app.command("run", hidden=True) +def manager_run(config: ConfigFile) -> None: + """Native service entry point; install uses systemd and returns immediately.""" + import uvicorn + + from powercontext.source_management.manager import create_manager_app + + try: + settings = read_config(config, ManagerConfig) + secret(settings.server.credential_file) + uvicorn.run(create_manager_app(settings), host=settings.host, port=settings.port, access_log=False) + except Exception as error: + fail(error) + + +@runtime_app.command("run", hidden=True) +def runtime_run(config: ConfigFile) -> None: + """Native service entry point for one managed Source instance.""" + from powercontext.source_management.runtime import run_runtime + + try: + asyncio.run(run_runtime(read_config(config, RuntimeConfig))) + except Exception as error: + fail(error) + + +@credentials_app.command("set") +def credentials_set( + alias: Annotated[str, typer.Argument(help="Local external credential reference.")], + plugin: Annotated[str, typer.Option(help="Module or local entry declaring credential fields.")], +) -> None: + """Prompt for plugin-declared external secrets and persist only protected files.""" + from powercontext.source_management.loading import load_local_module + + try: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", alias): + raise ManagementError("invalid-alias", "Credential aliases must be simple local names") + # Loading a plugin is a trusted local code operation; no Server is contacted. + module = load_local_module(Path.cwd(), plugin) + names = sorted(getattr(module, "credential_fields", ())) + if not names: + raise ManagementError("no-credentials", "This plugin declares no external credential fields") + if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) for name in names): + raise ManagementError("invalid-secret-name", "Plugin credential names must be simple local names") + values = {name: typer.prompt(name, hide_input=True) for name in names} + if any(not value.strip() for value in values.values()): + raise ManagementError("empty-secret", "Credential values must not be empty") + directory = powercontext_data_dir() / "source-credentials" / alias + for name, value in values.items(): + protected_write(directory / name, value) + output({"credential": alias, "fields": names}) + except Exception as error: + fail(error) + + +@source_app.command("load") +def load_command( + config: ConfigFile, + kind: Annotated[ + str, typer.Option(help="write loads trusted code in Server; collect deploys a Runtime.") + ] = "collect", + server_url: Annotated[str | None, typer.Option(help="Running Server URL for write deployment.")] = None, + credential_file: Annotated[Path | None, typer.Option(help="Server administrator credential file.")] = None, + env_file: Annotated[Path | None, typer.Option(help="The running Server's environment file.")] = None, + update: Annotated[bool, typer.Option(help="Replace a stopped collect deployment; keep it disabled.")] = False, +) -> None: + """Load trusted write code in Server, or deploy one collect instance.""" + from powercontext.server.settings import ServerSettings + from powercontext.source_management.deployment import DeploymentCoordinator + + try: + if kind == "collect": + import yaml + + from powercontext.source_management.deployment import LocalInstance + + local = LocalInstance.model_validate(yaml.safe_load(config.read_text(encoding="utf-8"))) + if local.deployment is not None: + from powercontext.source_management.remote_deployment import load + + output(load(config, local, update=update)) + return + settings = ServerSettings(_env_file=env_file) if env_file is not None else ServerSettings() # ty: ignore[unknown-argument] + if kind == "write": + if update: + raise ManagementError("invalid-update", "Write code updates require a controlled Server restart") + import httpx + import yaml + + from powercontext.source_management.write import WritePluginConfig + + configuration = WritePluginConfig.model_validate(yaml.safe_load(config.read_text(encoding="utf-8"))) + entry_path = config.resolve().parent / configuration.entry + if entry_path.exists(): + configuration = configuration.model_copy(update={"entry": str(entry_path.resolve())}) + token = ( + secret(credential_file) + if credential_file is not None + else (None if settings.auth.token is None else settings.auth.token.get_secret_value()) + ) + url = server_url or settings.public_url or f"http://127.0.0.1:{settings.http.port}" + from powercontext.client.settings import normalize_server_url + + url = normalize_server_url(url) + with httpx.Client( + base_url=url, headers={} if token is None else {"Authorization": "Bearer " + token}, timeout=60 + ) as client: + response = client.post("/v1/source-management/write/load", json=configuration.model_dump(mode="json")) + if response.status_code >= 400: + error = response.json().get("error", {}) + raise ManagementError( + error.get("code", "deployment-failed"), + error.get("message", "Server rejected the write plugin"), + response.status_code, + ) + output(response.json()) + return + if kind != "collect": + raise ManagementError("invalid-kind", "Choose write or collect") + if settings.source_plugins_dir is None and not settings.source_management.autostart: + raise ManagementError( + "deployment-unconfigured", + "Configure source_plugins_dir or source_management.autostart on the running Server", + ) + output(asyncio.run(DeploymentCoordinator(settings).load(config, update=update))) + except Exception as error: + fail(error) + + +def register_commands(app: typer.Typer) -> set[str]: + app.add_typer(source_app) + app.add_typer(credentials_app) + return {"source", "credentials"} + + +if __name__ == "__main__": + source_app() diff --git a/src/powercontext/source_management/config.py b/src/powercontext/source_management/config.py new file mode 100644 index 0000000000..609bd5650c --- /dev/null +++ b/src/powercontext/source_management/config.py @@ -0,0 +1,116 @@ +# 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. + +"""Deployment configuration and protected secret references.""" + +from __future__ import annotations + +import os +from hashlib import sha256 +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit + +import yaml +from pydantic import Field, field_validator + +from powercontext.client.transport_policy import normalize_client_url +from powercontext.source_management.models import Model +from powercontext.source_management.storage import canonical +from powercontext.transport import is_loopback_host + + +class Endpoint(Model): + url: str + credential_file: Path + + @field_validator("url") + @classmethod + def normalize_url(cls, value: str) -> str: + normalized = normalize_client_url(value) + parsed = urlsplit(normalized) + if parsed.scheme != "https" and not is_loopback_host(parsed.hostname or ""): + raise ValueError("Source management credentials require HTTPS outside loopback") # noqa: TRY003 + return normalized + + +class ManagerConfig(Model): + server: Endpoint + deployment_id: str + manager_id: str + state_dir: Path + host: str = "127.0.0.1" + port: int = Field(default=8010, ge=1, le=65535) + request_seconds: float = Field(default=10, gt=0, le=60) + lease_seconds: float = Field(default=30, ge=3, le=300) + + +class RuntimeConfig(Model): + deployment_id: str = "powercontext" + instance_id: str + plugin: str + plugin_dir: Path + management: Endpoint + server: Endpoint + state_dir: Path + execution_location: str + configuration_digest: str + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, Path] = Field(default_factory=dict) + + +class CollectDeployment(Model): + """Execution-machine deployment references; administrator credentials stay out of RuntimeConfig.""" + + server: Endpoint + management_url: str + execution_credential_file: Path + state_dir: Path + deployment_id: str = "powercontext" + start: Literal["background", "local-service"] = "background" + + @field_validator("management_url") + @classmethod + def normalize_management_url(cls, value: str) -> str: + return Endpoint(url=value, credential_file=Path("unused")).url + + +def configuration_digest(plugin: str, config: dict[str, Any], secrets) -> str: + """Digest the approved business parameters and declared secret aliases, without their values.""" + return sha256(canonical({"plugin": plugin, "config": config, "secrets": sorted(secrets)}).encode()).hexdigest() + + +def read_config(path: Path, model): + path = path.expanduser().resolve() + value = model.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + for name in ("state_dir", "plugin_dir"): + configured = getattr(value, name, None) + if configured is not None: + object.__setattr__(value, name, (path.parent / configured).resolve()) + for name in ("server", "management"): + endpoint = getattr(value, name, None) + if endpoint is not None: + object.__setattr__(endpoint, "credential_file", (path.parent / endpoint.credential_file).resolve()) + return value + + +def secret(path: Path) -> str: + if path.is_symlink() or not path.is_file(): + raise ValueError("Credential must be a regular protected file") # noqa: TRY003 + if os.name == "posix" and path.stat().st_mode & 0o077: + raise ValueError("Credential file must not be accessible to other users (chmod 600)") # noqa: TRY003 + value = path.read_text(encoding="utf-8").strip() + if not value: + raise ValueError("Credential file is empty") # noqa: TRY003 + return value diff --git a/src/powercontext/source_management/context.py b/src/powercontext/source_management/context.py new file mode 100644 index 0000000000..82f75581af --- /dev/null +++ b/src/powercontext/source_management/context.py @@ -0,0 +1,149 @@ +# 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. + +"""The pipeline and resources owned by one plugin instance.""" + +from __future__ import annotations + +import inspect +import logging +from collections.abc import Awaitable, Callable, Mapping +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from pathlib import Path +from typing import TypeVar + +from powercontext.client import PowerContextClient, RemoteConnectorWorker +from powercontext.sources import ( + Connector, + ConnectorBinding, + ConnectorRunResult, + ConnectorSubmissionStatus, + SourceDefinitionRegistry, +) + +from .errors import PluginError +from .models import SourceTarget +from .targets import ManagedTargets + +T = TypeVar("T") + + +class Secrets: + def __init__(self, plugin_dir: Path, paths: Mapping[str, Path]) -> None: + self._directory = plugin_dir + self._paths = dict(paths) + + async def get(self, alias: str) -> bytes: + if alias not in self._paths: + raise PluginError("secret-missing", "secret alias is not configured for this plugin") + return (self._directory / self._paths[alias]).read_bytes() + + +class PluginContext: + def __init__( + self, + *, + plugin_id: str, + connector_name: str, + connector_version: str, + plugin_dir: Path, + secrets: Mapping[str, Path], + client: PowerContextClient, + registry: SourceDefinitionRegistry, + targets: ManagedTargets, + ) -> None: + self.plugin_id = plugin_id + self._connector_name = connector_name + self._connector_version = connector_version + self.plugin_dir = plugin_dir + self.secrets = Secrets(plugin_dir, secrets) + self.logger = logging.getLogger(f"powercontext.source.{plugin_id}") + self._client = client + self._registry = registry + self.targets = targets + self._stack = AsyncExitStack() + self._connector: Connector | None = None + self._batch_running = False + self._stopping = False + + async def enter_async_context(self, resource: AbstractAsyncContextManager[T]) -> T: + return await self._stack.enter_async_context(resource) + + def on_dispose(self, callback: Callable[[], Awaitable[None] | None]) -> None: + async def dispose() -> None: + result = callback() + if inspect.isawaitable(result): + await result + + self._stack.push_async_callback(dispose) + + def connect(self, connector: Connector) -> None: + if self._connector is not None: + raise PluginError("plugin-contract", "apply must connect exactly one Connector") + self._connector = connector + + async def run_batch( + self, + *, + scope_id: str, + targets: tuple[SourceTarget, ...] | None = None, + ) -> ConnectorRunResult: + """Run one Scope's batch with its own checkpoint; reject overlapping batches.""" + if self._stopping: + raise PluginError("plugin-stopping", "cannot start a batch on a stopping or closed context") + if self._connector is None: + raise PluginError("plugin-contract", "connect a Connector before run_batch") + if self._batch_running: + raise PluginError("batch-running", "a batch is already running on this PluginContext") + if targets is not None and any(not isinstance(t, SourceTarget) for t in targets): + raise PluginError("invalid-batch", "Select collect targets for a managed batch") + selected = targets + if selected is None: + raise PluginError("targets-required", "Managed batches must explicitly select their targets") + binding = ConnectorBinding( + scope_id=scope_id, + binding_id=self.plugin_id, + connector_name=self._connector_name, + connector_version=self._connector_version, + ) + self._batch_running = True + observer = None + try: + observer = self.targets.begin(binding, selected) + connector = self._connector + result = await RemoteConnectorWorker( + client=self._client, + registry=self._registry, + observer=observer, + register_definitions=False, + ).run(connector, binding) + accepted = sum(item.status is ConnectorSubmissionStatus.ACCEPTED for item in result.items) + self.logger.info( + "%s: %s (%d accepted) [scope=%s]", self.plugin_id, result.status.value, accepted, binding.scope_id + ) + except BaseException as error: + if observer is not None: + await observer.interrupted(type(error).__name__) + raise + finally: + self.targets.end() + self._batch_running = False + return result + + def stop(self) -> None: + self._stopping = True + + async def close(self) -> None: + self.stop() + await self._stack.aclose() diff --git a/src/powercontext/source_management/deployment.py b/src/powercontext/source_management/deployment.py new file mode 100644 index 0000000000..9f7d2f130b --- /dev/null +++ b/src/powercontext/source_management/deployment.py @@ -0,0 +1,399 @@ +# 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. + +"""Local deployment coordinator; services and collection run in separate processes.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import secrets +import socket +import sys +from contextlib import AsyncExitStack +from pathlib import Path +from typing import Any + +import httpx +import yaml +from pydantic import Field +from typing_extensions import override + +from powercontext._logging import log_safely +from powercontext.server.authentication import ( + AuthenticationRejectedError, + MultiBearerAuthenticationProvider, +) +from powercontext.source_management.config import ( + CollectDeployment, + Endpoint, + ManagerConfig, + RuntimeConfig, + configuration_digest, + secret, +) +from powercontext.source_management.models import InstanceRegistration, ManagementError, Model +from powercontext.source_management.service import launch, protected_write + +logger = logging.getLogger(__name__) + + +class InitialTarget(Model): + scope_id: str + capability: str + capability_version: str + input: dict[str, Any] + + +class LocalInstance(Model): + id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=128) + plugin: str + config: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, Path] = Field(default_factory=dict) + scope_ids: tuple[str, ...] = () + targets: tuple[InitialTarget, ...] = () + public_principals: tuple[str, ...] = ("*",) + default_capabilities: tuple[str, ...] = () + deployment: CollectDeployment | None = None + + +class DeploymentAuthentication(MultiBearerAuthenticationProvider): + """Add generated service credentials without changing user-managed identity files.""" + + def __init__(self, path, delegate): + super().__init__(path) + self.delegate = delegate + + @override + async def authenticate(self, request): + try: + return await super().authenticate(request) + except AuthenticationRejectedError: + if self.delegate is None: + raise + return await self.delegate.authenticate(request) + + @override + def principals(self): + original = self.delegate.principals() if isinstance(self.delegate, MultiBearerAuthenticationProvider) else () + return (*original, *super().principals()) + + @override + async def readiness(self): + current = await super().readiness() + if not current.ready or self.delegate is None: + return current + return await self.delegate.readiness() + + +def deployment_root(settings): + from powercontext.paths import powercontext_data_dir + + address = settings.public_url or f"http://127.0.0.1:{settings.http.port}" + key = hashlib.sha256((address + "\0" + settings.access.deployment_id).encode()).hexdigest()[:24] + return ( + (settings.source_management.state_dir or powercontext_data_dir() / "source-deployments" / key) + .expanduser() + .resolve() + ) + + +def credential(root, name, subject): + """Serialize credential creation and keep their plaintext in protected startup files.""" + from powercontext.source_management.durable import RuntimeOwner + + with_lock = RuntimeOwner(root / "configuration-lock") + try: + path = root / (name + ".token") + if not path.exists(): + protected_write(path, secrets.token_urlsafe(32), immutable=True) + token = secret(path) + identities_file = root / "identities.json" + values = json.loads(identities_file.read_text())["identities"] if identities_file.exists() else [] + digest = hashlib.sha256(token.encode()).hexdigest() + expected = {"credential_id": name, "token_digest": digest, "subject": {"type": "service", "id": subject}} + existing = next((v for v in values if v["credential_id"] == name), None) + if existing is not None and existing != expected: + raise ManagementError("credential-conflict", "Generated service credential configuration differs") + if existing is None: + protected_write(identities_file, json.dumps({"identities": [*values, expected]})) + return path + finally: + with_lock.close() + + +def prepare(settings, provider): + if settings.source_plugins_dir is None and not settings.source_management.autostart: + return provider, None + if settings.access.mode != "enforced": + raise ManagementError("access-required", "Automatic Source deployment requires enforced PC Access") + root = deployment_root(settings) + key = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:16] + subject = "pc-source-deployer-" + key + manager = "pc-source-manager-" + key + credential(root, "deployer", subject) + credential(root, "manager", manager) + if settings.source_management.manager_subject not in (None, "service:" + manager): + raise ManagementError("manager-conflict", "Automatic deployment cannot replace a configured remote manager") + settings.source_management.manager_subject = "service:" + manager + settings.auth.administrators = (*settings.auth.administrators, "service:" + subject) + provider = DeploymentAuthentication(root / "identities.json", provider) + return provider, next(p for p in provider.principals() if p.id == subject) + + +class DeploymentCoordinator: + def __init__(self, settings): + self.settings = settings + self.root = deployment_root(settings) + self.server_url = settings.public_url or f"http://127.0.0.1:{settings.http.port}" + self._lock = asyncio.Lock() + + async def request(self, client, path, body): + response = await client.post(path, json=body) + if response.status_code >= 400: + error = response.json().get("error", {}) + raise ManagementError( + error.get("code", "deployment-failed"), + error.get("message", "Deployment request failed"), + response.status_code, + ) + return response.json() + + async def manager(self): + config_path = self.root / "manager.json" + if config_path.exists(): + config = ManagerConfig.model_validate_json(config_path.read_text()) + else: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + config = ManagerConfig( + server=Endpoint(url=self.server_url, credential_file=self.root / "manager.token"), + deployment_id=self.settings.access.deployment_id, + manager_id=self.settings.source_management.manager_id, + state_dir=self.root / "manager", + port=port, + ) + protected_write(config_path, config.model_dump_json(indent=2), immutable=True) + await asyncio.to_thread(launch, "manager", config, self.settings.source_management.process_mode) + return config + + async def load(self, path: Path, *, update=False): # noqa: C901 - ordered deployment and readiness checks + path = await asyncio.to_thread(lambda: path.expanduser().resolve(strict=True)) + local = LocalInstance.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + if local.deployment is not None: + raise ManagementError("execution-machine-required", "Run source load on the configured execution machine") + async with self._lock, AsyncExitStack() as updating: + manager = await self.manager() + instance_root = self.root / "instances" / local.id + execution_subject = "pc-source-" + hashlib.sha256((str(self.root) + local.id).encode()).hexdigest()[:24] + execution_file = credential(self.root, "execution-" + local.id, execution_subject) + instance_root.mkdir(parents=True, exist_ok=True, mode=0o700) + if update: + from powercontext.source_management.durable import RuntimeOwner + + updating.callback(RuntimeOwner(instance_root).close) + instance_file = instance_root / "instance.token" + if not instance_file.exists(): + protected_write(instance_file, secrets.token_urlsafe(32), immutable=True) + aliases = {k: (path.parent / v).resolve() for k, v in local.secrets.items()} + config = RuntimeConfig( + instance_id=local.id, + deployment_id=self.settings.access.deployment_id, + plugin=local.plugin, + plugin_dir=path.parent, + config=local.config, + secrets=aliases, + server=Endpoint(url=self.server_url, credential_file=execution_file), + management=Endpoint(url=f"http://127.0.0.1:{manager.port}", credential_file=instance_file), + state_dir=instance_root, + execution_location=str(path.parent), + configuration_digest=configuration_digest(local.plugin, local.config, aliases), + ) + from powercontext.client import PowerContextClient + from powercontext.http import RegisterSourceDefinitionRequest, SourceDefinitionManifest + + manifests = await inspect_manifests(config) + token = secret(self.root / "deployer.token") + async with PowerContextClient(self.server_url, token=token) as client: + for value in manifests: + manifest = SourceDefinitionManifest.model_validate(value) + registered = await client.register_source_definition( + RegisterSourceDefinitionRequest(manifest=manifest) + ) + if registered != manifest: + raise ManagementError("manifest-mismatch", "Server returned a different Definition manifest") + async with httpx.AsyncClient( + base_url=self.server_url, headers={"Authorization": "Bearer " + token}, timeout=65 + ) as client: + registration_path = instance_root / "registration.json" + previous = json.loads(registration_path.read_text()) if registration_path.exists() else None + scopes = ( + tuple(previous["scope_ids"]) + if previous is not None and not update + else tuple(dict.fromkeys((*local.scope_ids, *(t.scope_id for t in local.targets)))) + ) + registration = InstanceRegistration( + instance_id=local.id, + credential_digest=hashlib.sha256(secret(instance_file).encode()).hexdigest(), + execution_location=config.execution_location, + scope_ids=scopes, + public_principals=local.public_principals, + default_capabilities=local.default_capabilities, + configuration_digest=config.configuration_digest, + ) + async with asyncio.timeout(60): + while True: + try: + result = await self.request( + client, + "/v1/source-management/configure", + {"registration": registration.model_dump(mode="json"), "update": update}, + ) + break + except ManagementError as error: + if error.code not in {"management-unavailable", "result-unconfirmed"}: + raise + await asyncio.sleep(0.2) + protected_write(registration_path, registration.model_dump_json(indent=2)) + for scope in scopes: + await self.request( + client, + "/v1/access/bindings/create", + { + "subject": {"type": "service", "id": execution_subject}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "source-" + + hashlib.sha256((execution_subject + scope).encode()).hexdigest()[:48], + }, + ) + protected_write(instance_root / "runtime-config.json", config.model_dump_json(indent=2)) + await updating.aclose() + result["service"] = await asyncio.to_thread( + launch, "runtime", config, self.settings.source_management.process_mode + ) + if result.get("targets_initialized"): + return result + principal_response = await client.get("/v1/access/me") + principal_response.raise_for_status() + initializer = principal_response.json()["principal"] + for scope in dict.fromkeys(t.scope_id for t in local.targets): + await self.request( + client, + "/v1/access/bindings/create", + { + "subject": {"type": initializer["type"], "id": initializer["id"]}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "source-seed-" + + hashlib.sha256((initializer["id"] + scope).encode()).hexdigest()[:48], + }, + ) + async with asyncio.timeout(60): + while True: + try: + await self.request( + client, + "/v1/source-management/configure", + { + "registration": { + "action": "initialize_targets", + "instance_id": local.id, + "targets": [t.model_dump(mode="json") for t in local.targets], + } + }, + ) + break + except ManagementError as error: + if error.code not in { + "instance-unavailable", + "management-unavailable", + "capability-unavailable", + "result-unconfirmed", + }: + raise + await asyncio.sleep(0.2) + return result + + async def startup(self): + # Run after the HTTP listener becomes ready; one failed plugin must not block built-ins. + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(100): + try: + response = await client.get(self.server_url + "/health") + if response.status_code < 500: + break + except httpx.HTTPError: + pass + await asyncio.sleep(0.1) + if self.settings.source_management.autostart: + try: + await self.manager() + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Source manager startup failed; inspect local service configuration", + extra={ + "error_code": error.code + if isinstance(error, ManagementError) + and error.code in {"background-unsupported", "service-unavailable"} + else "deployment-failed", + }, + ) + directory = self.settings.source_plugins_dir + if directory is not None: + for path in sorted(directory.expanduser().glob("*/powercontext.yml")): + try: + await self.load(path) + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Source instance preparation failed; inspect local configuration", + extra={ + "error_code": error.code + if isinstance(error, ManagementError) + and error.code in {"background-unsupported", "service-unavailable"} + else "deployment-failed", + }, + ) + + +async def inspect_manifests(config): + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "powercontext.source_management.inspection", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + async with asyncio.timeout(30): + output, _ = await process.communicate(config.model_dump_json().encode()) + if process.returncode or len(output) > 2 * 1024 * 1024: + raise ManagementError("plugin-inspection-failed", "The plugin entry or declarations could not be loaded") + return json.loads(output) + except (ValueError, TimeoutError) as error: + raise ManagementError( + "plugin-inspection-failed", "The plugin inspection did not return valid declarations" + ) from error + finally: + if process.returncode is None: + process.kill() + await process.wait() diff --git a/src/powercontext/source_management/durable.py b/src/powercontext/source_management/durable.py new file mode 100644 index 0000000000..30138c00c0 --- /dev/null +++ b/src/powercontext/source_management/durable.py @@ -0,0 +1,93 @@ +# 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. + +"""Exclusive process ownership for local services and plugin instances.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from powercontext.source_management.models import ManagementError + + +def protected_directory(directory: Path) -> None: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if directory.is_symlink(): + raise ManagementError("invalid-storage", "Runtime state directory must not be a symlink") + os.chmod(directory, 0o700) + + +class RuntimeOwner: + """A process lock held across control reconnects and all business Contexts.""" + + def __init__(self, directory: Path) -> None: + import fcntl + + protected_directory(directory) + self.directory = directory.resolve() + path = directory / "owner.lock" + if path.is_symlink(): + raise ManagementError("invalid-storage", "Runtime owner lock must not be a symlink") + self.stream = path.open("a+b") + os.chmod(path, 0o600) + try: + fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + self.stream.close() + raise ManagementError( + "instance-running", "This execution location already has a runtime owner", 409 + ) from error + + def close(self) -> None: + self.stream.close() + + def identity(self) -> dict[str, Any]: + """A local process identity; PID reuse and copied state cannot authorize recovery.""" + stat = Path(f"/proc/{os.getpid()}/stat").read_text().rsplit(")", 1)[1].split() + return { + "pid": os.getpid(), + "start_time": stat[19], + "boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(), + "machine_id": Path("/etc/machine-id").read_text().strip(), + "state_dir": str(self.directory), + } + + def exited(self, previous: dict[str, Any]) -> bool: + owner = previous.get("owner") + if owner is None or owner.get("state_dir") != str(self.directory): + return False + current = self.identity() + if owner.get("machine_id") != current["machine_id"]: + return False + if owner.get("boot_id") != current["boot_id"]: + return True + try: + stat = Path(f"/proc/{int(owner['pid'])}/stat").read_text().rsplit(")", 1)[1].split() + except (FileNotFoundError, ProcessLookupError): + return True + return stat[0] == "Z" or stat[19] != owner.get("start_time") + + +class InstanceOwner(RuntimeOwner): + """Prevent multiple local Runtimes for the same Server and instance identity.""" + + def __init__(self, server_url: str, instance_id: str) -> None: + from hashlib import sha256 + + from powercontext.paths import powercontext_data_dir + + key = sha256((server_url.rstrip("/") + "\0" + instance_id).encode()).hexdigest() + super().__init__(powercontext_data_dir() / "source-owners" / key) diff --git a/src/powercontext/source_management/errors.py b/src/powercontext/source_management/errors.py new file mode 100644 index 0000000000..f3e39ca32e --- /dev/null +++ b/src/powercontext/source_management/errors.py @@ -0,0 +1,22 @@ +# 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. + +"""Stable, deliberately non-secret errors for plugin operations.""" + + +class PluginError(RuntimeError): + def __init__(self, code: str, detail: str) -> None: + self.code = code + self.detail = detail + super().__init__(f"{code}: {detail}") diff --git a/src/powercontext/source_management/gateway.py b/src/powercontext/source_management/gateway.py new file mode 100644 index 0000000000..d460d2e0ee --- /dev/null +++ b/src/powercontext/source_management/gateway.py @@ -0,0 +1,437 @@ +# 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. + +"""Server public Source facade, without a custom-target database or delivery queue.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from powercontext.builtin.runtime.models import CaptureSource +from powercontext.http._generated import operations as operations +from powercontext.server.authentication import AuthenticationRequest +from powercontext.server.authz import AccessAction, AccessAuditContext, ResourceRef, access_control_for_mode +from powercontext.server.context import current_principal +from powercontext.source_management.channel import ReverseChannel +from powercontext.source_management.models import ( + BUILTIN_CAPABILITY, + MAX_ADD_BYTES, + MAX_MESSAGE_BYTES, + PROTOCOL_VERSION, + Caller, + Capability, + ManagementError, +) +from powercontext.source_management.storage import canonical + + +class SourceGateway: + def __init__(self, settings, write_plugins=None): + self.settings = settings + self.write_plugins = write_plugins + self.channel = ReverseChannel( + lease_seconds=settings.source_management.lease_seconds, + request_seconds=settings.source_management.request_seconds, + ) + self.ready = False + self._has_catalog = False + self._contracts = {} + self._manager_headers = {} + self._writes: set[asyncio.Task[dict[str, Any]]] = set() + self._catalog_lock = asyncio.Lock() + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + self.channel.close() + for task in self._writes: + task.cancel() + if self._writes: + await asyncio.gather(*self._writes, return_exceptions=True) + + async def available(self, request: Request) -> bool: + if not self.ready or not self.channel.online: + return False + provider = request.app.state.authentication_provider + if provider is None: + return False + try: + result = await provider.authenticate( + AuthenticationRequest(transport="http", headers=self._manager_headers, client_host=None) + ) + except Exception: + self.channel.close() + self.ready = False + return False + return f"{result.subject.type}:{result.subject.id}" == self.settings.source_management.manager_subject + + def synchronize(self, catalog: dict[str, Any]) -> None: + declarations = catalog["capabilities"] + contracts = dict(self._contracts) + keys = set() + for value in declarations: + declared = {key: value[key] for key in Capability.model_fields if key in value} + capability = Capability.model_validate(declared) + if capability.kind != "collect": + raise ManagementError( + "capability-conflict", "The management catalog accepts only collect capabilities", 409 + ) + key = capability.name, capability.version + if ( + capability.name == BUILTIN_CAPABILITY.name + or key in keys + or (self.write_plugins is not None and self.write_plugins.find(*key) is not None) + ): + raise ManagementError("capability-conflict", "The capability snapshot has conflicting identities", 409) + keys.add(key) + contract = canonical(capability.declaration()) + if contracts.get(key, contract) != contract: + raise ManagementError("capability-conflict", "A capability version cannot change its contract", 409) + contracts[key] = contract + if self.ready and catalog["revision"] < self.channel.catalog["revision"]: + raise ManagementError("stale-catalog", "The capability snapshot is stale", 409) + self.channel.catalog = catalog + self._contracts = contracts + self._has_catalog = True + self.ready = True + + async def call(self, request: Request, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: # noqa: C901 - explicit protocol state transitions + if operation == "add": + try: + size = len(canonical(parameters).encode()) + except (ValueError, UnicodeError) as error: + raise ManagementError("invalid-input", "Source add parameters must be valid JSON") from error + if size > MAX_ADD_BYTES: + raise ManagementError("input-too-large", "Source add parameters exceed 1 MiB") + control = parameters.get("view") == "control" + admin = operation in {"configure", "set_enabled", "write_load"} or (operation == "instances" and control) + if operation == "instances": + if control and (not parameters.get("instance_id") or set(parameters) - {"instance_id", "view"}): + raise ManagementError("invalid-query", "Control requires only instance_id and view") + if not control and not parameters.get("scope_id"): + raise ManagementError("invalid-query", "Discovery requires scope_id") + principal = current_principal() + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + scope = parameters.get("scope_id") + action = ( + AccessAction.SERVER_ADMIN + if admin + else ( + AccessAction.SCOPE_CONTRIBUTE + if operation in {"add", "target_set_enabled"} or (operation == "status" and control) + else AccessAction.SCOPE_READ + ) + ) + if access is not None: + await access.require( + principal, + action, + ResourceRef.server(access.deployment_id) if admin else ResourceRef.scope(scope or ""), + context=AccessAuditContext(transport="http", operation="pc_source_" + operation), + ) + if operation == "add": + await access.require( + principal, + AccessAction.SCOPE_READ, + ResourceRef.scope(scope or ""), + context=AccessAuditContext(transport="http", operation="pc_source_" + operation), + ) + if ( + operation == "configure" + and parameters["registration"].get("action") == "initialize_targets" + and access is not None + ): + for target in parameters["registration"].get("targets", []): + for target_action in (AccessAction.SCOPE_READ, AccessAction.SCOPE_CONTRIBUTE): + await access.require( + principal, + target_action, + ResourceRef.scope(target["scope_id"]), + context=AccessAuditContext(transport="http", operation="source_initialize_targets"), + ) + can_write = True + if operation in {"capabilities", "instances"} and not control and access is not None: + can_write = ( + await access.check( + principal, + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(scope or ""), + context=AccessAuditContext(transport="http", operation="pc_source_" + operation), + ) + ).allowed + caller = Caller( + deployment=self.settings.access.deployment_id, + subject="anonymous" if principal is None else f"{principal.type}:{principal.id}", + scope_id=scope, + operation=operation, + administrator=admin, + ) + if operation == "write_load": + if self.write_plugins is None: + raise ManagementError("server-unavailable", "Source write storage is not configured", 503) + from powercontext.source_management.write import WritePluginConfig + + async with self._catalog_lock: + return await self.write_plugins.load( + WritePluginConfig.model_validate(parameters), reserved_capabilities=set(self._contracts) + ) + if operation in {"capabilities", "instances"} and not control: + return self.discover(parameters, caller, can_write, await self.available(request), operation) + if operation == "add" and ( + parameters["capability"] == BUILTIN_CAPABILITY.name + or ( + self.write_plugins is not None + and ( + self.write_plugins.find(parameters["capability"], parameters["capability_version"]) is not None + or ( + parameters.get("instance_id") is None + and any(c.name == parameters["capability"] for c in self.write_plugins.capabilities()) + ) + ) + ) + ): + if parameters.get("instance_id") is not None: + raise ManagementError("invalid-input", "write does not accept instance_id") + result = await self.write_add(request, caller, parameters) + else: + if not await self.available(request): + raise ManagementError("management-unavailable", "Source management is unavailable", 503) + call = self.channel.request( + operation, + {"caller": caller.model_dump(mode="json"), "parameters": parameters}, + wait_seconds=self.settings.source_management.write_wait_timeout_seconds if operation == "add" else None, + ) + if operation == "add": + pending = asyncio.create_task(call) + try: + while not pending.done(): + await asyncio.wait({pending}, timeout=1) + await self.require_result_read(request, caller) + result = await pending + finally: + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + else: + result = await call + if operation == "add" or (operation == "status" and not control): + await self.require_result_read(request, caller) + return result + + def discover(self, parameters, caller, can_write, online, operation): # noqa: C901 - explicit protocol state transitions + capabilities = [] + instances = {} + if parameters.get("instance_id") is None and parameters.get("capability") in (None, BUILTIN_CAPABILITY.name): + capabilities.append({**BUILTIN_CAPABILITY.declaration(), "instances": [], "can_write": can_write}) + if self.write_plugins is not None and parameters.get("instance_id") is None: + for capability in self.write_plugins.capabilities(): + if parameters.get("capability") in (None, capability.name): + capabilities.append({**capability.declaration(), "instances": [], "can_write": can_write}) + if self._has_catalog: + for capability in self.channel.catalog["capabilities"]: + if parameters.get("capability") not in (None, capability["name"]): + continue + visible = [] + for instance in capability["instances"]: + if caller.scope_id not in instance["scope_ids"]: + continue + if caller.subject not in instance["public_principals"] and "*" not in instance["public_principals"]: + continue + if parameters.get("instance_id") not in (None, instance["id"]): + continue + public = { + k: v for k, v in instance.items() if k not in {"scope_ids", "public_principals", "run_id"} + } + public["can_write"] = can_write + if not online: + public["availability"] = "unavailable" + visible.append(public) + instances[instance["id"]] = public + if visible: + item = {**capability, "instances": visible, "can_write": can_write} + if item.get("default_instance_id") not in {v["id"] for v in visible}: + item.pop("default_instance_id", None) + capabilities.append(item) + return { + "capabilities": capabilities, + "management": "online" + if online + else ("not-configured" if self.settings.source_management.manager_subject is None else "unavailable"), + **({"instances": list(instances.values())} if operation == "instances" else {}), + } + + async def builtin_add(self, request, caller, parameters): + if parameters["capability_version"] != BUILTIN_CAPABILITY.version or parameters.get("instance_id") is not None: + raise ManagementError("capability-unavailable", "Use the discovered built-in text capability") + value = parameters["input"] + BUILTIN_CAPABILITY.validate_input(value) + if not value["text"].strip(): + raise ManagementError("invalid-input", "Text must contain non-whitespace content") + from powercontext.server.app import _reject_reserved_handoff_receipt_content + + _reject_reserved_handoff_receipt_content(value["text"]) + receipt = await request.app.state.application.sources.for_scope(caller.scope_id).capture( + CaptureSource(source_id=value["source_id"], content=value["text"], metadata=value.get("metadata") or {}) + ) + return { + "kind": "write", + "result": {"outcome": "saved", "source_refs": [receipt.source_ref.model_dump(mode="json")]}, + } + + async def write_add(self, request, caller, parameters): + if len(self._writes) >= 64: + raise ManagementError("server-busy", "Too many writes are awaiting completion", 503) + + async def authorize(): + await self.require_result_read(request, caller) + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is not None: + await access.require( + current_principal(), + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(caller.scope_id), + context=AccessAuditContext(transport="http", operation="pc_source_add"), + ) + + async def capture(): + if parameters["capability"] == BUILTIN_CAPABILITY.name: + await authorize() + return await self.builtin_add(request, caller, parameters) + if self.write_plugins is None: + raise ManagementError("capability-unavailable", "The write plugin is not loaded", 503) + return await self.write_plugins.write( + request.app.state.application, + caller.scope_id, + parameters["capability"], + parameters["capability_version"], + parameters["input"], + authorize, + ) + + task = asyncio.create_task(capture()) + self._writes.add(task) + try: + async with asyncio.timeout(self.settings.source_management.write_wait_timeout_seconds): + while not task.done(): + await asyncio.wait({task}, timeout=1) + await self.require_result_read(request, caller) + return await task + except TimeoutError as error: + raise ManagementError( + "result-unconfirmed", "The write result was not confirmed; the Source may already be saved", 503 + ) from error + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self._writes.discard(task) + + async def require_result_read(self, request, caller): + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is not None: + await access.require( + current_principal(), + AccessAction.SCOPE_READ, + ResourceRef.scope(caller.scope_id or ""), + context=AccessAuditContext(transport="http", operation="pc_source_result"), + ) + + async def channel_operation(self, request: Request, operation: str, data: dict[str, Any]) -> dict[str, Any]: + if len(canonical(data).encode()) > MAX_MESSAGE_BYTES: + raise ManagementError("request-too-large", "The management exchange exceeds its size limit") + principal = current_principal() + if principal is None or f"{principal.type}:{principal.id}" != self.settings.source_management.manager_subject: + raise ManagementError("forbidden", "This identity is not bound to the Source management channel", 403) + owner = self.settings.source_management.manager_subject + if operation == "open": + if ( + data["protocol"] != PROTOCOL_VERSION + or data["deployment_id"] != self.settings.access.deployment_id + or data["manager_id"] != self.settings.source_management.manager_id + ): + raise ManagementError("manager-binding", "The management binding or protocol does not match", 403) + generation = self.channel.open(owner) + self._manager_headers = {"authorization": request.headers.get("authorization", "")} + self.ready = False + return {"generation": generation, "protocol": PROTOCOL_VERSION} + generation = data["generation"] + self.channel.check(owner, generation) + if operation == "catalog": + async with self._catalog_lock: + self.synchronize(data["catalog"]) + elif operation == "poll": + return {"messages": await self.channel.poll(owner, generation)} + elif operation == "reply": + self.channel.reply(owner, generation, data["message_id"], data["response"]) + elif operation == "close": + self.channel.close(owner, generation) + self.ready = False + return {"confirmed": True} + + +def mount_source_routes(app: FastAPI) -> None: + app.state.source_gateway = None + + @app.exception_handler(ManagementError) + async def source_error(request, error): + return JSONResponse( + {"error": {"code": error.code, "message": error.message, "details": None}}, status_code=error.status + ) + + def endpoint(operation, model, channel=False): + async def call(body, request: Request): + if operation == "add" and len(await request.body()) > MAX_ADD_BYTES: + raise ManagementError("input-too-large", "Source add parameters exceed 1 MiB") + gateway = request.app.state.source_gateway + if gateway is None: + raise ManagementError("server-unavailable", "The Server is not ready", 503) + data = body.model_dump(mode="json", exclude_none=True) + return await ( + gateway.channel_operation(request, operation, data) + if channel + else gateway.call(request, operation, data) + ) + + call.__annotations__["body"] = model + return call + + specs = [ + ("capabilities", operations.PC_SOURCE_CAPABILITIES), + ("add", operations.PC_SOURCE_ADD), + ("status", operations.PC_SOURCE_STATUS), + ("target_set_enabled", operations.PC_SOURCE_TARGET_SET_ENABLED), + ("instances", operations.PC_SOURCE_INSTANCES), + ("set_enabled", operations.PC_SOURCE_INSTANCE_SET_ENABLED), + ("configure", operations.SOURCE_CONFIGURE), + ("write_load", operations.LOAD_SOURCE_WRITE_PLUGIN), + ] + specs += [ + (name, getattr(operations, "SOURCE_CHANNEL_" + name.upper())) + for name in ("open", "catalog", "poll", "reply", "close") + ] + for name, operation in specs: + app.add_api_route( + operation.path, + endpoint(name, operation.request_type, name in {"open", "catalog", "poll", "reply", "close"}), + methods=[operation.method], + operation_id=operation.operation_id, + response_model=operation.response_type, + response_model_exclude_none=True, + status_code=operation.success_status, + ) diff --git a/src/powercontext/source_management/inspection.py b/src/powercontext/source_management/inspection.py new file mode 100644 index 0000000000..5a4a1ca24b --- /dev/null +++ b/src/powercontext/source_management/inspection.py @@ -0,0 +1,38 @@ +# 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. + +"""Inspect trusted local plugin declarations outside the Server process.""" + +from __future__ import annotations + +import json +import sys + +from powercontext.source_management.config import RuntimeConfig +from powercontext.source_management.loading import load_runtime_plugin +from powercontext.sources import manifest_for_definition + + +def main() -> None: + config = RuntimeConfig.model_validate_json(sys.stdin.buffer.read(2 * 1024 * 1024 + 1)) + loaded = load_runtime_plugin(config) + print( + json.dumps([ + manifest_for_definition(d).model_dump(mode="json", by_alias=True) for d in loaded.registry.definitions + ]) + ) + + +if __name__ == "__main__": + main() diff --git a/src/powercontext/source_management/loading.py b/src/powercontext/source_management/loading.py new file mode 100644 index 0000000000..a851c5f37d --- /dev/null +++ b/src/powercontext/source_management/loading.py @@ -0,0 +1,106 @@ +# 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. + +"""Load trusted local code on the execution machine, never in the manager.""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import inspect +import sys +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +from pydantic import BaseModel + +from powercontext.source_management.config import RuntimeConfig +from powercontext.source_management.errors import PluginError +from powercontext.source_management.models import DYNAMIC_PLUGIN_API_VERSION, Capability +from powercontext.sources import SourceDefinitionRegistry + + +@dataclass(frozen=True) +class LoadedRuntimePlugin: + module: ModuleType + config: BaseModel + registry: SourceDefinitionRegistry + connector_name: str + connector_version: str + capabilities: tuple[Capability, ...] + + +def load_local_module(directory: Path, entry: str) -> ModuleType: + path = (directory / entry).resolve() + if path.is_dir(): + path /= "__init__.py" + if path.suffix != ".py" or not path.is_file(): + if "/" not in entry and "\\" not in entry and not entry.endswith(".py"): + return importlib.import_module(entry) + raise PluginError("plugin-entry", "The trusted local Python entry does not exist") + name = "_pc_managed_" + hashlib.sha256(str(path).encode()).hexdigest() + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise PluginError("plugin-entry", "Could not load the local Python entry") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + except BaseException: + for key in tuple(sys.modules): + if key == name or key.startswith(name + "."): + del sys.modules[key] + raise + return module + + +def load_runtime_plugin(config: RuntimeConfig) -> LoadedRuntimePlugin: + module = load_local_module(config.plugin_dir, config.plugin) + if getattr(module, "plugin_api_version", None) != DYNAMIC_PLUGIN_API_VERSION: + raise PluginError("plugin-version", "Managed dynamic plugins require plugin_api_version = '2'") + configuration = getattr(module, "Config", None) + if not isinstance(configuration, type) or not issubclass(configuration, BaseModel): + raise PluginError("plugin-contract", "Config must be a Pydantic model") + if not inspect.iscoroutinefunction(getattr(module, "apply", None)): + raise PluginError("plugin-contract", "apply must be an async function") + definitions = getattr(module, "definitions", None) + if not isinstance(definitions, tuple) or not definitions: + raise PluginError("plugin-contract", "definitions must be a non-empty tuple") + capabilities = tuple( + capability + for value in getattr(module, "capabilities", ()) + if (capability := Capability.model_validate(value)).kind == "collect" + ) + if not capabilities: + raise PluginError("plugin-contract", "Managed plugins must declare capabilities") + if any(c.kind == "collect" for c in capabilities) and not inspect.iscoroutinefunction( + getattr(module, "identify_target", None) + ): + raise PluginError("plugin-contract", "collect requires async identify_target(request, config)") + identities = [] + for field in ("connector_name", "connector_version"): + value = getattr(module, field, None) + if not isinstance(value, str) or not value or value != value.strip() or len(value) > 128: + raise PluginError("plugin-contract", "Connector identities must be non-empty trimmed strings") + identities.append(value) + return LoadedRuntimePlugin( + module, + configuration.model_validate(config.config), + SourceDefinitionRegistry(definitions), + identities[0], + identities[1], + capabilities, + ) diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py new file mode 100644 index 0000000000..16c0c20a94 --- /dev/null +++ b/src/powercontext/source_management/manager.py @@ -0,0 +1,720 @@ +# 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. + +"""Standalone management authority, reached through its outbound Server channel.""" + +from __future__ import annotations + +import asyncio +import hashlib +from contextlib import asynccontextmanager, suppress +from datetime import UTC, datetime +from secrets import compare_digest +from typing import Any +from uuid import uuid4 + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from powercontext.http._generated.models import SourceInstanceExchangeRequest +from powercontext.source_management.channel import ReverseChannel +from powercontext.source_management.config import ManagerConfig, secret +from powercontext.source_management.durable import RuntimeOwner +from powercontext.source_management.models import ( + MAX_ADD_BYTES, + MAX_MESSAGE_BYTES, + PROTOCOL_VERSION, + Caller, + Capability, + InstanceRegistration, + ManagementError, + SourceTarget, + TargetIdentity, + TargetIdentityRequest, + TargetSubmissionReport, +) +from powercontext.source_management.storage import Store, canonical + + +def timestamp() -> str: + return datetime.now(UTC).isoformat() + + +def error_payload(error: ManagementError) -> dict[str, Any]: + return {"error": {"code": error.code, "message": error.message, "status": error.status}} + + +def _sync_target_size(snapshot: dict[str, Any]) -> int: + length = len(canonical(snapshot).encode()) + if length > MAX_MESSAGE_BYTES - 3072: + raise ManagementError("response-too-large", "One target exceeds the sync limit") + return length + + +class SourceManager: + def __init__(self, config: ManagerConfig) -> None: + self.config = config + self.owner = RuntimeOwner(config.state_dir) + self.store = Store(config.state_dir) + binding = { + "server_url": config.server.url, + "deployment_id": config.deployment_id, + "manager_id": config.manager_id, + } + existing = self.store.get("binding", "deployment") + if existing is not None and existing != binding: + self.store.close() + self.owner.close() + raise ManagementError("state-binding-conflict", "This management state belongs to another Server", 409) + self.store.put("binding", "deployment", binding) + self.channels: dict[str, ReverseChannel] = {} + self._lock = asyncio.Lock() + self._generation: str | None = None + self._catalog_revision = 0 + self._catalog_event = asyncio.Event() + self._initialize_locks: dict[str, asyncio.Lock] = {} + + def channel(self, instance_id: str) -> ReverseChannel: + return self.channels.setdefault( + instance_id, + ReverseChannel(lease_seconds=self.config.lease_seconds, request_seconds=self.config.request_seconds), + ) + + def instance(self, identifier: str) -> dict[str, Any]: + instance = self.store.get("instance", identifier) + if instance is None: + raise ManagementError("instance-not-found", "The instance is not registered", 404) + return instance + + def authorize_instance(self, request: Request, identifier: str) -> dict[str, Any]: + instance = self.instance(identifier) + supplied = request.headers.get("authorization", "") + scheme, _, token = supplied.partition(" ") + digest = hashlib.sha256(token.encode()).hexdigest() + if scheme.lower() != "bearer" or not compare_digest(digest, instance["credential_digest"]): + raise ManagementError("unauthorized", "A valid instance credential is required", 401) + return instance + + def runnable(self, instance: dict[str, Any]) -> bool: + return self.channel(instance["instance_id"]).online and instance["enabled"] and instance["actual"] == "running" + + def catalog(self) -> dict[str, Any]: + capabilities: dict[tuple[str, str], dict[str, Any]] = {} + instances = [] + for instance in self.store.all("instance"): + availability = "online" if self.runnable(instance) else "unavailable" + public = { + "id": instance["instance_id"], + "availability": availability, + "scope_ids": instance["scope_ids"], + "public_principals": instance["public_principals"], + "desired": "enabled" if instance["enabled"] else "disabled", + "actual": instance["actual"], + "run_id": instance["run_id"], + "updated_at": instance["updated_at"], + } + instances.append(public) + for old_declaration in instance.get("capabilities", []): + declaration = {k: v for k, v in old_declaration.items() if k in Capability.model_fields} + key = declaration["name"], declaration["version"] + item = capabilities.setdefault(key, {**declaration, "instances": []}) + item["instances"].append(public) + if declaration["name"] in instance["default_capabilities"]: + if item.get("default_instance_id") not in (None, instance["instance_id"]): + raise ManagementError("default-conflict", "Multiple default instances were declared", 409) + item["default_instance_id"] = instance["instance_id"] + return {"revision": self._catalog_revision, "capabilities": list(capabilities.values()), "instances": instances} + + def changed(self) -> None: + self._catalog_revision += 1 + self._catalog_event.set() + + async def handle(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: + caller = Caller.model_validate(arguments["caller"]) + parameters = arguments["parameters"] + if ( + caller.deployment != self.config.deployment_id + or caller.operation != operation + or caller.scope_id != parameters.get("scope_id") + or not caller.operation_authorized + ): + raise ManagementError( + "untrusted-context", "The operation is not bound to an authorized Server context", 403 + ) + if operation == "configure": + return await self.configure(caller, parameters) + if operation == "set_enabled": + return await self.set_enabled(caller, parameters) + if operation == "instances" and parameters.get("view") == "control": + self.require_admin(caller) + return {"instances": [self.instance_control(self.instance(parameters["instance_id"]))]} + if operation == "add": + return await self.add(caller, parameters) + if operation == "status": + target = self.target(parameters["target_id"]) + self.require_target(caller, target) + return self.target_control(target) if parameters.get("view") == "control" else self.receipt(target) + if operation == "target_set_enabled": + return await self.target_set_enabled(caller, parameters) + raise ManagementError("unknown-operation", "Unknown Source management operation") + + @staticmethod + def require_admin(caller): + if not caller.administrator: + raise ManagementError("forbidden", "This operation requires server.admin", 403) + + async def configure(self, caller, parameters): # noqa: C901 - explicit protocol state transitions + self.require_admin(caller) + data = parameters["registration"] + if data.get("action") == "initialize_targets": + identifier = data["instance_id"] + async with self._initialize_locks.setdefault(identifier, asyncio.Lock()): + instance = self.instance(identifier) + if instance.get("targets_initialized"): + return {"instance_id": identifier, "reused": True} + for entry in data.get("targets", []): + if entry["scope_id"] not in instance["scope_ids"]: + raise ManagementError("scope-mismatch", "Initial target lies outside instance Scopes", 403) + await self.add( + caller.model_copy(update={"scope_id": entry["scope_id"], "operation": "add"}), + {**entry, "instance_id": identifier}, + ) + # A lost response can safely repeat add; an empty initialized list stays initialized. + instance = self.instance(identifier) + instance["targets_initialized"] = True + self.store.put("instance", identifier, instance) + return {"instance_id": identifier, "reused": False} + registration = InstanceRegistration.model_validate(data) + value = registration.model_dump(mode="json") + async with self._lock: + current = self.store.get("instance", registration.instance_id) + for other in self.store.all("instance"): + if other["instance_id"] != registration.instance_id and set(other["default_capabilities"]) & set( + registration.default_capabilities + ): + raise ManagementError("default-conflict", "This capability already has a default instance", 409) + if current is not None: + configured = current.get("registration", {}) + configured = {k: v for k, v in configured.items() if k in InstanceRegistration.model_fields} + same_deployment = configured == value or ( + current.get("targets_initialized") + and not parameters.get("update") + and {k: v for k, v in configured.items() if k != "scope_ids"} + == {k: v for k, v in value.items() if k != "scope_ids"} + ) + if same_deployment and not parameters.get("update"): + return { + "instance_id": registration.instance_id, + "reused": True, + "targets_initialized": current.get("targets_initialized", False), + } + extending = ( + parameters.get("update") + and {k: v for k, v in configured.items() if k != "scope_ids"} + == {k: v for k, v in value.items() if k != "scope_ids"} + and set(current["scope_ids"]) < set(value["scope_ids"]) + ) + if not extending and not ( + parameters.get("update") + and not current["enabled"] + and current["actual"] == "stopped" + and not self.channel(registration.instance_id).online + ): + raise ManagementError( + "instance-conflict", "Stop the instance before changing its configuration", 409 + ) + desired = current["enabled"] + current.update(value) + current["registration"] = value + current["enabled"] = desired if extending else False + if not extending: + current.update( + run_id=uuid4().hex, + execution=None, + owner=None, + actual="stopped", + ) + self.store.put("instance", registration.instance_id, current) + else: + value.update( + registration=registration.model_dump(mode="json"), + run_id=uuid4().hex, + execution=None, + owner=None, + actual="pending" if registration.enabled else "stopped", + capabilities=[], + updated_at=timestamp(), + targets_initialized=False, + ) + self.store.put("instance", registration.instance_id, value) + self.changed() + return { + "instance_id": registration.instance_id, + "reused": False, + "targets_initialized": False if current is None else current.get("targets_initialized", False), + } + + async def set_enabled(self, caller, parameters): + self.require_admin(caller) + async with self._lock: + instance = self.instance(parameters["instance_id"]) + if instance["enabled"] == parameters["enabled"]: + return self.instance_control(instance) + if parameters["enabled"]: + if instance["actual"] != "stopped": + raise ManagementError("stop-unconfirmed", "Wait for the previous Context to finish cleanup", 409) + instance.update(run_id=uuid4().hex, execution=None, actual="pending") + else: + instance["actual"] = "stopping" + instance.update(enabled=parameters["enabled"], updated_at=timestamp()) + self.store.put("instance", instance["instance_id"], instance) + self.changed() + return self.instance_control(instance) + + def instance_control(self, instance): + return { + "instance_id": instance["instance_id"], + "enabled": instance["enabled"], + "actual": instance["actual"], + "availability": "online" if self.channel(instance["instance_id"]).online else "offline", + } + + def target(self, identifier): + target = self.store.get("target", identifier) + if target is None: + raise ManagementError("target-not-found", "The target does not exist", 404) + return target + + def save_work(self, target): + self.store.put("target", target["target_id"], target) + + @staticmethod + def require_target(caller, target): + if caller.scope_id != target["scope_id"]: + raise ManagementError("scope-mismatch", "The target does not belong to the authorized Scope", 403) + + @staticmethod + def target_control(target): + return { + "kind": "collect", + "target_id": target["target_id"], + "instance_id": target["instance_id"], + "enabled": target["tracking"] == "enabled", + "tracking": target["tracking"], + } + + def receipt(self, target): + instance = self.instance(target["instance_id"]) + return { + **self.target_control(target), + "capability": target["capability"], + "capability_version": target["capability_version"], + "source_refs": target.get("source_refs", []), + "last_submitted_at": target.get("last_submitted_at"), + "availability": "online" if self.runnable(instance) else "unavailable", + } + + async def identify(self, instance, capability, value): + request = TargetIdentityRequest(capability=capability.name, capability_version=capability.version, input=value) + result = TargetIdentity.model_validate( + await self.channel(instance["instance_id"]).request("identify", request.model_dump(mode="json")) + ) + capability.validate_input(result.normalized_input) + return result + + @staticmethod + def identity(scope, instance, capability, key): + return canonical([scope, instance, capability.name, capability.version, key]) + + async def add(self, caller, parameters): + if len(canonical(parameters).encode()) > MAX_ADD_BYTES: + raise ManagementError("input-too-large", "Source add exceeds 1 MiB") + if not parameters.get("instance_id"): + raise ManagementError("instance-selection-required", "Select an explicit instance_id") + instance = self.instance(parameters["instance_id"]) + if caller.scope_id not in instance["scope_ids"]: + raise ManagementError("scope-mismatch", "The instance cannot serve this Scope", 403) + declaration = next( + ( + c + for c in instance["capabilities"] + if c["name"] == parameters["capability"] and c["version"] == parameters["capability_version"] + ), + None, + ) + if declaration is None: + raise ManagementError("capability-unavailable", "The capability version is unavailable", 503) + capability = Capability.model_validate({k: v for k, v in declaration.items() if k in Capability.model_fields}) + capability.validate_input(parameters["input"]) + if capability.kind != "collect": + raise ManagementError("capability-unavailable", "write must be loaded and executed by the PC Server") + identified = await self.identify(instance, capability, parameters["input"]) + identity = self.identity(caller.scope_id, instance["instance_id"], capability, identified.target_key) + async with self._lock: + matches = [t for t in self.store.all("target") if t.get("identity") == identity] + if len(matches) > 1: + raise ManagementError("target-identity-conflict", "Conflicting collect target identities", 409) + if matches: + target = matches[0] + if target["input"] != identified.normalized_input: + raise ManagementError("target-config-conflict", "The target already has different settings", 409) + self.store.claim_identity(identity, target["target_id"]) + return self.target_control(target) + instance = self.instance(instance["instance_id"]) + if not self.runnable(instance): + raise ManagementError("instance-unavailable", "The plugin is not running", 503) + target: dict[str, Any] = { + "target_id": "tgt_" + uuid4().hex, + "instance_id": instance["instance_id"], + "scope_id": caller.scope_id, + "capability": capability.name, + "capability_version": capability.version, + "input": identified.normalized_input, + "identity": identity, + "tracking": "enabled", + "creator": caller.subject, + "source_refs": [], + "updated_at": timestamp(), + } + snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target} + _sync_target_size(snapshot) + with self.store.transaction(): + self.store.claim_identity(identity, target["target_id"]) + self.save_work(target) + return self.target_control(target) + + async def target_set_enabled(self, caller, parameters): + async with self._lock: + target = self.target(parameters["target_id"]) + self.require_target(caller, target) + if (target["tracking"] == "enabled") == parameters["enabled"]: + return self.target_control(target) + if parameters["enabled"]: + if target["tracking"] != "stopped": + raise ManagementError("stop-unconfirmed", "Wait for target cleanup", 409) + if caller.scope_id not in self.instance(target["instance_id"])["scope_ids"]: + raise ManagementError("scope-mismatch", "The instance cannot serve this Scope", 403) + declarations = self.instance(target["instance_id"])["capabilities"] + if not any( + c["name"] == target["capability"] and c["version"] == target["capability_version"] + for c in declarations + ): + raise ManagementError( + "capability-unavailable", "The target's capability version is unavailable", 409 + ) + target["tracking"] = "enabled" + else: + target["tracking"] = "stopping" + self.save_work(target) + return self.target_control(target) + + async def connect_instance(self, instance, data): # noqa: C901 - ownership and contract checks + declarations = [Capability.model_validate(c).declaration() for c in data["capabilities"]] + if any(c["kind"] != "collect" for c in declarations): + raise ManagementError("capability-conflict", "Managed instances may publish only collect capabilities", 409) + if len({(c["name"], c["version"]) for c in declarations}) != len(declarations): + raise ManagementError("capability-conflict", "Duplicate capability declarations", 409) + async with self._lock: + instance = self.instance(instance["instance_id"]) + if ( + data["protocol"] != PROTOCOL_VERSION + or data["execution_location"] != instance["execution_location"] + or data["configuration_digest"] != instance["configuration_digest"] + ): + raise ManagementError("instance-binding", "The runtime does not match registration", 409) + for c in declarations: + key = canonical([c["name"], c["version"]]) + known = self.store.get("capability", key) + if known is not None: + known = {k: v for k, v in known.items() if k in Capability.model_fields} + if c["name"] == "content.inline" or (known is not None and known != c): + raise ManagementError("capability-conflict", "A capability version cannot change its contract", 409) + owner = data["owner"] + old_owner = instance.get("owner") + # Lease expiry alone never authorizes a second execution process. + if ( + old_owner is not None + and old_owner != owner + and (not data.get("owner_exited") or data.get("previous_owner") != old_owner) + ): + raise ManagementError("execution-owned", "The previous process has not confirmed exit", 409) + if old_owner is None and instance.get("execution") and instance["actual"] != "stopped": + raise ManagementError("execution-owned", "The existing execution has not confirmed exit", 409) + offered = {(c["name"], c["version"]) for c in declarations} + if any( + t["instance_id"] == instance["instance_id"] + and (t["capability"], t["capability_version"]) not in offered + for t in self.store.all("target") + ): + raise ManagementError("capability-in-use", "Keep capability versions required by retained targets", 409) + generation = self.channel(instance["instance_id"]).open(instance["instance_id"]) + try: + with self.store.transaction(): + for c in declarations: + self.store.put("capability", canonical([c["name"], c["version"]]), c) + if owner != old_owner: + instance.update( + run_id=uuid4().hex, execution=None, actual="pending" if instance["enabled"] else "stopped" + ) + instance.update(owner=owner, capabilities=declarations) + self.store.put("instance", instance["instance_id"], instance) + except BaseException: + self.channel(instance["instance_id"]).close() + raise + self.changed() + return {"generation": generation, "protocol": PROTOCOL_VERSION} + + async def sync_instance(self, instance, generation, cursor=""): + self.channel(instance["instance_id"]).check(instance["instance_id"], generation) + targets, stopping, size, next_cursor, last_id = [], [], 1024, None, cursor + for target in sorted(self.store.all("target"), key=lambda t: t["target_id"]): + if target["instance_id"] != instance["instance_id"] or target["target_id"] <= cursor: + continue + if target["tracking"] == "stopping": + snapshot = {"target_id": target["target_id"]} + destination = stopping + elif target["tracking"] == "enabled" and target["scope_id"] in instance["scope_ids"]: + snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target} + destination = targets + else: + continue + length = _sync_target_size(snapshot) + if size + length > MAX_MESSAGE_BYTES - 2048: + next_cursor = last_id + break + destination.append(snapshot) + size += length + last_id = target["target_id"] + return { + "enabled": instance["enabled"], + "run_id": instance["run_id"], + "actual": instance["actual"], + "execution": instance["execution"], + "targets": targets, + "stopping": stopping, + "next_cursor": next_cursor, + } + + async def execution(self, instance, data): + async with self._lock: + instance = self.instance(instance["instance_id"]) + if data["run_id"] != instance["run_id"]: + raise ManagementError("stale-run", "This report belongs to an old execution", 409) + if instance["execution"] not in (None, data["execution"]): + raise ManagementError("execution-owned", "Another Context owns this run", 409) + if data["status"] == "running" and ( + not instance["enabled"] or instance["actual"] in {"complete", "failed", "stopped", "cleanup-failed"} + ): + raise ManagementError("run-terminal", "This business run cannot be restarted", 409) + if instance["actual"] == "cleanup-failed" and data["status"] != "cleanup-failed": + raise ManagementError("cleanup-unconfirmed", "Previous resources have not been cleaned up", 409) + instance.update(execution=data["execution"], actual=data["status"], updated_at=timestamp()) + self.store.put("instance", instance["instance_id"], instance) + self.changed() + return {"confirmed": True} + + async def report_result(self, instance, data): + report = TargetSubmissionReport.model_validate(data) + target = self.target(report.target_id) + if target["instance_id"] != instance["instance_id"]: + raise ManagementError("forbidden", "The target belongs to another instance", 403) + # This is the last received submission report, not a history or a completed batch. + target["source_refs"] = [report.source_refs[0].model_dump(mode="json")] + target["last_submitted_at"] = report.submitted_at + self.save_work(target) + return {"confirmed": True} + + async def stopped_targets(self, instance, data): + async with self._lock: + instance = self.instance(instance["instance_id"]) + if data["run_id"] != instance["run_id"] or data.get("execution") != instance["execution"]: + raise ManagementError("execution-owned", "Only the current Context can confirm cleanup", 409) + if instance["actual"] == "cleanup-failed": + raise ManagementError("cleanup-unconfirmed", "Resource cleanup failed", 409) + with self.store.transaction(): + for stop in data["targets"]: + target = self.target(stop["target_id"]) + if target["instance_id"] != instance["instance_id"]: + raise ManagementError("forbidden", "The target belongs to another instance", 403) + if target["tracking"] == "stopping": + target["tracking"] = "stopped" + self.save_work(target) + return {"confirmed": True} + + async def serve_channel(self) -> None: + headers = {"Authorization": "Bearer " + secret(self.config.server.credential_file)} + async with httpx.AsyncClient(base_url=self.config.server.url, headers=headers, timeout=20) as client: + while True: + try: + # Refresh credentials on reconnect; every transport operation is reauthenticated by Server. + client.headers["Authorization"] = "Bearer " + secret(self.config.server.credential_file) + response = await client.post( + "/v1/source-management/channel/open", + json={ + "protocol": PROTOCOL_VERSION, + "deployment_id": self.config.deployment_id, + "manager_id": self.config.manager_id, + }, + ) + response.raise_for_status() + self._generation = response.json()["generation"] + await self._connected(client) + except (httpx.HTTPError, ManagementError): + await asyncio.sleep(0.2) + finally: + if self._generation is not None: + with suppress(httpx.HTTPError): + await client.post( + "/v1/source-management/channel/close", json={"generation": self._generation} + ) + self._generation = None + + async def _connected(self, client: httpx.AsyncClient) -> None: # noqa: C901 - bounded channel tasks and their cleanup + generation = self._generation + + async def synchronize() -> None: + while True: + self._catalog_event.clear() + response = await client.post( + "/v1/source-management/channel/catalog", json={"generation": generation, "catalog": self.catalog()} + ) + response.raise_for_status() + try: + async with asyncio.timeout(1): + await self._catalog_event.wait() + except TimeoutError: + pass + + async def respond(message: dict[str, Any]) -> None: + try: + result = {"result": await self.handle(message["operation"], message["arguments"])} + except ManagementError as error: + result = error_payload(error) + except (ValidationError, KeyError, TypeError, ValueError): + result = error_payload(ManagementError("invalid-request", "The management request is invalid")) + response = await client.post( + "/v1/source-management/channel/reply", + json={"generation": generation, "message_id": message["message_id"], "response": result}, + ) + response.raise_for_status() + + async def poll() -> None: + additions, controls = asyncio.Semaphore(16), asyncio.Semaphore(8) + + async def limited_respond(message: dict[str, Any], capacity) -> None: + try: + await respond(message) + finally: + capacity.release() + + async with asyncio.TaskGroup() as requests: + while True: + response = await client.post("/v1/source-management/channel/poll", json={"generation": generation}) + response.raise_for_status() + for message in response.json()["messages"]: + if message["generation"] == generation: + capacity = additions if message["operation"] == "add" else controls + if capacity.locked(): + response = await client.post( + "/v1/source-management/channel/reply", + json={ + "generation": generation, + "message_id": message["message_id"], + "response": error_payload( + ManagementError( + "management-busy", "Too many concurrent Source operations", 503 + ) + ), + }, + ) + response.raise_for_status() + continue + await capacity.acquire() + requests.create_task(limited_respond(message, capacity)) + + try: + async with asyncio.TaskGroup() as tasks: + tasks.create_task(synchronize()) + tasks.create_task(poll()) + except* (httpx.HTTPError, ManagementError): + pass + + +def create_manager_app(config: ManagerConfig) -> FastAPI: # noqa: C901 - lifecycle and nested protocol dispatcher + manager = SourceManager(config) + + @asynccontextmanager + async def lifespan(app): + task = asyncio.create_task(manager.serve_channel()) + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + for channel in manager.channels.values(): + channel.close() + manager.store.close() + manager.owner.close() + + app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) + app.state.source_manager = manager + + @app.exception_handler(ManagementError) + async def management_error(request, error): + return JSONResponse(error_payload(error), status_code=error.status) + + @app.post("/v1/source-management/instances/{instance_id}/{operation}") + async def instance_endpoint( # noqa: C901 - one branch per authenticated protocol operation + instance_id: str, operation: str, request: Request + ): + instance = manager.authorize_instance(request, instance_id) + try: + content = await request.body() + from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES + + if len(content) > MAX_SOURCE_OBSERVATION_BYTES: + raise ManagementError("request-too-large", "The instance exchange exceeds its size limit") + data = SourceInstanceExchangeRequest.model_validate_json(content).model_dump(mode="json", exclude_none=True) + if operation == "owner": + return {"owner": instance.get("owner")} + if operation == "connect": + return await manager.connect_instance(instance, data) + generation = data["generation"] + channel = manager.channel(instance_id) + channel.check(instance_id, generation) + if operation == "poll": + return {"messages": await channel.poll(instance_id, generation)} + if operation == "reply": + channel.reply(instance_id, generation, data["message_id"], data["response"]) + return {"confirmed": True} + if operation == "sync": + return await manager.sync_instance(instance, generation, data.get("cursor", "")) + if operation == "execution": + return await manager.execution(instance, data) + if operation == "results": + return await manager.report_result(instance, data) + if operation == "stopped-targets": + return await manager.stopped_targets(instance, data) + if operation == "close": + channel.close(instance_id, generation) + manager.changed() + return {"confirmed": True} + except (KeyError, TypeError, ValidationError) as error: + raise ManagementError("invalid-request", "The instance request is invalid") from error + raise ManagementError("unknown-operation", "Unknown instance operation", 404) + + return app diff --git a/src/powercontext/source_management/models.py b/src/powercontext/source_management/models.py new file mode 100644 index 0000000000..aed143496d --- /dev/null +++ b/src/powercontext/source_management/models.py @@ -0,0 +1,135 @@ +# 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. + +"""Serializable Source capability, authorization and target contracts.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from powercontext.sources import SourceRef + +PROTOCOL_VERSION = "3" +DYNAMIC_PLUGIN_API_VERSION = "2" +MAX_ADD_BYTES = 1024 * 1024 +MAX_MESSAGE_BYTES = 2 * 1024 * 1024 + + +class ManagementError(Exception): + def __init__(self, code: str, message: str, status: int = 400) -> None: + self.code, self.message, self.status = code, message, status + super().__init__(message) + + +class Model(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Capability(Model): + name: str = Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_.-]*$") + version: str = Field(min_length=1, max_length=64) + description: str = Field(min_length=1, max_length=2048) + input_schema: dict[str, JsonValue] + kind: Literal["collect", "write"] = "collect" + definition_name: str | None = Field(default=None, min_length=1, max_length=128) + definition_version: str | None = Field(default=None, min_length=1, max_length=128) + + @model_validator(mode="after") + def validate_schema(self) -> Capability: + from jsonschema import Draft202012Validator + + Draft202012Validator.check_schema(self.input_schema) + return self + + def declaration(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def validate_input(self, value: dict[str, Any]) -> None: + from jsonschema import Draft202012Validator + + if next(Draft202012Validator(self.input_schema).iter_errors(value), None) is not None: + raise ManagementError("invalid-input", "Input does not satisfy the discovered capability schema") + + +class Caller(Model): + deployment: str + subject: str + scope_id: str | None = None + operation: str + administrator: bool = False + operation_authorized: bool = True + + +class TargetIdentityRequest(Model): + capability: str + capability_version: str + input: dict[str, JsonValue] + + +class TargetIdentity(Model): + target_key: str = Field(min_length=1, max_length=512) + normalized_input: dict[str, JsonValue] + + @model_validator(mode="after") + def nonblank_key(self) -> TargetIdentity: + if not self.target_key.strip(): + raise ValueError("target_key must not be blank") # noqa: TRY003 + return self + + +class SourceTarget(Model): + target_id: str + scope_id: str + instance_id: str + capability: str + capability_version: str + input: dict[str, JsonValue] + + +class InstanceRegistration(Model): + instance_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$") + credential_digest: str = Field(pattern=r"^[0-9a-f]{64}$", repr=False) + execution_location: str = Field(min_length=1, max_length=255) + scope_ids: tuple[str, ...] = () + public_principals: tuple[str, ...] = () + default_capabilities: tuple[str, ...] = () + enabled: bool = True + configuration_digest: str = Field(min_length=1) + + +class TargetSubmissionReport(Model): + generation: str + target_id: str = Field(min_length=1, max_length=128) + source_refs: tuple[SourceRef, ...] = Field(min_length=1, max_length=1) + submitted_at: str + + +BUILTIN_CAPABILITY = Capability( + name="content.inline", + version="2", + kind="write", + description="Save supplied text as a captured Source", + input_schema={ + "type": "object", + "properties": { + "source_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "text": {"type": "string", "minLength": 1}, + "metadata": {"type": "object"}, + }, + "required": ["source_id", "text"], + "additionalProperties": False, + }, +) diff --git a/src/powercontext/source_management/remote_deployment.py b/src/powercontext/source_management/remote_deployment.py new file mode 100644 index 0000000000..b5f0ae3284 --- /dev/null +++ b/src/powercontext/source_management/remote_deployment.py @@ -0,0 +1,161 @@ +# 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. + +"""Load a collect instance on its execution machine using existing Server identities.""" + +from __future__ import annotations + +import hashlib +import json +import secrets +import socket +import time +from contextlib import ExitStack + +import httpx + +from powercontext.source_management.bootstrap import _allowed, _grant, _principal, request +from powercontext.source_management.config import Endpoint, RuntimeConfig, configuration_digest, secret +from powercontext.source_management.durable import RuntimeOwner +from powercontext.source_management.loading import load_runtime_plugin +from powercontext.source_management.models import InstanceRegistration, ManagementError +from powercontext.source_management.service import launch, protected_write +from powercontext.sources import manifest_for_definition + + +def load(path, local, *, update=False): + """Register, authorize and start locally without changing remote authentication configuration.""" + root = path.expanduser().resolve(strict=True).parent + deployment = local.deployment + state = (root / deployment.state_dir).resolve() + lock = RuntimeOwner(state / "deployment-lock") + try: + with ExitStack() as updating: + if update: + updating.callback(RuntimeOwner(state).close) + return _load(root, state, local, deployment, update, updating) + finally: + lock.close() + + +def _load(root, state, local, deployment, update, updating): # noqa: C901 - validate identities before registering or starting + admin_token = secret(root / deployment.server.credential_file) + execution_file = root / deployment.execution_credential_file + execution_token = secret(execution_file) + instance_file = state / "instance.token" + if not instance_file.exists(): + protected_write(instance_file, secrets.token_urlsafe(32), immutable=True) + aliases = {key: (root / value).resolve() for key, value in local.secrets.items()} + config = RuntimeConfig( + deployment_id=deployment.deployment_id, + instance_id=local.id, + plugin=local.plugin, + plugin_dir=root, + config=local.config, + secrets=aliases, + management=Endpoint(url=deployment.management_url, credential_file=instance_file), + server=Endpoint(url=deployment.server.url, credential_file=execution_file), + state_dir=state, + execution_location=socket.gethostname() + ":" + str(root), + configuration_digest=configuration_digest(local.plugin, local.config, aliases), + ) + runtime_path = state / "runtime-config.json" + if not update and runtime_path.exists() and RuntimeConfig.model_validate_json(runtime_path.read_text()) != config: + raise ManagementError( + "configuration-conflict", "Stop the instance and its Runtime, then use source load --update" + ) + registration_path = state / "registration.json" + previous = json.loads(registration_path.read_text()) if registration_path.exists() else None + scopes = ( + tuple(previous["scope_ids"]) + if previous is not None and not update + else tuple(dict.fromkeys((*local.scope_ids, *(target.scope_id for target in local.targets)))) + ) + with ( + httpx.Client( + base_url=deployment.server.url, headers={"Authorization": "Bearer " + admin_token}, timeout=65 + ) as admin, + httpx.Client( + base_url=deployment.server.url, headers={"Authorization": "Bearer " + execution_token}, timeout=20 + ) as execution, + ): + admin_identity, identity = _principal(admin), _principal(execution) + server = {"type": "server", "deployment_id": deployment.deployment_id} + if not _allowed(admin, "server.admin", server): + raise ManagementError("deployment-forbidden", "Loading a collect instance requires server.admin") + if admin_identity == identity or _allowed(execution, "server.admin", server): + raise ManagementError("credential-separation", "The Runtime requires a separate non-administrator identity") + plugin = load_runtime_plugin(config) + for definition in plugin.registry.definitions: + manifest = manifest_for_definition(definition).model_dump(mode="json", by_alias=True) + registered = request(admin, "POST", "/v1/source-definitions/register", {"manifest": manifest}) + if registered != manifest: + raise ManagementError("manifest-mismatch", "Server returned a different Definition manifest") + for scope in scopes: + _grant(admin, identity, scope) + if any( + not _allowed(execution, action, {"type": "scope", "scope_id": scope}) + for action in ("scope.read", "scope.contribute") + ): + raise ManagementError("execution-forbidden", "Execution identity cannot access the configured Scope") + registration = InstanceRegistration( + instance_id=local.id, + credential_digest=hashlib.sha256(secret(instance_file).encode()).hexdigest(), + execution_location=config.execution_location, + scope_ids=scopes, + public_principals=local.public_principals, + default_capabilities=local.default_capabilities, + configuration_digest=config.configuration_digest, + ) + result = _retry(admin, {"registration": registration.model_dump(mode="json"), "update": update}) + protected_write(registration_path, registration.model_dump_json(indent=2)) + protected_write(runtime_path, config.model_dump_json(indent=2)) + result["runtime_config"] = str(runtime_path) + updating.close() + result["service"] = launch("runtime", config, deployment.start) + if not result.get("targets_initialized"): + for scope in dict.fromkeys(target.scope_id for target in local.targets): + _grant(admin, admin_identity, scope) + _retry( + admin, + { + "registration": { + "action": "initialize_targets", + "instance_id": local.id, + "targets": [target.model_dump(mode="json") for target in local.targets], + } + }, + ) + return result + + +def _retry(admin, body): + deadline = time.monotonic() + 60 + while True: + try: + return request(admin, "POST", "/v1/source-management/configure", body) + except ManagementError as error: + if ( + error.code + not in { + "management-unavailable", + "management-busy", + "result-unconfirmed", + "instance-unavailable", + "capability-unavailable", + } + or time.monotonic() >= deadline + ): + raise + time.sleep(0.2) diff --git a/src/powercontext/source_management/runtime.py b/src/powercontext/source_management/runtime.py new file mode 100644 index 0000000000..fe7dd83ab5 --- /dev/null +++ b/src/powercontext/source_management/runtime.py @@ -0,0 +1,353 @@ +# 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. + +# ruff: noqa: TRY301 + +"""Independent runtime owner: control stays responsive while apply owns scheduling.""" + +from __future__ import annotations + +import asyncio +import logging +import signal +from contextlib import suppress +from typing import Any +from uuid import uuid4 + +import httpx + +from powercontext.client import PowerContextClient +from powercontext.source_management.config import RuntimeConfig, configuration_digest, secret +from powercontext.source_management.context import PluginContext +from powercontext.source_management.durable import InstanceOwner, RuntimeOwner +from powercontext.source_management.loading import load_runtime_plugin +from powercontext.source_management.models import ( + PROTOCOL_VERSION, + ManagementError, + TargetIdentity, + TargetIdentityRequest, +) +from powercontext.source_management.targets import ManagedTargets + +logger = logging.getLogger(__name__) +TERMINAL = {"complete", "failed", "stopped", "cleanup-failed"} + + +class SourceRuntime: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + if configuration_digest(config.plugin, config.config, config.secrets) != config.configuration_digest: + raise ManagementError( + "configuration-conflict", "Runtime business parameters do not match the approved digest", 409 + ) + self.plugin = load_runtime_plugin(config) + self.owner = RuntimeOwner(config.state_dir) + try: + self.identity_owner = InstanceOwner(config.server.url, config.instance_id) + except BaseException: + self.owner.close() + raise + self.local: dict[str, Any] | None = None + self.generation: str | None = None + self.task: asyncio.Task[None] | None = None + self._started_run: str | None = None + self.context: PluginContext | None = None + self.targets: ManagedTargets | None = None + self._client: PowerContextClient | None = None + self._http: httpx.AsyncClient | None = None + self._stopping = False + + async def post(self, operation: str, value: dict[str, Any]) -> dict[str, Any]: + if self._http is None: + raise ManagementError("runtime-unavailable", "The management transport is not open", 503) + self._http.headers["Authorization"] = "Bearer " + secret(self.config.management.credential_file) + response = await self._http.post( + "/v1/source-management/instances/" + self.config.instance_id + "/" + operation, + json={**value, **({"generation": self.generation} if self.generation is not None else {})}, + ) + if response.status_code >= 400: + error = response.json().get("error", {}) + raise ManagementError( + error.get("code", "management-unavailable"), + error.get("message", "Source management rejected the request"), + response.status_code, + ) + return response.json() + + async def run(self, *, ready=None) -> None: + try: + token = secret(self.config.server.credential_file) + async with ( + PowerContextClient(self.config.server.url, token=token) as client, + httpx.AsyncClient(base_url=self.config.management.url, timeout=20) as http, + ): + try: + self._client, self._http = client, http + await client.get_access_principal() + # This credential is deliberately unusable for registering output Definitions. + from powercontext.http import AccessCheckRequest + + result = await client.check_access( + AccessCheckRequest.model_validate({ + "match": "all", + "requirements": [ + { + "action": "server.admin", + "resource": {"type": "server", "deployment_id": self.config.deployment_id}, + } + ], + }) + ) + if result.allowed: + raise ManagementError( + "execution-credential-privileged", "Runtime credentials must not have server.admin", 403 + ) + while True: + try: + previous = (await self.post("owner", {})).get("owner") + proof = {"owner": self.owner.identity()} + if previous is not None and previous != self.owner.identity(): + proof.update( + previous_owner=previous, owner_exited=self.owner.exited({"owner": previous}) + ) + connected = await self.post( + "connect", + { + **proof, + "protocol": PROTOCOL_VERSION, + "execution_location": self.config.execution_location, + "configuration_digest": self.config.configuration_digest, + "capabilities": [value.declaration() for value in self.plugin.capabilities], + }, + ) + self.generation = connected["generation"] + if ready is not None: + ready() + ready = None + await self.connected() + except (httpx.HTTPError, ManagementError): + await asyncio.sleep(0.2) + finally: + current_task = asyncio.current_task() + if self.generation is not None and (current_task is None or not current_task.cancelling()): + with suppress(httpx.HTTPError, ManagementError): + await self.post("close", {}) + self.generation = None + finally: + await self.shutdown_business() + finally: + self.owner.close() + self.identity_owner.close() + + async def shutdown_business(self) -> None: + """Clean up while the data client remains available to registered resources.""" + self._stopping = True + if self.context is not None: + self.context.stop() + if self.task is not None and not self.task.done(): + self.task.cancel() + with suppress(asyncio.CancelledError): + await self.task + if self.generation is not None: + if self.local is not None and self.local["status"] in TERMINAL: + with suppress(httpx.HTTPError, ManagementError): + await self.post("execution", self.local) + with suppress(httpx.HTTPError, ManagementError): + await self.post("close", {}) + self.generation = None + + async def connected(self) -> None: # noqa: C901 - explicit protocol state transitions + async def respond(message): + try: + if message["operation"] == "identify": + request = TargetIdentityRequest.model_validate(message["arguments"]) + capability = next( + ( + c + for c in self.plugin.capabilities + if c.name == request.capability + and c.version == request.capability_version + and c.kind == "collect" + ), + None, + ) + if capability is None: + raise ManagementError("capability-unavailable", "The collect capability is unavailable") + capability.validate_input(request.input) + async with asyncio.timeout(10): + identity = TargetIdentity.model_validate( + await self.plugin.module.identify_target(request, self.plugin.config) + ) + capability.validate_input(identity.normalized_input) + result = identity.model_dump(mode="json") + else: + raise ManagementError("unknown-operation", "Unknown Runtime operation") + payload = {"result": result} + except ManagementError as error: + payload = {"error": {"code": error.code, "message": error.message, "status": error.status}} + except Exception: + payload = { + "error": {"code": "plugin-input-invalid", "message": "The plugin could not process the input"} + } + await self.post("reply", {"message_id": message["message_id"], "response": payload}) + + async def poll(): + capacity = asyncio.Semaphore(32) + + async def limited(message): + try: + await respond(message) + finally: + capacity.release() + + async with asyncio.TaskGroup() as handlers: + while True: + response = await self.post("poll", {}) + for message in response["messages"]: + if message["generation"] == self.generation: + await capacity.acquire() + handlers.create_task(limited(message)) + + async def synchronize(): + while True: + state = await self.post("sync", {}) + while state.get("next_cursor"): + page = await self.post("sync", {"cursor": state["next_cursor"]}) + if (page["run_id"], page["enabled"]) != (state["run_id"], state["enabled"]): + state = await self.post("sync", {}) + continue + state["targets"].extend(page["targets"]) + state["stopping"].extend(page["stopping"]) + state["next_cursor"] = page.get("next_cursor") + await self.synchronize(state) + await asyncio.sleep(0.1) + + try: + async with asyncio.TaskGroup() as tasks: + tasks.create_task(poll()) + tasks.create_task(synchronize()) + except* (httpx.HTTPError, ManagementError): + pass + + async def report_submission(self, report): + if self.generation is None: + return + try: + async with asyncio.timeout(2): + await self.post("results", report) + except (httpx.HTTPError, ManagementError, TimeoutError): + logger.warning("Source submission report was not confirmed for instance %s", self.config.instance_id) + + async def synchronize(self, state): # noqa: C901 - explicit protocol state transitions + if self._client is None: + raise ManagementError("runtime-unavailable", "Data client is not connected", 503) + if self.targets is not None: + self.targets.synchronize(state["targets"], state["stopping"]) + if not state["enabled"]: + if self.context is not None: + self.context.stop() + if self.task is not None and not self.task.done(): + self.task.cancel() + await self.task + if self.local is None or self.local["run_id"] != state["run_id"]: + self.local = { + "run_id": state["run_id"], + "execution": state["execution"] or uuid4().hex, + "status": "stopped", + } + if self.local["status"] != "cleanup-failed": + self.local["status"] = "stopped" + if state["actual"] != self.local["status"] or state["execution"] != self.local["execution"]: + await self.post("execution", self.local) + await self.ack_stops(state) + return + if ( + self.local is not None + and self.local["run_id"] == state["run_id"] + and (self._started_run == state["run_id"] or self.local["status"] != "running") + ): + if state["actual"] != self.local["status"] or state["execution"] != self.local["execution"]: + await self.post("execution", self.local) + await self.ack_stops(state) + return + if self.task is not None and not self.task.done(): + raise ManagementError("execution-owned", "The old Context is still running", 409) + if self.local is None or self.local["run_id"] != state["run_id"]: + self.local = {"run_id": state["run_id"], "execution": uuid4().hex, "status": "running"} + await self.post("execution", self.local) + self.targets = ManagedTargets(instance_id=self.config.instance_id, report=self.report_submission) + self.targets.synchronize(state["targets"], state["stopping"]) + self.context = PluginContext( + plugin_id=self.config.instance_id, + connector_name=self.plugin.connector_name, + connector_version=self.plugin.connector_version, + plugin_dir=self.config.plugin_dir, + secrets=self.config.secrets, + client=self._client, + registry=self.plugin.registry, + targets=self.targets, + ) + self._started_run = state["run_id"] + self.task = asyncio.create_task(self.business(self.context)) + await self.ack_stops(state) + + async def business(self, ctx): + status = "complete" + try: + await self.plugin.module.apply(ctx, self.plugin.config) + except asyncio.CancelledError: + status = "stopped" + except Exception: + status = "failed" + finally: + try: + await ctx.close() + except BaseException: + status = "cleanup-failed" + if self.local is not None: + self.local["status"] = status + logger.info("Source instance %s: %s", self.config.instance_id, status) + + async def ack_stops(self, state): + if self.local is None or self.local["status"] == "cleanup-failed": + return + active = set() if self.targets is None else self.targets.active_target_ids() + stops = [s for s in state["stopping"] if s["target_id"] not in active] + for offset in range(0, len(stops), 256): + await self.post( + "stopped-targets", + { + "targets": stops[offset : offset + 256], + "run_id": self.local["run_id"], + "execution": self.local["execution"], + }, + ) + + +async def run_runtime(config: RuntimeConfig, *, ready=None) -> None: + """Run until cancelled or signalled, waiting for all business cleanup before exit.""" + runtime = SourceRuntime(config) + task = asyncio.create_task(runtime.run(ready=ready)) + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + with suppress(NotImplementedError, RuntimeError): + loop.add_signal_handler(sig, task.cancel) + try: + await task + except asyncio.CancelledError: + pass + finally: + for sig in (signal.SIGTERM, signal.SIGINT): + with suppress(NotImplementedError, RuntimeError): + loop.remove_signal_handler(sig) diff --git a/src/powercontext/source_management/service.py b/src/powercontext/source_management/service.py new file mode 100644 index 0000000000..8f498bfaf6 --- /dev/null +++ b/src/powercontext/source_management/service.py @@ -0,0 +1,191 @@ +# 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. + +"""Linux systemd user services for the Source manager and managed instances.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, Literal + +from powercontext.source_management.config import ManagerConfig, RuntimeConfig +from powercontext.source_management.models import ManagementError + +Role = Literal["manager", "runtime"] +MARKER = "# Managed by PowerContext Source services v1" + + +def protected_write(path: Path, content: str, *, immutable: bool = False) -> None: + """Atomically write private configuration, refusing foreign paths and conflicts.""" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if path.is_symlink() or (path.exists() and not path.is_file()): + raise ManagementError("unsafe-file", "Configuration must be a regular file") + if path.exists() and immutable: + if path.read_text(encoding="utf-8") != content: + raise ManagementError( + "configuration-conflict", "Existing configuration differs; preserve it or deploy a new instance ID" + ) + return + descriptor, name = tempfile.mkstemp(prefix=".pc-", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _systemctl(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + executable = shutil.which("systemctl") + if sys.platform != "linux" or executable is None: + raise ManagementError("service-unavailable", "Source services require Linux with systemd --user") + try: + result = subprocess.run( # noqa: S603 + [executable, "--user", *arguments], + capture_output=True, + text=True, + timeout=45, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise ManagementError("service-unavailable", "The user service manager did not respond") from error + if check and result.returncode: + raise ManagementError("service-operation-failed", "systemd operation failed; inspect the user journal") + return result + + +def require_service_support() -> None: + _systemctl("show-environment") + + +def _identity(role: Role, config: ManagerConfig | RuntimeConfig) -> tuple[str, Path]: + identity = config.manager_id if isinstance(config, ManagerConfig) else config.instance_id + digest = hashlib.sha256(f"{config.server.url}\n{identity}".encode()).hexdigest()[:24] + name = f"powercontext-source-{role}-{digest}.service" + root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser() + return name, root / "systemd" / "user" / name + + +def _quote(value: str) -> str: + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ManagementError("invalid-service-path", "Service arguments must not contain control characters") + return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"' + + +def render(role: Role, config_file: Path) -> str: + command = " ".join( + _quote(part) + for part in ( + sys.executable, + "-m", + "powercontext.source_management.cli", + role, + "run", + "--config", + str(config_file), + ) + ) + return f"""{MARKER} +[Unit] +Description=PowerContext Source {role} +After=network.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +ExecStart={command} +Restart=on-failure +RestartSec=5 +TimeoutStopSec=45 +KillMode=control-group +UMask=0077 +NoNewPrivileges=true +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target +""" + + +def _service_action(role: Role, config: ManagerConfig | RuntimeConfig, action: str) -> dict[str, Any]: + """Operate only the exact managed unit; repeated install never restarts it.""" + require_service_support() + name, path = _identity(role, config) + snapshot = config.state_dir / f"{role}-service.json" + contents = render(role, snapshot) + if path.is_symlink() or (path.exists() and path.read_text(encoding="utf-8") != contents): + raise ManagementError("service-conflict", "The existing service differs from this configuration") + if action == "install": + serialized = json.dumps(config.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n" + if snapshot.exists() and snapshot.read_text(encoding="utf-8") != serialized: + state = _systemctl("is-active", name, check=False).stdout.strip() + if state not in {"inactive", "failed", "unknown"}: + raise ManagementError( + "configuration-conflict", "Stop the existing service before replacing its configuration" + ) + protected_write(snapshot, serialized) + protected_write(path, contents, immutable=True) + _systemctl("daemon-reload") + _systemctl("enable", "--now", name) + elif action in {"stop", "uninstall"}: + if not path.exists(): + return {"unit": name, "state": "not-installed"} + _systemctl("stop", name) + if action == "uninstall": + _systemctl("disable", name) + path.unlink() + if snapshot.is_symlink(): + raise ManagementError("unsafe-file", "Service configuration must not be a symlink") + snapshot.unlink(missing_ok=True) + _systemctl("daemon-reload") + elif action != "status": + raise ManagementError("invalid-action", "Unknown service operation") + state = _systemctl("is-active", name, check=False).stdout.strip() or "unknown" + return {"unit": name, "state": state, "configuration": str(snapshot), "logs": f"journalctl --user -u {name}"} + + +def service_action(role: Role, config: ManagerConfig | RuntimeConfig, action: str) -> dict[str, Any]: + """Serialize changes to a unit even when two initializers run concurrently.""" + require_service_support() + import fcntl + + _, path = _identity(role, config) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor = os.open(path.with_suffix(".lock"), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + return _service_action(role, config, action) + finally: + os.close(descriptor) + + +def launch(role: Role, config: ManagerConfig | RuntimeConfig, mode: str = "background") -> dict[str, Any]: + """Default to a detached process; system service installation is explicit.""" + if mode == "local-service": + return service_action(role, config, "install") + from powercontext.source_management.background import process_action + + return process_action(role, config, "start") diff --git a/src/powercontext/source_management/storage.py b/src/powercontext/source_management/storage.py new file mode 100644 index 0000000000..4ed9a43d5d --- /dev/null +++ b/src/powercontext/source_management/storage.py @@ -0,0 +1,88 @@ +# 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. + +"""Small protected transactional store for the sole management authority.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from powercontext.source_management.models import ManagementError + + +def canonical(value: object) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +class Store: + def __init__(self, directory: Path) -> None: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if directory.is_symlink(): + raise ManagementError("invalid-storage", "State directory must not be a symlink") + os.chmod(directory, 0o700) + path = directory / "management.sqlite3" + if path.is_symlink(): + raise ManagementError("invalid-storage", "State database must not be a symlink") + self.connection = sqlite3.connect(path, isolation_level=None) + os.chmod(path, 0o600) + self.connection.execute("PRAGMA journal_mode=WAL") + self.connection.execute("PRAGMA synchronous=FULL") + self.connection.execute("CREATE TABLE IF NOT EXISTS records(kind TEXT,id TEXT,value TEXT,PRIMARY KEY(kind,id))") + self.connection.execute("CREATE TABLE IF NOT EXISTS follow_identity(identity TEXT PRIMARY KEY,record_id TEXT)") + + def claim_identity(self, identity: str, identifier: str) -> None: + existing = self.connection.execute( + "SELECT record_id FROM follow_identity WHERE identity=?", (identity,) + ).fetchone() + if existing is not None and existing[0] != identifier: + raise ManagementError("target-identity-conflict", "This follow identity is already reserved", 409) + self.connection.execute("INSERT OR IGNORE INTO follow_identity VALUES(?,?)", (identity, identifier)) + + def retire_identity(self, identity: str, identifier: str) -> None: + self.connection.execute("DELETE FROM follow_identity WHERE identity=? AND record_id=?", (identity, identifier)) + + def close(self) -> None: + self.connection.close() + + def get(self, kind: str, identifier: str) -> dict[str, Any] | None: + row = self.connection.execute("SELECT value FROM records WHERE kind=? AND id=?", (kind, identifier)).fetchone() + return None if row is None else json.loads(row[0]) + + def put(self, kind: str, identifier: str, value: dict[str, Any]) -> None: + self.connection.execute( + "INSERT INTO records VALUES(?,?,?) ON CONFLICT(kind,id) DO UPDATE SET value=excluded.value", + (kind, identifier, canonical(value)), + ) + + def all(self, kind: str) -> list[dict[str, Any]]: + return [ + json.loads(row[0]) + for row in self.connection.execute("SELECT value FROM records WHERE kind=? ORDER BY id", (kind,)) + ] + + @contextmanager + def transaction(self) -> Iterator[None]: + self.connection.execute("BEGIN IMMEDIATE") + try: + yield + self.connection.execute("COMMIT") + except BaseException: + self.connection.execute("ROLLBACK") + raise diff --git a/src/powercontext/source_management/targets.py b/src/powercontext/source_management/targets.py new file mode 100644 index 0000000000..0f55ac0d0f --- /dev/null +++ b/src/powercontext/source_management/targets.py @@ -0,0 +1,120 @@ +# 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. + +"""In-memory target snapshots and submission tracking; no execution journal.""" + +from __future__ import annotations + +from builtins import list as BuiltinList +from datetime import UTC, datetime +from typing import Any + +from powercontext.source_management.models import ManagementError, SourceTarget + + +class ManagedTargets: + def __init__(self, *, instance_id: str, report) -> None: + self.instance_id = instance_id + self.report = report + self._targets: dict[str, SourceTarget] = {} + self._synchronized = False + self._current: BatchObserver | None = None + self._stopping: set[str] = set() + + def synchronize(self, targets: BuiltinList[dict[str, Any]], stopping: BuiltinList[dict[str, Any]]) -> None: + self._targets = {t.target_id: t for v in targets if (t := SourceTarget.model_validate(v))} + self._stopping = {v["target_id"] for v in stopping} + self._synchronized = True + + async def list(self) -> tuple[SourceTarget, ...]: + if not self._synchronized: + raise ManagementError("targets-unavailable", "Targets have not yet been synchronized", 503) + return tuple(t for t in self._targets.values() if t.target_id not in self._stopping) + + def begin(self, binding, selected) -> BatchObserver: + if self._current is not None: + raise ManagementError("batch-running", "A batch is already running", 409) + if not self._synchronized: + raise ManagementError("targets-unavailable", "Targets have not yet been synchronized", 503) + seen = set() + for item in selected: + known = self._targets.get(item.target_id) + if ( + item.target_id in seen + or known is None + or known != item + or item.instance_id != self.instance_id + or item.scope_id != binding.scope_id + or item.target_id in self._stopping + ): + raise ManagementError("stale-target", "The batch contains a stopped, stale or foreign item", 409) + seen.add(item.target_id) + self._current = BatchObserver(self, binding, selected) + return self._current + + def end(self) -> None: + self._current = None + + def active_target_ids(self) -> set[str]: + return set() if self._current is None else set(self._current.selected) + + def batch(self, target, session): + current = self._current + if current is None or current.session is not session or current.selected.get(target.target_id) != target: + raise ManagementError("invalid-batch", "The item does not belong to this batch") + return current + + async def submit(self, target, *, session, item_id, definition_name, value): + batch = self.batch(target, session) + if item_id in batch.items: + raise ManagementError("duplicate-item", "Each batch item needs a distinct item_id") + batch.items[item_id] = target.target_id + return await session.submit(item_id, definition_name, value) + + +class BatchObserver: + def __init__(self, targets, binding, selected): + self.targets, self.binding, self.session = targets, binding, None + self.selected = {t.target_id: t for t in selected} + self.items: dict[str, str] = {} + self.refs: dict[str, list[Any]] = {} + + async def started(self, session): + self.session = session + + async def materialized(self, binding, item_id, observation): + if binding != self.binding or (self.selected and item_id not in self.items): + raise ManagementError("unassociated-source", "Use targets.submit for managed collect batches") + + async def accepted(self, item_id, source_ref): + if item_id not in self.items: + return + identifier = self.items[item_id] + self.refs.setdefault(identifier, []).append(source_ref) + if isinstance(self.selected[identifier], SourceTarget): + # A best-effort report cannot revoke the Source that the Server has already accepted. + await self.targets.report({ + "target_id": identifier, + "source_refs": [source_ref.model_dump(mode="json")], + "submitted_at": datetime.now(UTC).isoformat(), + }) + + async def prepared(self, session, completion): + pass + + async def completed(self, result): + pass + + async def interrupted(self, error): + pass diff --git a/src/powercontext/source_management/write.py b/src/powercontext/source_management/write.py new file mode 100644 index 0000000000..d3348c0311 --- /dev/null +++ b/src/powercontext/source_management/write.py @@ -0,0 +1,268 @@ +# 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. + +"""Server-owned executable Source definitions and synchronous writes.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field +from sqlalchemy import insert, select + +from powercontext.builtin.persistence.errors import StoredPayloadConflictError +from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository +from powercontext.builtin.persistence.tables import SOURCE_WRITE_PLUGINS_TABLE +from powercontext.builtin.runtime.models import SubmitSourceObservation +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY +from powercontext.builtin.sources.observation_validation import validate_source_definition_manifest +from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES +from powercontext.source_management.loading import load_local_module +from powercontext.source_management.models import BUILTIN_CAPABILITY, Capability, ManagementError, Model +from powercontext.sources import SourceDefinitionRegistry, manifest_for_definition, project_source_for_transport + +logger = logging.getLogger(__name__) + + +class WritePluginConfig(Model): + id: str = Field(min_length=1, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") + entry: str = Field(min_length=1, max_length=4096) + + +@dataclass(frozen=True) +class LoadedWritePlugin: + configuration: WritePluginConfig + registry: SourceDefinitionRegistry + capabilities: tuple[Capability, ...] + + def declaration(self) -> dict[str, Any]: + return { + "configuration": self.configuration.model_dump(mode="json"), + "capabilities": [capability.declaration() for capability in self.capabilities], + "definitions": [ + manifest_for_definition(d).model_dump(mode="json", by_alias=True) for d in self.registry.definitions + ], + } + + +def load_write_plugin(config: WritePluginConfig, workspace: Path) -> LoadedWritePlugin: + path = (workspace / config.entry).expanduser() + if path.exists(): + config = config.model_copy(update={"entry": str(path.resolve())}) + module = load_local_module(workspace, config.entry) + registry = SourceDefinitionRegistry(module.definitions) + if not registry.definitions: + raise ManagementError("invalid-plugin", "A write plugin must export Source definitions") + builtin_names = {d.name for d in BUILTIN_SOURCE_REGISTRY.definitions} + if any(d.name in builtin_names for d in registry.definitions): + raise ManagementError("definition-conflict", "A plugin cannot replace a built-in Source definition", 409) + capabilities = tuple(c for value in module.capabilities if (c := Capability.model_validate(value)).kind == "write") + if not capabilities: + raise ManagementError("invalid-plugin", "A write plugin must export write capabilities") + identities = set() + for capability in capabilities: + key = capability.name, capability.version + if capability.name == BUILTIN_CAPABILITY.name or key in identities: + raise ManagementError("capability-conflict", "Write capability identities must be unique", 409) + identities.add(key) + definition = registry.definition_for_name(capability.definition_name or "") + if not issubclass(definition.input_class, BaseModel) or capability.input_schema.get("type") != "object": + raise ManagementError("invalid-plugin", "Write inputs must use a Pydantic object model") + if ( + definition.version != capability.definition_version + or capability.input_schema != definition.input_class.model_json_schema() + ): + raise ManagementError("invalid-plugin", "Write capabilities must match their Definition and input model") + for definition in registry.definitions: + _validate_definition(definition) + return LoadedWritePlugin(config, registry, capabilities) + + +def _validate_definition(definition) -> None: + if any(not inspect.iscoroutinefunction(getattr(definition, name)) for name in ("resolve", "read")): + raise ManagementError("invalid-plugin", "Source Definition resolve and read must be async") + if any(inspect.iscoroutinefunction(p.project) for p in definition.projections): + raise ManagementError("invalid-plugin", "Source Projection project must be synchronous") + for method in (definition.resolve, definition.read, *(p.project for p in definition.projections)): + try: + inspect.signature(method).bind(object()) + except (TypeError, ValueError) as error: + raise ManagementError( + "invalid-plugin", "Definition and Projection methods must accept one value" + ) from error + validate_source_definition_manifest(manifest_for_definition(definition)) + + +class SourceWritePlugins: + def __init__(self, database, workspace: Path) -> None: + self.database, self.workspace = database, workspace + self.plugins: dict[str, LoadedWritePlugin] = {} + self._lock = asyncio.Lock() + self._jobs: set[asyncio.Task[Any]] = set() + + def capabilities(self) -> tuple[Capability, ...]: + return tuple(c for p in self.plugins.values() for c in p.capabilities) + + def find(self, name: str, version: str) -> tuple[LoadedWritePlugin, Capability] | None: + return next( + ((p, c) for p in self.plugins.values() for c in p.capabilities if (c.name, c.version) == (name, version)), + None, + ) + + async def restore(self) -> None: + async with self.database.transaction() as connection: + rows = (await connection.execute(select(SOURCE_WRITE_PLUGINS_TABLE))).mappings().all() + for row in rows: + try: + definition = json.loads(row["definition"]) + await self.load(WritePluginConfig.model_validate(definition["configuration"])) + except Exception as error: + # Entrypoint/configuration exceptions may contain secrets or submitted content. + logger.error( # noqa: TRY400 - never log entry/configuration exception inputs + "Source write plugin %s could not be restored (%s)", row["plugin_id"], type(error).__name__ + ) + + async def load( + self, config: WritePluginConfig, *, reserved_capabilities: set[tuple[str, str]] | None = None + ) -> dict[str, Any]: + async with self._lock: + path = (self.workspace / config.entry).expanduser() + if path.exists(): + config = config.model_copy(update={"entry": str(path.resolve())}) + old = self.plugins.get(config.id) + if old is not None: + if old.configuration != config: + raise ManagementError("plugin-conflict", "The plugin id already has a different entry", 409) + return self.state(old) + try: + candidate = await asyncio.to_thread(load_write_plugin, config, self.workspace) + except ManagementError: + raise + except Exception as error: + raise ManagementError( + "invalid-plugin", "The local entry or Source declarations could not be loaded" + ) from error + # Each capability identity has exactly one loaded owner. A collect declaration + # cannot make write executable, and conflicts are checked before publication. + if any( + self.find(c.name, c.version) is not None or (c.name, c.version) in (reserved_capabilities or ()) + for c in candidate.capabilities + ): + raise ManagementError("capability-conflict", "The write capability is already loaded", 409) + await self._persist(candidate) + self.plugins[config.id] = candidate + return self.state(candidate) + + async def _persist(self, plugin: LoadedWritePlugin) -> None: + definition = plugin.declaration() + table = SOURCE_WRITE_PLUGINS_TABLE + try: + async with self.database.transaction() as connection: + saved = await connection.scalar( + select(table.c.definition).where(table.c.plugin_id == plugin.configuration.id) + ) + if saved is not None and json.loads(saved) != definition: + raise ManagementError( + "plugin-conflict", "The persisted plugin configuration or contract differs", 409 + ) + repository = SourceDefinitionManifestRepository() + for source in plugin.registry.definitions: + await repository.register(connection, manifest_for_definition(source)) + if saved is None: + await connection.execute( + insert(table).values( + plugin_id=plugin.configuration.id, + definition=json.dumps(definition, ensure_ascii=False).encode(), + ) + ) + except StoredPayloadConflictError as error: + raise ManagementError( + "definition-conflict", "A Source Definition version cannot change its contract", 409 + ) from error + + @staticmethod + def state(plugin: LoadedWritePlugin) -> dict[str, Any]: + return { + "id": plugin.configuration.id, + "entry": plugin.configuration.entry, + "capabilities": [c.declaration() for c in plugin.capabilities], + } + + async def write( + self, application, scope_id: str, name: str, version: str, value: dict[str, Any], authorize + ) -> dict[str, Any]: + found = self.find(name, version) + if found is None: + raise ManagementError("capability-unavailable", "The write capability version is not loaded", 503) + plugin, capability = found + definition = plugin.registry.definition_for_name(capability.definition_name or "") + + def validate_input(): + capability.validate_input(value) + try: + return definition.input_class.model_validate(value) + except Exception as error: + raise ManagementError("invalid-input", "The Source input is invalid") from error + + def materialize(source): + try: + observation = project_source_for_transport(plugin.registry, source) + size = len(observation.model_dump_json().encode()) + except Exception as error: + raise ManagementError("invalid-input", "The materialized Source output is invalid") from error + if ( + observation.source_type != capability.definition_name + or observation.definition_version != capability.definition_version + ): + raise ManagementError("invalid-input", "The resolved Source does not match the write Definition") + if size > MAX_SOURCE_OBSERVATION_BYTES: + raise ManagementError("observation-too-large", "Materialized Source observation exceeds 4 MiB") + return observation + + validated = await self._in_thread(validate_input) + try: + source = await plugin.registry.resolve(validated) + except Exception as error: + raise ManagementError("invalid-input", "The Source could not be resolved") from error + observation = await self._in_thread(materialize, source) + await authorize() + receipt = await application.ingestion.submit( + SubmitSourceObservation(scope_id=scope_id, observation=observation) + ) + return { + "kind": "write", + "result": {"outcome": "saved", "source_refs": [receipt.source_ref.model_dump(mode="json")]}, + } + + async def _in_thread(self, function, *args): + # Cancelling an await does not stop trusted Python code already executing in + # a thread. Keep its slot until it actually exits, including after a timeout. + if len(self._jobs) >= 64: + raise ManagementError("server-busy", "Too many Source materializations are running", 503) + task = asyncio.create_task(asyncio.to_thread(function, *args)) + self._jobs.add(task) + + def completed(task): + self._jobs.discard(task) + if not task.cancelled(): + task.exception() + + task.add_done_callback(completed) + return await asyncio.shield(task) diff --git a/src/powercontext/sources/connectors.py b/src/powercontext/sources/connectors.py index 109a06b95d..dcad4ad33b 100644 --- a/src/powercontext/sources/connectors.py +++ b/src/powercontext/sources/connectors.py @@ -16,6 +16,7 @@ from __future__ import annotations +from copy import deepcopy from enum import StrEnum from typing import Protocol @@ -133,6 +134,16 @@ class Connector(Protocol): async def run(self, session: ConnectorRunSession, /) -> ConnectorRunCompletion: ... +class ConnectorRunObserver(Protocol): + """Lifecycle hooks awaited before work and before advancing a checkpoint.""" + + async def started(self, session: ConnectorRunSession, /) -> None: ... + + async def prepared(self, session: ConnectorRunSession, completion: ConnectorRunCompletion, /) -> None: ... + + async def completed(self, result: ConnectorRunResult, /) -> None: ... + + class ConnectorRunSession: """Constrain one Connector run to declared Definitions and visible outcomes.""" @@ -249,18 +260,24 @@ def __init__(self, *, sink: ConnectorSourceSink, checkpoints: ConnectorCheckpoin self._sink = sink self._checkpoints = checkpoints - async def run(self, connector: Connector, binding: ConnectorBinding, /) -> ConnectorRunResult: + async def run( + self, connector: Connector, binding: ConnectorBinding, /, *, observer: ConnectorRunObserver | None = None + ) -> ConnectorRunResult: source_definitions = validate_connector(connector, binding) previous = await self._checkpoints.load(binding) session = ConnectorRunSession( binding=binding, - checkpoint=previous, + checkpoint=deepcopy(previous), source_definitions=source_definitions, sink=self._sink, ) + if observer is not None: + await observer.started(session) completion = await connector.run(session) if not isinstance(completion, ConnectorRunCompletion): raise InvalidConnectorRunError("completion", "Connector must return ConnectorRunCompletion") + if observer is not None: + await observer.prepared(session, completion) unsafe = tuple( outcome @@ -271,7 +288,7 @@ async def run(self, connector: Connector, binding: ConnectorBinding, /) -> Conne if completion.status is ConnectorRunStatus.COMPLETE and completion.checkpoint != previous and not unsafe: await self._checkpoints.save(binding, completion.checkpoint, expected=previous) committed = completion.checkpoint - return ConnectorRunResult( + result = ConnectorRunResult( binding=binding, status=ConnectorRunStatus.INCOMPLETE if unsafe else completion.status, previous_checkpoint=previous, @@ -279,6 +296,9 @@ async def run(self, connector: Connector, binding: ConnectorBinding, /) -> Conne committed_checkpoint=committed, items=session.outcomes, ) + if observer is not None: + await observer.completed(result) + return result def validate_connector( diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 6ff90fcf8e..abf1fba78f 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -42,7 +42,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t database = tmp_path / "vector.db" runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) - assert result.revision == "v1_4_1_005" + assert result.revision == "v1_4_1_006" with closing(sqlite3.connect(database)) as connection: columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} assert columns["description"][2:4] == ("VARCHAR(200)", 0) @@ -63,6 +63,8 @@ def test_mysql_vector_admission_retains_description_and_exact_previous_revision( del inventory["tables"][name] else: inventory["tables"][name] = table + # Inspect the description revision before the subsequent Source registration table. + del inventory["tables"]["pc_source_write_plugins"] for name, sql in vector.vector_profile.mysql_tables().items(): if name == "pc_atomic_memory_current": sql = sql.replace( @@ -99,6 +101,13 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm shutil.copytree(current.snapshot_directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + plugin_resources = manifest["revision_resources"].pop("v1_4_1_006") + for resource in (*plugin_resources, "versions/v1_4_1_006_source_write_plugins.py", "mysql/versions/v1_4_1_006.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_006"] + del manifest["domain_readiness"]["v1_4_1_006"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_006.json") + manifest["affected_objects"].remove("pc_source_write_plugins") resources = manifest["revision_resources"].pop("v1_4_1_005") for resource in (*resources, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): (directory / resource).unlink() @@ -122,8 +131,8 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() - assert plan.source_revision == "v1_4_1_004" and plan.revisions == ("v1_4_1_005",) - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_4_1_005" + assert plan.source_revision == "v1_4_1_004" and plan.revisions == ("v1_4_1_005", "v1_4_1_006") + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_4_1_006" assert upgraded.verify().state == "ready" evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) for revision, checksum in original_evidence["release_checksums"].items(): diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index fcdb7c7529..66ed8646bc 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,8 +73,8 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_005" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") + assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_006" + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006") assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index e66ca5ec1a..0357bbcf43 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -561,7 +561,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -692,7 +692,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -702,7 +702,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -769,7 +769,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_005", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -837,7 +837,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_4_1_005", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_4_1_006", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -992,7 +992,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_005" + assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_006" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1014,7 +1014,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1094,7 +1094,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index 8203528ee2..24c12480d8 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -15,45 +15,25 @@ """Append owner recovery storage without changing historical migration evidence.""" import json -import shutil import sqlite3 from contextlib import closing import pytest from powercontext.builtin.artifacts.memory import EmbeddingProfile -from powercontext.builtin.persistence.migrations.bundle import MigrationBundle from powercontext.builtin.persistence.migrations.deployment import server_bundle from powercontext.builtin.persistence.migrations.models import MigrationError from powercontext.builtin.persistence.migrations.mysql_schema import MySQLSchema from powercontext.builtin.persistence.migrations.sqlite import SQLiteMigrationRunner from tests.builtin.persistence.test_mysql_migration_schema import _inventory, _ReflectedDatabase -from tests.builtin.persistence.test_portable_storage_migration import apply, configuration +from tests.builtin.persistence.test_portable_storage_migration import apply, configuration, previous_bundle @pytest.mark.parametrize("vectors", [False, True]) def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checksums(tmp_path, vectors): embedding = EmbeddingProfile(profile_id="owner-vectors", model="fixture", dimension=3) if vectors else None current = server_bundle(embedding_profile=embedding) - directory = tmp_path / "previous" - shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) - manifest_path = directory / "manifest.json" - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - description = manifest["revision_resources"].pop("v1_4_1_005") - for resource in (*description, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_005"] - del manifest["domain_readiness"]["v1_4_1_005"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") - resources = manifest["revision_resources"].pop("v1_4_1_004") - for resource in (*resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_004"] - del manifest["domain_readiness"]["v1_4_1_004"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_004.json") - manifest["affected_objects"].remove("pc_access_owner_recovery") - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - previous = MigrationBundle(directory, embedding_profile=embedding) + previous = previous_bundle(tmp_path / "previous", embedding, include_portable=True) database = tmp_path / "managed.db" old = SQLiteMigrationRunner(database, previous, configuration=configuration(embedding)) assert apply(old).revision == "s1402" @@ -65,8 +45,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() - assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005") - assert apply(upgraded).revision == "v1_4_1_005" + assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006") + assert apply(upgraded).revision == "v1_4_1_006" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 8332d6fab8..8306425d6a 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -30,32 +30,43 @@ from powercontext.builtin.runtime.processing_registry import canonical_processing_manifest -def previous_bundle(directory, embedding): +def previous_bundle(directory, embedding, *, include_portable=False, include_owner=False): current = server_bundle(embedding_profile=embedding) shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) - description = manifest["revision_resources"].pop("v1_4_1_005") - for resource in (*description, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_005"] - del manifest["domain_readiness"]["v1_4_1_005"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") - owner_resources = manifest["revision_resources"].pop("v1_4_1_004") - for resource in (*owner_resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_004"] - del manifest["domain_readiness"]["v1_4_1_004"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_004.json") - manifest["affected_objects"].remove("pc_access_owner_recovery") - removed = manifest["revision_resources"].pop("s1402") - for resource in (*removed, "versions/s1402_portable_storage.py", "mysql/versions/s1402.py", "portable_storage.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["s1402"] - del manifest["domain_readiness"]["s1402"] - manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != "s1402"] - manifest["mysql_state_layouts"].remove("schemas/mysql-s1402.json") - manifest["affected_objects"].remove("pc_portable_restore_receipts") + later_revisions = [ + ("v1_4_1_006", "source_write_plugins", "pc_source_write_plugins"), + ("v1_4_1_005", "atomic_memory_description", None), + ] + if not include_owner: + later_revisions.append(("v1_4_1_004", "owner_recovery", "pc_access_owner_recovery")) + for later, suffix, table in later_revisions: + for resource in ( + *manifest["revision_resources"].pop(later), + f"versions/{later}_{suffix}.py", + f"mysql/versions/{later}.py", + ): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"][later] + del manifest["domain_readiness"][later] + manifest["mysql_state_layouts"].remove(f"schemas/mysql-{later}.json") + if table is not None: + manifest["affected_objects"].remove(table) + if not include_portable: + removed = manifest["revision_resources"].pop("s1402") + for resource in ( + *removed, + "versions/s1402_portable_storage.py", + "mysql/versions/s1402.py", + "portable_storage.py", + ): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["s1402"] + del manifest["domain_readiness"]["s1402"] + manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != "s1402"] + manifest["mysql_state_layouts"].remove("schemas/mysql-s1402.json") + manifest["affected_objects"].remove("pc_portable_restore_receipts") manifest_path.write_text(json.dumps(manifest), encoding="utf-8") return MigrationBundle(directory, embedding_profile=embedding) @@ -99,8 +110,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005") - assert apply(upgraded).revision == "v1_4_1_005" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006") + assert apply(upgraded).revision == "v1_4_1_006" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -140,3 +151,39 @@ def test_pending_portable_restore_blocks_migration_readiness_without_repair(tmp_ with closing(sqlite3.connect(runner.database)) as connection, connection: connection.execute("UPDATE pc_portable_restore_receipts SET status='ready',projections_ready=1") assert runner.verify().state == "ready" + + +@pytest.mark.parametrize("vectors", [False, True]) +@pytest.mark.parametrize("include_owner", [False, True]) +def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, vectors, include_owner): + embedding = EmbeddingProfile(profile_id="source-vectors", model="fixture", dimension=3) if vectors else None + config = configuration(embedding) + previous = previous_bundle(tmp_path / "previous", embedding, include_portable=True, include_owner=include_owner) + database = tmp_path / "existing.db" + old = SQLiteMigrationRunner(database, previous, configuration=config) + assert apply(old).revision == ("v1_4_1_004" if include_owner else "s1402") + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute( + "INSERT INTO pc_scopes (scope_id,title,summary,scope_id_search,title_search,summary_search,version) " + "VALUES ('scope','preserved','summary','scope','preserved','summary',1)" + ) + bundle = server_bundle(embedding_profile=embedding) + upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) + assert upgraded.plan().revisions == ( + ("v1_4_1_005", "v1_4_1_006") if include_owner else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006") + ) + assert apply(upgraded).revision == "v1_4_1_006" + definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' + with closing(sqlite3.connect(database)) as connection, connection: + assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] + connection.execute("INSERT INTO pc_source_write_plugins VALUES (?, ?)", ("tickets", definition)) + reopened = SQLiteMigrationRunner(database, bundle, configuration=config) + assert reopened.verify().state == "ready" + assert not apply(reopened).changed + with closing(sqlite3.connect(database)) as connection: + assert connection.execute("SELECT plugin_id, definition FROM pc_source_write_plugins").fetchall() == [ + ("tickets", definition) + ] + fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) + apply(fresh) + assert reopened.plan().schema_fingerprint == fresh.plan().schema_fingerprint diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 367746e664..d8178f7dd0 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -17,6 +17,7 @@ import asyncio from collections import defaultdict from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -30,6 +31,7 @@ from powercontext.builtin.artifacts.handoff import Handoff from powercontext.builtin.artifacts.skill import Skill from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec from powercontext.builtin.persistence.experience_index import ( ExperienceIndex, NoExperienceIndex, @@ -44,6 +46,7 @@ ) from powercontext.builtin.persistence.records import RelationalRecordService from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.sqlite.experience_index import SQLiteExperienceFTSIndex @@ -60,6 +63,7 @@ ArtifactWrite, BaseOperationNotSupportedError, BaseValueConflictError, + CursorExpiredError, InvalidBaseAccessRequestError, InvalidCursorError, ) @@ -69,6 +73,13 @@ from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, ContentSource from powercontext.builtin.tags import ArtifactTagTarget, TagFilter, TagPreconditionError, TagQuery +from powercontext.sources import ( + SourceDefinitionRegistry, + SourceMaterialization, + manifest_for_definition, + project_source_for_transport, +) +from tests.builtin.persistence.contract import SOURCE_ADAPTERS, NoteSource class _FailingExperienceIndex(NoExperienceIndex): @@ -460,6 +471,121 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_source_list_continues_the_signed_type_selection() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, sources = _services(profile) + registry = SourceDefinitionRegistry.from_adapters(SOURCE_ADAPTERS) + manifest = manifest_for_definition(registry.definition_for_name("note")) + async with profile.database.transaction() as connection: + await SourceDefinitionManifestRepository().register(connection, manifest) + contents = [] + for index in range(3): + contents.append(await records.create_source("scope-a", "content", str(index))) + if index < 2: + note = NoteSource( + name=f"note-{index}", materialization=SourceMaterialization.CAPTURED, body=str(index) + ) + async with profile.database.transaction() as connection: + await sources.add(connection, "scope-a", project_source_for_transport(registry, note)) + + codec = SignedCursorCodec(secret=b"record-test-secret") + context = { + "version": 1, + "endpoint": "list_sources", + "scope_id": "scope-a", + "authorization": "scope_read", + "caller": "user:one", + "limit": 1, + "order": "journal_position:asc", + } + # This is the signed wire format issued by master, before Observation reads. + cursor = codec.encode({**context, "source_types": ["content"]}, "5:1") + page = await records.list_sources("scope-a", limit=1, cursor=cursor, caller="user:one") + assert page.items == (contents[1],) + assert page.next_cursor is not None + late = await records.create_source("scope-a", "content", "outside the snapshot") + page = await records.list_sources("scope-a", limit=1, cursor=page.next_cursor, caller="user:one") + assert page.items == (contents[2],) + assert page.next_cursor is None + + page = await records.list_sources("scope-a", limit=1, cursor=None, caller="user:one") + found = list(page.items) + while page.next_cursor is not None: + page = await records.list_sources("scope-a", limit=1, cursor=page.next_cursor, caller="user:one") + found.extend(page.items) + assert [(item.source_type, item.position) for item in found] == [ + ("content", 1), + ("note", 2), + ("content", 3), + ("note", 4), + ("content", 5), + ("content", late.position), + ] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("source_types", [[], ["missing"], ["content", "missing"], [1], ["content", "content"]]) +def test_source_list_rejects_invalid_signed_type_selections(source_types) -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + cursor = SignedCursorCodec(secret=b"record-test-secret").encode( + { + "version": 1, + "endpoint": "list_sources", + "scope_id": "scope-a", + "source_types": source_types, + "authorization": "scope_read", + "caller": "user:one", + "limit": 1, + "order": "journal_position:asc", + }, + "0:0", + ) + with pytest.raises(InvalidCursorError): + await records.list_sources("scope-a", limit=1, cursor=cursor, caller="user:one") + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("source_types", [["content"], "content-and-observations"]) +def test_source_list_type_selection_preserves_cursor_security_checks(source_types) -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + context = { + "version": 1, + "endpoint": "list_sources", + "scope_id": "scope-a", + "source_types": source_types, + "authorization": "scope_read", + "caller": "user:one", + "limit": 1, + "order": "journal_position:asc", + } + cursor = SignedCursorCodec(secret=b"record-test-secret").encode(context, "0:0") + for scope, limit, caller in ( + ("scope-b", 1, "user:one"), + ("scope-a", 2, "user:one"), + ("scope-a", 1, "user:two"), + ): + with pytest.raises(InvalidCursorError): + await records.list_sources(scope, limit=limit, cursor=cursor, caller=caller) + forged = SignedCursorCodec(secret=b"another-secret").encode(context, "0:0") + with pytest.raises(InvalidCursorError): + await records.list_sources("scope-a", limit=1, cursor=forged, caller="user:one") + expired = SignedCursorCodec( + secret=b"record-test-secret", + clock=lambda: datetime.now(UTC) - timedelta(hours=2), + ).encode(context, "0:0") + with pytest.raises(CursorExpiredError): + await records.list_sources("scope-a", limit=1, cursor=expired, caller="user:one") + + asyncio.run(scenario()) + + def test_source_create_rejects_blank_text_content() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index 9d3743b7ad..1c5e500c4d 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -134,7 +134,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected _settings, _embedding, runner = _source(tmp_path, release=release) plan = runner.plan() assert plan.source_revision == expected - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006") @pytest.mark.parametrize(("vectors", "code"), [(False, False), (True, False), (False, True), (True, True)]) diff --git a/tests/builtin/runtime/test_model_usage_recorder.py b/tests/builtin/runtime/test_model_usage_recorder.py index 619591d752..abeaeb0589 100644 --- a/tests/builtin/runtime/test_model_usage_recorder.py +++ b/tests/builtin/runtime/test_model_usage_recorder.py @@ -102,7 +102,11 @@ async def scenario() -> None: usage.requests = 900 usage.output_tokens = 900 _offer(recorder, InferenceUsage(requests=1, input_tokens=3, output_tokens=5)) - await recorder.flush() + # flush is best-effort; wait for this prefix before reading its usage. + target = recorder.checkpoint() + async with asyncio.timeout(5.0): + while recorder._settled < target: + await recorder.flush(target) rows = await _rows(database) assert len(rows) == 1 assert rows[0].usage_date == _DAY diff --git a/tests/e2e/source_collect_helpers.py b/tests/e2e/source_collect_helpers.py new file mode 100644 index 0000000000..e0786490ed --- /dev/null +++ b/tests/e2e/source_collect_helpers.py @@ -0,0 +1,109 @@ +# 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. + +"""Deploy and stop collect plugins using the delivered CLI and public management API.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import httpx + + +def prepare_environment(directory: Path, base: Path | None = None) -> tuple[Path, Path]: + state = directory / "source-services" / uuid4().hex + envfile = directory / "source.env" + original = base.read_text() if base is not None else "" + values = { + "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), + "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", + } + envfile.write_text(original + "\n" + "\n".join(f"{key}='{value}'" for key, value in values.items()) + "\n") + return envfile, state + + +def bind_server(envfile: Path, url: str) -> None: + with envfile.open("a") as stream: + stream.write(f"POWERCONTEXT_SERVER_PUBLIC_URL='{url.rstrip('/')}'\n") + + +async def cli(directory: Path, *args: str) -> dict[str, Any]: + environment = {k: v for k, v in os.environ.items() if not k.startswith("POWERCONTEXT_SERVER_")} + environment["POWERCONTEXT_HOME"] = str(directory / "home") + process = await asyncio.create_subprocess_exec( + str(Path(sys.executable).with_name("powercontext")), + *args, + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), 100) + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 0, (stdout + stderr).decode() + return json.loads(stdout) + + +async def instance_state(client: httpx.AsyncClient, identifier: str, actual: str | None = None) -> dict[str, Any]: + async with asyncio.timeout(40): + while True: + response = await client.post( + "/v1/source-management/instances", json={"instance_id": identifier, "view": "control"} + ) + if response.status_code == 200: + state = response.json()["instances"][0] + if actual is None or state["actual"] == actual: + return state + await asyncio.sleep(0.1) + + +async def set_enabled(client: httpx.AsyncClient, identifier: str, enabled: bool) -> dict[str, Any]: + response = await client.post( + "/v1/source-management/instance-set-enabled", json={"instance_id": identifier, "enabled": enabled} + ) + assert response.status_code == 200, response.text + return await instance_state(client, identifier, "running" if enabled else "stopped") + + +async def stop_services(directory: Path, state: Path) -> None: + for path in sorted((state / "instances").glob("*/runtime-config.json")): + await cli(directory, "source", "runtime", "stop", "--config", str(path)) + if (state / "manager.json").exists(): + await cli(directory, "source", "manager", "stop", "--config", str(state / "manager.json")) + + +async def grant_scope(client: httpx.AsyncClient, scope: str) -> None: + identity = await client.get("/v1/access/me") + assert identity.status_code == 200, identity.text + principal = identity.json()["principal"] + response = await client.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": principal["type"], "id": principal["id"]}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "collect-test-" + scope, + }, + ) + assert response.status_code == 201, response.text diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 8e554a43ae..3605b52c09 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -15,6 +15,8 @@ from __future__ import annotations import asyncio +import hashlib +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path @@ -47,6 +49,7 @@ CreateSourceRequest, FinalizeHandoffRequest, HandoffSelection, + ListAccessBindingsRequest, ListAccessResourcesRequest, ListArtifactsRequest, ListMemoryEntriesRequest, @@ -65,6 +68,7 @@ McpConfig, MetricsConfig, ServerSettings, + SourceManagementConfig, ) from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text from tests.e2e.server_storage import prepare_server_database @@ -529,6 +533,102 @@ async def run(): return asyncio.run(run()) +@pytest.mark.parametrize("authentication", ["legacy", "identity-one", "identity-two"]) +@pytest.mark.parametrize("source_plugins", [False, True]) +def test_scope_access_does_not_depend_on_server_administrator_count( + tmp_path: Path, authentication: str, source_plugins: bool +) -> None: + async def scenario() -> None: + seed_settings = ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'scope-access.db'}"), + runtime=RuntimeConfig(artifact_processing_families=()), + metrics=MetricsConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + seed_app = create_server_app(settings=seed_settings) + async with _client(seed_app, "unused-seed-token") as seed: + scope = await seed.create_scope( + CreateScopeRequest(title="Private Scope", summary="Existing data", idempotency_key="private-scope") + ) + source = await seed.create_source(scope.scope_id, CreateSourceRequest(content="private evidence")) + + token = "scope-access-test-token" # noqa: S105 - test credential. + principal = PrincipalRef(type="service", id="server-token" if authentication == "legacy" else "admin") + auth = BearerAuthConfig(token=SecretStr(token)) + if authentication != "legacy": + identities = tmp_path / "identities.json" + identities.write_text( + json.dumps({ + "identities": [ + { + "credential_id": name, + "token_digest": hashlib.sha256( + (token if name == "admin" else token + "-other").encode() + ).hexdigest(), + "subject": {"type": "service", "id": name}, + } + for name in (("admin", "other-admin") if authentication == "identity-two" else ("admin",)) + ] + }) + ) + identities.chmod(0o600) + auth = BearerAuthConfig( + identities_file=identities, + administrators=("service:admin", "service:other-admin") + if authentication == "identity-two" + else ("service:admin",), + ) + directory = tmp_path / "plugins" if source_plugins else None + if directory is not None: + directory.mkdir() + app = create_server_app( + settings=ServerSettings( + database=seed_settings.database, + runtime=seed_settings.runtime, + metrics=seed_settings.metrics, + mcp=seed_settings.mcp, + access=AccessControlConfig(mode="enforced", deployment_id=DEPLOYMENT_ID), + auth=auth, + source_plugins_dir=directory, + source_management=SourceManagementConfig(state_dir=tmp_path / "deployment"), + ) + ) + query = ListAccessBindingsRequest.model_validate({ + "management_resource": {"type": "scope", "scope_id": scope.scope_id}, + "subject": {"type": principal.type, "id": principal.id}, + }) + async with _client(app, token) as client: + assert not (await client.list_access_bindings(query)).items + if authentication == "legacy": + assert await client.get_source(scope.scope_id, "content", source.source_id) == source + assert {binding.role for binding in (await client.list_access_bindings(query)).items} == { + "scope.viewer", + "scope.contributor", + "scope.reviewer", + "scope.delegator", + } + else: + with pytest.raises(ForbiddenResponseError): + await client.get_source(scope.scope_id, "content", source.source_id) + assert not (await client.list_access_bindings(query)).items + await client.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": principal.type, "id": principal.id}, + "resource": {"type": "scope", "scope_id": scope.scope_id}, + "role": "scope.viewer", + "idempotency_key": "explicit-scope-viewer", + }) + ) + assert await client.get_source(scope.scope_id, "content", source.source_id) == source + with pytest.raises(ForbiddenResponseError): + await client.create_source(scope.scope_id, CreateSourceRequest(content="viewer cannot write")) + assert {binding.role for binding in (await client.list_access_bindings(query)).items} == { + "scope.viewer" + } + + asyncio.run(scenario()) + + def test_scheduled_memory_processing_uses_the_static_service_principal_as_owner(tmp_path: Path, monkeypatch) -> None: # Only the model adapter is deterministic. Server identity reconstruction, # spawned child, transaction hooks and public access checks are real. diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py index 235d3aa032..615fc2b716 100644 --- a/tests/e2e/test_atomic_memory_description_upgrade.py +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -85,7 +85,7 @@ def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, r ) result = _apply(runner) - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_006" verified = runner.verify() assert verified.state == "ready" and verified.readiness_scope == "server" assert not _apply(runner).changed diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py index a22857bbf9..3ed6c2d76e 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -128,9 +128,13 @@ async def scenario() -> None: await transport.get(f"/v1/scopes/{encoded_scope}/sources", params={"source_type": "content"}) ).status_code == 422 - invalid_source_type = await transport.get( + unknown_source_type = await transport.get( f"/v1/scopes/{encoded_scope}/sources/private/{quote(source.source_id, safe='')}" ) + assert unknown_source_type.status_code == 404 + invalid_source_type = await transport.get( + f"/v1/scopes/{encoded_scope}/sources/{quote(' ', safe='')}/{quote(source.source_id, safe='')}" + ) assert invalid_source_type.status_code == 422 invalid_family = await transport.post( f"/v1/scopes/{encoded_scope}/artifacts", diff --git a/tests/e2e/test_mcp_transport.py b/tests/e2e/test_mcp_transport.py index 2cac9b2765..06a2c2f185 100644 --- a/tests/e2e/test_mcp_transport.py +++ b/tests/e2e/test_mcp_transport.py @@ -216,6 +216,12 @@ async def exercise_tools() -> set[str]: tools = asyncio.run(exercise_tools()) assert tools == { + "pc_source_add", + "pc_source_capabilities", + "pc_source_instance_set_enabled", + "pc_source_instances", + "pc_source_status", + "pc_source_target_set_enabled", "generate_experience", "get_experience", "propose_experience", diff --git a/tests/e2e/test_source_collect.py b/tests/e2e/test_source_collect.py new file mode 100644 index 0000000000..f0641a9764 --- /dev/null +++ b/tests/e2e/test_source_collect.py @@ -0,0 +1,431 @@ +# 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. + +"""Managed collect instances preserve Scope progress and independent resource lifetimes.""" + +from __future__ import annotations + +import asyncio +import json +import shutil + +import yaml + +from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES +from tests.e2e.source_collect_helpers import ( + bind_server, + cli, + grant_scope, + instance_state, + prepare_environment, + set_enabled, + stop_services, +) +from tests.source_helpers import ROOT, create_scope, running_server + +EXAMPLE = ROOT / "examples/source_plugins/ticket_chat" + + +async def checkpoint(client, scope, identifier, *, after=None): + async with asyncio.timeout(40): + while True: + response = await client.post( + "/v1/connector-checkpoints/get", + json={ + "binding": { + "scope_id": scope, + "binding_id": identifier, + "connector_name": "example-ticket-chat", + "connector_version": "1", + } + }, + ) + assert response.status_code == 200, response.text + current = response.json()["checkpoint"] + if current is not None and current != after: + return current + await asyncio.sleep(0.1) + + +async def received(client, scope, target, count, *, namespace): + async with asyncio.timeout(40): + while True: + response = await client.post("/v1/source-management/status", json={"scope_id": scope, "target_id": target}) + assert response.status_code == 200, response.text + status = response.json() + page = await client.get(f"/v1/scopes/{scope}/sources") + assert page.status_code == 200, page.text + assert page.json()["next_cursor"] is None + sources = [ + item for item in page.json()["items"] if item["content"]["payload"]["capture"]["namespace"] == namespace + ] + if len(sources) == count: + latest = max(sources, key=lambda item: item["position"]) + if status["source_refs"] == [{"source_type": latest["source_type"], "source_id": latest["source_id"]}]: + return status + await asyncio.sleep(0.1) + + +def test_managed_instances_isolate_scopes_and_release_resources(tmp_path): + asyncio.run(_managed_instances(tmp_path)) + + +async def _managed_instances(directory): + envfile, state = prepare_environment(directory) + environment = { + "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), + } + async with running_server(directory, environment=environment) as (admin, url, _process): + bind_server(envfile, url) + scopes = [await create_scope(admin, name) for name in ("first", "second")] + for scope in scopes: + await grant_scope(admin, scope) + first, second = directory / "first", directory / "second" + shutil.copytree(EXAMPLE, first, ignore=shutil.ignore_patterns("__pycache__")) + second.mkdir() + shutil.copy(first / "events.json", second / "events.json") + entry = first / "__init__.py" + entry.write_text( + entry.read_text() + + """ + +_collect = apply + +async def apply(ctx, config): + def closed(): + (ctx.plugin_dir / "closed").touch() + ctx.on_dispose(closed) + await _collect(ctx, config) +""" + ) + targets = {} + try: + for instance, selected in ((first, scopes), (second, scopes[1:])): + manifest = { + "id": instance.name, + "plugin": str(first), + "scope_ids": selected, + "config": {"namespace": instance.name, "interval_seconds": 0.1}, + "targets": [ + {"scope_id": scope, "capability": "ticket-chat.dataset", "capability_version": "1", "input": {}} + for scope in selected + ], + } + path = instance / "powercontext.yml" + path.write_text(yaml.safe_dump(manifest)) + await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile)) + await instance_state(admin, instance.name, "running") + for scope in selected: + added = await admin.post( + "/v1/source-management/add", + json={ + "scope_id": scope, + "instance_id": instance.name, + "capability": "ticket-chat.dataset", + "capability_version": "1", + "input": {}, + }, + ) + assert added.status_code == 200, added.text + target = added.json()["target_id"] + targets[instance.name, scope] = target + await received(admin, scope, target, 2, namespace=instance.name) + previous = await checkpoint(admin, scopes[1], "first") + stopped_target = await admin.post( + "/v1/source-management/target-set-enabled", + json={ + "scope_id": scopes[1], + "target_id": targets["first", scopes[1]], + "enabled": False, + }, + ) + assert stopped_target.status_code == 200, stopped_target.text + async with asyncio.timeout(40): + while True: + status = await admin.post( + "/v1/source-management/status", + json={ + "scope_id": scopes[1], + "target_id": targets["first", scopes[1]], + }, + ) + if status.json()["tracking"] == "stopped": + break + await asyncio.sleep(0.1) + fixture = json.loads((first / "events.json").read_text()) + fixture["tickets"][0]["body"] = "Changed in the first Scope only" + replacement = first / "events.next.json" + replacement.write_text(json.dumps(fixture)) + replacement.replace(first / "events.json") + await received(admin, scopes[0], targets["first", scopes[0]], 3, namespace="first") + assert await checkpoint(admin, scopes[1], "first") == previous + await set_enabled(admin, "first", False) + assert (first / "closed").exists() + assert not (second / "closed").exists() + assert (await instance_state(admin, "second"))["actual"] == "running" + previous = await checkpoint(admin, scopes[1], "second") + fixture["tickets"][0]["body"] = "Second instance continues after the first stops" + replacement = second / "events.next.json" + replacement.write_text(json.dumps(fixture)) + replacement.replace(second / "events.json") + last_success = await received(admin, scopes[1], targets["second", scopes[1]], 3, namespace="second") + # Submission reports can precede checkpoint persistence. Wait for both. + previous = await checkpoint(admin, scopes[1], "second", after=previous) + # A rejected observation must not advance the Scope's checkpoint. + fixture["tickets"][0]["body"] = "x" * MAX_SOURCE_OBSERVATION_BYTES + replacement.write_text(json.dumps(fixture)) + replacement.replace(second / "events.json") + await instance_state(admin, "second", "failed") + assert (second / "closed").exists() + assert await checkpoint(admin, scopes[1], "second") == previous + failed = await received(admin, scopes[1], targets["second", scopes[1]], 3, namespace="second") + assert failed["source_refs"] == last_success["source_refs"] + assert failed["last_submitted_at"] == last_success["last_submitted_at"] + finally: + await stop_services(directory, state) + + +def test_scope_shrink_preserves_progress_and_offline_discovery(tmp_path): + asyncio.run(_scope_shrink_and_discovery(tmp_path)) + + +async def _scope_shrink_and_discovery(directory): + from fastmcp import Client + + from tests.source_helpers import TOKENS, call + + envfile, state = prepare_environment(directory) + environment = { + "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), + } + async with running_server(directory, environment=environment) as (admin, url, _process): + bind_server(envfile, url) + scopes = [await create_scope(admin, name) for name in ("kept", "removed")] + for scope in scopes: + await grant_scope(admin, scope) + plugin = directory / "collector" + shutil.copytree(EXAMPLE, plugin, ignore=shutil.ignore_patterns("__pycache__")) + manifest = { + "id": "scope-review", + "plugin": str(plugin), + "scope_ids": scopes, + "config": {"namespace": "scope-review", "interval_seconds": 0.1}, + "targets": [ + {"scope_id": s, "capability": "ticket-chat.dataset", "capability_version": "1", "input": {}} + for s in scopes + ], + } + path = plugin / "powercontext.yml" + path.write_text(yaml.safe_dump(manifest)) + try: + await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile)) + await instance_state(admin, "scope-review", "running") + targets = {} + for scope in scopes: + response = await admin.post( + "/v1/source-management/add", + json={ + "scope_id": scope, + "instance_id": "scope-review", + "capability": "ticket-chat.dataset", + "capability_version": "1", + "input": {}, + }, + ) + assert response.status_code == 200, response.text + targets[scope] = response.json()["target_id"] + await received(admin, scope, targets[scope], 2, namespace="scope-review") + previous = await checkpoint(admin, scopes[1], "scope-review") + await set_enabled(admin, "scope-review", False) + runtime_config = state / "instances/scope-review/runtime-config.json" + await cli(directory, "source", "runtime", "stop", "--config", str(runtime_config)) + manifest["scope_ids"] = scopes[:1] + manifest["targets"] = manifest["targets"][:1] + path.write_text(yaml.safe_dump(manifest)) + await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile), "--update") + await instance_state(admin, "scope-review", "stopped") + fixture = json.loads((plugin / "events.json").read_text()) + fixture["tickets"][0]["body"] = "Changed after removing a Scope" + (plugin / "events.json").write_text(json.dumps(fixture)) + await set_enabled(admin, "scope-review", True) + await received(admin, scopes[0], targets[scopes[0]], 3, namespace="scope-review") + # Observe another collection cycle before draining, regardless of target iteration order. + fixture["tickets"][0]["body"] = "Another change while the Scope remains excluded" + replacement = plugin / "events.next.json" + replacement.write_text(json.dumps(fixture)) + replacement.replace(plugin / "events.json") + await received(admin, scopes[0], targets[scopes[0]], 4, namespace="scope-review") + # Stop and drain actual work before checking the removed Scope's stored progress. + await set_enabled(admin, "scope-review", False) + assert await checkpoint(admin, scopes[1], "scope-review") == previous + await received(admin, scopes[1], targets[scopes[1]], 2, namespace="scope-review") + page = await admin.get(f"/v1/scopes/{scopes[1]}/sources") + assert page.status_code == 200 and len(page.json()["items"]) == 2 + + # Re-admit the Scope through the public management API, without recreating its target. + registration = json.loads((state / "instances/scope-review/registration.json").read_text()) + registration["scope_ids"] = scopes + response = await admin.post( + "/v1/source-management/configure", json={"registration": registration, "update": True} + ) + assert response.status_code == 200, response.text + await set_enabled(admin, "scope-review", True) + restored = await received(admin, scopes[1], targets[scopes[1]], 3, namespace="scope-review") + assert restored["target_id"] == targets[scopes[1]] + receipts = [json.loads(line) for line in (plugin / "receipts.jsonl").read_text().splitlines()] + assert any(r["scope_id"] == scopes[1] and r["source"] in restored["source_refs"] for r in receipts) + + async with Client(url + "/mcp", auth=TOKENS["deployer"]) as host: + query = {"scope_id": scopes[0], "instance_id": "scope-review"} + async with asyncio.timeout(40): + while True: + result = await call(host, "pc_source_instances", query) + if result["instances"] and result["instances"][0]["availability"] == "online": + break + await asyncio.sleep(0.1) + await cli(directory, "source", "manager", "stop", "--config", str(state / "manager.json")) + async with asyncio.timeout(40): + while True: + result = await call(host, "pc_source_instances", query) + if result["management"] == "unavailable": + break + await asyncio.sleep(0.1) + assert result["instances"][0]["availability"] == "unavailable" + capabilities = await call(host, "pc_source_capabilities", query) + assert capabilities["capabilities"][0]["instances"][0]["availability"] == "unavailable" + written = await call( + host, + "pc_source_add", + { + "scope_id": scopes[0], + "capability": "content.inline", + "capability_version": "2", + "input": {"source_id": "offline-write", "text": "write remains available"}, + }, + ) + assert written["result"]["source_refs"] == [{"source_type": "content", "source_id": "offline-write"}] + finally: + await stop_services(directory, state) + + +def test_tickets_refreshes_targets_when_a_later_scope_is_disabled(tmp_path): + asyncio.run(_tickets_target_refresh(tmp_path)) + + +async def _tickets_target_refresh(directory): + envfile, state = prepare_environment(directory) + environment = { + "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), + } + async with running_server(directory, environment=environment) as (admin, url, _process): + bind_server(envfile, url) + scopes = sorted([await create_scope(admin, name) for name in ("first", "second")]) + for scope in scopes: + await grant_scope(admin, scope) + plugin = directory / "tickets" + shutil.copytree(ROOT / "examples/source_plugins/tickets", plugin, ignore=shutil.ignore_patterns("__pycache__")) + entry = plugin / "__init__.py" + # Pause real connector I/O so a later Scope's saved target becomes stale deterministically. + entry.write_text( + entry.read_text() + + f""" + +_run = TicketConnector.run +async def paused_run(self, session): + if self.targets[0].scope_id == {scopes[0]!r} and (self.ctx.plugin_dir / "pause").exists(): + (self.ctx.plugin_dir / "paused").touch() + while not (self.ctx.plugin_dir / "resume").exists(): + await asyncio.sleep(0.05) + result = await _run(self, session) + if (self.ctx.plugin_dir / "paused").exists(): + (self.ctx.plugin_dir / "finished").touch() + return result +TicketConnector.run = paused_run +""" + ) + records = plugin / "tickets.json" + records.write_text('[{"id":"A","body":"Initial"}]') + manifest = plugin / "powercontext.yml" + manifest.write_text( + yaml.safe_dump({ + "id": "tickets-refresh", + "plugin": str(plugin), + "scope_ids": scopes, + "config": {"namespace": "refresh", "poll_interval": 0.1}, + "targets": [ + {"scope_id": scope, "capability": "tickets.item", "capability_version": "1", "input": {"id": "A"}} + for scope in scopes + ], + }) + ) + try: + await cli(directory, "source", "load", "--config", str(manifest), "--env-file", str(envfile)) + targets = [] + for scope in scopes: + response = await admin.post( + "/v1/source-management/add", + json={ + "scope_id": scope, + "instance_id": "tickets-refresh", + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + }, + ) + assert response.status_code == 200, response.text + targets.append(response.json()["target_id"]) + await received(admin, scope, targets[-1], 1, namespace="refresh") + (plugin / "pause").touch() + async with asyncio.timeout(40): + while not (plugin / "paused").exists(): # noqa: ASYNC110 - subprocess rendezvous + await asyncio.sleep(0.1) + response = await admin.post( + "/v1/source-management/target-set-enabled", + json={ + "scope_id": scopes[1], + "target_id": targets[1], + "enabled": False, + }, + ) + assert response.status_code == 200, response.text + async with asyncio.timeout(40): + while True: + response = await admin.post( + "/v1/source-management/status", + json={ + "scope_id": scopes[1], + "target_id": targets[1], + }, + ) + assert response.status_code == 200, response.text + if response.json()["tracking"] == "stopped": + break + await asyncio.sleep(0.1) + (plugin / "resume").touch() + # The paused batch may finish; require another batch to consume a new snapshot. + async with asyncio.timeout(40): + while not (plugin / "finished").exists(): # noqa: ASYNC110 - subprocess rendezvous + await asyncio.sleep(0.1) + replacement = plugin / "tickets.next.json" + replacement.write_text('[{"id":"A","body":"After target stop"}]') + replacement.replace(records) + await received(admin, scopes[0], targets[0], 2, namespace="refresh") + assert (await instance_state(admin, "tickets-refresh"))["actual"] == "running" + await received(admin, scopes[1], targets[1], 1, namespace="refresh") + finally: + (plugin / "resume").touch() + await stop_services(directory, state) diff --git a/tests/e2e/test_source_deployment.py b/tests/e2e/test_source_deployment.py new file mode 100644 index 0000000000..59947cfce4 --- /dev/null +++ b/tests/e2e/test_source_deployment.py @@ -0,0 +1,320 @@ +# 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. + +"""Load collect plugins through the public CLI against real Server and Runtime processes.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import sys +from pathlib import Path + +import httpx +import pytest +import yaml + +from tests.source_helpers import protected, wait_until + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples/source_plugins" +DEPLOYMENT_TIMEOUT_SECONDS = 100 + + +@pytest.mark.parametrize("remote", [False, True]) +def test_load_real_background_processes_and_preserves_disabled_state(tmp_path, remote): # noqa: C901 - real deployment lifecycle + import hashlib + import socket + + def port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + async def scenario(): # noqa: C901 - real deployment lifecycle + tokens = {name: "load-e2e-" + name for name in ("deployer", "manager", "worker")} + for name, token in tokens.items(): + protected(tmp_path / (name + ".token"), token) + protected( + tmp_path / "identities.json", + json.dumps({ + "identities": [ + { + "credential_id": name, + "token_digest": hashlib.sha256(token.encode()).hexdigest(), + "subject": {"type": "service", "id": name}, + } + for name, token in tokens.items() + ] + }), + ) + server_port, manager_port = port(), port() + url = f"http://127.0.0.1:{server_port}" + plugins = tmp_path / "plugins" + plugins.mkdir() + settings = { + "POWERCONTEXT_SERVER_HTTP": {"host": "127.0.0.1", "port": server_port}, + "POWERCONTEXT_SERVER_DATABASE": {"kind": "sqlite", "url": f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"}, + "POWERCONTEXT_SERVER_AUTH": { + "enabled": True, + "identities_file": str(tmp_path / "identities.json"), + "administrators": ["service:deployer"], + }, + "POWERCONTEXT_SERVER_ACCESS": {"mode": "enforced", "background_principal_id": "deployer"}, + "POWERCONTEXT_SERVER_RUNTIME": {"artifact_processing_families": []}, + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": {"manager_subject": "service:manager"} + if remote + else {"state_dir": str(tmp_path / "deployment")}, + } + values = {key: json.dumps(value) for key, value in settings.items()} + values["POWERCONTEXT_SERVER_PUBLIC_URL"] = url + if not remote: + values["POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR"] = str(plugins) + envfile = tmp_path / "server.env" + protected(envfile, "\n".join(f"{key}='{value}'" for key, value in values.items()) + "\n") + environment = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} + environment["POWERCONTEXT_HOME"] = str(tmp_path / "home") + entrypoint = str(Path(sys.executable).with_name("powercontext")) + + async def cli(*args, success=True): + child = await asyncio.create_subprocess_exec( + entrypoint, *args, env=environment, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + try: + out, err = await asyncio.wait_for(child.communicate(), DEPLOYMENT_TIMEOUT_SECONDS) + finally: + if child.returncode is None: + child.kill() + await child.wait() + if success: + assert child.returncode == 0, err.decode() + return json.loads(out) + assert child.returncode != 0 + return err.decode() + + manager_path = tmp_path / "manager.json" if remote else tmp_path / "deployment/manager.json" + if remote: + protected( + manager_path, + json.dumps({ + "server": {"url": url, "credential_file": str(tmp_path / "manager.token")}, + "deployment_id": "powercontext", + "manager_id": "sources", + "state_dir": str(tmp_path / "manager"), + "port": manager_port, + }), + ) + runtime_paths = [] + process = None + with (tmp_path / "server.log").open("a") as log: + + async def start_server(): + child = await asyncio.create_subprocess_exec( + entrypoint, "server", "run", "--env-file", str(envfile), env=environment, stdout=log, stderr=log + ) + async with httpx.AsyncClient(base_url=url) as client, asyncio.timeout(40): + while True: + assert child.returncode is None, (tmp_path / "server.log").read_text() + try: + if (await client.get("/health/ready")).status_code == 200: + return child + except httpx.HTTPError: + pass + await asyncio.sleep(0.1) + + try: + process = await start_server() + if remote: + await cli("source", "manager", "start", "--config", str(manager_path)) + async with httpx.AsyncClient( + base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}, timeout=65 + ) as admin: + created = await admin.post( + "/v1/scopes", json={"title": "Load", "summary": "Real processes", "idempotency_key": "load"} + ) + assert created.status_code == 201, created.text + scope = created.json()["scope_id"] + await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": "deployer"}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "admin-read", + }, + ) + directory = plugins / "tickets" + shutil.copytree(EXAMPLES / "tickets", directory, ignore=shutil.ignore_patterns("__pycache__")) + (directory / "tickets.json").write_text('[{"id":"A","body":"alpha"},{"id":"B","body":"bravo"}]') + config = { + "id": "cli-tickets", + "plugin": ".", + "scope_ids": [scope], + "config": {"poll_interval": 0.1}, + "targets": [ + { + "scope_id": scope, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + } + ], + } + if remote: + config["deployment"] = { + "server": {"url": url, "credential_file": str(tmp_path / "deployer.token")}, + "management_url": f"http://127.0.0.1:{manager_port}", + "execution_credential_file": str(tmp_path / "worker.token"), + "state_dir": str(tmp_path / "runtime"), + } + path = directory / "powercontext.yml" + path.write_text(yaml.safe_dump(config)) + args = ["source", "load", "--config", str(path)] + automatic = None + if not remote: + args += ["--env-file", str(envfile)] + # The Scope already exists; restart with a new manifest present before startup. + # Verify registration AND collection before invoking source load. + runtime_path = tmp_path / "deployment/instances/cli-tickets/runtime-config.json" + runtime_paths.append(runtime_path) + process.terminate() + await asyncio.wait_for(process.wait(), 25) + process = await start_server() + await wait_until( + lambda: admin.post( + "/v1/source-management/instances", + json={"instance_id": "cli-tickets", "view": "control"}, + ), + lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running", + # Server readiness precedes manager and Runtime startup; use the CLI deployment budget. + timeout_seconds=DEPLOYMENT_TIMEOUT_SECONDS, + ) + await wait_until( + lambda: admin.get(f"/v1/scopes/{scope}/sources"), + lambda r: r.status_code == 200 and bool(r.json()["items"]), + ) + automatic = await cli("source", "runtime", "status", "--config", str(runtime_path)) + first = await cli(*args) + runtime_path = Path( + first.get("runtime_config", tmp_path / "deployment/instances/cli-tickets/runtime-config.json") + ) + if runtime_path not in runtime_paths: + runtime_paths.append(runtime_path) + pid = first["service"]["pid"] + if not remote: + assert automatic is not None + assert pid == automatic["pid"] + repeated = await cli(*args) + assert repeated["service"]["pid"] == pid + added = await admin.post( + "/v1/source-management/add", + json={ + "scope_id": scope, + "instance_id": "cli-tickets", + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + }, + ) + assert added.status_code == 200, added.text + target = added.json()["target_id"] + await wait_until( + lambda: admin.post( + "/v1/source-management/status", json={"scope_id": scope, "target_id": target} + ), + lambda r: r.status_code == 200 and bool(r.json()["source_refs"]), + ) + runtime_config = json.loads(await asyncio.to_thread(runtime_path.read_text)) + execution_token = ( + await asyncio.to_thread(Path(runtime_config["server"]["credential_file"]).read_text) + ).strip() + denied = await admin.post( + "/v1/source-management/instance-set-enabled", + headers={"Authorization": "Bearer " + execution_token}, + json={"instance_id": "cli-tickets", "enabled": False}, + ) + assert denied.status_code == 403 + response = await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": "cli-tickets", "enabled": False}, + ) + assert response.status_code == 200, response.text + await wait_until( + lambda: admin.post( + "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} + ), + lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "stopped", + ) + config["targets"][0]["input"] = {"id": "B"} + path.write_text(yaml.safe_dump(config)) + assert (await cli(*args))["service"]["pid"] == pid + process.terminate() + await asyncio.wait_for(process.wait(), 25) + process = await start_server() + state = await wait_until( + lambda: admin.post( + "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} + ), + lambda r: r.status_code == 200, + ) + assert not state.json()["instances"][0]["enabled"] + assert (await cli(*args))["service"]["pid"] == pid + config["config"]["poll_interval"] = 0.2 + path.write_text(yaml.safe_dump(config)) + assert "conflict" in await cli(*args, success=False) + assert (await cli("source", "runtime", "status", "--config", str(runtime_path)))["pid"] == pid + # An explicit update cannot replace an outer Runtime that is still alive. + assert "instance-running" in await cli(*args, "--update", success=False) + await cli("source", "runtime", "stop", "--config", str(runtime_path)) + await wait_until( + lambda: admin.post( + "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} + ), + lambda r: r.status_code == 200 and r.json()["instances"][0]["availability"] == "offline", + ) + updated = await cli(*args, "--update") + assert updated["service"]["pid"] != pid + state = await admin.post( + "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} + ) + assert state.status_code == 200, state.text + assert not state.json()["instances"][0]["enabled"] + assert state.json()["instances"][0]["actual"] == "stopped" + status = await admin.post( + "/v1/source-management/status", json={"scope_id": scope, "target_id": target} + ) + assert status.status_code == 200 and status.json()["source_refs"] + enabled = await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": "cli-tickets", "enabled": True}, + ) + assert enabled.status_code == 200, enabled.text + await wait_until( + lambda: admin.post( + "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} + ), + lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running", + ) + finally: + for runtime_path in runtime_paths: + if runtime_path.exists(): + await cli("source", "runtime", "stop", "--config", str(runtime_path)) + if manager_path.exists(): + await cli("source", "manager", "stop", "--config", str(manager_path)) + if process is not None and process.returncode is None: + process.terminate() + await asyncio.wait_for(process.wait(), 25) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_source_write.py b/tests/e2e/test_source_write.py new file mode 100644 index 0000000000..3226c003a8 --- /dev/null +++ b/tests/e2e/test_source_write.py @@ -0,0 +1,303 @@ +# 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. + +"""Deploy trusted write code through the CLI and use the real public MCP.""" + +from __future__ import annotations + +import asyncio +import base64 +import io +import shutil + +import httpx +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError +from PIL import Image + +from tests.source_helpers import ROOT, TOKENS, call, create_scope, discover, grant, load_write_cli, read, running_server + +EXAMPLE = ROOT / "examples/source_plugins/tickets" +LOAD = "/v1/source-management/write/load" + + +def test_server_local_write_mcp_projection_and_restart(tmp_path): + asyncio.run(_write_and_restart(tmp_path)) + + +async def _write_and_restart(directory): + deployment = directory / "tickets" + shutil.copytree(EXAMPLE, deployment, ignore=shutil.ignore_patterns("__pycache__")) + value = { + "namespace": "support", + "project": "powercontext", + "ticket_id": "PC-123", + "title": "数据库恢复", + "body": "重试连接后服务恢复。", + "labels": ["数据库"], + "metadata": {"owner": None, "attempts": 2, "nested": {"verified": True}}, + } + async with running_server(directory) as (admin, url, _process): + scope = await create_scope(admin, "write-evidence") + await grant(admin, scope, "deployer", "scope.viewer") + await grant(admin, scope) + await grant(admin, scope, "reader", "scope.viewer") + other_scope = await create_scope(admin, "other-evidence") + await grant(admin, other_scope, "deployer", "scope.viewer") + configuration = deployment / "write.yml" + first = await load_write_cli(configuration, url) + assert await load_write_cli(configuration, url) == first + assert first["entry"] == str(deployment) + args = {"scope_id": scope, "capability": "ticket.write", "capability_version": "1", "input": value} + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + catalog = await discover(host, scope) + assert catalog["management"] == "not-configured" + ticket = next(c for c in catalog["capabilities"] if c["name"] == "ticket.write") + assert ticket["kind"] == "write" and ticket["can_write"] and ticket["instances"] == [] + assert ticket["definition_name"] == "example-ticket" + a, repeated = await asyncio.gather(call(host, "pc_source_add", args), call(host, "pc_source_add", args)) + assert a == repeated + assert a["kind"] == "write" and "request_id" not in a and "target_id" not in a + ref = a["result"]["source_refs"][0] + stored = await read(admin, scope, ref) + assert stored["content"]["payload"]["capture"] == value + details = next(p for p in stored["content"]["projections"] if p["key"]["name"] == "example.ticket-details") + assert details == {"key": {"name": "example.ticket-details", "version": "1"}, "value": value} + changed = await call(host, "pc_source_add", {**args, "input": {**value, "body": "服务持续稳定。"}}) + second_ref = changed["result"]["source_refs"][0] + assert second_ref != ref + page = await admin.get(f"/v1/scopes/{scope}/sources", params={"limit": 1}) + assert page.status_code == 200, page.text + assert [s["source_id"] for s in page.json()["items"]] == [ref["source_id"]] + page = await admin.get( + f"/v1/scopes/{scope}/sources", params={"limit": 1, "cursor": page.json()["next_cursor"]} + ) + assert [s["source_id"] for s in page.json()["items"]] == [second_ref["source_id"]] + assert page.json()["next_cursor"] is None + with pytest.raises(ToolError): + await call(host, "pc_source_add", {**args, "scope_id": other_scope}) + with pytest.raises(ToolError): + await call(host, "pc_source_add", {**args, "instance_id": "unused"}) + with pytest.raises(ToolError, match="capability-unavailable"): + await call(host, "pc_source_add", {**args, "capability_version": "999"}) + async with Client(url + "/mcp", auth=TOKENS["reader"]) as reader: + assert not next(c for c in (await discover(reader, scope))["capabilities"] if c["name"] == "ticket.write")[ + "can_write" + ] + with pytest.raises(ToolError): + await call(reader, "pc_source_add", args) + response = await admin.post(LOAD, json={"id": first["id"], "entry": "does-not-exist.py"}) + assert response.status_code == 409 + await grant(admin, other_scope) + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + isolated = await call(host, "pc_source_add", {**args, "scope_id": other_scope}) + assert isolated["result"]["source_refs"] == [ref] + assert (await read(admin, other_scope, ref))["content"] == stored["content"] + async with running_server(directory) as (admin, url, _process): + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + assert any(c["name"] == "ticket.write" for c in (await discover(host, scope))["capabilities"]) + assert (await call(host, "pc_source_add", args))["result"]["source_refs"] == [ref] + assert (await read(admin, scope, ref))["position"] == stored["position"] + shutil.move(deployment, directory / "unavailable-tickets") + async with running_server(directory) as (admin, url, _process): + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + catalog = await discover(host, scope) + assert [c["name"] for c in catalog["capabilities"]] == ["content.inline"] + builtin = await call( + host, + "pc_source_add", + { + "scope_id": scope, + "capability": "content.inline", + "capability_version": "2", + "input": {"source_id": "still-available", "text": "Server remains usable"}, + }, + ) + assert builtin["result"]["source_refs"][0]["source_type"] == "content" + # Saved projections remain readable without executable write code. + assert (await read(admin, scope, ref))["content"] == stored["content"] + + +BOUNDARY_PLUGIN = """ +import asyncio +import os +from pathlib import Path +from pydantic import BaseModel, ConfigDict, Field +from powercontext.sources import Source, SourceMaterialization +from powercontext.source_management import Capability + +Path(__file__).with_name("loaded-pid").write_text(str(os.getpid())) + +class Input(BaseModel): + model_config = ConfigDict(extra="forbid") + source_id: str + data: str = "" + repeat: int = Field(default=1, ge=1, le=5000000) + delay: float = Field(default=0, ge=0, le=30) + fail: bool = False + +class Saved(Source): + data: str + +class Definition: + name = "boundary-example" + version = "1" + input_class = Input + source_class = Saved + projections = () + async def resolve(self, value, /): + if value.delay: + Path(__file__).with_name("resolving").write_text(value.source_id) + await asyncio.sleep(value.delay) + if value.fail: + raise ValueError("private input must not be echoed: " + value.data) + return Saved(name=value.source_id, materialization=SourceMaterialization.CAPTURED, data=value.data * value.repeat) + async def read(self, source, /): + return source.data + +definitions = (Definition(),) +capabilities = (Capability(name="boundary.write", version="7", kind="write", description="Capture supplied content", + definition_name="boundary-example", definition_version="1", input_schema=Input.model_json_schema()),) +""" + + +def test_write_loading_limits_conflicts_and_live_permissions(tmp_path): + asyncio.run(_write_boundaries(tmp_path)) + + +async def _write_boundaries(directory): + entry = directory / "plugin.py" + entry.write_text(BOUNDARY_PLUGIN) + async with running_server(directory, write_timeout=1) as (admin, url, process): + scope = await create_scope(admin, "write-limits") + await grant(admin, scope, "deployer", "scope.viewer") + binding = await grant(admin, scope) + config = {"id": "boundary", "entry": str(entry)} + response = await admin.post(LOAD, json=config) + assert response.status_code == 200, response.text + assert int((directory / "loaded-pid").read_text()) == process.pid + response = await admin.post(LOAD, json={**config, "id": "conflicting-capability"}) + assert response.status_code == 409 and response.json()["error"]["code"] == "capability-conflict" + invalid = directory / "invalid.py" + invalid.write_text(BOUNDARY_PLUGIN.replace('definition_version="1"', 'definition_version="wrong"')) + response = await admin.post(LOAD, json={"id": "invalid", "entry": str(invalid)}) + assert response.status_code == 400 + invalid.write_text(BOUNDARY_PLUGIN.replace("resolve(self, value, /)", "resolve(self, value, required, /)")) + response = await admin.post(LOAD, json={"id": "invalid", "entry": str(invalid)}) + assert response.status_code == 400 + conflict = directory / "definition_conflict.py" + conflict.write_text( + BOUNDARY_PLUGIN.replace('name="boundary.write"', 'name="conflicting-definition.write"').replace( + "class Saved(Source):\n data: str", "class Saved(Source):\n data: str\n extra: str = ''" + ) + ) + response = await admin.post(LOAD, json={"id": "definition-conflict", "entry": str(conflict)}) + assert response.status_code == 409 and response.json()["error"]["code"] == "definition-conflict" + async with httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + TOKENS["host"]}) as http_host: + assert (await http_host.post(LOAD, json=config)).status_code == 403 + response = await admin.post(LOAD, json={"id": "images", "entry": str(ROOT / "examples/source_plugins/images")}) + assert response.status_code == 200, response.text + args = { + "scope_id": scope, + "capability": "boundary.write", + "capability_version": "7", + "input": {"source_id": "one", "data": "original"}, + } + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + names = {c["name"] for c in (await discover(host, scope))["capabilities"]} + assert names == {"content.inline", "boundary.write", "images.write"} + output = io.BytesIO() + Image.new("RGB", (2, 2), "red").save(output, format="PNG") + image_args = { + "scope_id": scope, + "capability": "images.write", + "capability_version": "1", + "input": {"media_type": "image/png", "data_base64": base64.b64encode(output.getvalue()).decode()}, + } + image = await call(host, "pc_source_add", image_args) + assert (await call(host, "pc_source_add", image_args)) == image + assert "request_id" not in image + stored_image = await read(admin, scope, image["result"]["source_refs"][0]) + assert stored_image["content"]["payload"]["capture"] == {**image_args["input"], "title": ""} + saved = await call(host, "pc_source_add", args) + ref = saved["result"]["source_refs"][0] + assert (await call(host, "pc_source_add", args)) == saved + with pytest.raises(ToolError): + await call(host, "pc_source_add", {**args, "input": {"source_id": "one", "data": "different"}}) + assert (await read(admin, scope, ref))["content"]["payload"]["data"] == "original" + with pytest.raises(ToolError, match="invalid-input"): + await call(host, "pc_source_add", {**args, "input": {"source_id": "bad", "unknown": True}}) + with pytest.raises(ToolError, match="input-too-large"): + await call( + host, "pc_source_add", {**args, "input": {"source_id": "oversize", "data": "x" * (1024 * 1024)}} + ) + with pytest.raises(ToolError, match="input-too-large"): + await call( + host, + "pc_source_add", + { + "scope_id": scope, + "capability": "images.write", + "capability_version": "1", + "input": {"media_type": "image/png", "data_base64": "A" * (1024 * 1024)}, + }, + ) + with pytest.raises(ToolError, match="observation-too-large"): + await call( + host, + "pc_source_add", + {**args, "input": {"source_id": "expanded", "data": "x", "repeat": 4 * 1024 * 1024}}, + ) + with pytest.raises(ToolError, match="invalid-input") as failure: + await call( + host, + "pc_source_add", + {**args, "input": {"source_id": "failure", "data": "sensitive-marker", "fail": True}}, + ) + assert "sensitive-marker" not in str(failure.value) + before_timeout = (await admin.get(f"/v1/scopes/{scope}/sources")).json()["items"] + assert len(before_timeout) == 2 + with pytest.raises(ToolError, match="result-unconfirmed"): + await call(host, "pc_source_add", {**args, "input": {"source_id": "timed-out", "delay": 10}}) + page = await admin.get(f"/v1/scopes/{scope}/sources") + assert page.status_code == 200 and page.json()["items"] == before_timeout + # A timed-out invocation must not prevent subsequent synchronous writes. + recovered = await call(host, "pc_source_add", {**args, "input": {"source_id": "after-timeout"}}) + assert "request_id" not in recovered + assert (await read(admin, scope, recovered["result"]["source_refs"][0]))["content"]["payload"]["data"] == "" + page = await admin.get(f"/v1/scopes/{scope}/sources") + assert page.status_code == 200 + (directory / "resolving").unlink(missing_ok=True) + pending = asyncio.create_task( + call(host, "pc_source_add", {**args, "input": {"source_id": "revoked", "delay": 0.6}}) + ) + async with asyncio.timeout(5): + while True: + if (directory / "resolving").exists(): + break + await asyncio.sleep(0.01) + revoked = await admin.post( + "/v1/access/bindings/revoke", + json={ + "binding_id": binding["binding_id"], + "expected_version": binding["version"], + "idempotency_key": "revoke-host", + }, + ) + assert revoked.status_code == 200, revoked.text + with pytest.raises(ToolError): + await pending + assert (await admin.get(f"/v1/scopes/{scope}/sources")).json()["items"] == page.json()["items"] + assert "sensitive-marker" not in (directory / "server.log").read_text() diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 0503d987f1..298d731c1a 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_4_1_005" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_4_1_006" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index ccdea630d3..f1effba04b 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -401,7 +401,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_005" + assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_006" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase diff --git a/tests/release_upgrades/README.md b/tests/release_upgrades/README.md index 0d4193f67e..a11de1797b 100644 --- a/tests/release_upgrades/README.md +++ b/tests/release_upgrades/README.md @@ -8,7 +8,7 @@ matrix and enables publication after the reviewed support declaration is updated ## Reviewed sources and current target The declared target release is **1.4.1**. Public migration output maps the two source baselines to -`v1_3_0_000` / `v1_4_0_000` and the shared steps to `v1_4_1_001`–`v1_4_1_003`. The suite checks +`v1_3_0_000` / `v1_4_0_000` and the shared steps to `v1_4_1_001`–`v1_4_1_006`. The suite checks these names while retaining the original revision IDs in execution and recovery evidence. The `automatic_sources` policy uses `strategy: "fixed-version"` and `source_version: "1.3.0"`. diff --git a/tests/source_helpers.py b/tests/source_helpers.py new file mode 100644 index 0000000000..0548ae327a --- /dev/null +++ b/tests/source_helpers.py @@ -0,0 +1,193 @@ +# 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. + +"""Shared HTTP, MCP and real Server CLI helpers for Source acceptance tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import socket +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +ENTRYPOINT = Path(sys.executable).with_name("powercontext") +TOKENS = {name: f"source-write-e2e-{name}" for name in ("deployer", "host", "reader")} + + +@asynccontextmanager +async def running_server(directory: Path, *, write_timeout=10, environment=None): + identities = directory / "identities.json" + identities.write_text( + json.dumps({ + "identities": [ + { + "credential_id": name, + "token_digest": hashlib.sha256(token.encode()).hexdigest(), + "subject": {"type": "service", "id": name}, + } + for name, token in TOKENS.items() + ] + }) + ) + identities.chmod(0o600) + env = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} + env.update({ + "POWERCONTEXT_HOME": str(directory / "home"), + "POWERCONTEXT_SERVER_DATABASE": json.dumps({ + "kind": "sqlite", + "url": f"sqlite+aiosqlite:///{directory / 'server.db'}", + }), + "POWERCONTEXT_SERVER_WORKSPACE": str(directory), + "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), + "POWERCONTEXT_SERVER_AUTH": json.dumps({ + "enabled": True, + "identities_file": str(identities), + "administrators": ["service:deployer"], + }), + "POWERCONTEXT_SERVER_ACCESS": json.dumps({"mode": "enforced", "background_principal_id": "deployer"}), + "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"write_wait_timeout_seconds": write_timeout}), + "POWERCONTEXT_SERVER_MCP_ENABLED": "true", + "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", + }) + env.update(environment or {}) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + url = f"http://127.0.0.1:{port}" + with (directory / "server.log").open("a") as log: + process = await asyncio.create_subprocess_exec( + str(ENTRYPOINT), + "server", + "run", + "--no-env-file", + "--host", + "127.0.0.1", + "--port", + str(port), + cwd=directory, + env=env, + stdout=log, + stderr=log, + ) + try: + async with httpx.AsyncClient( + base_url=url, headers={"Authorization": "Bearer " + TOKENS["deployer"]}, timeout=15 + ) as admin: + async with asyncio.timeout(40): + while True: + assert process.returncode is None, (directory / "server.log").read_text() + try: + if (await admin.get("/health/ready")).status_code == 200: + break + except httpx.ConnectError: + pass + await asyncio.sleep(0.1) + yield admin, url, process + finally: + if process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), 20) + except TimeoutError: + process.kill() + await process.wait() + + +async def load_write_cli(config: Path, url: str): + credential = config.parent / "admin-token" + credential.write_text(TOKENS["deployer"]) + credential.chmod(0o600) + process = await asyncio.create_subprocess_exec( + str(ENTRYPOINT), + "source", + "load", + "--kind", + "write", + "--config", + str(config), + "--server-url", + url, + "--credential-file", + str(credential), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), 40) + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 0, (stdout + stderr).decode() + return json.loads(stdout) + + +async def create_scope(client, key): + response = await client.post( + "/v1/scopes", json={"title": key, "summary": "Source acceptance", "idempotency_key": key} + ) + assert response.status_code == 201, response.text + return response.json()["scope_id"] + + +async def grant(admin, scope, subject="host", role="scope.contributor"): + response = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": subject}, + "resource": {"type": "scope", "scope_id": scope}, + "role": role, + "idempotency_key": f"{scope}-{subject}-{role}", + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def call(client, name, arguments): + result = await client.call_tool(name, arguments) + assert not result.is_error, result.content + return result.data + + +async def discover(client, scope): + return await call(client, "pc_source_capabilities", {"scope_id": scope}) + + +async def read(admin, scope, ref): + response = await admin.get(f"/v1/scopes/{scope}/sources/{ref['source_type']}/{ref['source_id']}") + assert response.status_code == 200, response.text + return response.json() + + +def protected(path: Path, value: str) -> Path: + path.write_text(value, encoding="utf-8") + path.chmod(0o600) + return path + + +async def wait_until(call, predicate, *, timeout_seconds: float = 15): + async with asyncio.timeout(timeout_seconds): + while True: + value = await call() + if predicate(value): + return value + await asyncio.sleep(0.05) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 20611d63e0..ef44d6eb45 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -268,3 +268,24 @@ async def scenario() -> None: assert events == [] asyncio.run(scenario()) + + +def test_connector_can_mutate_its_checkpoint_without_losing_the_previous_cas_value() -> None: + class MutableCursorConnector(ContentConnector): + async def run(self, session: ConnectorRunSession, /) -> ConnectorRunCompletion: + assert isinstance(session.checkpoint, dict) + session.checkpoint["cursor"] = 2 + await session.submit(self.capture.source_id, CONTENT_SOURCE_NAME, self.capture) + return ConnectorRunCompletion(status=ConnectorRunStatus.COMPLETE, checkpoint=session.checkpoint) + + async def scenario() -> None: + checkpoints = MemoryCheckpointStore([]) + checkpoints.values[_binding().binding_id] = {"cursor": 1} + result = await ConnectorLifecycle(sink=AcceptingSourceSink([]), checkpoints=checkpoints).run( + MutableCursorConnector(ContentCapture(source_id="note-1", content="A changed cursor")), _binding() + ) + assert result.previous_checkpoint == {"cursor": 1} + assert result.committed_checkpoint == {"cursor": 2} + assert checkpoints.values[_binding().binding_id] == {"cursor": 2} + + asyncio.run(scenario()) diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 17dcdcf851..473d68eabe 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_4_1_005" + assert plan["target_revision"] == "v1_4_1_006" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index 76aa1849d9..806d1555b9 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b46b9f5659..68d3850c19 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -82,6 +82,12 @@ async def inspect_components() -> tuple[list[str], int, int]: tool_names, resource_count, prompt_count = run_async(inspect_components) assert set(tool_names) == { + "pc_source_add", + "pc_source_capabilities", + "pc_source_instance_set_enabled", + "pc_source_instances", + "pc_source_status", + "pc_source_target_set_enabled", "generate_experience", "get_experience", "propose_experience", diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 83e3b1171b..017f748591 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -60,9 +60,10 @@ def invoke(action, *options): "v1_4_1_003", "v1_4_1_004", "v1_4_1_005", + "v1_4_1_006", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_4_1_005", "name": "v1_4_1_005", "software_version": "1.4.1", "sequence": 5} + target = {"revision": "v1_4_1_006", "name": "v1_4_1_006", "software_version": "1.4.1", "sequence": 6} assert plan["target_migration"] == target applied = invoke( @@ -77,7 +78,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -88,6 +89,7 @@ def invoke(action, *options): "s1402", "v1_4_1_004", "v1_4_1_005", + "v1_4_1_006", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index deda3e6d2b..68f737b4c0 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_005" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_006" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_source_management.py b/tests/test_source_management.py new file mode 100644 index 0000000000..193d4af240 --- /dev/null +++ b/tests/test_source_management.py @@ -0,0 +1,924 @@ +# 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. + +"""HTTP/MCP integration and fault recovery for the Source management boundary.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import socket +import sys +from contextlib import asynccontextmanager, contextmanager, suppress +from pathlib import Path + +import httpx +import pytest +import uvicorn +from fastmcp import Client +from fastmcp.exceptions import ToolError +from sse_starlette.sse import AppStatus + +from examples.source_plugins.tickets.definition import TicketCapture +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig as BuiltinRuntimeConfig +from powercontext.client import PowerContextClient, register_source_definitions +from powercontext.server.factory import create_server_app +from powercontext.server.settings import ( + AccessControlConfig, + BearerAuthConfig, + MetricsConfig, + ServerSettings, + SourceManagementConfig, +) +from powercontext.source_management.config import Endpoint, ManagerConfig, RuntimeConfig, configuration_digest +from powercontext.source_management.loading import load_runtime_plugin +from powercontext.source_management.manager import create_manager_app +from powercontext.source_management.models import InstanceRegistration, ManagementError +from powercontext.source_management.runtime import SourceRuntime +from powercontext.source_management.storage import Store +from tests.source_helpers import protected, wait_until + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples/source_plugins" + + +@pytest.fixture(scope="module") +def scenario_runner(): + # The scenarios share one event loop, as the HTTP and MCP service process does. + with asyncio.Runner() as runner: + yield runner + + +class FixtureServer(uvicorn.Server): + @contextmanager + def capture_signals(self): + # Several isolated services share pytest's process; its signal handler owns shutdown. + yield + + +@asynccontextmanager +async def listen(app): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen() + url = f"http://127.0.0.1:{sock.getsockname()[1]}" + service = FixtureServer(uvicorn.Config(app, log_level="error", lifespan="off")) + async with app.router.lifespan_context(app): + task = asyncio.create_task(service.serve(sockets=[sock])) + try: + async with asyncio.timeout(10): + while not service.started: + if task.done(): + await task + await asyncio.sleep(0.01) + yield url + finally: + service.should_exit = True + await asyncio.wait_for(task, 10) + sock.close() + + +@asynccontextmanager +async def source_server(tmp_path, *, management=True): + # SSE keeps process-level shutdown state; a fresh test Server represents a fresh service process. + AppStatus.should_exit = False + names = ("deployer", "host", "reader", "manager", "worker", "worker-two") + tokens = {name: f"source-test-{name}" for name in names} + identities = protected( + tmp_path / "identities.json", + json.dumps({ + "identities": [ + { + "credential_id": name, + "token_digest": hashlib.sha256(tokens[name].encode()).hexdigest(), + "subject": {"type": "service", "id": name}, + } + for name in names + ] + }), + ) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"), + auth=BearerAuthConfig(identities_file=identities, administrators=("service:deployer",)), + access=AccessControlConfig(mode="enforced"), + metrics=MetricsConfig(enabled=False), + runtime=BuiltinRuntimeConfig(artifact_processing_families=()), + source_management=SourceManagementConfig( + manager_subject="service:manager" if management else None, + lease_seconds=3, + request_seconds=2, + ), + ), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + listen(app) as url, + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}) as admin, + ): + response = await admin.post( + "/v1/scopes", json={"title": "Sources", "summary": "Managed", "idempotency_key": "main"} + ) + assert response.status_code == 201, response.text + scope = response.json()["scope_id"] + for name in ("host", "reader", "worker", "worker-two"): + response = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": name}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.viewer" if name == "reader" else "scope.contributor", + "idempotency_key": "bind-" + name, + }, + ) + assert response.status_code == 201, response.text + yield app, url, tokens, scope + + +async def tool(client, name, arguments): + result = await client.call_tool(name, arguments) + assert not result.is_error, result.content + return result.data + + +async def retry_tool(client, name, arguments): + async with asyncio.timeout(15): + while True: + try: + return await tool(client, name, arguments) + except ToolError as error: + if not any( + code in str(error) + for code in ("result-unconfirmed", "management-unavailable", "instance-unavailable") + ): + raise + await asyncio.sleep(0.1) + + +async def status(client, scope, target): + return await tool(client, "pc_source_status", {"scope_id": scope, **target}) + + +@asynccontextmanager +async def management(tmp_path, url, tokens): + credential = protected(tmp_path / "manager-token", tokens["manager"]) + app = create_manager_app( + ManagerConfig( + server=Endpoint(url=url, credential_file=credential), + deployment_id="powercontext", + manager_id="sources", + state_dir=tmp_path / "management", + request_seconds=2, + lease_seconds=3, + ) + ) + async with listen(app) as manager_url: + yield app, manager_url + + +async def configured_runtime( + tmp_path, + server_url, + manager_url, + tokens, + scope, + *, + identifier="tickets-one", + worker="worker", + plugin=str(EXAMPLES / "tickets"), + config=None, + default_capabilities=None, +): + config = config or {"poll_interval": 0.1} + digest = configuration_digest(plugin, config, {}) + plugin_dir = tmp_path / identifier + plugin_dir.mkdir() + (plugin_dir / "tickets.json").write_text(json.dumps([{"id": "A", "body": "Alpha"}, {"id": "B", "body": "Bravo"}])) + pc_credential = protected(plugin_dir / "pc-token", tokens[worker]) + instance_token = "instance-test-" + identifier + management_credential = protected(plugin_dir / "instance-token", instance_token) + async with httpx.AsyncClient( + base_url=server_url, headers={"Authorization": "Bearer " + tokens["deployer"]} + ) as client: + await wait_until( + lambda: client.post( + "/v1/source-management/configure", + json={ + "registration": InstanceRegistration( + instance_id=identifier, + credential_digest=hashlib.sha256(instance_token.encode()).hexdigest(), + execution_location=str(plugin_dir), + scope_ids=(scope,), + public_principals=("service:host", "service:reader"), + default_capabilities=(("tickets.item",) if identifier == "tickets-one" else ()) + if default_capabilities is None + else default_capabilities, + configuration_digest=digest, + ).model_dump(mode="json") + }, + ), + lambda response: response.status_code == 200, + ) + runtime_config = RuntimeConfig( + instance_id=identifier, + plugin=plugin, + plugin_dir=plugin_dir, + management=Endpoint(url=manager_url, credential_file=management_credential), + server=Endpoint(url=server_url, credential_file=pc_credential), + state_dir=plugin_dir / "state", + execution_location=str(plugin_dir), + configuration_digest=digest, + config=config, + ) + async with PowerContextClient(server_url, token=tokens["deployer"]) as deployer: + await register_source_definitions(client=deployer, registry=load_runtime_plugin(runtime_config).registry) + return runtime_config + + +@asynccontextmanager +async def running(config): + runtime = SourceRuntime(config) + task = asyncio.create_task(runtime.run()) + try: + yield runtime + if task.done(): + await task + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +@asynccontextmanager +async def runtime_process(config): + path = protected(config.plugin_dir / "runtime-process.json", config.model_dump_json(indent=2)) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "powercontext.source_management.cli", + "runtime", + "run", + "--config", + str(path), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + yield process + finally: + if process.returncode is None: + process.terminate() + try: + _stdout, stderr = await asyncio.wait_for(process.communicate(), 15) + except TimeoutError: + process.kill() + await process.wait() + raise + assert process.returncode in (0, -9), stderr.decode() + + +async def discover_running(host, scope, name="tickets.item"): + return await wait_until( + lambda: tool(host, "pc_source_capabilities", {"scope_id": scope}), + lambda r: any(c["name"] == name and c["instances"][0]["actual"] == "running" for c in r["capabilities"]), + ) + + +def test_collect_dynamic_targets_and_pc_permissions(tmp_path, scenario_runner): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with running(config), Client(url + "/mcp", auth=tokens["host"]) as host: + await discover_running(host, scope) + write_entry = tmp_path / "ticket_write.py" + write_entry.write_text("""from powercontext.source_management import Capability +from examples.source_plugins.tickets.definition import TicketCapture, TicketDefinition +definitions = (TicketDefinition(),) +capabilities = (Capability(name="tickets.item", version="2", kind="write", description="Write a ticket snapshot", + definition_name="example-ticket", definition_version="1", input_schema=TicketCapture.model_json_schema()),) +""") + async with httpx.AsyncClient( + base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]} + ) as admin: + loaded = await admin.post( + "/v1/source-management/write/load", json={"id": "ticket-write", "entry": str(write_entry)} + ) + assert loaded.status_code == 200, loaded.text + written = await tool( + host, + "pc_source_add", + { + "scope_id": scope, + "capability": "tickets.item", + "capability_version": "2", + "input": { + "namespace": "host", + "project": "test", + "ticket_id": "W", + "title": "", + "body": "Write", + }, + }, + ) + assert written["kind"] == "write" and written["result"]["source_refs"] + args = { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + } + a, repeated = await asyncio.gather(tool(host, "pc_source_add", args), tool(host, "pc_source_add", args)) + assert a["target_id"] == repeated["target_id"] + saved = await wait_until( + lambda: status(host, scope, {"target_id": a["target_id"]}), lambda r: bool(r["source_refs"]) + ) + assert saved["last_submitted_at"] + b = await tool(host, "pc_source_add", {**args, "input": {"id": "B"}}) + await wait_until( + lambda: status(host, scope, {"target_id": b["target_id"]}), lambda r: bool(r["source_refs"]) + ) + stop = {"scope_id": scope, "target_id": b["target_id"], "enabled": False} + await tool(host, "pc_source_target_set_enabled", stop) + await tool(host, "pc_source_target_set_enabled", stop) + await wait_until( + lambda: status(host, scope, {"target_id": b["target_id"]}), lambda r: r["tracking"] == "stopped" + ) + repeated = await tool(host, "pc_source_add", {**args, "input": {"id": "B"}}) + assert repeated["tracking"] == "stopped" + async with Client(url + "/mcp", auth=tokens["worker-two"]) as other: + # Another contributor can manage the same Scope's target without a manager ACL. + resumed = await tool(other, "pc_source_target_set_enabled", {**stop, "enabled": True}) + assert resumed["tracking"] == "enabled" + async with Client(url + "/mcp", auth=tokens["reader"]) as reader: + assert (await status(reader, scope, {"target_id": a["target_id"]}))["source_refs"] + with pytest.raises(ToolError): + await tool(reader, "pc_source_target_set_enabled", stop) + with pytest.raises(ToolError): + await tool( + host, "pc_source_instance_set_enabled", {"instance_id": config.instance_id, "enabled": False} + ) + + scenario_runner.run(scenario()) + + +def test_collect_status_keeps_only_the_last_received_submission(tmp_path, scenario_runner): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with ( + running(config) as runtime, + Client(url + "/mcp", auth=tokens["host"]) as host, + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["host"]}) as http, + ): + await discover_running(host, scope) + arguments = { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "missing"}, + } + added = await tool(host, "pc_source_add", arguments) + target_id = added["target_id"] + assert "source_refs" not in added and "last_submitted_at" not in added + initial = await status(host, scope, {"target_id": target_id}) + assert initial["source_refs"] == [] and initial.get("last_submitted_at") is None + references = [ + {"source_type": "example-ticket", "source_id": "a" * 256}, + {"source_type": "example-chat", "source_id": '界\\"\n' * 63 + "xxxx"}, + ] + # Each report confirms one item. Arrival order, not the Runtime clock, selects the state. + # Reporting an earlier Source again is still a new confirmation, not a history union. + reports = [ + { + "target_id": target_id, + "source_refs": [reference], + "submitted_at": f"2026-10-26T00:00:0{3 - index}+00:00", + } + for index, reference in enumerate([*references, references[0]]) + ] + for report in reports: + assert await runtime.post("results", report) == {"confirmed": True} + response = await http.post( + "/v1/source-management/status", json={"scope_id": scope, "target_id": target_id} + ) + assert response.status_code == 200, response.text + repeated = await tool(host, "pc_source_add", arguments) + assert repeated == added + receipt = response.json() + assert receipt["target_id"] == target_id + assert receipt["source_refs"] == report["source_refs"] + assert receipt["last_submitted_at"] == report["submitted_at"] + assert receipt["tracking"] == added["tracking"] + repeated_http = await http.post("/v1/source-management/add", json=arguments) + assert repeated_http.status_code == 200, repeated_http.text + assert repeated_http.json() == added + with pytest.raises(ManagementError, match="instance request is invalid"): + await runtime.post("results", {**reports[-1], "source_refs": references}) + unchanged = await status(host, scope, {"target_id": target_id}) + assert unchanged["source_refs"] == reports[-1]["source_refs"] + assert unchanged["last_submitted_at"] == reports[-1]["submitted_at"] + store = Store(tmp_path / "management") + try: + persisted = store.get("target", target_id) + assert persisted is not None + assert persisted["source_refs"] == reports[-1]["source_refs"] + assert persisted["last_submitted_at"] == reports[-1]["submitted_at"] + finally: + store.close() + + scenario_runner.run(scenario()) + + +def test_discovery_uses_scope_and_keeps_control_permissions(tmp_path, scenario_runner): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}) as admin, + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with ( + running(config), + Client(url + "/mcp", auth=tokens["host"]) as host, + Client(url + "/mcp", auth=tokens["reader"]) as reader, + Client(url + "/mcp", auth=tokens["deployer"]) as deployer, + ): + await discover_running(host, scope) + listed = await tool(host, "pc_source_instances", {"scope_id": scope}) + assert [i["id"] for i in listed["instances"]] == [config.instance_id] + filtered = await tool( + host, "pc_source_capabilities", {"scope_id": scope, "instance_id": config.instance_id} + ) + assert [c["name"] for c in filtered["capabilities"]] == ["tickets.item"] + query = {"scope_id": scope, "capability": "tickets.item", "instance_id": config.instance_id} + capabilities = await tool(host, "pc_source_capabilities", query) + assert [c["name"] for c in capabilities["capabilities"]] == ["tickets.item"] + assert capabilities["capabilities"][0]["kind"] == "collect" + instances = await tool(host, "pc_source_instances", query) + assert [i["id"] for i in instances["instances"]] == [config.instance_id] + assert instances["instances"][0]["can_write"] + for name in ("pc_source_capabilities", "pc_source_instances"): + visible = await tool(reader, name, query) + assert visible["capabilities"] and not visible["capabilities"][0]["can_write"] + definition = next(t for t in await host.list_tools() if t.name == name) + assert "operation" not in definition.inputSchema["properties"] + for endpoint in ("capabilities", "instances"): + response = await admin.post( + "/v1/source-management/" + endpoint, json={"scope_id": scope, "operation": "add"} + ) + assert response.status_code == 422 + + hidden_scopes = [] + for key in ("unserved", "unauthorized"): + response = await admin.post( + "/v1/scopes", json={"title": key, "summary": "Discovery boundary", "idempotency_key": key} + ) + assert response.status_code == 201, response.text + hidden_scopes.append(response.json()["scope_id"]) + unserved, unauthorized = hidden_scopes + response = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": "host"}, + "resource": {"type": "scope", "scope_id": unserved}, + "role": "scope.contributor", + "idempotency_key": "unserved-host", + }, + ) + assert response.status_code == 201, response.text + for name in ("pc_source_capabilities", "pc_source_instances"): + empty = await tool(host, name, {**query, "scope_id": unserved}) + assert empty["capabilities"] == [] + with pytest.raises(ToolError): + await tool(host, name, {**query, "scope_id": unauthorized}) + + control = {"instance_id": config.instance_id, "view": "control"} + state = await tool(deployer, "pc_source_instances", control) + assert state["instances"] == [ + {"instance_id": config.instance_id, "enabled": True, "actual": "running", "availability": "online"} + ] + for client in (host, reader): + with pytest.raises(ToolError): + await tool(client, "pc_source_instances", control) + assert (await admin.post("/v1/source-management/instances", json={})).status_code == 400 + assert ( + await admin.post("/v1/source-management/instances", json={**control, "scope_id": scope}) + ).status_code == 400 + + scenario_runner.run(scenario()) + + +def test_builtin_write_uses_source_identity_without_management(tmp_path, scenario_runner): + async def scenario(): + async with ( + source_server(tmp_path, management=False) as (_app, url, tokens, scope), + Client(url + "/mcp", auth=tokens["host"]) as host, + ): + args = { + "scope_id": scope, + "capability": "content.inline", + "capability_version": "2", + "input": {"source_id": "doc-a", "text": "Alpha"}, + } + first = await tool(host, "pc_source_add", args) + second = await tool(host, "pc_source_add", args) + assert first["result"]["source_refs"] == second["result"]["source_refs"] + assert "request_id" not in first and first["kind"] == "write" + with pytest.raises(ToolError): + await tool(host, "pc_source_add", {**args, "input": {"source_id": "doc-a", "text": "Different"}}) + + scenario_runner.run(scenario()) + + +def test_collect_continues_offline_and_reconnects_without_restarting(tmp_path, scenario_runner, monkeypatch): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with ( + Client(url + "/mcp", auth=tokens["host"]) as host, + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["worker"]}) as worker, + ): + args = { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + } + checkpoint_args = { + "binding": { + "scope_id": scope, + "binding_id": config.instance_id, + "connector_name": "tickets", + "connector_version": "1", + } + } + async with running(config) as runtime: + await discover_running(host, scope) + target = (await tool(host, "pc_source_add", args))["target_id"] + await wait_until( + lambda: status(host, scope, {"target_id": target}), lambda r: bool(r["source_refs"]) + ) + original = runtime.post + offline = False + + async def exchange(operation, value): + if offline: + raise httpx.ConnectError("simulated management outage") # noqa: TRY003 + return await original(operation, value) + + monkeypatch.setattr(runtime, "post", exchange) + offline = True + await asyncio.sleep(0.2) + (config.plugin_dir / "tickets.json").write_text('[{"id":"A","body":"Changed while disconnected"}]') + expected = TicketCapture( + namespace="tickets", + project="default", + ticket_id="A", + title="", + body="Changed while disconnected", + ).snapshot_id() + await wait_until( + lambda: worker.post("/v1/connector-checkpoints/get", json=checkpoint_args), + lambda r: ( + r.status_code == 200 + and (r.json().get("checkpoint") or {}).get("seen", {}).get(target) == expected + ), + ) + stopped = await tool( + host, "pc_source_target_set_enabled", {"scope_id": scope, "target_id": target, "enabled": False} + ) + assert stopped["tracking"] == "stopping" + offline = False + await wait_until( + lambda: status(host, scope, {"target_id": target}), lambda r: r["tracking"] == "stopped" + ) + + scenario_runner.run(scenario()) + + +def test_runtime_process_restart_reruns_enabled_apply(tmp_path, scenario_runner): + async def scenario(): + plugin = tmp_path / "single.py" + plugin.write_text("""from examples.source_plugins.tickets import Config, capabilities, connector_name, connector_version, definitions, identify_target, plugin_api_version, TicketConnector +async def apply(ctx, config): + ctx.connect(TicketConnector(ctx, config)) + path = ctx.plugin_dir / "starts.txt" + count = int(path.read_text()) if path.exists() else 0 + path.write_text(str(count + 1)) +""") + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope, plugin=str(plugin)) + async with httpx.AsyncClient( + base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]} + ) as admin: + + async def control(): + return await admin.post( + "/v1/source-management/instances", json={"instance_id": config.instance_id, "view": "control"} + ) + + async with runtime_process(config): + await wait_until( + control, lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "complete" + ) + assert (config.plugin_dir / "starts.txt").read_text() == "1" + await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": config.instance_id, "enabled": True}, + ) + await asyncio.sleep(0.3) + assert (config.plugin_dir / "starts.txt").read_text() == "1" + async with runtime_process(config): + await wait_until( + control, + lambda r: ( + r.status_code == 200 + and r.json()["instances"][0]["actual"] == "complete" + and (config.plugin_dir / "starts.txt").read_text() == "2" + ), + ) + + scenario_runner.run(scenario()) + + +def test_reenable_survives_lost_start_confirmation(tmp_path, scenario_runner, monkeypatch): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with ( + running(config) as runtime, + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}) as admin, + ): + + async def control(): + return await admin.post( + "/v1/source-management/instances", json={"instance_id": config.instance_id, "view": "control"} + ) + + await wait_until( + control, lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running" + ) + response = await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": config.instance_id, "enabled": False}, + ) + assert response.status_code == 200 + await wait_until(control, lambda r: r.json()["instances"][0]["actual"] == "stopped") + original = runtime.post + lose = True + + async def exchange(operation, value): + nonlocal lose + result = await original(operation, value) + if operation == "execution" and value.get("status") == "running" and lose: + lose = False + raise httpx.ConnectError("lost start confirmation") # noqa: TRY003 + return result + + monkeypatch.setattr(runtime, "post", exchange) + response = await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": config.instance_id, "enabled": True}, + ) + assert response.status_code == 200 + await wait_until( + control, lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running" + ) + async with Client(url + "/mcp", auth=tokens["host"]) as host: + added = await retry_tool( + host, + "pc_source_add", + { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + }, + ) + await wait_until( + lambda: status(host, scope, {"target_id": added["target_id"]}), + lambda result: bool(result["source_refs"]), + ) + + scenario_runner.run(scenario()) + + +def test_managed_plugin_can_collect_autonomously_without_targets(tmp_path, scenario_runner): + async def scenario(): + plugin = tmp_path / "autonomous.py" + plugin.write_text("""from examples.source_plugins.tickets import Config, capabilities, connector_name, connector_version, definitions, identify_target, plugin_api_version, TicketCapture, ticket_definition +from powercontext.sources import ConnectorRunCompletion, ConnectorRunStatus +class Connector: + name = connector_name + version = connector_version + source_definitions = frozenset({ticket_definition.name}) + async def run(self, session): + await session.submit("ticket", ticket_definition.name, TicketCapture(namespace="tickets", project="default", ticket_id="A", title="", body="Autonomous")) + return ConnectorRunCompletion(status=ConnectorRunStatus.COMPLETE, checkpoint={"done": True}) +async def apply(ctx, config): + ctx.connect(Connector()) + await ctx.run_batch(scope_id=config.file, targets=()) +""") + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime( + tmp_path, url, manager_url, tokens, scope, plugin=str(plugin), config={"file": scope} + ) + async with ( + running(config), + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["worker"]}) as worker, + ): + binding = { + "scope_id": scope, + "binding_id": config.instance_id, + "connector_name": "tickets", + "connector_version": "1", + } + result = await wait_until( + lambda: worker.post("/v1/connector-checkpoints/get", json={"binding": binding}), + lambda r: r.status_code == 200 and r.json()["checkpoint"] == {"done": True}, + ) + assert result.status_code == 200 + + scenario_runner.run(scenario()) + + +@pytest.mark.parametrize("initially_enabled", [False, True]) +def test_explicit_update_requires_stopped_state_and_never_enables(tmp_path, scenario_runner, initially_enabled): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens), + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}) as admin, + ): + registration = { + "instance_id": "update-test", + "credential_digest": hashlib.sha256(b"instance-token").hexdigest(), + "execution_location": "test", + "scope_ids": [scope], + "configuration_digest": "original", + "enabled": initially_enabled, + } + await wait_until( + lambda: admin.post("/v1/source-management/configure", json={"registration": registration}), + lambda r: r.status_code == 200, + ) + updated = {k: v for k, v in registration.items() if k != "enabled"} + if not initially_enabled: + updated["configuration_digest"] = "replacement" + response = await admin.post( + "/v1/source-management/configure", json={"registration": updated, "update": True} + ) + assert response.status_code == (409 if initially_enabled else 200), response.text + state = await admin.post( + "/v1/source-management/instances", json={"instance_id": "update-test", "view": "control"} + ) + assert state.status_code == 200, state.text + assert state.json()["instances"][0]["enabled"] is initially_enabled + if not initially_enabled: + assert state.json()["instances"][0]["actual"] == "stopped" + + scenario_runner.run(scenario()) + + +def test_reconnect_preserves_capabilities_required_by_stopped_targets(tmp_path, scenario_runner): + async def scenario(): + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime(tmp_path, url, manager_url, tokens, scope) + async with ( + running(config), + Client(url + "/mcp", auth=tokens["host"]) as host, + httpx.AsyncClient( + base_url=manager_url, + headers={"Authorization": "Bearer " + config.management.credential_file.read_text()}, + ) as instance, + ): + await discover_running(host, scope) + added = await tool( + host, + "pc_source_add", + { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + }, + ) + target = {"target_id": added["target_id"]} + await tool(host, "pc_source_target_set_enabled", {"scope_id": scope, **target, "enabled": False}) + await wait_until(lambda: status(host, scope, target), lambda r: r["tracking"] == "stopped") + prefix = f"/v1/source-management/instances/{config.instance_id}" + owner = await instance.post(prefix + "/owner", json={}) + assert owner.status_code == 200, owner.text + response = await instance.post( + prefix + "/connect", + json={ + "protocol": "3", + "execution_location": config.execution_location, + "configuration_digest": config.configuration_digest, + "owner": owner.json()["owner"], + "capabilities": [], + }, + ) + assert response.status_code == 409, response.text + assert response.json()["error"]["code"] == "capability-in-use" + assert (await status(host, scope, target))["tracking"] == "stopped" + + scenario_runner.run(scenario()) + + +def test_oversized_normalized_target_is_rejected_without_blocking_sync(tmp_path, scenario_runner): + async def scenario(): + plugin = tmp_path / "expanded.py" + plugin.write_text("""from pathlib import Path +from examples.source_plugins.tickets import Config, capabilities, connector_name, connector_version, definitions, plugin_api_version +from powercontext.source_management import TargetIdentity +import asyncio +async def apply(ctx, config): + await asyncio.Event().wait() +async def identify_target(request, config): + return TargetIdentity(target_key="stable", normalized_input={"id": Path(config.file).read_text()}) +""") + normalized = tmp_path / "normalized.txt" + normalized.write_text("x" * 2_094_552) + async with ( + source_server(tmp_path) as (_app, url, tokens, scope), + management(tmp_path, url, tokens) as (_manager, manager_url), + ): + config = await configured_runtime( + tmp_path, + url, + manager_url, + tokens, + scope, + plugin=str(plugin), + config={"file": str(normalized), "poll_interval": 0.1}, + ) + async with ( + running(config), + Client(url + "/mcp", auth=tokens["host"]) as host, + httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}) as admin, + ): + await discover_running(host, scope) + args = { + "scope_id": scope, + "instance_id": config.instance_id, + "capability": "tickets.item", + "capability_version": "1", + "input": {"id": "A"}, + } + with pytest.raises(ToolError, match="response-too-large"): + await tool(host, "pc_source_add", args) + normalized.write_text("A") + added = await tool(host, "pc_source_add", args) + assert added["tracking"] == "enabled" + stopped = await admin.post( + "/v1/source-management/instance-set-enabled", + json={"instance_id": config.instance_id, "enabled": False}, + ) + assert stopped.status_code == 200, stopped.text + await wait_until( + lambda: admin.post( + "/v1/source-management/instances", json={"instance_id": config.instance_id, "view": "control"} + ), + lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "stopped", + ) + + scenario_runner.run(scenario()) diff --git a/uv.lock b/uv.lock index 752d57ae61..8054eb4b84 100644 --- a/uv.lock +++ b/uv.lock @@ -2685,6 +2685,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -2726,6 +2811,7 @@ wheels = [ name = "powercontext" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "pydantic" }, { name = "rfc8785" }, { name = "typing-extensions" }, @@ -2739,7 +2825,6 @@ builtin = [ { name = "filelock" }, { name = "httpx" }, { name = "jieba" }, - { name = "jsonschema" }, { name = "packaging" }, { name = "pycasbin" }, { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, @@ -2760,7 +2845,6 @@ cli = [ { name = "httpx", extra = ["socks"] }, { name = "inquirerpy" }, { name = "jieba" }, - { name = "jsonschema" }, { name = "opentelemetry-api" }, { name = "packaging" }, { name = "platformdirs" }, @@ -2800,7 +2884,6 @@ ops = [ { name = "httpx" }, { name = "jieba" }, { name = "jinja2" }, - { name = "jsonschema" }, { name = "markdown-it-py" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -2826,7 +2909,6 @@ seekdb = [ { name = "filelock" }, { name = "httpx" }, { name = "jieba" }, - { name = "jsonschema" }, { name = "packaging" }, { name = "pycasbin" }, { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, @@ -2849,7 +2931,6 @@ server = [ { name = "httpx" }, { name = "jieba" }, { name = "jinja2" }, - { name = "jsonschema" }, { name = "markdown-it-py" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -2868,6 +2949,9 @@ server = [ { name = "tzdata" }, { name = "uvicorn" }, ] +source-images = [ + { name = "pillow" }, +] tracing-otlp = [ { name = "opentelemetry-exporter-otlp-proto-http" }, ] @@ -2878,7 +2962,7 @@ dev = [ { name = "langchain" }, { name = "langchain-core" }, { name = "langgraph" }, - { name = "powercontext", extra = ["cli", "code", "server"] }, + { name = "powercontext", extra = ["cli", "code", "server", "source-images"] }, { name = "powercontext-langchain" }, { name = "powercontext-langgraph" }, { name = "prek" }, @@ -2972,11 +3056,7 @@ requires-dist = [ { name = "jieba", marker = "extra == 'server'", specifier = "==0.42.1" }, { name = "jinja2", marker = "extra == 'ops'", specifier = ">=3.1.6,<4" }, { name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.6,<4" }, - { name = "jsonschema", marker = "extra == 'builtin'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'cli'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'ops'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'seekdb'", specifier = ">=4.23,<5" }, - { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23,<5" }, + { name = "jsonschema", specifier = ">=4.23,<5" }, { name = "markdown-it-py", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "markdown-it-py", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "opentelemetry-api", marker = "extra == 'cli'", specifier = ">=1.30,<2" }, @@ -2992,6 +3072,7 @@ requires-dist = [ { name = "packaging", marker = "extra == 'ops'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'seekdb'", specifier = ">=24,<27" }, { name = "packaging", marker = "extra == 'server'", specifier = ">=24,<27" }, + { name = "pillow", marker = "extra == 'source-images'", specifier = ">=11,<13" }, { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'ops'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, @@ -3060,7 +3141,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "tracing-otlp"] +provides-extras = ["builtin", "cli", "client", "code", "ops", "seekdb", "server", "source-images", "tracing-otlp"] [package.metadata.requires-dev] dev = [ @@ -3070,6 +3151,7 @@ dev = [ { name = "langgraph", specifier = ">=1.2,<2" }, { name = "powercontext", extras = ["cli", "server"] }, { name = "powercontext", extras = ["code"] }, + { name = "powercontext", extras = ["source-images"] }, { name = "powercontext-langchain", editable = "integrations/langchain" }, { name = "powercontext-langgraph", editable = "integrations/langgraph" }, { name = "prek", specifier = ">=0.2.0" },