From caf8a56a2bbb683d47bc644ee83c82f07f4d681e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 19 Oct 2026 16:35:47 +0800 Subject: [PATCH 01/15] feat(source): add managed source plugins --- docs/en/docs/workflows/meta.json | 1 + docs/en/docs/workflows/source-plugins.md | 174 ++++ docs/zh/docs/workflows/meta.json | 1 + docs/zh/docs/workflows/source-plugins.md | 172 ++++ examples/source_plugins/README.zh.md | 67 ++ examples/source_plugins/images/__init__.py | 19 + examples/source_plugins/images/definition.py | 64 ++ examples/source_plugins/images/write.yml | 2 + .../source_plugins/ticket_chat/__init__.py | 139 +++ .../source_plugins/ticket_chat/definition.py | 113 +++ .../source_plugins/ticket_chat/events.json | 47 + .../ticket_chat/powercontext.yml | 13 + examples/source_plugins/tickets/__init__.py | 116 +++ examples/source_plugins/tickets/definition.py | 89 ++ .../source_plugins/tickets/powercontext.yml | 6 + examples/source_plugins/tickets/tickets.json | 13 + examples/source_plugins/tickets/write.yml | 2 + integrations/capabilities.toml | 18 + .../powercontext/src/operations.generated.ts | 14 + .../powercontext/src/operations.generated.ts | 14 + .../powercontext/src/operations.generated.ts | 14 + openapi/powercontext.yaml | 948 +++++++++++++++++- pyproject.toml | 6 +- .../builtin/persistence/records.py | 34 +- .../builtin/persistence/sources.py | 3 + .../builtin/persistence/tables.py | 8 + src/powercontext/builtin/records.py | 2 +- src/powercontext/cli/app.py | 3 + src/powercontext/client/__init__.py | 3 +- src/powercontext/client/ingestion.py | 110 +- src/powercontext/http/_generated/models.py | 256 ++++- .../http/_generated/operations.py | 377 +++++++ src/powercontext/http/_generated/schema.py | 813 ++++++++++++++- src/powercontext/server/app.py | 11 +- src/powercontext/server/authentication.py | 71 +- src/powercontext/server/factory.py | 44 +- src/powercontext/server/mcp.py | 9 + src/powercontext/server/settings.py | 28 +- .../source_management/__init__.py | 5 + .../source_management/background.py | 213 ++++ .../source_management/bootstrap.py | 67 ++ src/powercontext/source_management/channel.py | 129 +++ src/powercontext/source_management/cli.py | 219 ++++ src/powercontext/source_management/config.py | 102 ++ src/powercontext/source_management/context.py | 149 +++ .../source_management/deployment.py | 366 +++++++ src/powercontext/source_management/durable.py | 79 ++ src/powercontext/source_management/errors.py | 22 + src/powercontext/source_management/gateway.py | 423 ++++++++ .../source_management/inspection.py | 24 + src/powercontext/source_management/loading.py | 92 ++ src/powercontext/source_management/manager.py | 699 +++++++++++++ src/powercontext/source_management/models.py | 122 +++ .../source_management/remote_deployment.py | 147 +++ src/powercontext/source_management/runtime.py | 339 +++++++ src/powercontext/source_management/service.py | 177 ++++ src/powercontext/source_management/storage.py | 74 ++ src/powercontext/source_management/targets.py | 106 ++ src/powercontext/source_management/write.py | 254 +++++ src/powercontext/sources/connectors.py | 26 +- tests/e2e/source_collect_helpers.py | 95 ++ tests/e2e/test_source_collect.py | 172 ++++ tests/e2e/test_source_deployment.py | 303 ++++++ tests/e2e/test_source_write.py | 286 ++++++ tests/source_helpers.py | 179 ++++ tests/test_connectors.py | 21 + tests/test_source_management.py | 777 ++++++++++++++ uv.lock | 102 +- 68 files changed, 9520 insertions(+), 73 deletions(-) create mode 100644 docs/en/docs/workflows/source-plugins.md create mode 100644 docs/zh/docs/workflows/source-plugins.md create mode 100644 examples/source_plugins/README.zh.md create mode 100644 examples/source_plugins/images/__init__.py create mode 100644 examples/source_plugins/images/definition.py create mode 100644 examples/source_plugins/images/write.yml create mode 100644 examples/source_plugins/ticket_chat/__init__.py create mode 100644 examples/source_plugins/ticket_chat/definition.py create mode 100644 examples/source_plugins/ticket_chat/events.json create mode 100644 examples/source_plugins/ticket_chat/powercontext.yml create mode 100644 examples/source_plugins/tickets/__init__.py create mode 100644 examples/source_plugins/tickets/definition.py create mode 100644 examples/source_plugins/tickets/powercontext.yml create mode 100644 examples/source_plugins/tickets/tickets.json create mode 100644 examples/source_plugins/tickets/write.yml create mode 100644 src/powercontext/source_management/__init__.py create mode 100644 src/powercontext/source_management/background.py create mode 100644 src/powercontext/source_management/bootstrap.py create mode 100644 src/powercontext/source_management/channel.py create mode 100644 src/powercontext/source_management/cli.py create mode 100644 src/powercontext/source_management/config.py create mode 100644 src/powercontext/source_management/context.py create mode 100644 src/powercontext/source_management/deployment.py create mode 100644 src/powercontext/source_management/durable.py create mode 100644 src/powercontext/source_management/errors.py create mode 100644 src/powercontext/source_management/gateway.py create mode 100644 src/powercontext/source_management/inspection.py create mode 100644 src/powercontext/source_management/loading.py create mode 100644 src/powercontext/source_management/manager.py create mode 100644 src/powercontext/source_management/models.py create mode 100644 src/powercontext/source_management/remote_deployment.py create mode 100644 src/powercontext/source_management/runtime.py create mode 100644 src/powercontext/source_management/service.py create mode 100644 src/powercontext/source_management/storage.py create mode 100644 src/powercontext/source_management/targets.py create mode 100644 src/powercontext/source_management/write.py create mode 100644 tests/e2e/source_collect_helpers.py create mode 100644 tests/e2e/test_source_collect.py create mode 100644 tests/e2e/test_source_deployment.py create mode 100644 tests/e2e/test_source_write.py create mode 100644 tests/source_helpers.py create mode 100644 tests/test_source_management.py diff --git a/docs/en/docs/workflows/meta.json b/docs/en/docs/workflows/meta.json index 75dcaac636..c63c7b2b1b 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..8d9c9d0959 --- /dev/null +++ b/docs/en/docs/workflows/source-plugins.md @@ -0,0 +1,174 @@ +--- +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. On Linux, the manager and Runtimes use background processes by default. Set `source_management.process_mode="local-service"` explicitly for systemd user services. + +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 returns a target identity, not proof that external content has been saved. Status reports confirmed Source submissions. Repeated add reuses the same target without 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 | +| --- | --- | +| 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/workflows/meta.json b/docs/zh/docs/workflows/meta.json index 119c3152d6..4b045e18c8 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..d9fd80e524 --- /dev/null +++ b/docs/zh/docs/workflows/source-plugins.md @@ -0,0 +1,172 @@ +--- +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` 显式加载。Linux 上管理服务和 Runtime 默认使用后台进程;`source_management.process_mode="local-service"` 显式选择 systemd 用户服务。 + +实例目录就近放置 `__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 返回目标身份,不能据此认定外部资料已保存。状态中的 Source 引用是已确认的提交结果。相同目标重复 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 或删除插件目录也不等于卸载独立服务。 + +## 常见问题 + +| 现象 | 检查和处理 | +| --- | --- | +| 管理服务断线 | 已同步的 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/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..a026429e3f --- /dev/null +++ b/examples/source_plugins/images/__init__.py @@ -0,0 +1,19 @@ +"""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..971eb394dc --- /dev/null +++ b/examples/source_plugins/images/definition.py @@ -0,0 +1,64 @@ +"""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..1eb1320561 --- /dev/null +++ b/examples/source_plugins/images/write.yml @@ -0,0 +1,2 @@ +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..96646b47ce --- /dev/null +++ b/examples/source_plugins/ticket_chat/__init__.py @@ -0,0 +1,139 @@ +# 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 https://www.apache.org/licenses/LICENSE-2.0 + +"""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..935bd008d5 --- /dev/null +++ b/examples/source_plugins/ticket_chat/definition.py @@ -0,0 +1,113 @@ +# 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 https://www.apache.org/licenses/LICENSE-2.0 + +"""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..c867a42844 --- /dev/null +++ b/examples/source_plugins/ticket_chat/powercontext.yml @@ -0,0 +1,13 @@ +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..45decb7b36 --- /dev/null +++ b/examples/source_plugins/tickets/__init__.py @@ -0,0 +1,116 @@ +"""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.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 + await ctx.run_batch(scope_id=scope_id, targets=selected) + 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..583705f58f --- /dev/null +++ b/examples/source_plugins/tickets/definition.py @@ -0,0 +1,89 @@ +# 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 https://www.apache.org/licenses/LICENSE-2.0 + +"""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..02bab467a7 --- /dev/null +++ b/examples/source_plugins/tickets/powercontext.yml @@ -0,0 +1,6 @@ +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..7fb64eb460 --- /dev/null +++ b/examples/source_plugins/tickets/write.yml @@ -0,0 +1,2 @@ +id: local-tickets +entry: . diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index d78fffda62..68fdba3483 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -66,6 +66,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 = "create_work_contract" capabilities = ["work_contract"] [[toolsets.tools]] diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 0988d3c908..91da4a3dcf 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -122,6 +122,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/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 0988d3c908..91da4a3dcf 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -122,6 +122,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 0988d3c908..91da4a3dcf 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -122,6 +122,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 df9235287d..b1ab4e5fd5 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3242,7 +3242,9 @@ paths: required: true schema: type: string - enum: [content] + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' - name: source_id in: path required: true @@ -3250,7 +3252,7 @@ paths: type: string minLength: 1 maxLength: 256 - pattern: '^[\x21-\x7E]+$' + pattern: '.*\S.*' responses: "200": description: The exact Source. @@ -4249,6 +4251,551 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" + /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: >- + 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: >- + 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: @@ -9546,12 +10093,14 @@ 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 maxLength: 256 - pattern: '^[\x21-\x7E]+$' + pattern: '.*\S.*' content: description: Persisted canonical JSON content. position: @@ -10199,3 +10748,394 @@ components: items: $ref: "#/components/schemas/AccessAuditEvent" next_cursor: {type: string, maxLength: 2048, nullable: true} + 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: &id001 + 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: *id001 + SourceCapabilityResponse: + type: object + additionalProperties: false + required: + - capabilities + - management + properties: + capabilities: + type: array + items: *id001 + management: + type: string + enum: + - not-configured + - online + - unavailable + SourceInstancesResponse: + type: object + additionalProperties: false + required: + - instances + properties: + instances: + type: array + items: *id001 + capabilities: + type: array + items: *id001 + management: + type: string + enum: + - not-configured + - online + - unavailable + SourceCollectionResult: + type: object + additionalProperties: false + required: + - outcome + - source_refs + properties: + outcome: + type: string + enum: + - saved + - unchanged + source_refs: + type: array + items: + $ref: '#/components/schemas/SourceTypeReference' + SourceManagementReceipt: + type: object + additionalProperties: false + properties: + 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: + type: array + items: + $ref: '#/components/schemas/SourceTypeReference' + last_submitted_at: + type: string + nullable: true + SourceInstanceStatus: + type: object + additionalProperties: false + required: + - instance_id + properties: + targets_initialized: + type: boolean + target_id: + type: string + instance_id: + type: string + minLength: 1 + 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: + - protocol + - deployment_id + - manager_id + properties: + protocol: + type: string + 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: + - generation + properties: + generation: + type: string + minLength: 1 + maxLength: 256 + message_id: + type: string + minLength: 1 + maxLength: 256 + response: *id001 + catalog: *id001 + 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: *id001 + SourceInstanceExchangeRequest: + type: object + additionalProperties: false + properties: + generation: &id002 + type: string + minLength: 1 + maxLength: 256 + protocol: *id002 + message_id: *id002 + execution_location: *id002 + configuration_digest: *id002 + run_id: *id002 + execution: *id002 + target_id: *id002 + capabilities: + type: array + maxItems: 64 + items: &id003 + type: object + additionalProperties: true + response: *id003 + owner: *id003 + status: + type: string + enum: + - running + - complete + - completed + - pending + - failed + - stopped + - cleanup-failed + source_refs: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/SourceTypeReference' + error: + type: string + maxLength: 1024 + targets: + type: array + maxItems: 256 + items: *id003 + 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_id + - enabled + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 256 + enabled: + type: boolean + SourceInstancesQuery: + type: object + additionalProperties: false + 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: + type: object + additionalProperties: true + view: + type: string + enum: + - discovery + - control + default: discovery diff --git a/pyproject.toml b/pyproject.toml index 27df21b23d..1103700f40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ readme = "README.md" 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", @@ -40,7 +41,6 @@ classifiers = [ builtin = [ "aiosqlite>=0.22,<1", "apscheduler>=3.11,<4", - "jsonschema>=4.23,<5", "packaging>=24,<27", "pydantic-ai-slim[anthropic,openai]>=2.27.1,<3", "pydantic-settings>=2.7,<3", @@ -87,6 +87,9 @@ cli = [ "pyyaml>=6,<7", "typer>=0.16,<1", ] +source-images = [ + "pillow>=11,<13", +] [project.scripts] powercontext = "powercontext.cli.app:main" @@ -118,6 +121,7 @@ notebooks = [ "playwright>=1.55,<2", ] dev = [ + "powercontext[source-images]", "datamodel-code-generator==0.64.0", "powercontext[cli,server]", "powercontext-langchain", diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index cea4b54cda..ed3ccc36d9 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -48,6 +48,7 @@ ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE, MEMORY_ENTRY_VERSIONS_TABLE, + SOURCE_DEFINITION_MANIFESTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) @@ -81,7 +82,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] @@ -220,7 +221,6 @@ async def _store_source( return _source_record(scope_id, 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)) @@ -239,7 +239,7 @@ async def list_sources( "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, @@ -261,13 +261,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, *observation_types), ) async with aclosing(source_stream): async for stored in source_stream: @@ -349,6 +354,12 @@ async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> 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, @@ -696,16 +707,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 ba3cd56c56..c969b9be69 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -268,6 +268,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.""" @@ -286,6 +287,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 530d3f13de..502cb4cc87 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -706,6 +706,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, @@ -919,6 +926,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 c021a5581a..f751bee45a 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 abc8d05e6c..c48face48c 100644 --- a/src/powercontext/cli/app.py +++ b/src/powercontext/cli/app.py @@ -88,6 +88,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: installed_commands: list[typer.Typer] = [] 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 0ae18b80cd..5f2f063baa 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2529,6 +2529,255 @@ class AccessAuditPage(BaseModel): next_cursor: Annotated[StrictStr | None, Field(max_length=2048)] +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 Outcome1(StrEnum): + SAVED = "saved" + UNCHANGED = "unchanged" + + +class SourceCollectionResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + outcome: Outcome1 + source_refs: list[SourceTypeReference] + + +class Tracking(StrEnum): + ENABLED = "enabled" + STOPPING = "stopping" + STOPPED = "stopped" + + +class Availability(StrEnum): + ONLINE = "online" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + + +class Kind3(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: Kind3 | None = None + enabled: StrictBool | None = None + source_refs: list[SourceTypeReference] | None = None + last_submitted_at: StrictStr | None = 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 Status3(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: Status3 | None = None + source_refs: Annotated[list[SourceTypeReference] | None, Field(max_length=1000)] = 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 FlushProfileResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3377,8 +3626,11 @@ class SourceRecord(BaseModel): ), ] = None scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: SourceType - source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] + 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)] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d32c78fb60..3b6b59be20 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -160,10 +160,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, UnpublishRemoteSkillRequest, @@ -3013,3 +3031,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 4e027b24f8..c754071604 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3265,13 +3265,13 @@ "name": "source_type", "in": "path", "required": True, - "schema": {"type": "string", "enum": ["content"]}, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*"}, }, { "name": "source_id", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[\\x21-\\x7E]+$"}, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, ], "responses": { @@ -4472,6 +4472,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": { @@ -8882,8 +9452,13 @@ "nullable": True, }, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "enum": ["content"]}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "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}, "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, @@ -9495,6 +10070,236 @@ "type": "object", "required": ["items", "next_cursor"], }, + "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"}, + "last_submitted_at": {"type": "string", "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": 1000, + }, + "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": { "BadRequest": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 58f4b193e5..dc9a5f657c 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -545,7 +545,6 @@ SourceObservationReceipt, SourcePage, SourceRecord, - SourceType, SubmitSourceObservationRequest, TopicMemoryArtifact, UnpublishRemoteSkillRequest, @@ -1352,6 +1351,10 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_LIVENESS, get_liveness) _add_route(app, GET_READINESS, get_readiness) _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) @@ -2335,8 +2338,8 @@ 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_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r"^[\x21-\x7E]+$")], + 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)], ) -> SourceRecord: @@ -2666,7 +2669,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/factory.py b/src/powercontext/server/factory.py index 284e297538..8166b450d4 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -34,6 +34,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.runtime import ( BuiltinRuntime, @@ -59,6 +60,7 @@ from powercontext.server.app import create_app from powercontext.server.authentication import ( AuthenticationProvider, + MultiBearerAuthenticationProvider, StaticBearerAuthenticationProvider, ) from powercontext.server.authz import ( @@ -150,6 +152,9 @@ def create_server_app( # noqa: C901 authentication_provider=authentication_provider, ) ) + from powercontext.source_management.deployment import prepare as prepare_source_deployment + + configured_authentication, source_deployer = prepare_source_deployment(resolved, configured_authentication) config = BuiltinConfig( runtime=resolved.runtime, database=resolved.database, @@ -180,7 +185,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: active_access_control = await resources.enter_async_context( open_builtin_access_control( resolved.database, - bootstrap_administrators=(static_principal,) if legacy_static_admin else (), + bootstrap_administrators=((static_principal,) + ((source_deployer,) if source_deployer else ())) + if legacy_static_admin + else ( + 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, ) ) @@ -250,17 +265,29 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: f"Receipt migration: {migrated} attested, {unresolved} pending in pc_receipt_migration_review", ) 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.access_control = active_access_control app.state.authentication_provider = configured_authentication app.state.capabilities = await _server_capabilities(runtime) 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()) 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.access_control = configured_access_control app.state.authentication_provider = configured_authentication app.state.capabilities = Capabilities( @@ -336,6 +363,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: 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 _resolve_security_providers( settings: ServerSettings, *, @@ -353,6 +392,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 5913c621b3..6e12e34a55 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -101,6 +101,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", CREATE_DREAM_RUN.operation_id, GET_DREAM_RUN.operation_id, LIST_DREAM_RUNS.operation_id, @@ -136,6 +142,9 @@ PUBLISH_ARTIFACT.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ + "pc_source_capabilities", + "pc_source_status", + "pc_source_instances", GET_DREAM_RUN.operation_id, LIST_DREAM_RUNS.operation_id, CONTINUE_HANDOFF.operation_id, diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 498cbd70b0..4557e8faa7 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -135,14 +135,29 @@ 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"] = "background" + 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 using static Bearer authentication.""" @@ -202,6 +217,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 @@ -211,6 +227,7 @@ class ServerSettings(BaseSettings): cursor_signing_secret: SecretStr | None = Field(default=None, repr=False) handoff_generation_verification_secrets: tuple[SecretStr, ...] = Field(default=(), max_length=8, repr=False) 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) @@ -303,11 +320,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" or self.auth.token is None or not self.auth.token.get_secret_value() ): diff --git a/src/powercontext/source_management/__init__.py b/src/powercontext/source_management/__init__.py new file mode 100644 index 0000000000..8dee8f6e19 --- /dev/null +++ b/src/powercontext/source_management/__init__.py @@ -0,0 +1,5 @@ +"""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..29def3f354 --- /dev/null +++ b/src/powercontext/source_management/background.py @@ -0,0 +1,213 @@ +"""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.""" + import fcntl + + if sys.platform != "linux": + raise ManagementError("background-unsupported", "Source background processes currently require Linux") + 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..0da4ee3a98 --- /dev/null +++ b/src/powercontext/source_management/bootstrap.py @@ -0,0 +1,67 @@ +"""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..6659290d96 --- /dev/null +++ b/src/powercontext/source_management/channel.py @@ -0,0 +1,129 @@ +"""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..86116aa222 --- /dev/null +++ b/src/powercontext/source_management/cli.py @@ -0,0 +1,219 @@ +"""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..05d28316bf --- /dev/null +++ b/src/powercontext/source_management/config.py @@ -0,0 +1,102 @@ +"""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..0febba0f24 --- /dev/null +++ b/src/powercontext/source_management/deployment.py @@ -0,0 +1,366 @@ +"""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.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: + logger.error("Source manager startup failed; inspect local service configuration") # noqa: TRY400 - no secret-bearing traceback + 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: + logger.error( # noqa: TRY400 - plugin failures can contain credentials + "Source instance preparation failed (%s); configuration: %s", type(error).__name__, path + ) + + +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..226c4548ce --- /dev/null +++ b/src/powercontext/source_management/durable.py @@ -0,0 +1,79 @@ +"""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..aac5714bfa --- /dev/null +++ b/src/powercontext/source_management/gateway.py @@ -0,0 +1,423 @@ +"""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["available"] = False + 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..f747550fc7 --- /dev/null +++ b/src/powercontext/source_management/inspection.py @@ -0,0 +1,24 @@ +"""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..9f2484d33d --- /dev/null +++ b/src/powercontext/source_management/loading.py @@ -0,0 +1,92 @@ +"""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..1d5ee92390 --- /dev/null +++ b/src/powercontext/source_management/manager.py @@ -0,0 +1,699 @@ +"""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}} + + +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.receipt(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(), + } + with self.store.transaction(): + self.store.claim_identity(identity, target["target_id"]) + self.save_work(target) + return self.receipt(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": + snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target and k != "source_refs"} + destination = targets + else: + continue + length = len(canonical(snapshot).encode()) + if length > MAX_MESSAGE_BYTES - 3072: + raise ManagementError("response-too-large", "One target exceeds the sync limit") + 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) + refs = [r.model_dump(mode="json") for r in report.source_refs] + target["source_refs"] = list({canonical(r): r for r in target.get("source_refs", []) + refs}.values()) + target["last_submitted_at"] = max(target.get("last_submitted_at") or "", 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..79b3dfc367 --- /dev/null +++ b/src/powercontext/source_management/models.py @@ -0,0 +1,122 @@ +"""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] + source_refs: tuple[SourceRef, ...] = () + + +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=1000) + 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..ee845d0ee9 --- /dev/null +++ b/src/powercontext/source_management/remote_deployment.py @@ -0,0 +1,147 @@ +"""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..a414047d74 --- /dev/null +++ b/src/powercontext/source_management/runtime.py @@ -0,0 +1,339 @@ +# 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..38310c2750 --- /dev/null +++ b/src/powercontext/source_management/service.py @@ -0,0 +1,177 @@ +"""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..d6c3e572cf --- /dev/null +++ b/src/powercontext/source_management/storage.py @@ -0,0 +1,74 @@ +"""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..25f04c2ee8 --- /dev/null +++ b/src/powercontext/source_management/targets.py @@ -0,0 +1,106 @@ +"""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.model_dump(exclude={"source_refs"}) != item.model_dump(exclude={"source_refs"}) + 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..e6d69ea5fe --- /dev/null +++ b/src/powercontext/source_management/write.py @@ -0,0 +1,254 @@ +"""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.runtime.relational import _validate_source_definition_manifest +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY +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), BUILTIN_SOURCE_REGISTRY) + + +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/e2e/source_collect_helpers.py b/tests/e2e/source_collect_helpers.py new file mode 100644 index 0000000000..786693d86e --- /dev/null +++ b/tests/e2e/source_collect_helpers.py @@ -0,0 +1,95 @@ +"""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_source_collect.py b/tests/e2e/test_source_collect.py new file mode 100644 index 0000000000..466e3ea555 --- /dev/null +++ b/tests/e2e/test_source_collect.py @@ -0,0 +1,172 @@ +"""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): + 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 + if len(response.json()["source_refs"]) == count: + return response.json() + 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) + 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) + 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") + await received(admin, scopes[1], targets["second", scopes[1]], 3) + # 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 + assert len((await received(admin, scopes[1], targets["second", scopes[1]], 3))["source_refs"]) == 3 + finally: + 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..caeec92341 --- /dev/null +++ b/tests/e2e/test_source_deployment.py @@ -0,0 +1,303 @@ +"""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" + + +@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(), 100) + 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", + ) + 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..8eb1cd1c61 --- /dev/null +++ b/tests/e2e/test_source_write.py @@ -0,0 +1,286 @@ +"""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) + await grant(admin, scope, "reader", "scope.viewer") + other_scope = await create_scope(admin, "other-evidence") + 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") + 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/source_helpers.py b/tests/source_helpers.py new file mode 100644 index 0000000000..4c6887c17b --- /dev/null +++ b/tests/source_helpers.py @@ -0,0 +1,179 @@ +"""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): + async with asyncio.timeout(15): + 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_source_management.py b/tests/test_source_management.py new file mode 100644 index 0000000000..9f0ad2e181 --- /dev/null +++ b/tests/test_source_management.py @@ -0,0 +1,777 @@ +"""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 +from powercontext.source_management.runtime import SourceRuntime +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_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()) diff --git a/uv.lock b/uv.lock index 2344225d0d..b3367572e9 100644 --- a/uv.lock +++ b/uv.lock @@ -2644,6 +2644,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" @@ -2685,6 +2770,7 @@ wheels = [ name = "powercontext" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "pydantic" }, { name = "rfc8785" }, { name = "typing-extensions" }, @@ -2694,7 +2780,6 @@ dependencies = [ builtin = [ { name = "aiosqlite" }, { name = "apscheduler" }, - { name = "jsonschema" }, { name = "packaging" }, { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pydantic-settings" }, @@ -2723,7 +2808,6 @@ client = [ seekdb = [ { name = "aiosqlite" }, { name = "apscheduler" }, - { name = "jsonschema" }, { name = "packaging" }, { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pydantic-settings" }, @@ -2740,7 +2824,6 @@ server = [ { name = "fastmcp" }, { name = "httpx" }, { name = "jinja2" }, - { name = "jsonschema" }, { name = "markdown-it-py" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, @@ -2757,6 +2840,9 @@ server = [ { name = "sqlite-vec" }, { name = "uvicorn" }, ] +source-images = [ + { name = "pillow" }, +] tracing-otlp = [ { name = "opentelemetry-exporter-otlp-proto-http" }, ] @@ -2767,7 +2853,7 @@ dev = [ { name = "langchain" }, { name = "langchain-core" }, { name = "langgraph" }, - { name = "powercontext", extra = ["cli", "server"] }, + { name = "powercontext", extra = ["cli", "server", "source-images"] }, { name = "powercontext-langchain" }, { name = "powercontext-langgraph" }, { name = "prek" }, @@ -2809,9 +2895,7 @@ requires-dist = [ { name = "httpx", extras = ["socks"], marker = "extra == 'client'", specifier = ">=0.28,<1" }, { name = "inquirerpy", marker = "extra == 'cli'", specifier = ">=0.3,<1" }, { name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.6,<4" }, - { name = "jsonschema", marker = "extra == 'builtin'", 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 == 'server'", specifier = ">=4,<5" }, { name = "opentelemetry-api", marker = "extra == 'cli'", specifier = ">=1.30,<2" }, { name = "opentelemetry-api", marker = "extra == 'client'", specifier = ">=1.30,<2" }, @@ -2823,6 +2907,7 @@ requires-dist = [ { name = "packaging", marker = "extra == 'client'", 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 == 'server'", specifier = ">=4,<5" }, { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, @@ -2857,7 +2942,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.12,<5" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "cli", "client", "seekdb", "server", "tracing-otlp"] +provides-extras = ["builtin", "cli", "client", "seekdb", "server", "source-images", "tracing-otlp"] [package.metadata.requires-dev] dev = [ @@ -2866,6 +2951,7 @@ dev = [ { name = "langchain-core", specifier = ">=1,<2" }, { name = "langgraph", specifier = ">=1.2,<2" }, { name = "powercontext", extras = ["cli", "server"] }, + { name = "powercontext", extras = ["source-images"] }, { name = "powercontext-langchain", editable = "integrations/langchain" }, { name = "powercontext-langgraph", editable = "integrations/langgraph" }, { name = "prek", specifier = ">=0.2.0" }, From 4569636979c873aa457dcf6e988ad1a87cf0f8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Thu, 22 Oct 2026 19:45:52 +0800 Subject: [PATCH 02/15] fix(source): simplify target snapshots and correct discovery boundaries --- src/powercontext/source_management/gateway.py | 2 +- src/powercontext/source_management/manager.py | 4 +- src/powercontext/source_management/models.py | 1 - src/powercontext/source_management/targets.py | 2 +- tests/e2e/test_source_collect.py | 124 ++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/powercontext/source_management/gateway.py b/src/powercontext/source_management/gateway.py index aac5714bfa..f2dfb2e82a 100644 --- a/src/powercontext/source_management/gateway.py +++ b/src/powercontext/source_management/gateway.py @@ -246,7 +246,7 @@ def discover(self, parameters, caller, can_write, online, operation): # noqa: C } public["can_write"] = can_write if not online: - public["available"] = False + public["availability"] = "unavailable" visible.append(public) instances[instance["id"]] = public if visible: diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py index 1d5ee92390..7511c5a98d 100644 --- a/src/powercontext/source_management/manager.py +++ b/src/powercontext/source_management/manager.py @@ -463,8 +463,8 @@ async def sync_instance(self, instance, generation, cursor=""): if target["tracking"] == "stopping": snapshot = {"target_id": target["target_id"]} destination = stopping - elif target["tracking"] == "enabled": - snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target and k != "source_refs"} + 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 diff --git a/src/powercontext/source_management/models.py b/src/powercontext/source_management/models.py index 79b3dfc367..1e823b8fc0 100644 --- a/src/powercontext/source_management/models.py +++ b/src/powercontext/source_management/models.py @@ -83,7 +83,6 @@ class SourceTarget(Model): capability: str capability_version: str input: dict[str, JsonValue] - source_refs: tuple[SourceRef, ...] = () class InstanceRegistration(Model): diff --git a/src/powercontext/source_management/targets.py b/src/powercontext/source_management/targets.py index 25f04c2ee8..b05f7cbdda 100644 --- a/src/powercontext/source_management/targets.py +++ b/src/powercontext/source_management/targets.py @@ -39,7 +39,7 @@ def begin(self, binding, selected) -> BatchObserver: if ( item.target_id in seen or known is None - or known.model_dump(exclude={"source_refs"}) != item.model_dump(exclude={"source_refs"}) + or known != item or item.instance_id != self.instance_id or item.scope_id != binding.scope_id or item.target_id in self._stopping diff --git a/tests/e2e/test_source_collect.py b/tests/e2e/test_source_collect.py index 466e3ea555..e293549abc 100644 --- a/tests/e2e/test_source_collect.py +++ b/tests/e2e/test_source_collect.py @@ -170,3 +170,127 @@ def closed(): assert len((await received(admin, scopes[1], targets["second", scopes[1]], 3))["source_refs"]) == 3 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) + 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) + # 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) + # 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 + assert len((await received(admin, scopes[1], targets[scopes[1]], 2))["source_refs"]) == 2 + 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) + 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) From d79b182111175a4c46d7a94e6596de778f6d450c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 23 Oct 2026 16:33:47 +0800 Subject: [PATCH 03/15] fix(source): preserve legacy cursor pagination scope --- .../builtin/persistence/records.py | 13 +- tests/builtin/persistence/test_records.py | 126 ++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 31eca98d67..5de63886ff 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -281,7 +281,7 @@ 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, @@ -291,7 +291,14 @@ async def list_sources( "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: @@ -318,7 +325,7 @@ async def list_sources( after=after, through=through, limit=limit + 1, - source_types=(CONTENT_SOURCE_NAME, *observation_types), + 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: diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 887f5cac12..3a68c1fec5 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_list_stops_decoding_when_the_response_budget_is_full(monkeypatch) -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: From 92105fe308ed865bc8cf179c9ba7015199f2ea6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 23 Oct 2026 16:50:00 +0800 Subject: [PATCH 04/15] fix(source): omit configuration paths from startup logs --- src/powercontext/source_management/deployment.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/powercontext/source_management/deployment.py b/src/powercontext/source_management/deployment.py index 0febba0f24..e7e858f5d8 100644 --- a/src/powercontext/source_management/deployment.py +++ b/src/powercontext/source_management/deployment.py @@ -18,6 +18,7 @@ from pydantic import Field from typing_extensions import override +from powercontext._logging import log_safely from powercontext.server.authentication import ( AuthenticationRejectedError, MultiBearerAuthenticationProvider, @@ -335,10 +336,8 @@ async def startup(self): for path in sorted(directory.expanduser().glob("*/powercontext.yml")): try: await self.load(path) - except Exception as error: - logger.error( # noqa: TRY400 - plugin failures can contain credentials - "Source instance preparation failed (%s); configuration: %s", type(error).__name__, path - ) + except Exception: + log_safely(logger, logging.ERROR, "Source instance preparation failed; inspect local configuration") async def inspect_manifests(config): From 6ca575cc60b431a79ebcd7b0b7fbdd5e2fa1d885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 23 Oct 2026 16:52:06 +0800 Subject: [PATCH 05/15] fix(source): align source listing contract and access tests --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 2 +- desktop/ui/src/generated/operations.ts | 2 +- openapi/powercontext.yaml | 4 +++- src/powercontext/http/_generated/schema.py | 9 ++++++++- tests/e2e/test_base_access_api.py | 6 +++++- 6 files changed, 19 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 9b47aa90e5..146d54b776 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "e3430ecb89f0e3c9fbd02c4a5e8a04cfa219f2c19238d187efb245fa6abec7d8", + "contractSha256": "37d012f3610fcf13d6b8c951d50167b17d36fe3bca81e8f69329c32f0f6f6f28", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index b084bb16f2..59a8867fe7 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1868,7 +1868,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; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 58266972f2..a7897359e2 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 = "e3430ecb89f0e3c9fbd02c4a5e8a04cfa219f2c19238d187efb245fa6abec7d8"; +export const contractSha256 = "37d012f3610fcf13d6b8c951d50167b17d36fe3bca81e8f69329c32f0f6f6f28"; export const operations = { "get_liveness": { "method": "GET", diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index c48bfd2493..0e115a3dac 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3858,7 +3858,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: diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index ddf0dba42c..1fb270b442 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3953,7 +3953,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": [ 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", From f034343f3a26fa875715e3299d6bde1d41db7b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 16:49:52 +0800 Subject: [PATCH 06/15] fix(auth): separate server admins from scope presets --- src/powercontext/server/authz/composition.py | 7 +- src/powercontext/server/factory.py | 7 +- tests/e2e/test_access_control_http.py | 100 +++++++++++++++++++ tests/e2e/test_source_deployment.py | 5 +- tests/e2e/test_source_write.py | 3 + tests/source_helpers.py | 4 +- 6 files changed, 117 insertions(+), 9 deletions(-) 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 b541347e0b..caa91d1e59 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -161,7 +161,7 @@ def create_server_app( # noqa: C901 ) from powercontext.source_management.deployment import prepare as prepare_source_deployment - configured_authentication, source_deployer = prepare_source_deployment(resolved, configured_authentication) + configured_authentication, _ = prepare_source_deployment(resolved, configured_authentication) config = BuiltinConfig( code=resolved.code, runtime=resolved.runtime, @@ -195,9 +195,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: active_access_control = await resources.enter_async_context( open_builtin_access_control( resolved.database, - bootstrap_administrators=((static_principal,) + ((source_deployer,) if source_deployer else ())) - if legacy_static_admin - else ( + bootstrap_administrators=(static_principal,) if legacy_static_admin else (), + server_administrators=( tuple( principal for principal in configured_authentication.principals() diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 9b9a9aa044..fa1a6ae041 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 @@ -512,6 +516,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_source_deployment.py b/tests/e2e/test_source_deployment.py index caeec92341..e362c1575a 100644 --- a/tests/e2e/test_source_deployment.py +++ b/tests/e2e/test_source_deployment.py @@ -16,6 +16,7 @@ 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]) @@ -78,7 +79,7 @@ async def cli(*args, success=True): entrypoint, *args, env=environment, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) try: - out, err = await asyncio.wait_for(child.communicate(), 100) + out, err = await asyncio.wait_for(child.communicate(), DEPLOYMENT_TIMEOUT_SECONDS) finally: if child.returncode is None: child.kill() @@ -183,6 +184,8 @@ async def start_server(): 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"), diff --git a/tests/e2e/test_source_write.py b/tests/e2e/test_source_write.py index 8eb1cd1c61..5af63a64d9 100644 --- a/tests/e2e/test_source_write.py +++ b/tests/e2e/test_source_write.py @@ -37,9 +37,11 @@ async def _write_and_restart(directory): } 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 @@ -166,6 +168,7 @@ async def _write_boundaries(directory): 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) diff --git a/tests/source_helpers.py b/tests/source_helpers.py index 4c6887c17b..0a781339c0 100644 --- a/tests/source_helpers.py +++ b/tests/source_helpers.py @@ -170,8 +170,8 @@ def protected(path: Path, value: str) -> Path: return path -async def wait_until(call, predicate): - async with asyncio.timeout(15): +async def wait_until(call, predicate, *, timeout_seconds: float = 15): + async with asyncio.timeout(timeout_seconds): while True: value = await call() if predicate(value): From 136696c9b6eb248f2bdc1152d6ab5094c1d9e3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 16:49:52 +0800 Subject: [PATCH 07/15] fix(source): bound collect status to the latest submission --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 3 + desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/workflows/source-plugins.md | 2 +- docs/zh/docs/workflows/source-plugins.md | 2 +- openapi/powercontext.yaml | 7 +- src/powercontext/http/_generated/models.py | 24 ++++- src/powercontext/http/_generated/schema.py | 92 ++++++++++++++++++- src/powercontext/source_management/manager.py | 10 +- src/powercontext/source_management/models.py | 2 +- tests/e2e/test_source_collect.py | 35 ++++--- tests/test_source_management.py | 75 ++++++++++++++- 12 files changed, 226 insertions(+), 30 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 146d54b776..adcd8d3a69 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "37d012f3610fcf13d6b8c951d50167b17d36fe3bca81e8f69329c32f0f6f6f28", + "contractSha256": "19ca28c8d235f0a09e5d3c77a5bcaf9057d9ceb845fc72130602a2e7ab061173", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 59a8867fe7..b84d31fcf4 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -5544,7 +5544,9 @@ export interface components { /** @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: { @@ -5600,6 +5602,7 @@ export interface components { }; /** @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?: { diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index a7897359e2..f3153f5343 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 = "37d012f3610fcf13d6b8c951d50167b17d36fe3bca81e8f69329c32f0f6f6f28"; +export const contractSha256 = "19ca28c8d235f0a09e5d3c77a5bcaf9057d9ceb845fc72130602a2e7ab061173"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/workflows/source-plugins.md b/docs/en/docs/workflows/source-plugins.md index 8d9c9d0959..3f2a8f44c6 100644 --- a/docs/en/docs/workflows/source-plugins.md +++ b/docs/en/docs/workflows/source-plugins.md @@ -94,7 +94,7 @@ pc_source_add({ pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID"}) ``` -Add returns a target identity, not proof that external content has been saved. Status reports confirmed Source submissions. Repeated add reuses the same target without re-enabling a disabled one. Optional manifest `targets` only initialize the first deployment; subsequent target state belongs to the manager. +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 diff --git a/docs/zh/docs/workflows/source-plugins.md b/docs/zh/docs/workflows/source-plugins.md index d9fd80e524..f029919b81 100644 --- a/docs/zh/docs/workflows/source-plugins.md +++ b/docs/zh/docs/workflows/source-plugins.md @@ -92,7 +92,7 @@ pc_source_add({ pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID"}) ``` -add 返回目标身份,不能据此认定外部资料已保存。状态中的 Source 引用是已确认的提交结果。相同目标重复 add 复用,不恢复已停用目标。可选 `targets` 清单只用于首次初始化,后续以管理服务记录为准。 +add 和重复 add 返回目标身份及控制状态,不携带 Source 引用和提交时间。状态查询只携带管理服务最近收到的一次成功提交报告:`source_refs` 包含一个引用,`last_submitted_at` 是同一报告的提交时间;尚未收到报告时,引用为空数组,也没有提交时间。报告按接收顺序覆盖,这既不表示一批已完成,也不是已采集 Source 的历史清单。管理服务不累计历史引用,报告丢失不撤销已保存的 Source。插件仍从每次真实提交回执取得 SourceRef,并用 checkpoint 管理采集进度。相同目标重复 add 复用,不重启采集,也不恢复已停用目标。可选 `targets` 清单只用于首次初始化,后续以管理服务记录为准。 ## 远端 collect Runtime diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 0e115a3dac..8b9602177c 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -13423,12 +13423,15 @@ components: 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: 1 items: $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 @@ -13542,7 +13545,9 @@ components: - cleanup-failed source_refs: type: array - maxItems: 1000 + description: One confirmed Source submission reported by the Runtime; not a batch or historical reference list. + minItems: 1 + maxItems: 1 items: $ref: '#/components/schemas/SourceTypeReference' error: diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f5e9cea547..37309a245a 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3669,8 +3669,19 @@ class SourceManagementReceipt(BaseModel): updated_at: StrictStr | None = None kind: Kind10 | None = None enabled: StrictBool | None = None - source_refs: list[SourceTypeReference] | None = None - last_submitted_at: StrictStr | 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): @@ -3742,7 +3753,14 @@ class SourceInstanceExchangeRequest(BaseModel): response: dict[str, Any] | None = None owner: dict[str, Any] | None = None status: Status6 | None = None - source_refs: Annotated[list[SourceTypeReference] | None, Field(max_length=1000)] = 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 diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 1fb270b442..3940d6e27d 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -13170,8 +13170,78 @@ "updated_at": {"type": "string"}, "kind": {"type": "string", "enum": ["collect", "write"]}, "enabled": {"type": "boolean"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, - "last_submitted_at": {"type": "string", "nullable": True}, + "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", @@ -13246,7 +13316,23 @@ "source_refs": { "items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array", - "maxItems": 1000, + "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": { diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py index 7511c5a98d..d9849908f3 100644 --- a/src/powercontext/source_management/manager.py +++ b/src/powercontext/source_management/manager.py @@ -351,7 +351,7 @@ async def add(self, caller, parameters): 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.receipt(target) + 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) @@ -371,7 +371,7 @@ async def add(self, caller, parameters): with self.store.transaction(): self.store.claim_identity(identity, target["target_id"]) self.save_work(target) - return self.receipt(target) + return self.target_control(target) async def target_set_enabled(self, caller, parameters): async with self._lock: @@ -510,9 +510,9 @@ async def report_result(self, instance, data): target = self.target(report.target_id) if target["instance_id"] != instance["instance_id"]: raise ManagementError("forbidden", "The target belongs to another instance", 403) - refs = [r.model_dump(mode="json") for r in report.source_refs] - target["source_refs"] = list({canonical(r): r for r in target.get("source_refs", []) + refs}.values()) - target["last_submitted_at"] = max(target.get("last_submitted_at") or "", report.submitted_at) + # 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} diff --git a/src/powercontext/source_management/models.py b/src/powercontext/source_management/models.py index 1e823b8fc0..3eb18fd754 100644 --- a/src/powercontext/source_management/models.py +++ b/src/powercontext/source_management/models.py @@ -99,7 +99,7 @@ class InstanceRegistration(Model): 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=1000) + source_refs: tuple[SourceRef, ...] = Field(min_length=1, max_length=1) submitted_at: str diff --git a/tests/e2e/test_source_collect.py b/tests/e2e/test_source_collect.py index e293549abc..453496f053 100644 --- a/tests/e2e/test_source_collect.py +++ b/tests/e2e/test_source_collect.py @@ -44,13 +44,22 @@ async def checkpoint(client, scope, identifier, *, after=None): await asyncio.sleep(0.1) -async def received(client, scope, target, count): +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 - if len(response.json()["source_refs"]) == count: - return response.json() + 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) @@ -118,7 +127,7 @@ def closed(): assert added.status_code == 200, added.text target = added.json()["target_id"] targets[instance.name, scope] = target - await received(admin, scope, target, 2) + 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", @@ -146,7 +155,7 @@ def closed(): 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) + 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() @@ -157,7 +166,7 @@ def closed(): replacement = second / "events.next.json" replacement.write_text(json.dumps(fixture)) replacement.replace(second / "events.json") - await received(admin, scopes[1], targets["second", scopes[1]], 3) + 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. @@ -167,7 +176,9 @@ def closed(): await instance_state(admin, "second", "failed") assert (second / "closed").exists() assert await checkpoint(admin, scopes[1], "second") == previous - assert len((await received(admin, scopes[1], targets["second", scopes[1]], 3))["source_refs"]) == 3 + 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) @@ -222,7 +233,7 @@ async def _scope_shrink_and_discovery(directory): ) assert response.status_code == 200, response.text targets[scope] = response.json()["target_id"] - await received(admin, scope, targets[scope], 2) + 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" @@ -236,17 +247,17 @@ async def _scope_shrink_and_discovery(directory): 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) + 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) + 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 - assert len((await received(admin, scopes[1], targets[scopes[1]], 2))["source_refs"]) == 2 + 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 @@ -258,7 +269,7 @@ async def _scope_shrink_and_discovery(directory): ) assert response.status_code == 200, response.text await set_enabled(admin, "scope-review", True) - restored = await received(admin, scopes[1], targets[scopes[1]], 3) + 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) diff --git a/tests/test_source_management.py b/tests/test_source_management.py index 9f0ad2e181..cee5d5fe5c 100644 --- a/tests/test_source_management.py +++ b/tests/test_source_management.py @@ -32,8 +32,9 @@ 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 +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" @@ -362,6 +363,78 @@ async def scenario(): 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 ( From f7c851386a639acc47835816b47a8edefcd88756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 16:49:53 +0800 Subject: [PATCH 08/15] fix(source): sync host tooling and restore guidance --- docs/en/docs/operate/troubleshoot.md | 2 +- docs/zh/docs/operate/troubleshoot.md | 2 +- .../dsh/plugins/powercontext/lib/index.js | 160 ++++++++++++++++++ src/powercontext/cli/system.py | 6 + 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index bfbba715b0..2cb7aa0dd3 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_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_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/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index a8cd9bd9e7..eeaa1c3f24 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_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_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/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index c1df7f1d68..4afc5c45b9 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1610,6 +1610,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); @@ -3386,6 +3540,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 }, diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index a68aef0da1..6ad7ccda5b 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -95,6 +95,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", From 4405d0763044b05a511f3b02fac82b60b6cea27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 18:00:46 +0800 Subject: [PATCH 09/15] fix(source): align CI contracts and fixtures --- e2e/bub/tests/test_access_audit.py | 3 ++ e2e/bub/uv.lock | 13 ++++---- evaluation/skills/skill-up/README.md | 2 +- .../skill-up/evals/cases/empty-search.yaml | 12 ++++++++ .../skill-up/evals/cases/explicit-save.yaml | 6 ++++ .../skill-up/evals/cases/failed-save.yaml | 6 ++++ .../evals/cases/inspect-candidates.yaml | 6 ++++ .../skill-up/evals/cases/ordinary-coding.yaml | 6 ++++ .../fixtures/mcp/powercontext-default.json | 18 +++++++++++ .../mcp/powercontext-failed-save.json | 18 +++++++++++ .../powercontext-tool-contract/CLAUDE.md | 30 +++++++++++++++++++ examples/source_plugins/images/__init__.py | 14 +++++++++ examples/source_plugins/images/definition.py | 14 +++++++++ examples/source_plugins/images/write.yml | 14 +++++++++ .../source_plugins/ticket_chat/__init__.py | 11 ++++++- .../source_plugins/ticket_chat/definition.py | 11 ++++++- .../ticket_chat/powercontext.yml | 14 +++++++++ examples/source_plugins/tickets/__init__.py | 14 +++++++++ examples/source_plugins/tickets/definition.py | 11 ++++++- .../source_plugins/tickets/powercontext.yml | 14 +++++++++ examples/source_plugins/tickets/write.yml | 14 +++++++++ integrations/capabilities.toml | 18 +++++++++++ .../plugins/powercontext/profiles.json | 12 ++++++++ .../source_management/__init__.py | 14 +++++++++ .../source_management/background.py | 14 +++++++++ .../source_management/bootstrap.py | 14 +++++++++ src/powercontext/source_management/channel.py | 14 +++++++++ src/powercontext/source_management/cli.py | 14 +++++++++ src/powercontext/source_management/config.py | 14 +++++++++ .../source_management/deployment.py | 14 +++++++++ src/powercontext/source_management/durable.py | 14 +++++++++ src/powercontext/source_management/gateway.py | 14 +++++++++ .../source_management/inspection.py | 14 +++++++++ src/powercontext/source_management/loading.py | 14 +++++++++ src/powercontext/source_management/manager.py | 14 +++++++++ src/powercontext/source_management/models.py | 14 +++++++++ .../source_management/remote_deployment.py | 14 +++++++++ src/powercontext/source_management/runtime.py | 14 +++++++++ src/powercontext/source_management/service.py | 14 +++++++++ src/powercontext/source_management/storage.py | 14 +++++++++ src/powercontext/source_management/targets.py | 14 +++++++++ src/powercontext/source_management/write.py | 14 +++++++++ .../runtime/test_model_usage_recorder.py | 6 +++- tests/e2e/source_collect_helpers.py | 14 +++++++++ tests/e2e/test_mcp_transport.py | 6 ++++ tests/e2e/test_source_collect.py | 14 +++++++++ tests/e2e/test_source_deployment.py | 14 +++++++++ tests/e2e/test_source_write.py | 14 +++++++++ tests/source_helpers.py | 14 +++++++++ tests/test_mcp.py | 6 ++++ tests/test_source_management.py | 14 +++++++++ 51 files changed, 637 insertions(+), 12 deletions(-) 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 730fc0e4c0..aca5d3bfc8 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -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 545a451de9..5a2f64b1ad 100644 --- a/evaluation/skills/skill-up/evals/cases/empty-search.yaml +++ b/evaluation/skills/skill-up/evals/cases/empty-search.yaml @@ -156,3 +156,15 @@ 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} diff --git a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml index 3340a96825..303ddfecf9 100644 --- a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml @@ -93,3 +93,9 @@ 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} diff --git a/evaluation/skills/skill-up/evals/cases/failed-save.yaml b/evaluation/skills/skill-up/evals/cases/failed-save.yaml index 8e69ad60f7..90950284d4 100644 --- a/evaluation/skills/skill-up/evals/cases/failed-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/failed-save.yaml @@ -109,3 +109,9 @@ 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} diff --git a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml index f419713734..dab5c13893 100644 --- a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml +++ b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml @@ -96,3 +96,9 @@ 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} diff --git a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml index c577206862..b1f00ae761 100644 --- a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml +++ b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml @@ -87,3 +87,9 @@ 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} 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 82de60a780..7290ba201c 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json @@ -245,6 +245,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 611d175d1a..e56175526e 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 @@ -235,6 +235,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 6abdcb4edb..fb47b69e20 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 @@ -195,6 +195,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/examples/source_plugins/images/__init__.py b/examples/source_plugins/images/__init__.py index a026429e3f..2c9f2c9636 100644 --- a/examples/source_plugins/images/__init__.py +++ b/examples/source_plugins/images/__init__.py @@ -1,3 +1,17 @@ +# 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 diff --git a/examples/source_plugins/images/definition.py b/examples/source_plugins/images/definition.py index 971eb394dc..eacf1f4452 100644 --- a/examples/source_plugins/images/definition.py +++ b/examples/source_plugins/images/definition.py @@ -1,3 +1,17 @@ +# 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 diff --git a/examples/source_plugins/images/write.yml b/examples/source_plugins/images/write.yml index 1eb1320561..fc3bb9e77b 100644 --- a/examples/source_plugins/images/write.yml +++ b/examples/source_plugins/images/write.yml @@ -1,2 +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 index 96646b47ce..70e634408b 100644 --- a/examples/source_plugins/ticket_chat/__init__.py +++ b/examples/source_plugins/ticket_chat/__init__.py @@ -1,7 +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 https://www.apache.org/licenses/LICENSE-2.0 +# 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.""" diff --git a/examples/source_plugins/ticket_chat/definition.py b/examples/source_plugins/ticket_chat/definition.py index 935bd008d5..91f071be7a 100644 --- a/examples/source_plugins/ticket_chat/definition.py +++ b/examples/source_plugins/ticket_chat/definition.py @@ -1,7 +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 https://www.apache.org/licenses/LICENSE-2.0 +# 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.""" diff --git a/examples/source_plugins/ticket_chat/powercontext.yml b/examples/source_plugins/ticket_chat/powercontext.yml index c867a42844..a5c3ff0c3c 100644 --- a/examples/source_plugins/ticket_chat/powercontext.yml +++ b/examples/source_plugins/ticket_chat/powercontext.yml @@ -1,3 +1,17 @@ +# 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] diff --git a/examples/source_plugins/tickets/__init__.py b/examples/source_plugins/tickets/__init__.py index 45decb7b36..8f8d77cfe8 100644 --- a/examples/source_plugins/tickets/__init__.py +++ b/examples/source_plugins/tickets/__init__.py @@ -1,3 +1,17 @@ +# 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 diff --git a/examples/source_plugins/tickets/definition.py b/examples/source_plugins/tickets/definition.py index 583705f58f..1f98a0eaa6 100644 --- a/examples/source_plugins/tickets/definition.py +++ b/examples/source_plugins/tickets/definition.py @@ -1,7 +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 https://www.apache.org/licenses/LICENSE-2.0 +# 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.""" diff --git a/examples/source_plugins/tickets/powercontext.yml b/examples/source_plugins/tickets/powercontext.yml index 02bab467a7..05dd9e46a7 100644 --- a/examples/source_plugins/tickets/powercontext.yml +++ b/examples/source_plugins/tickets/powercontext.yml @@ -1,3 +1,17 @@ +# 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] diff --git a/examples/source_plugins/tickets/write.yml b/examples/source_plugins/tickets/write.yml index 7fb64eb460..c677a8557f 100644 --- a/examples/source_plugins/tickets/write.yml +++ b/examples/source_plugins/tickets/write.yml @@ -1,2 +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 c0a4f4c04e..815f9a0330 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -1149,6 +1149,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/kimi-code/plugins/powercontext/profiles.json b/integrations/kimi-code/plugins/powercontext/profiles.json index 3b65f11715..77dbb99c22 100644 --- a/integrations/kimi-code/plugins/powercontext/profiles.json +++ b/integrations/kimi-code/plugins/powercontext/profiles.json @@ -25,6 +25,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", @@ -65,6 +71,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/src/powercontext/source_management/__init__.py b/src/powercontext/source_management/__init__.py index 8dee8f6e19..6db950956c 100644 --- a/src/powercontext/source_management/__init__.py +++ b/src/powercontext/source_management/__init__.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py index 29def3f354..36c6b7f472 100644 --- a/src/powercontext/source_management/background.py +++ b/src/powercontext/source_management/background.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/bootstrap.py b/src/powercontext/source_management/bootstrap.py index 0da4ee3a98..7b97cf7749 100644 --- a/src/powercontext/source_management/bootstrap.py +++ b/src/powercontext/source_management/bootstrap.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/channel.py b/src/powercontext/source_management/channel.py index 6659290d96..17a0c6c8fa 100644 --- a/src/powercontext/source_management/channel.py +++ b/src/powercontext/source_management/channel.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/cli.py b/src/powercontext/source_management/cli.py index 86116aa222..71299b16d6 100644 --- a/src/powercontext/source_management/cli.py +++ b/src/powercontext/source_management/cli.py @@ -1,3 +1,17 @@ +# 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. diff --git a/src/powercontext/source_management/config.py b/src/powercontext/source_management/config.py index 05d28316bf..609bd5650c 100644 --- a/src/powercontext/source_management/config.py +++ b/src/powercontext/source_management/config.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/deployment.py b/src/powercontext/source_management/deployment.py index e7e858f5d8..f812d158c7 100644 --- a/src/powercontext/source_management/deployment.py +++ b/src/powercontext/source_management/deployment.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/durable.py b/src/powercontext/source_management/durable.py index 226c4548ce..30138c00c0 100644 --- a/src/powercontext/source_management/durable.py +++ b/src/powercontext/source_management/durable.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/gateway.py b/src/powercontext/source_management/gateway.py index f2dfb2e82a..d460d2e0ee 100644 --- a/src/powercontext/source_management/gateway.py +++ b/src/powercontext/source_management/gateway.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/inspection.py b/src/powercontext/source_management/inspection.py index f747550fc7..5a4a1ca24b 100644 --- a/src/powercontext/source_management/inspection.py +++ b/src/powercontext/source_management/inspection.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/loading.py b/src/powercontext/source_management/loading.py index 9f2484d33d..a851c5f37d 100644 --- a/src/powercontext/source_management/loading.py +++ b/src/powercontext/source_management/loading.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py index d9849908f3..0c012eedc9 100644 --- a/src/powercontext/source_management/manager.py +++ b/src/powercontext/source_management/manager.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/models.py b/src/powercontext/source_management/models.py index 3eb18fd754..aed143496d 100644 --- a/src/powercontext/source_management/models.py +++ b/src/powercontext/source_management/models.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/remote_deployment.py b/src/powercontext/source_management/remote_deployment.py index ee845d0ee9..b5f0ae3284 100644 --- a/src/powercontext/source_management/remote_deployment.py +++ b/src/powercontext/source_management/remote_deployment.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/runtime.py b/src/powercontext/source_management/runtime.py index a414047d74..fe7dd83ab5 100644 --- a/src/powercontext/source_management/runtime.py +++ b/src/powercontext/source_management/runtime.py @@ -1,3 +1,17 @@ +# 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.""" diff --git a/src/powercontext/source_management/service.py b/src/powercontext/source_management/service.py index 38310c2750..8f498bfaf6 100644 --- a/src/powercontext/source_management/service.py +++ b/src/powercontext/source_management/service.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/storage.py b/src/powercontext/source_management/storage.py index d6c3e572cf..4ed9a43d5d 100644 --- a/src/powercontext/source_management/storage.py +++ b/src/powercontext/source_management/storage.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/targets.py b/src/powercontext/source_management/targets.py index b05f7cbdda..0f55ac0d0f 100644 --- a/src/powercontext/source_management/targets.py +++ b/src/powercontext/source_management/targets.py @@ -1,3 +1,17 @@ +# 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 diff --git a/src/powercontext/source_management/write.py b/src/powercontext/source_management/write.py index c16b5f3863..d3348c0311 100644 --- a/src/powercontext/source_management/write.py +++ b/src/powercontext/source_management/write.py @@ -1,3 +1,17 @@ +# 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 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 index 786693d86e..e0786490ed 100644 --- a/tests/e2e/source_collect_helpers.py +++ b/tests/e2e/source_collect_helpers.py @@ -1,3 +1,17 @@ +# 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 diff --git a/tests/e2e/test_mcp_transport.py b/tests/e2e/test_mcp_transport.py index b1569460f8..2488a3534f 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 index 453496f053..03cad26734 100644 --- a/tests/e2e/test_source_collect.py +++ b/tests/e2e/test_source_collect.py @@ -1,3 +1,17 @@ +# 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 diff --git a/tests/e2e/test_source_deployment.py b/tests/e2e/test_source_deployment.py index e362c1575a..59947cfce4 100644 --- a/tests/e2e/test_source_deployment.py +++ b/tests/e2e/test_source_deployment.py @@ -1,3 +1,17 @@ +# 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 diff --git a/tests/e2e/test_source_write.py b/tests/e2e/test_source_write.py index 5af63a64d9..3226c003a8 100644 --- a/tests/e2e/test_source_write.py +++ b/tests/e2e/test_source_write.py @@ -1,3 +1,17 @@ +# 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 diff --git a/tests/source_helpers.py b/tests/source_helpers.py index 0a781339c0..0548ae327a 100644 --- a/tests/source_helpers.py +++ b/tests/source_helpers.py @@ -1,3 +1,17 @@ +# 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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index fbc144d95b..ee0a333c39 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_source_management.py b/tests/test_source_management.py index cee5d5fe5c..9defb7d30c 100644 --- a/tests/test_source_management.py +++ b/tests/test_source_management.py @@ -1,3 +1,17 @@ +# 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 From 68491568359fd9f5723208bbeeec2f808fd2bcc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 20:33:36 +0800 Subject: [PATCH 10/15] fix(source): clarify local deployment platform requirements --- docs/en/docs/workflows/source-plugins.md | 3 +- docs/zh/docs/workflows/source-plugins.md | 3 +- src/powercontext/server/settings.py | 5 +++- .../source_management/background.py | 5 ++-- .../source_management/deployment.py | 28 ++++++++++++++++--- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/en/docs/workflows/source-plugins.md b/docs/en/docs/workflows/source-plugins.md index 3f2a8f44c6..87eca81725 100644 --- a/docs/en/docs/workflows/source-plugins.md +++ b/docs/en/docs/workflows/source-plugins.md @@ -59,7 +59,7 @@ 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. On Linux, the manager and Runtimes use background processes by default. Set `source_management.process_mode="local-service"` explicitly for systemd user services. +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: @@ -158,6 +158,7 @@ The current implementation has no complete unload command that deletes an instan | 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. | diff --git a/docs/zh/docs/workflows/source-plugins.md b/docs/zh/docs/workflows/source-plugins.md index f029919b81..b3633e1037 100644 --- a/docs/zh/docs/workflows/source-plugins.md +++ b/docs/zh/docs/workflows/source-plugins.md @@ -59,7 +59,7 @@ POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR=/srv/powercontext/source-plugins POWERCONTEXT_SERVER_ACCESS='{"mode":"enforced"}' ``` -首次配置后重启 Server。自动部署创建独立管理和执行凭据,不修改用户身份文件。Server 启动扫描直接子目录的 `powercontext.yml`;之后新增实例显式 load,不持续扫描。此自动扫描用于 collect;write 使用前述 `--kind write` 显式加载。Linux 上管理服务和 Runtime 默认使用后台进程;`source_management.process_mode="local-service"` 显式选择 systemd 用户服务。 +首次配置后重启 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`,例如: @@ -156,6 +156,7 @@ pc_source_status({"scope_id":"SCOPE_ID","target_id":"TARGET_ID","view":"control" | 现象 | 检查和处理 | | --- | --- | +| 启动日志出现 `background-unsupported` 或 `service-unavailable` | 本地 collect 部署要求 Linux;`local-service` 还需要可用的 `systemctl --user`。检查执行平台和进程模式。 | | 管理服务断线 | 已同步的 collect 可继续;新管理调用可能失败。内置及已加载的 write 不依赖管理服务。 | | Runtime 重启后管理服务不可达 | 等待重新同步目标,不从文件初始 targets 回退采集。 | | 目标长期没有新 Source | 检查插件采集逻辑是否仍运行、采集节奏、外部凭据和 Scope 权限;进程在线不保证采集成功。 | diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 4ec015df46..2a5f6d80d4 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -151,7 +151,10 @@ def require_token_when_enabled(self) -> BearerAuthConfig: class SourceManagementConfig(BaseModel): """One trusted manager and optional local background deployment.""" - process_mode: Literal["background", "local-service"] = "background" + 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 diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py index 36c6b7f472..93581b8bb4 100644 --- a/src/powercontext/source_management/background.py +++ b/src/powercontext/source_management/background.py @@ -57,10 +57,11 @@ def _alive(state): def process_action(role, config, action): """Start or stop only a confirmed local process; repeated starts preserve its state.""" - import fcntl - 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): diff --git a/src/powercontext/source_management/deployment.py b/src/powercontext/source_management/deployment.py index f812d158c7..9f7d2f130b 100644 --- a/src/powercontext/source_management/deployment.py +++ b/src/powercontext/source_management/deployment.py @@ -343,15 +343,35 @@ async def startup(self): if self.settings.source_management.autostart: try: await self.manager() - except Exception: - logger.error("Source manager startup failed; inspect local service configuration") # noqa: TRY400 - no secret-bearing traceback + 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: - log_safely(logger, logging.ERROR, "Source instance preparation failed; inspect local configuration") + 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): From 250c8a0059ee4cb22bf27e4491de23f92cb1ad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Mon, 26 Oct 2026 20:48:36 +0800 Subject: [PATCH 11/15] fix(desktop): qualify source plugin server contract --- desktop/README.md | 2 +- desktop/VALIDATION.md | 36 ++++++++++++++++++- .../src/connections/compatibility.json | 19 ++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 30c82e1a89..63fb351b07 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -150,7 +150,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 the qualified current Server sources, explicitly choose `sqlite-atomic-dd62723d-v1` and check the connection before selecting a Scope; that record's contract digest matches this build's bundled contract, while `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). +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/VALIDATION.md b/desktop/VALIDATION.md index d2c2963222..96e7527876 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -22,7 +22,7 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract is qualified by `sqlite-atomic-dd62723d-v1` below; ordinary builds require explicitly selecting it and checking the connection before business operations become available. The `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` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract is qualified by `sqlite-atomic-68491568-v1` below; ordinary builds require explicitly selecting it and checking the connection before business operations become available. The `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` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. 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. @@ -30,6 +30,40 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Current Server protocol qualification +#### 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 dd62723d protocol qualification `sqlite-atomic-dd62723d-v1` qualifies Server sources at `dd62723dceabae8b41e293531f0080a819f87c68` diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 109fcd6456..5bba407992 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -227,5 +227,24 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-dd62723d-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" } ] From c7d0bdec22f517d4d67976dcdd33fe0602530a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Thu, 29 Oct 2026 11:14:06 +0800 Subject: [PATCH 12/15] fix(source): enforce host guards and refresh stale targets --- examples/source_plugins/tickets/__init__.py | 9 +- .../dsh/plugins/powercontext/lib/index.js | 12 +- .../dsh/plugins/powercontext/src/mcp.ts | 7 +- .../dsh/plugins/powercontext/src/secrets.ts | 1 + .../plugins/powercontext/tests/mcp.spec.ts | 44 +++++++ tests/e2e/test_source_collect.py | 110 ++++++++++++++++++ 6 files changed, 178 insertions(+), 5 deletions(-) diff --git a/examples/source_plugins/tickets/__init__.py b/examples/source_plugins/tickets/__init__.py index 8f8d77cfe8..a5f32bfc23 100644 --- a/examples/source_plugins/tickets/__init__.py +++ b/examples/source_plugins/tickets/__init__.py @@ -22,6 +22,7 @@ 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 @@ -126,5 +127,11 @@ async def apply(ctx, config): 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 - await ctx.run_batch(scope_id=scope_id, 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/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 4afc5c45b9..020b0a3d9e 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -2801,6 +2801,7 @@ const PAYLOAD_WRITES = new Set([ "change_atomic_memory_lifecycle" ]); const CONTENT_FIELDS = new Map([ + ["pc_source_add", ["input"]], ["create_scope", [ "title", "summary", @@ -3711,7 +3712,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; @@ -3734,7 +3742,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.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/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/tests/e2e/test_source_collect.py b/tests/e2e/test_source_collect.py index 03cad26734..f0641a9764 100644 --- a/tests/e2e/test_source_collect.py +++ b/tests/e2e/test_source_collect.py @@ -319,3 +319,113 @@ async def _scope_shrink_and_discovery(directory): 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) From 35ed63c94cf155c9ec87e6948d2596f98a9a2fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 30 Oct 2026 10:27:30 +0800 Subject: [PATCH 13/15] fix(source): reject targets exceeding sync budget --- src/powercontext/source_management/manager.py | 13 +++- tests/test_source_management.py | 60 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py index 0c012eedc9..16c0c20a94 100644 --- a/src/powercontext/source_management/manager.py +++ b/src/powercontext/source_management/manager.py @@ -57,6 +57,13 @@ 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 @@ -382,6 +389,8 @@ async def add(self, caller, parameters): "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) @@ -482,9 +491,7 @@ async def sync_instance(self, instance, generation, cursor=""): destination = targets else: continue - length = len(canonical(snapshot).encode()) - if length > MAX_MESSAGE_BYTES - 3072: - raise ManagementError("response-too-large", "One target exceeds the sync limit") + length = _sync_target_size(snapshot) if size + length > MAX_MESSAGE_BYTES - 2048: next_cursor = last_id break diff --git a/tests/test_source_management.py b/tests/test_source_management.py index 9defb7d30c..193d4af240 100644 --- a/tests/test_source_management.py +++ b/tests/test_source_management.py @@ -862,3 +862,63 @@ async def scenario(): 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()) From 02df8478aef0846bffc2d7cf5e047cec3b7e2a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 30 Oct 2026 18:29:39 +0800 Subject: [PATCH 14/15] chore(desktop): qualify merged source plugin server --- desktop/VALIDATION.md | 40 ++++++++++++++++++- .../src/connections/compatibility.json | 20 ++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index ecc1ecdf95..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 has no matching protocol qualification record. 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. +[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 diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index f3a9e605f1..5c0f8605d9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -323,5 +323,25 @@ "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" } ] From 80a16a7347d145e1541d51fd5bd9780185c707ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E9=9C=96?= Date: Fri, 30 Oct 2026 18:38:19 +0800 Subject: [PATCH 15/15] fix(cindy): rebuild worker with source operations --- .../powercontext-hooks/node/worker.cjs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) 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);